mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user