发布 0.2.0:模型池自愈、探测修正、任务异步触发与删除加固
CI / test (push) Successful in 32s
Release / release (push) Successful in 52s

This commit is contained in:
2026-07-10 20:25:37 +08:00
parent dbba1f4905
commit 7706f59549
19 changed files with 978 additions and 107 deletions
+31
View File
@@ -76,3 +76,34 @@ func ServiceStatus(err error) (int, bool) {
}
return 0, false
}
// IsOnDemandUnsupported 识别「模型在该区域仅为微调基座、不支持按需调用」的 400:
// OCI 消息形如 "Not allowed to call finetune base model …, use Endpoint: false"。
// ListModels 无字段可事先区分,只能在调用报错时识别并换渠道。
func IsOnDemandUnsupported(err error) bool {
var svcErr common.ServiceError
if !errors.As(err, &svcErr) {
return false
}
return svcErr.GetHTTPStatusCode() == 400 &&
strings.Contains(strings.ToLower(svcErr.GetMessage()), "finetune base model")
}
// IsEntityNotFound 识别「实体不存在」404(消息 "Entity with key … not found"):
// GenAI 对区域内无按需供给的模型 OCID 返回此类 404,属模型级错误;
// 鉴权失败的 404 是 NotAuthorizedOrNotFound(消息为 Authorization failed…),不在此列。
func IsEntityNotFound(err error) bool {
var svcErr common.ServiceError
if !errors.As(err, &svcErr) {
return false
}
msg := strings.ToLower(svcErr.GetMessage())
return svcErr.GetHTTPStatusCode() == 404 &&
strings.Contains(msg, "entity with key") && strings.Contains(msg, "not found")
}
// IsModelUnavailable 归并「模型×区域不可按需调用」两类报错:微调基座 400 与实体不存在 404;
// 命中即应把该 (渠道, 模型) 从池中剔除并换候选/换渠道,而非定论租户配额问题。
func IsModelUnavailable(err error) bool {
return IsOnDemandUnsupported(err) || IsEntityNotFound(err)
}
+49
View File
@@ -109,3 +109,52 @@ func TestErrorHint(t *testing.T) {
})
}
}
func TestIsOnDemandUnsupported(t *testing.T) {
ft := fakeServiceError{status: 400, code: "InvalidParameter",
message: "Not allowed to call finetune base model ocid1.generativeaimodel.oc1.eu-frankfurt-1.tpel5q, use Endpoint: false"}
tests := []struct {
name string
err error
want bool
}{
{"微调基座 400 命中(含包装链)", fmt.Errorf("genai chat: %w", ft), true},
{"同消息但非 400 不命中", fakeServiceError{status: 500, code: "InternalError", message: ft.message}, false},
{"普通 400 不命中", fakeServiceError{status: 400, code: "InvalidParameter", message: "bad request"}, false},
{"非服务端错误不命中", errors.New("finetune base model"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsOnDemandUnsupported(tt.err); got != tt.want {
t.Errorf("IsOnDemandUnsupported() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsModelUnavailable(t *testing.T) {
entity404 := fakeServiceError{status: 404, code: "NotFound",
message: "Entity with key ocid1.generativeaimodel.oc1.eu-frankfurt-1.2flsfq not found"}
auth404 := fakeServiceError{status: 404, code: "NotAuthorizedOrNotFound",
message: "Authorization failed or requested resource not found."}
ft400 := fakeServiceError{status: 400, code: "InvalidParameter",
message: "Not allowed to call finetune base model ocid1.generativeaimodel.oc1..x, use Endpoint: false"}
tests := []struct {
name string
err error
want bool
}{
{"实体不存在 404 命中(含包装链)", fmt.Errorf("genai chat: %w", entity404), true},
{"鉴权类 404 不命中(仍属租户级)", auth404, false},
{"微调基座 400 命中", ft400, true},
{"其他 404 消息不命中", fakeServiceError{status: 404, code: "NotFound", message: "route not found"}, false},
{"非服务端错误不命中", errors.New("entity with key x not found"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsModelUnavailable(tt.err); got != tt.want {
t.Errorf("IsModelUnavailable() = %v, want %v", got, tt.want)
}
})
}
}
+31 -11
View File
@@ -17,11 +17,10 @@ import (
// GenAiModel 是区域可用基础模型的摘要(管理面 ListModels)。
type GenAiModel struct {
Ocid string `json:"ocid"`
Name string `json:"name"`
Vendor string `json:"vendor"`
ChatOnly bool `json:"-"`
Caps []string `json:"capabilities"`
Ocid string `json:"ocid"`
Name string `json:"name"`
Vendor string `json:"vendor"`
Caps []string `json:"capabilities"`
// Capability 是网关侧归一能力:CHAT / EMBEDDING(兼具时算 CHAT)
Capability string `json:"capability"`
// Deprecated 是 OCI 宣布的弃用时间(TimeDeprecated),nil 表示未宣布;
@@ -71,18 +70,39 @@ func (c *RealClient) ListGenAiModels(ctx context.Context, cred Credentials, regi
if err != nil {
return nil, fmt.Errorf("list genai models: %w", err)
}
seen := map[string]bool{}
now := time.Now()
return dedupGenAiModels(resp.Items, time.Now()), nil
}
// dedupGenAiModels 压平并按名称去重;同名多条目时优先保留不含 FINE_TUNE 能力的条目
// (微调基座条目在部分区域不支持按需调用,缓存其 OCID 会导致调用 400)。
func dedupGenAiModels(items []generativeai.ModelSummary, now time.Time) []GenAiModel {
seen := map[string]int{}
var out []GenAiModel
for _, m := range resp.Items {
for _, m := range items {
gm, ok := toGenAiModel(m, now)
if !ok || seen[gm.Name] {
if !ok {
continue
}
seen[gm.Name] = true
if i, dup := seen[gm.Name]; dup {
if hasFineTune(out[i].Caps) && !hasFineTune(gm.Caps) {
out[i] = gm
}
continue
}
seen[gm.Name] = len(out)
out = append(out, gm)
}
return out, nil
return out
}
// hasFineTune 判断能力列表是否含 FINE_TUNE(微调基座条目)。
func hasFineTune(caps []string) bool {
for _, c := range caps {
if c == string(generativeai.ModelCapabilityFineTune) {
return true
}
}
return false
}
// toGenAiModel 压平模型摘要;无归一能力、无名称或按需推理已退役
+30
View File
@@ -38,3 +38,33 @@ func TestToGenAiModelRetiredFilter(t *testing.T) {
t.Error("正常模型应入池")
}
}
func TestDedupGenAiModelsFineTunePreference(t *testing.T) {
now := time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC)
chat := []generativeai.ModelCapabilityEnum{generativeai.ModelCapabilityChat}
chatFT := []generativeai.ModelCapabilityEnum{generativeai.ModelCapabilityChat, generativeai.ModelCapabilityFineTune}
mk := func(id, name string, caps []generativeai.ModelCapabilityEnum) generativeai.ModelSummary {
return generativeai.ModelSummary{
Id: common.String(id), DisplayName: common.String(name), Vendor: common.String("meta"),
Capabilities: caps, LifecycleState: generativeai.ModelLifecycleStateActive,
}
}
const name = "meta.llama-3-70b-instruct"
tests := []struct {
name string
items []generativeai.ModelSummary
want string // 期望保留的 OCID
}{
{"基座在前、纯对话在后:保留纯对话条目", []generativeai.ModelSummary{mk("o-ft", name, chatFT), mk("o-od", name, chat)}, "o-od"},
{"纯对话在前、基座在后:保留纯对话条目", []generativeai.ModelSummary{mk("o-od", name, chat), mk("o-ft", name, chatFT)}, "o-od"},
{"仅基座条目:保留不误删", []generativeai.ModelSummary{mk("o-ft", name, chatFT)}, "o-ft"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dedupGenAiModels(tt.items, now)
if len(got) != 1 || got[0].Ocid != tt.want {
t.Errorf("dedupGenAiModels() = %+v, want 仅保留 %s", got, tt.want)
}
})
}
}