openai_apikey_responses_probe.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "io"
  7. "log/slog"
  8. "net/http"
  9. "sort"
  10. "strings"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  15. "github.com/tidwall/gjson"
  16. )
  17. // openaiResponsesProbeTimeout 是探测请求的超时时长。
  18. // 探测在后台 goroutine 中异步执行,不阻塞账号创建/更新;留出余量给推理型模型
  19. // 先思考再产出 function_call 的往返。超时则保持 unknown,不下结论。
  20. const openaiResponsesProbeTimeout = 15 * time.Second
  21. // responsesProbeMaxBodyBytes 限制读取探测响应体的字节数,够判定 output 项类型即可。
  22. const responsesProbeMaxBodyBytes = 256 * 1024
  23. // openaiResponsesProbeMaxOutputTokens 是探测请求的输出预算。
  24. // 推理型模型可能把预算全烧在 reasoning 上,还没轮到 function_call 就被截断——
  25. // 那种响应不能用来判定工具能力,见 responsesProbeVerdictIsConclusive。
  26. const openaiResponsesProbeMaxOutputTokens = 512
  27. // openaiResponsesProbePayload 构造探测用的 Responses 请求体。
  28. //
  29. // 关键设计:请求携带一个工具并以 tool_choice=required 强制模型调用它。这样
  30. // 一个真正支持 Responses 工具调用的上游必须在响应里产出 function_call 输出项;
  31. // 而"端点存在、基础补全可用、但工具调用坏掉"的上游(如火山方舟 coding/v3 ×
  32. // kimi-k2.6,只回 reasoning、不产出 function_call)会被这一步暴露出来。
  33. //
  34. // Stream=false 便于一次性读取 output 数组判定;不带 instructions 以免干扰。
  35. func openaiResponsesProbePayload(modelID string) []byte {
  36. if strings.TrimSpace(modelID) == "" {
  37. modelID = openai.DefaultTestModel
  38. }
  39. body, _ := json.Marshal(map[string]any{
  40. "model": modelID,
  41. "input": []map[string]any{
  42. {
  43. "role": "user",
  44. "content": []map[string]any{
  45. {"type": "input_text", "text": "Call the probe_ping function with ok=true to acknowledge readiness. You must use the tool."},
  46. },
  47. },
  48. },
  49. "tools": []map[string]any{
  50. {
  51. "type": "function",
  52. "name": "probe_ping",
  53. "description": "Capability probe. Call to acknowledge.",
  54. "parameters": map[string]any{
  55. "type": "object",
  56. "properties": map[string]any{
  57. "ok": map[string]any{"type": "boolean"},
  58. },
  59. "required": []string{"ok"},
  60. },
  61. },
  62. },
  63. "tool_choice": "required",
  64. "max_output_tokens": openaiResponsesProbeMaxOutputTokens,
  65. "stream": false,
  66. })
  67. return body
  68. }
  69. // selectResponsesProbeModel 选出用于探测的上游模型。
  70. //
  71. // 工具能力探测必须用上游真实存在的模型——用占位模型(DefaultTestModel)打第三方
  72. // 上游只会拿到 400 model-not-found,无从判定工具能力。优先取账号 model_mapping
  73. // 的上游模型(值),按字典序取首个具体(非通配符)模型以保证可复现;无映射时回退
  74. // DefaultTestModel(适配 OpenAI 官方 APIKey 账号)。
  75. func selectResponsesProbeModel(account *Account) string {
  76. mapping := account.GetModelMapping()
  77. candidates := make([]string, 0, len(mapping))
  78. for _, upstream := range mapping {
  79. upstream = strings.TrimSpace(upstream)
  80. if upstream == "" || strings.Contains(upstream, "*") {
  81. continue
  82. }
  83. candidates = append(candidates, upstream)
  84. }
  85. if len(candidates) == 0 {
  86. return openai.DefaultTestModel
  87. }
  88. sort.Strings(candidates)
  89. return candidates[0]
  90. }
  91. // ProbeOpenAIAPIKeyResponsesSupport 探测 OpenAI APIKey 账号上游是否支持
  92. // /v1/responses 端点,并将结果持久化到 accounts.extra.openai_responses_supported。
  93. //
  94. // 调用时机:账号创建/更新后,且仅当 platform=openai && type=apikey 时。
  95. //
  96. // 探测策略(参见包文档 internal/pkg/openai_compat):
  97. // - 上游 404 / 405 → 端点不存在,写 false
  98. // - 上游 2xx → 端点存在,进一步看工具能力:响应含 function_call 输出项才写 true;
  99. // 仅 reasoning / 无 function_call(如火山方舟 coding/v3 × kimi-k2.6)写 false
  100. // - 其他非 2xx(401/422/400/5xx 等)→ 端点存在但无法判定工具能力,保守写 true
  101. // - 网络层失败(连接错误、超时)→ 不写标记,保持 unknown
  102. // (后续请求仍按"现状即证据"默认走 Responses)
  103. //
  104. // 该方法是幂等的:重复调用会以最新探测结果覆盖标记。
  105. //
  106. // 关于失败处理:探测本身的失败不应阻塞账号创建——账号能创建/更新成功就够了,
  107. // 探测结果只影响后续路由优化。所有错误都仅记录日志,不向调用方传播。
  108. func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Context, accountID int64) {
  109. account, err := s.accountRepo.GetByID(ctx, accountID)
  110. if err != nil {
  111. logger.LegacyPrintf("service.openai_probe", "probe_load_account_failed: account_id=%d err=%v", accountID, err)
  112. return
  113. }
  114. if account.Type != AccountTypeAPIKey {
  115. return
  116. }
  117. if account.IsCNProvider() {
  118. // 国产 OpenAI 兼容上游默认仅支持 /v1/chat/completions。直接落标 false
  119. // 走 Chat Completions 直转,跳过网络探测。
  120. // 例外:deepseek / kimi 的固定 responses 和 adaptive 账号使用官方原生
  121. // Responses 端点,落标 force_responses;其余协议显式重置为 auto,避免
  122. // 切换后残留强制模式。
  123. if account.UsesNativeCNResponses() {
  124. _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
  125. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses),
  126. openai_compat.ExtraKeyResponsesSupported: true,
  127. })
  128. return
  129. }
  130. _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
  131. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto),
  132. openai_compat.ExtraKeyResponsesSupported: false,
  133. })
  134. return
  135. }
  136. if account.Platform != PlatformOpenAI {
  137. // 仅 OpenAI APIKey 账号需要探测;其他账号类型无能力差异。
  138. return
  139. }
  140. apiKey := account.GetOpenAIApiKey()
  141. if apiKey == "" {
  142. logger.LegacyPrintf("service.openai_probe", "probe_skip_no_apikey: account_id=%d", accountID)
  143. return
  144. }
  145. baseURL := account.GetOpenAIBaseURL()
  146. if baseURL == "" {
  147. baseURL = "https://api.openai.com"
  148. }
  149. normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
  150. if err != nil {
  151. logger.LegacyPrintf("service.openai_probe", "probe_invalid_baseurl: account_id=%d base_url=%q err=%v", accountID, baseURL, err)
  152. return
  153. }
  154. probeURL := buildOpenAIResponsesURL(normalizedBaseURL)
  155. probeModel := selectResponsesProbeModel(account)
  156. probeCtx, cancel := context.WithTimeout(ctx, openaiResponsesProbeTimeout)
  157. defer cancel()
  158. req, err := http.NewRequestWithContext(probeCtx, http.MethodPost, probeURL, bytes.NewReader(openaiResponsesProbePayload(probeModel)))
  159. if err != nil {
  160. logger.LegacyPrintf("service.openai_probe", "probe_build_request_failed: account_id=%d err=%v", accountID, err)
  161. return
  162. }
  163. req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
  164. req.Header.Set("Content-Type", "application/json")
  165. req.Header.Set("Authorization", "Bearer "+apiKey)
  166. req.Header.Set("Accept", "application/json")
  167. applyOpenAICodexProbeHeaders(req.Header)
  168. // 账号级请求头覆写:能力探测与真实转发保持一致的最终头
  169. account.ApplyHeaderOverrides(req.Header)
  170. proxyURL := ""
  171. if account.ProxyID != nil && account.Proxy != nil {
  172. proxyURL = account.Proxy.URL()
  173. }
  174. resp, err := s.httpUpstream.DoWithTLS(WithAccountTrafficRequest(req, account), proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
  175. if err != nil {
  176. // 网络层失败:不写标记,保持 unknown,下次重试或由网关 fallback 处理
  177. logger.LegacyPrintf("service.openai_probe", "probe_request_failed: account_id=%d url=%s err=%v", accountID, probeURL, err)
  178. return
  179. }
  180. defer func() { _ = resp.Body.Close() }()
  181. bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, responsesProbeMaxBodyBytes))
  182. // 有界排空剩余响应体:既帮助连接复用,又避免行为异常的上游用超大响应体拖住探测。
  183. _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, responsesProbeMaxBodyBytes))
  184. if readErr != nil {
  185. // 响应体读取失败(部分读取/传输错误):按网络层失败处理,保持 unknown,
  186. // 不写标记——否则可能给一个 2xx 响应误写 supported=false。
  187. logger.LegacyPrintf("service.openai_probe", "probe_read_body_failed: account_id=%d url=%s err=%v", accountID, probeURL, readErr)
  188. return
  189. }
  190. // 本次响应不足以下结论时保持 unknown,与网络层失败、响应体读取失败一致:
  191. // 标记一旦写成 false 就会一直粘住(只有下次账号创建/更新才重探),网关会静默
  192. // 改走 /v1/chat/completions —— 对 Codex 客户端意味着 prompt 缓存前缀被打散。
  193. // 宁可不写,让请求继续走既有的 Responses 路径。
  194. if !responsesProbeVerdictIsConclusive(resp.StatusCode, bodyBytes) {
  195. logger.LegacyPrintf("service.openai_probe",
  196. "probe_inconclusive_keep_unknown: account_id=%d base_url=%s probe_model=%s status=%d response_status=%s reason=%s",
  197. accountID, normalizedBaseURL, probeModel, resp.StatusCode,
  198. gjson.GetBytes(bodyBytes, "status").String(),
  199. gjson.GetBytes(bodyBytes, "incomplete_details.reason").String(),
  200. )
  201. return
  202. }
  203. supported := decideResponsesProbeSupport(resp.StatusCode, bodyBytes)
  204. if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{
  205. openai_compat.ExtraKeyResponsesSupported: supported,
  206. }); err != nil {
  207. logger.LegacyPrintf("service.openai_probe", "probe_persist_failed: account_id=%d supported=%v err=%v", accountID, supported, err)
  208. return
  209. }
  210. if !supported {
  211. // 落标为不支持等于把该账号长期钉在 /v1/chat/completions 上,成本与缓存命中率
  212. // 都会变化,且不会自动恢复。这条必须能被运维看到(#5371)。
  213. slog.Warn(
  214. "openai_responses_probe_marked_unsupported",
  215. "account_id", accountID,
  216. "account_name", account.Name,
  217. "base_url", normalizedBaseURL,
  218. "probe_model", probeModel,
  219. "upstream_status", resp.StatusCode,
  220. )
  221. }
  222. logger.LegacyPrintf("service.openai_probe",
  223. "probe_done: account_id=%d base_url=%s probe_model=%s status=%d supported=%v",
  224. accountID, normalizedBaseURL, probeModel, resp.StatusCode, supported,
  225. )
  226. }
  227. // responsesProbeVerdictIsConclusive 判断本次探测响应是否足以对「上游是否支持带工具的
  228. // Responses 调用」下结论。
  229. //
  230. // 2xx 分支靠「output 里有没有 function_call」下结论,但这只在响应真的跑完时成立:
  231. //
  232. // - status=incomplete 且 incomplete_details.reason=max_output_tokens:探测请求自己
  233. // 只给了 openaiResponsesProbeMaxOutputTokens 的预算,推理型模型可能把预算全烧在
  234. // reasoning 上,还没轮到 function_call 就被截断。此时「没有 function_call」是探测
  235. // 预算不足造成的,不是上游能力缺失。
  236. // - status=failed:HTTP 200 携带的失败响应(上游瞬时故障)同样不构成能力证据。
  237. //
  238. // 其余 2xx 一律可下结论——尤其 status=completed 却只回 reasoning 的上游(火山方舟
  239. // coding/v3 × kimi-k2.6),仍按原逻辑判为不支持。
  240. //
  241. // 非 2xx 的结论只看状态码、不依赖响应内容,恒可下结论。
  242. // 缺少 status 字段的响应体(含非 JSON)也按可下结论处理,保持既有行为。
  243. func responsesProbeVerdictIsConclusive(status int, body []byte) bool {
  244. if status < 200 || status >= 300 {
  245. return true
  246. }
  247. switch strings.TrimSpace(gjson.GetBytes(body, "status").String()) {
  248. case "failed":
  249. return false
  250. case "incomplete":
  251. return strings.TrimSpace(gjson.GetBytes(body, "incomplete_details.reason").String()) != "max_output_tokens"
  252. default:
  253. return true
  254. }
  255. }
  256. // isResponsesEndpointSupportedByStatus 根据探测响应的 HTTP 状态码判定上游
  257. // 是否暴露 /v1/responses 端点。
  258. //
  259. // 关键观察:第三方 OpenAI 兼容上游(DeepSeek/Kimi 等)对未知端点统一返回 404
  260. // 或 405;而 OpenAI 官方/有 Responses 实现的上游会因为请求体最简(缺字段)
  261. // 返回 400/422 等业务错误,但端点本身存在。
  262. //
  263. // 因此:仅 404 和 405 视为"端点不存在",其他 status 视为"端点存在"。
  264. //
  265. // 5xx 也视为"端点存在"——上游偶发故障不应误判为不支持。
  266. func isResponsesEndpointSupportedByStatus(status int) bool {
  267. switch status {
  268. case http.StatusNotFound, http.StatusMethodNotAllowed:
  269. return false
  270. }
  271. return true
  272. }
  273. // decideResponsesProbeSupport 依据探测响应判定上游 /v1/responses 是否真正可用于
  274. // 携带工具的请求。
  275. //
  276. // - 404 / 405:端点不存在 → false
  277. // - 其他非 2xx(401/403/422/5xx 等):端点存在,但本次无法判定工具能力
  278. // (鉴权/校验/瞬时故障)→ 保守按 true,保持既有"端点存在即支持"行为
  279. // - 2xx:探测以 tool_choice=required 强制工具调用,响应必须含 function_call
  280. // 输出项才算真正可用;否则(如火山方舟 coding/v3 × kimi-k2.6 仅回 reasoning)
  281. // 判为 false,使网关改走 /v1/chat/completions 直转路径。
  282. func decideResponsesProbeSupport(status int, body []byte) bool {
  283. if status == http.StatusNotFound || status == http.StatusMethodNotAllowed {
  284. return false
  285. }
  286. if status < 200 || status >= 300 {
  287. return true
  288. }
  289. return responsesProbeBodyHasFunctionCall(body)
  290. }
  291. // responsesProbeBodyHasFunctionCall 判断非流式 Responses 响应体的 output 数组里
  292. // 是否存在 function_call 输出项。
  293. func responsesProbeBodyHasFunctionCall(body []byte) bool {
  294. output := gjson.GetBytes(body, "output")
  295. if !output.IsArray() {
  296. return false
  297. }
  298. for _, item := range output.Array() {
  299. if strings.TrimSpace(item.Get("type").String()) == "function_call" {
  300. return true
  301. }
  302. }
  303. return false
  304. }