diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index 051527f316..64f963ab9b 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -6,24 +6,32 @@ import ( "encoding/json" "io" "net/http" + "sort" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/tidwall/gjson" ) // openaiResponsesProbeTimeout 是探测请求的超时时长。 -// 探测必须快速失败——超时不应阻塞账号创建/更新流程。 -const openaiResponsesProbeTimeout = 8 * time.Second +// 探测在后台 goroutine 中异步执行,不阻塞账号创建/更新;留出余量给推理型模型 +// 先思考再产出 function_call 的往返。超时则保持 unknown,不下结论。 +const openaiResponsesProbeTimeout = 15 * time.Second -// openaiResponsesProbePayload 是探测使用的最小 Responses 请求体。 -// 仅作能力探测,不期望响应内容质量;Stream=false 减少 SSE 解析开销。 +// responsesProbeMaxBodyBytes 限制读取探测响应体的字节数,够判定 output 项类型即可。 +const responsesProbeMaxBodyBytes = 256 * 1024 + +// openaiResponsesProbePayload 构造探测用的 Responses 请求体。 // -// 注意:探测的目标是区分"端点存在"与"端点不存在"——只要上游返回非 404 的 -// 4xx/5xx(如 400 invalid_request_error / 401 unauthorized / 422 等), -// 都视为"端点存在 → 支持 Responses"。仅 404 / 405 视为"端点不存在"。 +// 关键设计:请求携带一个工具并以 tool_choice=required 强制模型调用它。这样 +// 一个真正支持 Responses 工具调用的上游必须在响应里产出 function_call 输出项; +// 而"端点存在、基础补全可用、但工具调用坏掉"的上游(如火山方舟 coding/v3 × +// kimi-k2.6,只回 reasoning、不产出 function_call)会被这一步暴露出来。 +// +// Stream=false 便于一次性读取 output 数组判定;不带 instructions 以免干扰。 func openaiResponsesProbePayload(modelID string) []byte { if strings.TrimSpace(modelID) == "" { modelID = openai.DefaultTestModel @@ -34,24 +42,64 @@ func openaiResponsesProbePayload(modelID string) []byte { { "role": "user", "content": []map[string]any{ - {"type": "input_text", "text": "hi"}, + {"type": "input_text", "text": "Call the probe_ping function with ok=true to acknowledge readiness. You must use the tool."}, }, }, }, - "instructions": openai.DefaultInstructions, - "stream": false, + "tools": []map[string]any{ + { + "type": "function", + "name": "probe_ping", + "description": "Capability probe. Call to acknowledge.", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "ok": map[string]any{"type": "boolean"}, + }, + "required": []string{"ok"}, + }, + }, + }, + "tool_choice": "required", + "max_output_tokens": 512, + "stream": false, }) return body } +// selectResponsesProbeModel 选出用于探测的上游模型。 +// +// 工具能力探测必须用上游真实存在的模型——用占位模型(DefaultTestModel)打第三方 +// 上游只会拿到 400 model-not-found,无从判定工具能力。优先取账号 model_mapping +// 的上游模型(值),按字典序取首个具体(非通配符)模型以保证可复现;无映射时回退 +// DefaultTestModel(适配 OpenAI 官方 APIKey 账号)。 +func selectResponsesProbeModel(account *Account) string { + mapping := account.GetModelMapping() + candidates := make([]string, 0, len(mapping)) + for _, upstream := range mapping { + upstream = strings.TrimSpace(upstream) + if upstream == "" || strings.Contains(upstream, "*") { + continue + } + candidates = append(candidates, upstream) + } + if len(candidates) == 0 { + return openai.DefaultTestModel + } + sort.Strings(candidates) + return candidates[0] +} + // ProbeOpenAIAPIKeyResponsesSupport 探测 OpenAI APIKey 账号上游是否支持 // /v1/responses 端点,并将结果持久化到 accounts.extra.openai_responses_supported。 // // 调用时机:账号创建/更新后,且仅当 platform=openai && type=apikey 时。 // // 探测策略(参见包文档 internal/pkg/openai_compat): -// - 上游 404 / 405 → 不支持,写 false -// - 上游 2xx / 其他 4xx(401/422/400 等)/ 5xx → 支持,写 true +// - 上游 404 / 405 → 端点不存在,写 false +// - 上游 2xx → 端点存在,进一步看工具能力:响应含 function_call 输出项才写 true; +// 仅 reasoning / 无 function_call(如火山方舟 coding/v3 × kimi-k2.6)写 false +// - 其他非 2xx(401/422/400/5xx 等)→ 端点存在但无法判定工具能力,保守写 true // - 网络层失败(连接错误、超时)→ 不写标记,保持 unknown // (后续请求仍按"现状即证据"默认走 Responses) // @@ -86,11 +134,12 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte } probeURL := buildOpenAIResponsesURL(normalizedBaseURL) + probeModel := selectResponsesProbeModel(account) probeCtx, cancel := context.WithTimeout(ctx, openaiResponsesProbeTimeout) defer cancel() - req, err := http.NewRequestWithContext(probeCtx, http.MethodPost, probeURL, bytes.NewReader(openaiResponsesProbePayload(""))) + req, err := http.NewRequestWithContext(probeCtx, http.MethodPost, probeURL, bytes.NewReader(openaiResponsesProbePayload(probeModel))) if err != nil { logger.LegacyPrintf("service.openai_probe", "probe_build_request_failed: account_id=%d err=%v", accountID, err) return @@ -111,12 +160,18 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte logger.LegacyPrintf("service.openai_probe", "probe_request_failed: account_id=%d url=%s err=%v", accountID, probeURL, err) return } - defer func() { - _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) - _ = resp.Body.Close() - }() + defer func() { _ = resp.Body.Close() }() + bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, responsesProbeMaxBodyBytes)) + // 有界排空剩余响应体:既帮助连接复用,又避免行为异常的上游用超大响应体拖住探测。 + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, responsesProbeMaxBodyBytes)) + if readErr != nil { + // 响应体读取失败(部分读取/传输错误):按网络层失败处理,保持 unknown, + // 不写标记——否则可能给一个 2xx 响应误写 supported=false。 + logger.LegacyPrintf("service.openai_probe", "probe_read_body_failed: account_id=%d url=%s err=%v", accountID, probeURL, readErr) + return + } - supported := isResponsesEndpointSupportedByStatus(resp.StatusCode) + supported := decideResponsesProbeSupport(resp.StatusCode, bodyBytes) if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{ openai_compat.ExtraKeyResponsesSupported: supported, @@ -126,8 +181,8 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte } logger.LegacyPrintf("service.openai_probe", - "probe_done: account_id=%d base_url=%s status=%d supported=%v", - accountID, normalizedBaseURL, resp.StatusCode, supported, + "probe_done: account_id=%d base_url=%s probe_model=%s status=%d supported=%v", + accountID, normalizedBaseURL, probeModel, resp.StatusCode, supported, ) } @@ -148,3 +203,37 @@ func isResponsesEndpointSupportedByStatus(status int) bool { } return true } + +// decideResponsesProbeSupport 依据探测响应判定上游 /v1/responses 是否真正可用于 +// 携带工具的请求。 +// +// - 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 status == http.StatusNotFound || status == http.StatusMethodNotAllowed { + return false + } + if status < 200 || status >= 300 { + return true + } + return responsesProbeBodyHasFunctionCall(body) +} + +// responsesProbeBodyHasFunctionCall 判断非流式 Responses 响应体的 output 数组里 +// 是否存在 function_call 输出项。 +func responsesProbeBodyHasFunctionCall(body []byte) bool { + output := gjson.GetBytes(body, "output") + if !output.IsArray() { + return false + } + for _, item := range output.Array() { + if strings.TrimSpace(item.Get("type").String()) == "function_call" { + return true + } + } + return false +} diff --git a/backend/internal/service/openai_apikey_responses_probe_test.go b/backend/internal/service/openai_apikey_responses_probe_test.go new file mode 100644 index 0000000000..9eed851868 --- /dev/null +++ b/backend/internal/service/openai_apikey_responses_probe_test.go @@ -0,0 +1,78 @@ +package service + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/stretchr/testify/require" +) + +func TestDecideResponsesProbeSupport(t *testing.T) { + fnCall := []byte(`{"output":[{"type":"reasoning"},{"type":"function_call","name":"probe_ping"}]}`) + reasoningOnly := []byte(`{"output":[{"type":"reasoning"}]}`) + + cases := []struct { + name string + status int + body []byte + want bool + }{ + // Endpoint clearly absent on third-party OpenAI-compatible upstreams. + {"404 endpoint absent", 404, fnCall, false}, + {"405 method not allowed", 405, fnCall, false}, + // 2xx: tool capability is judged by presence of a function_call output item. + {"200 with function_call", 200, fnCall, true}, + // Volcengine Ark coding/v3 × kimi-k2.6: reasoning only, no function_call. + {"200 reasoning only", 200, reasoningOnly, false}, + {"200 invalid json", 200, []byte("not-json"), false}, + {"200 no output field", 200, []byte(`{"status":"completed"}`), false}, + // Non-2xx (other than 404/405): endpoint exists, capability undecidable -> conservative true. + {"400 conservative true", 400, reasoningOnly, true}, + {"401 conservative true", 401, nil, true}, + {"500 conservative true", 500, nil, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, decideResponsesProbeSupport(tc.status, tc.body)) + }) + } +} + +func TestResponsesProbeBodyHasFunctionCall(t *testing.T) { + require.True(t, responsesProbeBodyHasFunctionCall([]byte(`{"output":[{"type":"function_call"}]}`))) + require.True(t, responsesProbeBodyHasFunctionCall([]byte(`{"output":[{"type":"reasoning"},{"type":"function_call"}]}`))) + require.False(t, responsesProbeBodyHasFunctionCall([]byte(`{"output":[{"type":"reasoning"}]}`))) + require.False(t, responsesProbeBodyHasFunctionCall([]byte(`{"output":[]}`))) + require.False(t, responsesProbeBodyHasFunctionCall([]byte(`{}`))) + require.False(t, responsesProbeBodyHasFunctionCall([]byte(`garbage`))) +} + +func TestSelectResponsesProbeModel(t *testing.T) { + // No model_mapping -> fall back to DefaultTestModel (OpenAI official APIKey). + require.Equal(t, openai.DefaultTestModel, selectResponsesProbeModel(&Account{})) + + // model_mapping values are upstream models; pick first by sort for reproducibility. + acct := &Account{Credentials: map[string]any{ + "model_mapping": map[string]any{ + "client-b": "zeta-model", + "client-a": "alpha-model", + }, + }} + require.Equal(t, "alpha-model", selectResponsesProbeModel(acct)) + + // Wildcard / blank upstream values are skipped. + acctWild := &Account{Credentials: map[string]any{ + "model_mapping": map[string]any{ + "a": "*", + "b": " ", + "c": "real-model", + }, + }} + require.Equal(t, "real-model", selectResponsesProbeModel(acctWild)) + + // Only wildcard mappings -> DefaultTestModel. + acctAllWild := &Account{Credentials: map[string]any{ + "model_mapping": map[string]any{"a": "gpt-*"}, + }} + require.Equal(t, openai.DefaultTestModel, selectResponsesProbeModel(acctAllWild)) +}