openai_gateway_chat_completions_raw.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "net/http"
  7. "strings"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  10. "github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
  11. "github.com/gin-gonic/gin"
  12. "github.com/tidwall/gjson"
  13. "github.com/tidwall/sjson"
  14. "go.uber.org/zap"
  15. )
  16. // openaiCCRawAllowedHeaders 是 CC 直转路径专用的客户端 header 透传白名单。
  17. //
  18. // **关键**:不能复用 openaiAllowedHeaders——后者含 Codex 客户端专属 header
  19. // (originator / session_id / x-codex-turn-state / x-codex-turn-metadata / conversation_id),
  20. // 这些在 ChatGPT OAuth 上游是必需的,但透传给 DeepSeek/Kimi/GLM 等第三方
  21. // OpenAI 兼容上游会造成:
  22. // - 完全忽略(多数友好厂商)——隐性污染上游统计
  23. // - 400 "unknown parameter"(严格上游)——可见错误
  24. //
  25. // 这里仅放行通用 HTTP header;content-type / authorization / accept 由上下文
  26. // 显式设置,不依赖透传。
  27. //
  28. // 参见决策记录:
  29. // pensieve/short-term/maxims/dont-reuse-shared-headers-whitelist-across-different-upstream-trust-domains
  30. var openaiCCRawAllowedHeaders = map[string]bool{
  31. "accept-language": true,
  32. "user-agent": true,
  33. }
  34. // forwardAsRawChatCompletions 直转客户端的 Chat Completions 请求到上游
  35. // `{base_url}/v1/chat/completions`,**不**做 CC↔Responses 协议转换。
  36. //
  37. // 适用场景:account.platform=openai && account.type=apikey && 上游已被探测确认
  38. // 不支持 /v1/responses 端点(如 GLM/Qwen 等第三方 OpenAI 兼容上游);CN 供应商
  39. // 固定 chat_completions 协议也走此路径。
  40. //
  41. // 与 ForwardAsChatCompletions 的关键差异:
  42. //
  43. // - 不调用 apicompat.ChatCompletionsToResponses,body 仅做模型 ID 改写
  44. // - 上游 URL 拼到 /v1/chat/completions 而非 /v1/responses
  45. // - 流式响应 SSE 直接透传给客户端(上游 chunk 已是 CC 格式)
  46. // - 非流式响应 JSON 直接透传,仅按需提取 usage
  47. // - 不应用 codex OAuth transform(APIKey 路径无 OAuth)
  48. // - 不注入 prompt_cache_key(OAuth 专属机制)
  49. //
  50. // 调用入口:openai_gateway_chat_completions.go::ForwardAsChatCompletions
  51. // 在函数顶部按 openai_compat.ShouldUseResponsesAPI 分流。
  52. func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
  53. ctx context.Context,
  54. c *gin.Context,
  55. account *Account,
  56. body []byte,
  57. defaultMappedModel string,
  58. ) (*OpenAIForwardResult, error) {
  59. startTime := time.Now()
  60. // 1. Parse minimal fields needed for routing/billing
  61. originalModel := gjson.GetBytes(body, "model").String()
  62. if originalModel == "" {
  63. writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "model is required")
  64. return nil, fmt.Errorf("missing model in request")
  65. }
  66. clientStream := gjson.GetBytes(body, "stream").Bool()
  67. // 2. Resolve model mapping (same as ForwardAsChatCompletions)
  68. billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
  69. upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
  70. SetOpsUpstreamModel(c, upstreamModel)
  71. grokCacheIdentity := ""
  72. if account.Platform == PlatformGrok {
  73. // Resolve before image bridging or other body rewrites so the fallback is
  74. // anchored to the client's stable conversation prefix.
  75. grokCacheIdentity = resolveGrokCacheIdentity(c, body, "", upstreamModel)
  76. }
  77. reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel)
  78. // 国产模型默认 effort 补充:需要 mappedModel 判定,推迟到 billingModel 算出之后。
  79. reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel)
  80. // 3. Rewrite model in body (no protocol conversion)
  81. upstreamBody := body
  82. if upstreamModel != originalModel {
  83. upstreamBody = ReplaceModelInBody(body, upstreamModel)
  84. }
  85. if normalizedBody, normalized := NormalizeGLMOpenAIReasoningEffort(upstreamBody, upstreamModel); normalized {
  86. upstreamBody = normalizedBody
  87. }
  88. // 4. Apply OpenAI fast policy on the CC body
  89. updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, upstreamBody)
  90. if policyErr != nil {
  91. var blocked *OpenAIFastBlockedError
  92. if errors.As(policyErr, &blocked) {
  93. MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
  94. writeChatCompletionsError(c, http.StatusForbidden, "permission_error", blocked.Message)
  95. }
  96. return nil, policyErr
  97. }
  98. upstreamBody = updatedBody
  99. // Keep the final outbound tier separate from the observed response tier so
  100. // usage recording can apply the selected credential's response contract.
  101. serviceTier := extractOpenAIServiceTierFromBody(upstreamBody)
  102. if account.Platform == PlatformGrok {
  103. strippedBody, stripErr := stripRedundantGrokChatViewImageTool(upstreamBody)
  104. if stripErr != nil {
  105. return nil, fmt.Errorf("strip redundant Grok Chat view_image tool: %w", stripErr)
  106. }
  107. upstreamBody = strippedBody
  108. }
  109. // Grok Composer does not accept image_url parts directly, but Grok Build
  110. // can describe the images first. Bridge only this exact failure mode.
  111. token, tokenKind, err := s.getRequestCredential(ctx, c, account)
  112. if err != nil {
  113. return nil, err
  114. }
  115. if strings.TrimSpace(token) == "" {
  116. return nil, fmt.Errorf("account %d missing %s credential", account.ID, tokenKind)
  117. }
  118. var bridgeUsage OpenAIUsage
  119. if account.Platform == PlatformGrok {
  120. bridgedBody, usage, bridged, bridgeErr := s.bridgeGrokComposerImageInputs(ctx, c, account, upstreamBody, token)
  121. if bridgeErr != nil {
  122. var failoverErr *UpstreamFailoverError
  123. if !errors.As(bridgeErr, &failoverErr) && c != nil && c.Writer != nil && !c.Writer.Written() {
  124. writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", bridgeErr.Error())
  125. }
  126. return nil, bridgeErr
  127. }
  128. if bridged {
  129. upstreamBody = bridgedBody
  130. addOpenAIUsage(&bridgeUsage, usage)
  131. }
  132. }
  133. if clientStream {
  134. var usageErr error
  135. upstreamBody, usageErr = ensureOpenAIChatStreamUsage(upstreamBody)
  136. if usageErr != nil {
  137. return nil, fmt.Errorf("enable stream usage: %w", usageErr)
  138. }
  139. }
  140. if account.Platform == PlatformGrok {
  141. upstreamBody, err = stripGrokChatPromptCacheKey(upstreamBody)
  142. if err != nil {
  143. return nil, fmt.Errorf("remove Responses-only Grok prompt cache key: %w", err)
  144. }
  145. upstreamBody, err = normalizeGrokChatReasoningEffort(upstreamBody, upstreamModel)
  146. if err != nil {
  147. return nil, fmt.Errorf("normalize Grok chat reasoning effort: %w", err)
  148. }
  149. upstreamBody, err = sanitizeGrokUnsupportedFields(upstreamBody)
  150. if err != nil {
  151. return nil, fmt.Errorf("sanitize Grok unsupported fields: %w", err)
  152. }
  153. }
  154. upstreamBody = applyOllamaCloudRawChatCompletionsRequest(account, upstreamBody)
  155. upstreamBody = clampOllamaCloudUpstreamMaxTokens(account, upstreamBody)
  156. logger.L().Debug("openai chat_completions raw: forwarding without protocol conversion",
  157. zap.Int64("account_id", account.ID),
  158. zap.String("original_model", originalModel),
  159. zap.String("billing_model", billingModel),
  160. zap.String("upstream_model", upstreamModel),
  161. zap.Bool("stream", clientStream),
  162. )
  163. // 5. Build and send upstream request via the shared CC pipeline
  164. targetURL, err := s.rawChatCompletionsURL(account)
  165. if err != nil {
  166. return nil, err
  167. }
  168. SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint)
  169. customUA := account.GetOpenAIUserAgent()
  170. if customUA == "" && account.IsGrokOAuth() {
  171. customUA = defaultGrokUpstreamUserAgent()
  172. }
  173. resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, upstreamBody, clientStream, token, customUA, grokCacheIdentity)
  174. if err != nil {
  175. return nil, err
  176. }
  177. defer func() { _ = resp.Body.Close() }()
  178. // 7. Handle error response with failover
  179. if resp.StatusCode >= 400 {
  180. respBody, upstreamMsg := s.readOpenAIUpstreamError(resp)
  181. if account.Platform == PlatformGrok {
  182. kind := "http_error"
  183. if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) {
  184. kind = "failover"
  185. }
  186. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  187. ProxyID: opsUpstreamProxyID(account),
  188. ProxyName: opsUpstreamProxyName(account),
  189. Platform: account.Platform,
  190. AccountID: account.ID,
  191. AccountName: account.Name,
  192. UpstreamStatusCode: resp.StatusCode,
  193. UpstreamRequestID: firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")),
  194. Kind: kind,
  195. Message: upstreamMsg,
  196. })
  197. s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.StatusCode, resp.Header, respBody)
  198. if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) {
  199. retryable, retryDelay, retryDeadline, retryMax := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody)
  200. return nil, &UpstreamFailoverError{
  201. StatusCode: resp.StatusCode,
  202. ResponseBody: respBody,
  203. ResponseHeaders: resp.Header.Clone(),
  204. RetryableOnSameAccount: retryable,
  205. RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests,
  206. SameAccountRetryDelay: retryDelay,
  207. SameAccountRetryDeadline: retryDeadline,
  208. SameAccountRetryMax: retryMax,
  209. }
  210. }
  211. return s.handleChatCompletionsErrorResponse(resp, c, account, billingModel)
  212. }
  213. if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil {
  214. return nil, foErr
  215. }
  216. return s.handleChatCompletionsErrorResponse(resp, c, account, billingModel)
  217. }
  218. if account.Platform == PlatformGrok {
  219. s.updateGrokUsageFromResponse(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.Header, resp.StatusCode)
  220. }
  221. // 8. Forward response
  222. var result *OpenAIForwardResult
  223. var forwardErr error
  224. if clientStream {
  225. result, forwardErr = s.streamRawChatCompletions(c, resp, account, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime, len(body))
  226. } else {
  227. result, forwardErr = s.bufferRawChatCompletions(c, resp, account, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
  228. }
  229. if result != nil {
  230. addOpenAIUsage(&result.Usage, bridgeUsage)
  231. result.UpstreamEndpoint = grokChatRawEndpoint
  232. }
  233. return result, forwardErr
  234. }
  235. func (s *OpenAIGatewayService) rawChatCompletionsURL(account *Account) (string, error) {
  236. if account.Platform == PlatformGrok {
  237. targetURL, err := buildGrokChatCompletionsURL(account, s.cfg, s.settingService)
  238. if err != nil {
  239. return "", fmt.Errorf("invalid grok base_url: %w", err)
  240. }
  241. return targetURL, nil
  242. }
  243. return s.openAIChatCompletionsTargetURL(account)
  244. }
  245. // streamRawChatCompletions 透传上游 CC SSE 流到客户端,并提取 usage(包括
  246. // 末尾 [DONE] 之前的 chunk 中的 usage 字段,按 OpenAI CC 协议)。
  247. //
  248. // usage 字段仅在客户端请求 stream_options.include_usage=true 时出现于上游响应中。
  249. // 网关会对上游强制打开 include_usage 以保证计费完整,并原样向下游透传 usage,
  250. // 让级联代理或下游计费系统也能拿到完整用量。
  251. func (s *OpenAIGatewayService) streamRawChatCompletions(
  252. c *gin.Context,
  253. resp *http.Response,
  254. account *Account,
  255. originalModel string,
  256. billingModel string,
  257. upstreamModel string,
  258. reasoningEffort *string,
  259. serviceTier *string,
  260. startTime time.Time,
  261. requestBodyLen int,
  262. ) (*OpenAIForwardResult, error) {
  263. observer := upstreamResponseModelObserverFromContext(c)
  264. if observer == nil {
  265. observer = beginUpstreamResponseModelObservation(c)
  266. }
  267. requestID := resp.Header.Get("x-request-id")
  268. writeStreamHeaders := s.newStreamHeaderWriter(c, resp.Header)
  269. scanner := s.newUpstreamSSEScanner(resp.Body)
  270. var usage OpenAIUsage
  271. var firstTokenMs *int
  272. clientDisconnected := false
  273. clientOutputStarted := false
  274. pendingLines := make([]string, 0, 8)
  275. refusalDetector := newOpenAIChatSilentRefusalDetector(requestBodyLen)
  276. var terminal openAIRawStreamTerminalState
  277. writeLine := func(line string) {
  278. if clientDisconnected {
  279. return
  280. }
  281. if !clientOutputStarted && !refusalDetector.ShouldReleaseClientOutput() {
  282. pendingLines = append(pendingLines, line)
  283. return
  284. }
  285. if !clientOutputStarted {
  286. writeStreamHeaders()
  287. for _, pending := range pendingLines {
  288. if _, werr := c.Writer.WriteString(pending + "\n"); werr != nil {
  289. clientDisconnected = true
  290. logger.L().Debug("openai chat_completions raw: client disconnected, continuing to drain upstream for billing",
  291. zap.Error(werr),
  292. zap.String("request_id", requestID),
  293. )
  294. return
  295. }
  296. }
  297. pendingLines = pendingLines[:0]
  298. clientOutputStarted = true
  299. }
  300. if _, werr := c.Writer.WriteString(line + "\n"); werr != nil {
  301. clientDisconnected = true
  302. logger.L().Debug("openai chat_completions raw: client disconnected, continuing to drain upstream for billing",
  303. zap.Error(werr),
  304. zap.String("request_id", requestID),
  305. )
  306. }
  307. }
  308. for scanner.Scan() {
  309. line := scanner.Text()
  310. refusalDetector.ObserveSSELine(line)
  311. if payload, ok := extractOpenAISSEDataLine(line); ok {
  312. trimmedPayload := strings.TrimSpace(payload)
  313. terminal.ObserveDataLine(trimmedPayload)
  314. if trimmedPayload != "[DONE]" {
  315. observer.ObserveOpenAI([]byte(payload), strings.TrimSpace(gjson.Get(payload, "type").String()))
  316. usageOnlyChunk := isOpenAIChatUsageOnlyStreamChunk(payload)
  317. if u := extractCCStreamUsage(payload); u != nil {
  318. usage = *u
  319. }
  320. if firstTokenMs == nil && !usageOnlyChunk {
  321. elapsed := int(time.Since(startTime).Milliseconds())
  322. firstTokenMs = &elapsed
  323. }
  324. }
  325. }
  326. line = applyOllamaCloudRawChatCompletionsSSELine(account, line)
  327. line = stripEmptyChatToolCallIdentityFromSSELine(line)
  328. writeLine(line)
  329. if line == "" {
  330. if !clientDisconnected && clientOutputStarted {
  331. c.Writer.Flush()
  332. }
  333. continue
  334. }
  335. if !clientDisconnected && clientOutputStarted {
  336. c.Writer.Flush()
  337. }
  338. }
  339. resultWithUsage := func() *OpenAIForwardResult {
  340. return &OpenAIForwardResult{
  341. RequestID: requestID,
  342. UpstreamHeaders: resp.Header,
  343. Usage: usage,
  344. Model: originalModel,
  345. BillingModel: billingModel,
  346. UpstreamModel: upstreamModel,
  347. UpstreamResponseModel: observedUpstreamResponseModel(c),
  348. UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
  349. UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
  350. ReasoningEffort: reasoningEffort,
  351. ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
  352. Stream: true,
  353. Duration: time.Since(startTime),
  354. FirstTokenMs: firstTokenMs,
  355. }
  356. }
  357. scanErr := scanner.Err()
  358. if scanErr != nil && !errors.Is(scanErr, context.Canceled) && !errors.Is(scanErr, context.DeadlineExceeded) {
  359. logger.L().Warn("openai chat_completions raw: stream read error",
  360. zap.Error(scanErr),
  361. zap.String("request_id", requestID),
  362. )
  363. }
  364. // 客户端取消/断开后上游读失败与上游截断不可区分(取消会连带取消上游请求),
  365. // 沿用既有语义:按已收到的用量正常收尾计费,不判为上游故障。
  366. clientAborted := clientDisconnected ||
  367. errors.Is(scanErr, context.Canceled) ||
  368. errors.Is(scanErr, context.DeadlineExceeded)
  369. // 上游在任何终止信号之前结束:连接被 reset(scanErr != nil)或干净 EOF。
  370. // 两者都不能再记成功——此前统一返回 nil error,把上游截断伪装成
  371. // `HTTP 200 + usage 0/0`,客户端收到半截回答且 Ops 侧完全无感。
  372. if !clientAborted && terminal.IsTruncated(clientOutputStarted) {
  373. cause := scanErr
  374. if cause == nil {
  375. cause = ErrOpenAIUpstreamStreamTruncated
  376. }
  377. logger.L().Warn("openai chat_completions raw: upstream stream truncated before terminal chunk",
  378. zap.Error(cause),
  379. zap.String("request_id", requestID),
  380. zap.Int64("account_id", account.ID),
  381. zap.String("upstream_model", upstreamModel),
  382. zap.Bool("saw_sse_data", terminal.sawDataLine),
  383. zap.Bool("client_output_started", clientOutputStarted),
  384. )
  385. if !clientOutputStarted {
  386. // 响应头尚未提交:可以透明换号重试,客户端不会看到半截流。
  387. return nil, newOpenAIRawStreamTruncatedFailoverError(c, account, requestID, cause)
  388. }
  389. // 已写出语义字节:无法再 failover,改为带类型的上游错误。handler 会据此
  390. // 补发 SSE error 帧并把本次请求计入 SLA 失败。
  391. recordOpenAIRawStreamTruncation(c, account, requestID, cause, "http_error")
  392. return resultWithUsage(), newOpenAIUpstreamStreamReadError(cause)
  393. }
  394. if scanErr == nil && !clientDisconnected && !clientOutputStarted {
  395. if refusalDetector.IsSilentRefusal() {
  396. return nil, newOpenAISilentRefusalFailoverError(c, account, requestID)
  397. }
  398. if len(pendingLines) > 0 {
  399. writeStreamHeaders()
  400. for _, pending := range pendingLines {
  401. if _, werr := c.Writer.WriteString(pending + "\n"); werr != nil {
  402. clientDisconnected = true
  403. logger.L().Debug("openai chat_completions raw: client disconnected during final flush",
  404. zap.Error(werr),
  405. zap.String("request_id", requestID),
  406. )
  407. break
  408. }
  409. }
  410. if !clientDisconnected {
  411. c.Writer.Flush()
  412. clientOutputStarted = true
  413. }
  414. }
  415. }
  416. return resultWithUsage(), nil
  417. }
  418. // ensureOpenAIChatStreamUsage 确保 raw Chat Completions 流式请求会让上游返回 usage。
  419. // usage 也会继续向下游透传,支持级联代理和下游计费系统。
  420. func ensureOpenAIChatStreamUsage(body []byte) ([]byte, error) {
  421. updated, err := sjson.SetBytes(body, "stream_options.include_usage", true)
  422. if err != nil {
  423. return body, err
  424. }
  425. return updated, nil
  426. }
  427. func isOpenAIChatUsageOnlyStreamChunk(payload string) bool {
  428. if strings.TrimSpace(payload) == "" {
  429. return false
  430. }
  431. if !gjson.Get(payload, "usage").Exists() {
  432. return false
  433. }
  434. choices := gjson.Get(payload, "choices")
  435. return choices.Exists() && choices.IsArray() && len(choices.Array()) == 0
  436. }
  437. // extractCCStreamUsage 从单个 CC 流式 chunk 的 payload 中提取 usage 字段。
  438. // CC 协议中 usage 仅出现在末尾 chunk(且仅当 include_usage 生效时),
  439. // 但上游可能在多个 chunk 中重复——总是用最新值。
  440. func extractCCStreamUsage(payload string) *OpenAIUsage {
  441. usageResult := gjson.Get(payload, "usage")
  442. if !usageResult.Exists() || !usageResult.IsObject() {
  443. return nil
  444. }
  445. u, ok := openAIUsageFromGJSON(usageResult)
  446. if !ok {
  447. return nil
  448. }
  449. return &u
  450. }
  451. // bufferRawChatCompletions 透传上游 CC 非流式 JSON 响应。
  452. func (s *OpenAIGatewayService) bufferRawChatCompletions(
  453. c *gin.Context,
  454. resp *http.Response,
  455. account *Account,
  456. originalModel string,
  457. billingModel string,
  458. upstreamModel string,
  459. reasoningEffort *string,
  460. serviceTier *string,
  461. startTime time.Time,
  462. ) (*OpenAIForwardResult, error) {
  463. requestID := resp.Header.Get("x-request-id")
  464. respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
  465. if err != nil {
  466. if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
  467. writeChatCompletionsError(c, http.StatusBadGateway, "api_error", "Failed to read upstream response")
  468. }
  469. return nil, fmt.Errorf("read upstream body: %w", err)
  470. }
  471. observer := upstreamResponseModelObserverFromContext(c)
  472. if observer == nil {
  473. observer = beginUpstreamResponseModelObservation(c)
  474. }
  475. observer.ObserveOpenAI(respBody, strings.TrimSpace(gjson.GetBytes(respBody, "type").String()))
  476. var usage OpenAIUsage
  477. if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(respBody); ok {
  478. usage = parsedUsage
  479. }
  480. responseModel := gjson.GetBytes(respBody, "model").String()
  481. if requiresBillableGrokChatUsage(account, billingModel, upstreamModel, responseModel) && !hasBillableGrokChatUsage(usage) {
  482. upstreamRequestID := firstNonEmpty(requestID, resp.Header.Get("xai-request-id"))
  483. return nil, newGrokMissingUsageFailoverError(c, account, upstreamRequestID)
  484. }
  485. respBody = applyOllamaCloudRawChatCompletionsResponse(account, respBody)
  486. if s.responseHeaderFilter != nil {
  487. responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
  488. }
  489. if ct := resp.Header.Get("Content-Type"); ct != "" {
  490. c.Writer.Header().Set("Content-Type", ct)
  491. } else {
  492. c.Writer.Header().Set("Content-Type", "application/json")
  493. }
  494. c.Writer.WriteHeader(http.StatusOK)
  495. _, _ = c.Writer.Write(respBody)
  496. return &OpenAIForwardResult{
  497. RequestID: requestID,
  498. UpstreamHeaders: resp.Header,
  499. Usage: usage,
  500. Model: originalModel,
  501. BillingModel: billingModel,
  502. UpstreamModel: upstreamModel,
  503. UpstreamResponseModel: observedUpstreamResponseModel(c),
  504. UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
  505. UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
  506. ReasoningEffort: reasoningEffort,
  507. ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
  508. Stream: false,
  509. Duration: time.Since(startTime),
  510. }, nil
  511. }
  512. // buildOpenAIChatCompletionsURL 拼接上游 Chat Completions 端点 URL。
  513. //
  514. // - base 已是 /chat/completions:原样返回
  515. // - base 以 /v1 结尾:追加 /chat/completions
  516. // - base 以其他版本段结尾(如 /v4):追加 /chat/completions
  517. // - 其他情况:追加 /v1/chat/completions
  518. //
  519. // 与 buildOpenAIResponsesURL 是姐妹函数。
  520. func buildOpenAIChatCompletionsURL(base string) string {
  521. return buildOpenAIEndpointURL(base, "/v1/chat/completions")
  522. }