openai_gateway_responses_chat_fallback.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "net/http"
  9. "strings"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  13. "github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
  14. "github.com/gin-gonic/gin"
  15. "go.uber.org/zap"
  16. )
  17. // forwardResponsesViaRawChatCompletions serves /v1/responses clients through an
  18. // upstream that only supports /v1/chat/completions.
  19. func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
  20. ctx context.Context,
  21. c *gin.Context,
  22. account *Account,
  23. body []byte,
  24. ) (*OpenAIForwardResult, error) {
  25. startTime := time.Now()
  26. var responsesReq apicompat.ResponsesRequest
  27. if err := json.Unmarshal(body, &responsesReq); err != nil {
  28. writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
  29. return nil, fmt.Errorf("parse responses request: %w", err)
  30. }
  31. originalModel := strings.TrimSpace(responsesReq.Model)
  32. if originalModel == "" {
  33. writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", "model is required")
  34. return nil, fmt.Errorf("missing model in request")
  35. }
  36. clientStream := responsesReq.Stream
  37. // custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为
  38. // custom_tool_call 项,先记下名字集合;tool_search 工具同理,回程还原为
  39. // tool_search_call 项;namespace 子工具(如 MCP 工具)摊平转发,回程按映射还原
  40. // 为带 namespace 字段的 function_call 项。
  41. effectiveTools, err := apicompat.EffectiveResponsesTools(&responsesReq)
  42. if err != nil {
  43. writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
  44. return nil, fmt.Errorf("resolve responses tools: %w", err)
  45. }
  46. customTools := apicompat.CustomToolNames(effectiveTools)
  47. functionTools := apicompat.FunctionToolNames(effectiveTools)
  48. toolSearch := apicompat.HasToolSearchTool(effectiveTools)
  49. namespaceTools := apicompat.NamespaceToolNames(effectiveTools)
  50. // 自愈回写:历史里带明文 summary 的 reasoning item 刷新进缓存,覆盖 Redis
  51. // 被 flush / 跨实例漂移后同 id 的 encrypted-only 副本无法再取明文的情况。
  52. s.recacheReasoningItemsFromInput(responsesReq.Input)
  53. chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions(&responsesReq, &apicompat.ResponsesToChatOptions{
  54. ReasoningContentByID: s.reasoningContentByID,
  55. })
  56. if err != nil {
  57. writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
  58. return nil, fmt.Errorf("convert responses to chat completions: %w", err)
  59. }
  60. billingModel := resolveOpenAIForwardModel(account, originalModel, "")
  61. upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
  62. reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel)
  63. // 国产模型默认 effort 补充:需要 mappedModel 判定,推迟到 billingModel 算出之后。
  64. reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel)
  65. chatReq.Model = upstreamModel
  66. if clientStream {
  67. chatReq.StreamOptions = &apicompat.ChatStreamOptions{IncludeUsage: true}
  68. }
  69. chatBody, err := json.Marshal(chatReq)
  70. if err != nil {
  71. return nil, fmt.Errorf("marshal chat completions fallback request: %w", err)
  72. }
  73. chatBody, err = s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, chatBody)
  74. if err != nil {
  75. var blocked *OpenAIFastBlockedError
  76. if errors.As(err, &blocked) {
  77. writeOpenAIFastPolicyBlockedResponse(c, blocked)
  78. }
  79. return nil, err
  80. }
  81. // /v1/responses 降级到 raw CC 的出站与 forwardAsRawChatCompletions 共用同一个
  82. // 独立 Ollama Cloud token 钩子;chatReq.Model 已是模型映射后的 upstreamModel。
  83. chatBody = clampOllamaCloudUpstreamMaxTokens(account, chatBody)
  84. // Keep the final outbound tier for usage-time reconciliation. A policy
  85. // filter that removes the field therefore leaves this nil.
  86. serviceTier := extractOpenAIServiceTierFromBody(chatBody)
  87. logger.L().Debug("openai responses: forwarding via raw chat completions",
  88. zap.Int64("account_id", account.ID),
  89. zap.String("original_model", originalModel),
  90. zap.String("billing_model", billingModel),
  91. zap.String("upstream_model", upstreamModel),
  92. zap.Bool("stream", clientStream),
  93. )
  94. SetOpsUpstreamModel(c, upstreamModel)
  95. // Build and send upstream request via the shared CC pipeline
  96. apiKey, targetURL, err := s.resolveCCFallbackTarget(account)
  97. if err != nil {
  98. return nil, err
  99. }
  100. resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, chatBody, clientStream, apiKey, account.GetOpenAIUserAgent(), "")
  101. if err != nil {
  102. return nil, err
  103. }
  104. defer func() { _ = resp.Body.Close() }()
  105. if resp.StatusCode >= 400 {
  106. respBody, upstreamMsg := s.readOpenAIUpstreamError(resp)
  107. if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil {
  108. return nil, foErr
  109. }
  110. return s.handleErrorResponse(ctx, resp, c, account, chatBody, billingModel)
  111. }
  112. if clientStream {
  113. return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
  114. }
  115. return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
  116. }
  117. func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses(
  118. c *gin.Context,
  119. resp *http.Response,
  120. originalModel string,
  121. customTools map[string]bool,
  122. functionTools map[string]bool,
  123. toolSearch bool,
  124. namespaceTools map[string]apicompat.NamespacedToolName,
  125. billingModel string,
  126. upstreamModel string,
  127. reasoningEffort *string,
  128. serviceTier *string,
  129. startTime time.Time,
  130. ) (*OpenAIForwardResult, error) {
  131. requestID := resp.Header.Get("x-request-id")
  132. ccResp, usage, err := s.readCCUpstreamJSONResponse(c, resp, writeOpenAIResponsesFallbackError)
  133. if err != nil {
  134. return nil, err
  135. }
  136. responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, functionTools, toolSearch, namespaceTools)
  137. s.cacheReasoningItemsFromOutput(responsesResp.Output)
  138. if s.responseHeaderFilter != nil {
  139. responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
  140. }
  141. c.JSON(http.StatusOK, responsesResp)
  142. return &OpenAIForwardResult{
  143. RequestID: requestID,
  144. UpstreamHeaders: resp.Header,
  145. Usage: usage,
  146. Model: originalModel,
  147. BillingModel: billingModel,
  148. UpstreamModel: upstreamModel,
  149. ReasoningEffort: reasoningEffort,
  150. UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
  151. ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
  152. Stream: false,
  153. Duration: time.Since(startTime),
  154. }, nil
  155. }
  156. func (s *OpenAIGatewayService) streamChatCompletionsAsResponses(
  157. c *gin.Context,
  158. resp *http.Response,
  159. originalModel string,
  160. customTools map[string]bool,
  161. functionTools map[string]bool,
  162. toolSearch bool,
  163. namespaceTools map[string]apicompat.NamespacedToolName,
  164. billingModel string,
  165. upstreamModel string,
  166. reasoningEffort *string,
  167. serviceTier *string,
  168. startTime time.Time,
  169. ) (*OpenAIForwardResult, error) {
  170. requestID := resp.Header.Get("x-request-id")
  171. writeStreamHeaders := s.newStreamHeaderWriter(c, resp.Header)
  172. state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel)
  173. state.CustomTools = customTools
  174. state.FunctionTools = functionTools
  175. state.ToolSearchDeclared = toolSearch
  176. state.NamespaceTools = namespaceTools
  177. clientDisconnected := false
  178. writeEvents := func(events []apicompat.ResponsesStreamEvent) {
  179. if clientDisconnected || len(events) == 0 {
  180. return
  181. }
  182. writeStreamHeaders()
  183. for _, event := range events {
  184. sse, err := apicompat.ResponsesEventToSSE(event)
  185. if err != nil {
  186. logger.L().Warn("openai responses chat fallback: failed to marshal stream event",
  187. zap.Error(err),
  188. zap.String("request_id", requestID),
  189. )
  190. continue
  191. }
  192. if _, err := fmt.Fprint(c.Writer, sse); err != nil {
  193. clientDisconnected = true
  194. logger.L().Debug("openai responses chat fallback: client disconnected, continuing to drain upstream for billing",
  195. zap.Error(err),
  196. zap.String("request_id", requestID),
  197. )
  198. return
  199. }
  200. }
  201. c.Writer.Flush()
  202. }
  203. scan := s.scanCCStream(c, resp, "openai responses chat fallback", requestID, startTime, func(chunk *apicompat.ChatCompletionsChunk) {
  204. events := apicompat.ChatCompletionsChunkToResponsesEvents(chunk, state)
  205. s.cacheReasoningItemsFromEvents(events)
  206. writeEvents(events)
  207. })
  208. if scan.Err != nil {
  209. return &OpenAIForwardResult{
  210. RequestID: requestID,
  211. UpstreamHeaders: resp.Header,
  212. Usage: scan.Usage,
  213. Model: originalModel,
  214. BillingModel: billingModel,
  215. UpstreamModel: upstreamModel,
  216. ReasoningEffort: reasoningEffort,
  217. UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
  218. ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
  219. Stream: true,
  220. Duration: time.Since(startTime),
  221. FirstTokenMs: scan.FirstTokenMs,
  222. }, fmt.Errorf("stream usage incomplete: %w", scan.Err)
  223. }
  224. if err := state.ValidateToolCallArguments(); err != nil {
  225. return &OpenAIForwardResult{
  226. RequestID: requestID,
  227. UpstreamHeaders: resp.Header,
  228. Usage: scan.Usage,
  229. Model: originalModel,
  230. BillingModel: billingModel,
  231. UpstreamModel: upstreamModel,
  232. ReasoningEffort: reasoningEffort,
  233. UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
  234. ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
  235. Stream: true,
  236. Duration: time.Since(startTime),
  237. FirstTokenMs: scan.FirstTokenMs,
  238. }, fmt.Errorf("invalid tool call arguments from upstream: %w", err)
  239. }
  240. finalEvents := apicompat.FinalizeChatCompletionsResponsesStream(state)
  241. s.cacheReasoningItemsFromEvents(finalEvents)
  242. writeEvents(finalEvents)
  243. if !clientDisconnected {
  244. writeStreamHeaders()
  245. if _, err := fmt.Fprint(c.Writer, "data: [DONE]\n\n"); err != nil {
  246. clientDisconnected = true
  247. }
  248. if !clientDisconnected {
  249. c.Writer.Flush()
  250. }
  251. }
  252. if !scan.SawDone {
  253. logCCStreamMissingDoneSentinel("openai responses chat fallback", requestID)
  254. }
  255. return &OpenAIForwardResult{
  256. RequestID: requestID,
  257. UpstreamHeaders: resp.Header,
  258. Usage: scan.Usage,
  259. Model: originalModel,
  260. BillingModel: billingModel,
  261. UpstreamModel: upstreamModel,
  262. ReasoningEffort: reasoningEffort,
  263. UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
  264. ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
  265. Stream: true,
  266. Duration: time.Since(startTime),
  267. FirstTokenMs: scan.FirstTokenMs,
  268. }, nil
  269. }
  270. func chatChunkStartsResponsesOutput(chunk *apicompat.ChatCompletionsChunk) bool {
  271. if chunk == nil {
  272. return false
  273. }
  274. for _, choice := range chunk.Choices {
  275. if choice.Delta.Content != nil || choice.Delta.ReasoningContent != nil || len(choice.Delta.ToolCalls) > 0 {
  276. return true
  277. }
  278. }
  279. return false
  280. }
  281. // responsesReasoningCacheTTL 是 reasoning 缓存(按 reasoning item id)的过期时间。
  282. // Codex 会话可能跨多天恢复历史,取 7 天。
  283. const responsesReasoningCacheTTL = 7 * 24 * time.Hour
  284. // reasoningContentByID 按 reasoning item id 回查缓存的 reasoning 全文,供
  285. // Responses→CC 桥接在客户端不回传明文 summary(encrypted-only reasoning
  286. // item)时回注 reasoning_content。任何失败都 fail-open 返回 ""(维持桥接原
  287. // 行为),因为缓存只是优化而非正确性前提。
  288. func (s *OpenAIGatewayService) reasoningContentByID(itemID string) string {
  289. if s == nil || s.cache == nil {
  290. return ""
  291. }
  292. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  293. defer cancel()
  294. content, err := s.cache.GetReasoningContent(ctx, itemID)
  295. if err != nil {
  296. return ""
  297. }
  298. return content
  299. }
  300. // recacheReasoningItemsFromInput 把请求历史里带明文 summary 的 reasoning item
  301. // 重新写入缓存(best-effort)。Codex 多数时候会原样回传明文 summary,借机
  302. // 刷新 TTL 并自愈 Redis 被 flush / 跨实例漂移造成的缓存缺失。
  303. func (s *OpenAIGatewayService) recacheReasoningItemsFromInput(inputRaw json.RawMessage) {
  304. if s == nil || s.cache == nil {
  305. return
  306. }
  307. inputRaw = bytes.TrimSpace(inputRaw)
  308. if len(inputRaw) == 0 || inputRaw[0] != '[' {
  309. return
  310. }
  311. var items []json.RawMessage
  312. if err := json.Unmarshal(inputRaw, &items); err != nil {
  313. return
  314. }
  315. for _, raw := range items {
  316. id, text, ok := apicompat.ExtractResponsesReasoningItem(raw)
  317. if !ok || id == "" || text == "" {
  318. continue
  319. }
  320. s.setReasoningContent(id, text)
  321. }
  322. }
  323. // cacheReasoningItemsFromEvents 从 Responses 流事件里提取完成的 reasoning
  324. // item 写入缓存(覆盖一个流中的多个 reasoning item)。
  325. func (s *OpenAIGatewayService) cacheReasoningItemsFromEvents(events []apicompat.ResponsesStreamEvent) {
  326. for _, event := range events {
  327. if event.Type != "response.output_item.done" || event.Item == nil {
  328. continue
  329. }
  330. s.cacheReasoningItem(event.Item)
  331. }
  332. }
  333. // cacheReasoningItemsFromOutput 从非流式 Responses 响应的 output 里提取
  334. // reasoning item 写入缓存。
  335. func (s *OpenAIGatewayService) cacheReasoningItemsFromOutput(output []apicompat.ResponsesOutput) {
  336. for i := range output {
  337. s.cacheReasoningItem(&output[i])
  338. }
  339. }
  340. func (s *OpenAIGatewayService) cacheReasoningItem(item *apicompat.ResponsesOutput) {
  341. if item == nil || item.Type != "reasoning" || item.ID == "" {
  342. return
  343. }
  344. var parts []string
  345. for _, sum := range item.Summary {
  346. if t := strings.TrimSpace(sum.Text); t != "" {
  347. parts = append(parts, t)
  348. }
  349. }
  350. if len(parts) == 0 {
  351. return
  352. }
  353. s.setReasoningContent(item.ID, strings.Join(parts, "\n"))
  354. }
  355. // setReasoningContent 写入缓存,使用 detached ctx:客户端断连后仍在 drain
  356. // 上游流(计费需要),此时的 reasoning 也是后续轮次回注所依赖的,不能随
  357. // 请求 ctx 一起取消。失败仅记日志,不影响转发。
  358. func (s *OpenAIGatewayService) setReasoningContent(itemID, content string) {
  359. if s == nil || s.cache == nil {
  360. return
  361. }
  362. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  363. defer cancel()
  364. if err := s.cache.SetReasoningContent(ctx, itemID, content, responsesReasoningCacheTTL); err != nil {
  365. logger.L().Warn("openai responses chat fallback: cache reasoning content failed",
  366. zap.Error(err),
  367. zap.String("item_id", itemID),
  368. )
  369. }
  370. }