fix(openai-probe): /responses 能力探测增加工具调用校验

原探测仅以 HTTP 状态码判定 /v1/responses 端点是否存在(404/405 视为不存在,其余视为存在),无法识别"端点存在、基础补全可用、但工具调用不可用"的上游。火山方舟 coding/v3 的 kimi-k2.6 即属此类:携带 tools 的请求仅返回 reasoning、不产出 function_call,导致账号被误判 openai_responses_supported=true。网关据此走 /responses 转换路径后工具调用结果丢失,带工具的请求确定性失败。

改动:
- 探测请求携带一个工具并以 tool_choice=required 强制调用,仅当响应 output 数组包含 function_call 项时判定为支持;2xx 但无 function_call 判定为不支持,使网关改走 /v1/chat/completions 直转路径(同一模型在该路径下工具调用正常)。
- 探测模型改用账号 model_mapping 中的真实上游模型;占位模型在第三方上游会返回 400 model-not-found,无法判定能力。
- 探测超时 8s 调整为 15s(探测在后台异步执行,为推理型模型先推理再产出工具调用预留时间)。
- 非 2xx(404/405 除外)仍保守判定为支持,不改变既有账号行为;运行时回退函数 isResponsesEndpointSupportedByStatus 保持不变。
- 新增单元测试覆盖判定逻辑与探测模型选择。
This commit is contained in:
alfadb
2026-06-16 15:59:00 +08:00
parent f069c9ae00
commit b88f8e4c04
2 changed files with 187 additions and 20 deletions
@@ -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 / 其他 4xx401/422/400 等)/ 5xx → 支持,写 true
// - 上游 404 / 405 → 端点不存在,写 false
// - 上游 2xx → 端点存在,进一步看工具能力:响应含 function_call 输出项才写 true;
// 仅 reasoning / 无 function_call(如火山方舟 coding/v3 × kimi-k2.6)写 false
// - 其他非 2xx401/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
// - 其他非 2xx401/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
}
@@ -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))
}