diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index 5b2d92c69a..d942afab08 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -77,7 +77,8 @@ func openaiResponsesProbePayload(modelID string) []byte { // // 工具能力探测必须用上游真实存在的模型——用占位模型(DefaultTestModel)打第三方 // 上游只会拿到 400 model-not-found,无从判定工具能力。优先取账号 model_mapping -// 的上游模型(值),按字典序取首个具体(非通配符)模型以保证可复现;无映射时回退 +// 的上游模型(值),其中优先通用 GPT 文本模型,同类按字典序取首个具体(非通配符) +// 模型以保证可复现;无映射时回退 // DefaultTestModel(适配 OpenAI 官方 APIKey 账号)。 func selectResponsesProbeModel(account *Account) string { mapping := account.GetModelMapping() @@ -93,6 +94,13 @@ func selectResponsesProbeModel(account *Account) string { return openai.DefaultTestModel } sort.Strings(candidates) + // Prefer a general text model over auxiliary entries such as + // codex-auto-review or image-only models, which cannot prove tool support. + for _, candidate := range candidates { + if strings.HasPrefix(candidate, "gpt-") && !isOpenAIImageGenerationModel(candidate) { + return candidate + } + } return candidates[0] } @@ -259,9 +267,13 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte // 其余 2xx 一律可下结论——尤其 status=completed 却只回 reasoning 的上游(火山方舟 // coding/v3 × kimi-k2.6),仍按原逻辑判为不支持。 // -// 非 2xx 的结论只看状态码、不依赖响应内容,恒可下结论。 +// 明确指向探测模型不可用的 400/404(model_not_found 等)只说明模型不存在,不说明端点 +// 能力,不下结论;其余非 2xx 的结论只看状态码、不依赖响应内容,恒可下结论。 // 缺少 status 字段的响应体(含非 JSON)也按可下结论处理,保持既有行为。 func responsesProbeVerdictIsConclusive(status int, body []byte) bool { + if isResponsesProbeModelUnavailable(status, body) { + return false + } if status < 200 || status >= 300 { return true } @@ -296,13 +308,17 @@ func isResponsesEndpointSupportedByStatus(status int) bool { // decideResponsesProbeSupport 依据探测响应判定上游 /v1/responses 是否真正可用于 // 携带工具的请求。 // -// - 404 / 405:端点不存在 → false +// - 探测模型不可用的 400/404:与端点能力无关 → true(调用方同时不下结论,不写入) +// - 其他 404 / 405:端点不存在 → false // - 其他非 2xx(401/403/422/5xx 等):端点存在,但本次无法判定工具能力 // (鉴权/校验/瞬时故障)→ 保守按 true,保持既有"端点存在即支持"行为 // - 2xx:探测以 tool_choice=required 强制工具调用,响应必须含 function_call // 输出项才算真正可用;否则(如火山方舟 coding/v3 × kimi-k2.6 仅回 reasoning) // 判为 false,使网关改走 /v1/chat/completions 直转路径。 func decideResponsesProbeSupport(status int, body []byte) bool { + if isResponsesProbeModelUnavailable(status, body) { + return true // The model error says nothing negative about the endpoint. + } if status == http.StatusNotFound || status == http.StatusMethodNotAllowed { return false } @@ -312,6 +328,21 @@ func decideResponsesProbeSupport(status int, body []byte) bool { return responsesProbeBodyHasFunctionCall(body) } +// isResponsesProbeModelUnavailable 判断 400/404 是否只是在说探测模型不可用 +// (错误码/类型为 model_not_found 等,或错误文案明确说模型不存在/不可用)。 +func isResponsesProbeModelUnavailable(status int, body []byte) bool { + if status != http.StatusBadRequest && status != http.StatusNotFound { + return false + } + for _, path := range []string{"error.code", "error.type", "response.error.code", "response.error.type"} { + switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, path).String())) { + case "model_not_found", "model_not_available", "unsupported_model", "invalid_model": + return true + } + } + return isExplicitOpenAIModelAvailabilityMessage(extractUpstreamErrorMessage(body)) +} + // responsesProbeBodyHasFunctionCall 判断非流式 Responses 响应体的 output 数组里 // 是否存在 function_call 输出项。 func responsesProbeBodyHasFunctionCall(body []byte) bool { diff --git a/backend/internal/service/openai_apikey_responses_probe_model_unavailable_test.go b/backend/internal/service/openai_apikey_responses_probe_model_unavailable_test.go new file mode 100644 index 0000000000..0f3904380d --- /dev/null +++ b/backend/internal/service/openai_apikey_responses_probe_model_unavailable_test.go @@ -0,0 +1,49 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// A 400/404 that only says the probe model does not exist says nothing about +// the /v1/responses endpoint: the verdict must stay inconclusive and the +// account capability must not be overwritten with "unsupported". +func TestResponsesProbeModelUnavailableIsInconclusive(t *testing.T) { + for _, body := range []string{ + `{"error":{"type":"model_not_found","message":"Model codex-auto-review is not supported by any configured account in this group"}}`, + `{"error":{"code":"model_not_available"}}`, + `{"error":{"message":"The model missing does not exist"}}`, + } { + require.False(t, responsesProbeVerdictIsConclusive(404, []byte(body))) + require.True(t, decideResponsesProbeSupport(404, []byte(body))) + updates := make(chan map[string]any, 1) + account := Account{ID: 96, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test", "base_url": "https://upstream.example"}} + repo := &snapshotUpdateAccountRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, updateExtraCalls: updates} + svc := &AccountTestService{accountRepo: repo, cfg: &config.Config{}, httpUpstream: &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: 404, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)), + }}} + svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID) + select { + case <-updates: + t.Fatal("a model failure must not overwrite the account endpoint capability") + default: + } + } + // A plain 404/405 still means the endpoint does not exist. + require.True(t, responsesProbeVerdictIsConclusive(404, []byte("404 page not found"))) + require.False(t, decideResponsesProbeSupport(404, []byte("404 page not found"))) + require.False(t, decideResponsesProbeSupport(405, nil)) +} + +func TestSelectResponsesProbeModelPrefersGeneralTextModel(t *testing.T) { + account := &Account{Credentials: map[string]any{"model_mapping": map[string]any{ + "codex-auto-review": "codex-auto-review", "gpt-image-2": "gpt-image-2", "gpt-5.5": "gpt-5.5", + }}} + require.Equal(t, "gpt-5.5", selectResponsesProbeModel(account)) +}