Merge pull request #7571 from SPHINX998/fix/openai-probe-model-unavailable-inconclusive

fix(openai): keep Responses support unknown when the probe model is missing
This commit is contained in:
Wesley Liddick
2026-09-28 09:54:31 +08:00
committed by GitHub
2 changed files with 83 additions and 3 deletions
@@ -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 {
@@ -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))
}