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