antigravity_gateway_claude.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "log"
  9. "net/http"
  10. "strings"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  14. "github.com/gin-gonic/gin"
  15. )
  16. // Forward 转发 Claude 协议请求(Claude → Gemini 转换)
  17. //
  18. // 限流处理流程:
  19. //
  20. // 请求 → antigravityRetryLoop → 预检查(remaining>0? → 切换账号) → 发送上游
  21. // ├─ 成功 → 正常返回
  22. // └─ 429/503 → handleSmartRetry
  23. // ├─ retryDelay >= 7s → 设置模型限流 + 清除粘性绑定 → 切换账号
  24. // └─ retryDelay < 7s → 等待后重试 1 次
  25. // ├─ 成功 → 正常返回
  26. // └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号
  27. func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte, isStickySession bool) (*ForwardResult, error) {
  28. beginUpstreamResponseModelObservation(c)
  29. // 上游透传账号直接转发,不走 OAuth token 刷新
  30. if account.Type == AccountTypeUpstream {
  31. return s.ForwardUpstream(ctx, c, account, body)
  32. }
  33. startTime := time.Now()
  34. sessionID := getSessionID(c)
  35. prefix := logPrefix(sessionID, account.Name)
  36. // 解析 Claude 请求
  37. var claudeReq antigravity.ClaudeRequest
  38. if err := json.Unmarshal(body, &claudeReq); err != nil {
  39. return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body")
  40. }
  41. if strings.TrimSpace(claudeReq.Model) == "" {
  42. return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Missing model")
  43. }
  44. originalModel := claudeReq.Model
  45. mappedModel := s.getMappedModel(account, claudeReq.Model)
  46. if mappedModel == "" {
  47. MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
  48. return nil, s.writeClaudeError(c, http.StatusForbidden, "permission_error", fmt.Sprintf("model %s not in whitelist", claudeReq.Model))
  49. }
  50. // 应用 thinking 模式自动后缀:如果 thinking 开启且目标是 claude-sonnet-4-5,自动改为 thinking 版本
  51. thinkingEnabled := claudeReq.Thinking != nil && (claudeReq.Thinking.Type == "enabled" || claudeReq.Thinking.Type == "adaptive")
  52. mappedModel = applyThinkingModelSuffix(mappedModel, thinkingEnabled)
  53. billingModel := mappedModel
  54. // 获取 access_token
  55. if s.tokenProvider == nil {
  56. return nil, s.writeClaudeError(c, http.StatusBadGateway, "api_error", "Antigravity token provider not configured")
  57. }
  58. accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
  59. if err != nil {
  60. return nil, &UpstreamFailoverError{
  61. StatusCode: http.StatusBadGateway,
  62. ResponseBody: []byte(`{"error":{"type":"authentication_error","message":"Failed to get upstream access token"},"type":"error"}`),
  63. }
  64. }
  65. projectID, err := resolveAntigravityProjectID(account)
  66. if err != nil {
  67. _ = s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
  68. return nil, err
  69. }
  70. // 代理 URL
  71. proxyURL := ""
  72. if account.ProxyID != nil && account.Proxy != nil {
  73. proxyURL = account.Proxy.URL()
  74. }
  75. // 获取转换选项
  76. // Antigravity 上游要求必须包含身份提示词,否则会返回 429
  77. transformOpts := s.getClaudeTransformOptions(ctx)
  78. transformOpts.EnableIdentityPatch = true // 强制启用,Antigravity 上游必需
  79. // 转换 Claude 请求为 Gemini 格式
  80. geminiBody, err := antigravity.TransformClaudeToGeminiWithOptions(&claudeReq, projectID, mappedModel, transformOpts)
  81. if err != nil {
  82. return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request")
  83. }
  84. // Antigravity 上游只支持流式请求,统一使用 streamGenerateContent
  85. // 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回
  86. action := "streamGenerateContent"
  87. // 执行带重试的请求
  88. result, err := s.antigravityRetryLoop(antigravityRetryLoopParams{
  89. ctx: ctx,
  90. prefix: prefix,
  91. account: account,
  92. proxyURL: proxyURL,
  93. accessToken: accessToken,
  94. action: action,
  95. body: geminiBody,
  96. c: c,
  97. httpUpstream: s.httpUpstream,
  98. settingService: s.settingService,
  99. accountRepo: s.accountRepo,
  100. handleError: s.handleUpstreamError,
  101. requestedModel: originalModel,
  102. isStickySession: isStickySession, // Forward 由上层判断粘性会话
  103. groupID: 0, // Forward 方法没有 groupID,由上层处理粘性会话清除
  104. sessionHash: "", // Forward 方法没有 sessionHash,由上层处理粘性会话清除
  105. })
  106. if err != nil {
  107. // 检查是否是账号切换信号,转换为 UpstreamFailoverError 让 Handler 切换账号
  108. if switchErr, ok := IsAntigravityAccountSwitchError(err); ok {
  109. return nil, &UpstreamFailoverError{
  110. StatusCode: http.StatusServiceUnavailable,
  111. ForceCacheBilling: switchErr.IsStickySession,
  112. }
  113. }
  114. // 区分客户端取消和真正的上游失败,返回更准确的错误消息
  115. if c.Request.Context().Err() != nil {
  116. return nil, s.writeClaudeError(c, http.StatusBadGateway, "client_disconnected", "Client disconnected before upstream response")
  117. }
  118. return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries")
  119. }
  120. resp := result.resp
  121. defer func() { _ = resp.Body.Close() }()
  122. if resp.StatusCode >= 400 {
  123. respBody := s.readUpstreamErrorBody(resp)
  124. // 优先检测 thinking block 的 signature 相关错误(400)并重试一次:
  125. // Antigravity /v1internal 链路在部分场景会对 thought/thinking signature 做严格校验,
  126. // 当历史消息携带的 signature 不合法时会直接 400;去除 thinking 后可继续完成请求。
  127. if resp.StatusCode == http.StatusBadRequest && isSignatureRelatedError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx) {
  128. upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody))
  129. upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
  130. logBody, maxBytes := s.getLogConfig()
  131. upstreamDetail := s.getUpstreamErrorDetail(respBody)
  132. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  133. ProxyID: opsUpstreamProxyID(account),
  134. ProxyName: opsUpstreamProxyName(account),
  135. Platform: account.Platform,
  136. AccountID: account.ID,
  137. AccountName: account.Name,
  138. UpstreamStatusCode: resp.StatusCode,
  139. UpstreamRequestID: resp.Header.Get("x-request-id"),
  140. Kind: "signature_error",
  141. Message: upstreamMsg,
  142. Detail: upstreamDetail,
  143. })
  144. // Conservative two-stage fallback:
  145. // 1) Disable top-level thinking + thinking->text
  146. // 2) Only if still signature-related 400: also downgrade tool_use/tool_result to text.
  147. retryStages := []struct {
  148. name string
  149. strip func(*antigravity.ClaudeRequest) (bool, error)
  150. }{
  151. {name: "thinking-only", strip: stripThinkingFromClaudeRequest},
  152. {name: "thinking+tools", strip: stripSignatureSensitiveBlocksFromClaudeRequest},
  153. }
  154. for _, stage := range retryStages {
  155. retryClaudeReq := claudeReq
  156. retryClaudeReq.Messages = append([]antigravity.ClaudeMessage(nil), claudeReq.Messages...)
  157. stripped, stripErr := stage.strip(&retryClaudeReq)
  158. if stripErr != nil || !stripped {
  159. continue
  160. }
  161. logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: detected signature-related 400, retrying once (%s)", account.ID, stage.name)
  162. retryGeminiBody, txErr := antigravity.TransformClaudeToGeminiWithOptions(&retryClaudeReq, projectID, mappedModel, s.getClaudeTransformOptions(ctx))
  163. if txErr != nil {
  164. continue
  165. }
  166. retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{
  167. ctx: ctx,
  168. prefix: prefix,
  169. account: account,
  170. proxyURL: proxyURL,
  171. accessToken: accessToken,
  172. action: action,
  173. body: retryGeminiBody,
  174. c: c,
  175. httpUpstream: s.httpUpstream,
  176. settingService: s.settingService,
  177. accountRepo: s.accountRepo,
  178. handleError: s.handleUpstreamError,
  179. requestedModel: originalModel,
  180. isStickySession: isStickySession,
  181. groupID: 0, // Forward 方法没有 groupID,由上层处理粘性会话清除
  182. sessionHash: "", // Forward 方法没有 sessionHash,由上层处理粘性会话清除
  183. })
  184. if retryErr != nil {
  185. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  186. ProxyID: opsUpstreamProxyID(account),
  187. ProxyName: opsUpstreamProxyName(account),
  188. Platform: account.Platform,
  189. AccountID: account.ID,
  190. AccountName: account.Name,
  191. UpstreamStatusCode: 0,
  192. Kind: "signature_retry_request_error",
  193. Message: sanitizeUpstreamErrorMessage(retryErr.Error()),
  194. })
  195. logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: signature retry request failed (%s): %v", account.ID, stage.name, retryErr)
  196. continue
  197. }
  198. retryResp := retryResult.resp
  199. if retryResp.StatusCode < 400 {
  200. _ = resp.Body.Close()
  201. resp = retryResp
  202. respBody = nil
  203. break
  204. }
  205. retryBody, _ := io.ReadAll(io.LimitReader(retryResp.Body, 8<<10))
  206. _ = retryResp.Body.Close()
  207. if retryResp.StatusCode == http.StatusTooManyRequests {
  208. retryBaseURL := ""
  209. if retryResp.Request != nil && retryResp.Request.URL != nil {
  210. retryBaseURL = retryResp.Request.URL.Scheme + "://" + retryResp.Request.URL.Host
  211. }
  212. logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 rate_limited base_url=%s retry_stage=%s body=%s", prefix, retryBaseURL, stage.name, truncateForLog(retryBody, 200))
  213. }
  214. kind := "signature_retry"
  215. if strings.TrimSpace(stage.name) != "" {
  216. kind = "signature_retry_" + strings.ReplaceAll(stage.name, "+", "_")
  217. }
  218. retryUpstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(retryBody))
  219. retryUpstreamMsg = sanitizeUpstreamErrorMessage(retryUpstreamMsg)
  220. retryUpstreamDetail := ""
  221. if logBody {
  222. retryUpstreamDetail = truncateString(string(retryBody), maxBytes)
  223. }
  224. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  225. ProxyID: opsUpstreamProxyID(account),
  226. ProxyName: opsUpstreamProxyName(account),
  227. Platform: account.Platform,
  228. AccountID: account.ID,
  229. AccountName: account.Name,
  230. UpstreamStatusCode: retryResp.StatusCode,
  231. UpstreamRequestID: retryResp.Header.Get("x-request-id"),
  232. Kind: kind,
  233. Message: retryUpstreamMsg,
  234. Detail: retryUpstreamDetail,
  235. })
  236. // If this stage fixed the signature issue, we stop; otherwise we may try the next stage.
  237. if retryResp.StatusCode != http.StatusBadRequest || !isSignatureRelatedError(retryBody) {
  238. respBody = retryBody
  239. resp = &http.Response{
  240. StatusCode: retryResp.StatusCode,
  241. Header: retryResp.Header.Clone(),
  242. Body: io.NopCloser(bytes.NewReader(retryBody)),
  243. }
  244. break
  245. }
  246. // Still signature-related; capture context and allow next stage.
  247. respBody = retryBody
  248. resp = &http.Response{
  249. StatusCode: retryResp.StatusCode,
  250. Header: retryResp.Header.Clone(),
  251. Body: io.NopCloser(bytes.NewReader(retryBody)),
  252. }
  253. }
  254. }
  255. // Budget 整流:检测 budget_tokens 约束错误并自动修正重试
  256. if resp.StatusCode == http.StatusBadRequest && respBody != nil && !isSignatureRelatedError(respBody) {
  257. errMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody))
  258. if isThinkingBudgetConstraintError(errMsg) && s.settingService.IsBudgetRectifierEnabled(ctx) {
  259. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  260. ProxyID: opsUpstreamProxyID(account),
  261. ProxyName: opsUpstreamProxyName(account),
  262. Platform: account.Platform,
  263. AccountID: account.ID,
  264. AccountName: account.Name,
  265. UpstreamStatusCode: resp.StatusCode,
  266. UpstreamRequestID: resp.Header.Get("x-request-id"),
  267. Kind: "budget_constraint_error",
  268. Message: errMsg,
  269. Detail: s.getUpstreamErrorDetail(respBody),
  270. })
  271. // 修正 claudeReq 的 thinking 参数(adaptive 模式不修正)
  272. if claudeReq.Thinking == nil || claudeReq.Thinking.Type != "adaptive" {
  273. retryClaudeReq := claudeReq
  274. retryClaudeReq.Messages = append([]antigravity.ClaudeMessage(nil), claudeReq.Messages...)
  275. // 创建新的 ThinkingConfig 避免修改原始 claudeReq.Thinking 指针
  276. retryClaudeReq.Thinking = &antigravity.ThinkingConfig{
  277. Type: "enabled",
  278. BudgetTokens: BudgetRectifyBudgetTokens,
  279. }
  280. if retryClaudeReq.MaxTokens < BudgetRectifyMinMaxTokens {
  281. retryClaudeReq.MaxTokens = BudgetRectifyMaxTokens
  282. }
  283. logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: detected budget_tokens constraint error, retrying with rectified budget (budget_tokens=%d, max_tokens=%d)", account.ID, BudgetRectifyBudgetTokens, BudgetRectifyMaxTokens)
  284. retryGeminiBody, txErr := antigravity.TransformClaudeToGeminiWithOptions(&retryClaudeReq, projectID, mappedModel, transformOpts)
  285. if txErr == nil {
  286. retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{
  287. ctx: ctx,
  288. prefix: prefix,
  289. account: account,
  290. proxyURL: proxyURL,
  291. accessToken: accessToken,
  292. action: action,
  293. body: retryGeminiBody,
  294. c: c,
  295. httpUpstream: s.httpUpstream,
  296. settingService: s.settingService,
  297. accountRepo: s.accountRepo,
  298. handleError: s.handleUpstreamError,
  299. requestedModel: originalModel,
  300. isStickySession: isStickySession,
  301. groupID: 0,
  302. sessionHash: "",
  303. })
  304. if retryErr == nil {
  305. retryResp := retryResult.resp
  306. if retryResp.StatusCode < 400 {
  307. _ = resp.Body.Close()
  308. resp = retryResp
  309. respBody = nil
  310. } else {
  311. retryBody := s.readUpstreamErrorBody(retryResp)
  312. _ = retryResp.Body.Close()
  313. respBody = retryBody
  314. resp = &http.Response{
  315. StatusCode: retryResp.StatusCode,
  316. Header: retryResp.Header.Clone(),
  317. Body: io.NopCloser(bytes.NewReader(retryBody)),
  318. }
  319. }
  320. } else {
  321. logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: budget rectifier retry failed: %v", account.ID, retryErr)
  322. }
  323. }
  324. }
  325. }
  326. }
  327. // 处理错误响应(重试后仍失败或不触发重试)
  328. if resp.StatusCode >= 400 {
  329. // 检测 prompt too long 错误,返回特殊错误类型供上层 fallback
  330. if resp.StatusCode == http.StatusBadRequest && isPromptTooLongError(respBody) {
  331. upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody))
  332. upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
  333. upstreamDetail := s.getUpstreamErrorDetail(respBody)
  334. logBody, maxBytes := s.getLogConfig()
  335. if logBody {
  336. logger.LegacyPrintf("service.antigravity_gateway", "%s status=400 prompt_too_long=true upstream_message=%q request_id=%s body=%s", prefix, upstreamMsg, resp.Header.Get("x-request-id"), truncateForLog(respBody, maxBytes))
  337. }
  338. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  339. ProxyID: opsUpstreamProxyID(account),
  340. ProxyName: opsUpstreamProxyName(account),
  341. Platform: account.Platform,
  342. AccountID: account.ID,
  343. AccountName: account.Name,
  344. UpstreamStatusCode: resp.StatusCode,
  345. UpstreamRequestID: resp.Header.Get("x-request-id"),
  346. Kind: "prompt_too_long",
  347. Message: upstreamMsg,
  348. Detail: upstreamDetail,
  349. })
  350. return nil, &PromptTooLongError{
  351. StatusCode: resp.StatusCode,
  352. RequestID: resp.Header.Get("x-request-id"),
  353. Body: respBody,
  354. }
  355. }
  356. s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, 0, "", isStickySession)
  357. // 精确匹配服务端配置类 400 错误,触发同账号重试 + failover
  358. if resp.StatusCode == http.StatusBadRequest {
  359. msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody)))
  360. if isGoogleProjectConfigError(msg) {
  361. upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(respBody)))
  362. upstreamDetail := s.getUpstreamErrorDetail(respBody)
  363. log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID)
  364. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  365. ProxyID: opsUpstreamProxyID(account),
  366. ProxyName: opsUpstreamProxyName(account),
  367. Platform: account.Platform,
  368. AccountID: account.ID,
  369. AccountName: account.Name,
  370. UpstreamStatusCode: resp.StatusCode,
  371. UpstreamRequestID: resp.Header.Get("x-request-id"),
  372. Kind: "failover",
  373. Message: upstreamMsg,
  374. Detail: upstreamDetail,
  375. })
  376. return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: true}
  377. }
  378. }
  379. if s.shouldFailoverUpstreamError(resp.StatusCode) {
  380. upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody))
  381. upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
  382. upstreamDetail := s.getUpstreamErrorDetail(respBody)
  383. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  384. ProxyID: opsUpstreamProxyID(account),
  385. ProxyName: opsUpstreamProxyName(account),
  386. Platform: account.Platform,
  387. AccountID: account.ID,
  388. AccountName: account.Name,
  389. UpstreamStatusCode: resp.StatusCode,
  390. UpstreamRequestID: resp.Header.Get("x-request-id"),
  391. Kind: "failover",
  392. Message: upstreamMsg,
  393. Detail: upstreamDetail,
  394. })
  395. return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
  396. }
  397. return nil, s.writeMappedClaudeError(c, account, resp.StatusCode, resp.Header.Get("x-request-id"), respBody)
  398. }
  399. }
  400. requestID := resp.Header.Get("x-request-id")
  401. if requestID != "" {
  402. c.Header("x-request-id", requestID)
  403. }
  404. var usage *ClaudeUsage
  405. var firstTokenMs *int
  406. var clientDisconnect bool
  407. if claudeReq.Stream {
  408. // 客户端要求流式,直接透传转换
  409. streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel)
  410. if err != nil {
  411. logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err)
  412. return nil, err
  413. }
  414. usage = streamRes.usage
  415. firstTokenMs = streamRes.firstTokenMs
  416. clientDisconnect = streamRes.clientDisconnect
  417. } else {
  418. // 客户端要求非流式,收集流式响应后转换返回
  419. streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel)
  420. if err != nil {
  421. logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err)
  422. return nil, err
  423. }
  424. usage = streamRes.usage
  425. firstTokenMs = streamRes.firstTokenMs
  426. }
  427. return &ForwardResult{
  428. RequestID: requestID,
  429. UpstreamHeaders: resp.Header,
  430. Usage: *usage,
  431. Model: originalModel,
  432. UpstreamModel: billingModel,
  433. UpstreamResponseModel: observedUpstreamResponseModel(c),
  434. UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
  435. Stream: claudeReq.Stream,
  436. Duration: time.Since(startTime),
  437. FirstTokenMs: firstTokenMs,
  438. ClientDisconnect: clientDisconnect,
  439. }, nil
  440. }
  441. func isSignatureRelatedError(respBody []byte) bool {
  442. msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody)))
  443. if msg == "" {
  444. // Fallback: best-effort scan of the raw payload.
  445. msg = strings.ToLower(string(respBody))
  446. }
  447. // Keep this intentionally broad: different upstreams may use "signature" or "thought_signature".
  448. if strings.Contains(msg, "thought_signature") || strings.Contains(msg, "signature") {
  449. return true
  450. }
  451. // Also detect thinking block structural errors:
  452. // "Expected `thinking` or `redacted_thinking`, but found `text`"
  453. if strings.Contains(msg, "expected") && (strings.Contains(msg, "thinking") || strings.Contains(msg, "redacted_thinking")) {
  454. return true
  455. }
  456. return false
  457. }
  458. // isPromptTooLongError 检测是否为 prompt too long 错误
  459. func isPromptTooLongError(respBody []byte) bool {
  460. msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody)))
  461. if msg == "" {
  462. msg = strings.ToLower(string(respBody))
  463. }
  464. return strings.Contains(msg, "prompt is too long") ||
  465. strings.Contains(msg, "request is too long") ||
  466. strings.Contains(msg, "context length exceeded") ||
  467. strings.Contains(msg, "max_tokens")
  468. }
  469. // isPassthroughErrorMessage 检查错误消息是否在透传白名单中
  470. func isPassthroughErrorMessage(msg string) bool {
  471. lower := strings.ToLower(msg)
  472. for _, pattern := range antigravityPassthroughErrorMessages {
  473. if strings.Contains(lower, pattern) {
  474. return true
  475. }
  476. }
  477. return false
  478. }
  479. // getPassthroughOrDefault 若消息在白名单内则返回原始消息,否则返回默认消息
  480. func getPassthroughOrDefault(upstreamMsg, defaultMsg string) string {
  481. if isPassthroughErrorMessage(upstreamMsg) {
  482. return upstreamMsg
  483. }
  484. return defaultMsg
  485. }
  486. func extractAntigravityErrorMessage(body []byte) string {
  487. var payload map[string]any
  488. if err := json.Unmarshal(body, &payload); err != nil {
  489. return ""
  490. }
  491. // Google-style: {"error": {"message": "..."}}
  492. if errObj, ok := payload["error"].(map[string]any); ok {
  493. if msg, ok := errObj["message"].(string); ok && strings.TrimSpace(msg) != "" {
  494. return msg
  495. }
  496. }
  497. // Fallback: top-level message
  498. if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
  499. return msg
  500. }
  501. return ""
  502. }
  503. // stripThinkingFromClaudeRequest converts thinking blocks to text blocks in a Claude Messages request.
  504. // This preserves the thinking content while avoiding signature validation errors.
  505. // Note: redacted_thinking blocks are removed because they cannot be converted to text.
  506. // It also disables top-level `thinking` to avoid upstream structural constraints for thinking mode.
  507. func stripThinkingFromClaudeRequest(req *antigravity.ClaudeRequest) (bool, error) {
  508. if req == nil {
  509. return false, nil
  510. }
  511. changed := false
  512. if req.Thinking != nil {
  513. req.Thinking = nil
  514. changed = true
  515. }
  516. for i := range req.Messages {
  517. raw := req.Messages[i].Content
  518. if len(raw) == 0 {
  519. continue
  520. }
  521. // If content is a string, nothing to strip.
  522. var str string
  523. if json.Unmarshal(raw, &str) == nil {
  524. continue
  525. }
  526. // Otherwise treat as an array of blocks and convert thinking blocks to text.
  527. var blocks []map[string]any
  528. if err := json.Unmarshal(raw, &blocks); err != nil {
  529. continue
  530. }
  531. filtered := make([]map[string]any, 0, len(blocks))
  532. modifiedAny := false
  533. for _, block := range blocks {
  534. t, _ := block["type"].(string)
  535. switch t {
  536. case "thinking":
  537. thinkingText, _ := block["thinking"].(string)
  538. if thinkingText != "" {
  539. filtered = append(filtered, map[string]any{
  540. "type": "text",
  541. "text": thinkingText,
  542. })
  543. }
  544. modifiedAny = true
  545. case "redacted_thinking":
  546. modifiedAny = true
  547. case "":
  548. if thinkingText, hasThinking := block["thinking"].(string); hasThinking {
  549. if thinkingText != "" {
  550. filtered = append(filtered, map[string]any{
  551. "type": "text",
  552. "text": thinkingText,
  553. })
  554. }
  555. modifiedAny = true
  556. } else {
  557. filtered = append(filtered, block)
  558. }
  559. default:
  560. filtered = append(filtered, block)
  561. }
  562. }
  563. if !modifiedAny {
  564. continue
  565. }
  566. if len(filtered) == 0 {
  567. filtered = append(filtered, map[string]any{
  568. "type": "text",
  569. "text": "(content removed)",
  570. })
  571. }
  572. newRaw, err := json.Marshal(filtered)
  573. if err != nil {
  574. return changed, err
  575. }
  576. req.Messages[i].Content = newRaw
  577. changed = true
  578. }
  579. return changed, nil
  580. }
  581. // stripSignatureSensitiveBlocksFromClaudeRequest is a stronger retry degradation that additionally converts
  582. // tool blocks to plain text. Use this only after a thinking-only retry still fails with signature errors.
  583. func stripSignatureSensitiveBlocksFromClaudeRequest(req *antigravity.ClaudeRequest) (bool, error) {
  584. if req == nil {
  585. return false, nil
  586. }
  587. changed := false
  588. if req.Thinking != nil {
  589. req.Thinking = nil
  590. changed = true
  591. }
  592. for i := range req.Messages {
  593. raw := req.Messages[i].Content
  594. if len(raw) == 0 {
  595. continue
  596. }
  597. // If content is a string, nothing to strip.
  598. var str string
  599. if json.Unmarshal(raw, &str) == nil {
  600. continue
  601. }
  602. // Otherwise treat as an array of blocks and convert signature-sensitive blocks to text.
  603. var blocks []map[string]any
  604. if err := json.Unmarshal(raw, &blocks); err != nil {
  605. continue
  606. }
  607. filtered := make([]map[string]any, 0, len(blocks))
  608. modifiedAny := false
  609. for _, block := range blocks {
  610. t, _ := block["type"].(string)
  611. switch t {
  612. case "thinking":
  613. // Convert thinking to text, skip if empty
  614. thinkingText, _ := block["thinking"].(string)
  615. if thinkingText != "" {
  616. filtered = append(filtered, map[string]any{
  617. "type": "text",
  618. "text": thinkingText,
  619. })
  620. }
  621. modifiedAny = true
  622. case "redacted_thinking":
  623. // Remove redacted_thinking (cannot convert encrypted content)
  624. modifiedAny = true
  625. case "tool_use":
  626. // Convert tool_use to text to avoid upstream signature/thought_signature validation errors.
  627. // This is a retry-only degradation path, so we prioritise request validity over tool semantics.
  628. name, _ := block["name"].(string)
  629. id, _ := block["id"].(string)
  630. input := block["input"]
  631. inputJSON, _ := json.Marshal(input)
  632. text := "(tool_use)"
  633. if name != "" {
  634. text += " name=" + name
  635. }
  636. if id != "" {
  637. text += " id=" + id
  638. }
  639. if len(inputJSON) > 0 && string(inputJSON) != "null" {
  640. text += " input=" + string(inputJSON)
  641. }
  642. filtered = append(filtered, map[string]any{
  643. "type": "text",
  644. "text": text,
  645. })
  646. modifiedAny = true
  647. case "tool_result":
  648. // Convert tool_result to text so it stays consistent when tool_use is downgraded.
  649. toolUseID, _ := block["tool_use_id"].(string)
  650. isError, _ := block["is_error"].(bool)
  651. content := block["content"]
  652. contentJSON, _ := json.Marshal(content)
  653. text := "(tool_result)"
  654. if toolUseID != "" {
  655. text += " tool_use_id=" + toolUseID
  656. }
  657. if isError {
  658. text += " is_error=true"
  659. }
  660. if len(contentJSON) > 0 && string(contentJSON) != "null" {
  661. text += "\n" + string(contentJSON)
  662. }
  663. filtered = append(filtered, map[string]any{
  664. "type": "text",
  665. "text": text,
  666. })
  667. modifiedAny = true
  668. case "":
  669. // Handle untyped block with "thinking" field
  670. if thinkingText, hasThinking := block["thinking"].(string); hasThinking {
  671. if thinkingText != "" {
  672. filtered = append(filtered, map[string]any{
  673. "type": "text",
  674. "text": thinkingText,
  675. })
  676. }
  677. modifiedAny = true
  678. } else {
  679. filtered = append(filtered, block)
  680. }
  681. default:
  682. filtered = append(filtered, block)
  683. }
  684. }
  685. if !modifiedAny {
  686. continue
  687. }
  688. if len(filtered) == 0 {
  689. // Keep request valid: upstream rejects empty content arrays.
  690. filtered = append(filtered, map[string]any{
  691. "type": "text",
  692. "text": "(content removed)",
  693. })
  694. }
  695. newRaw, err := json.Marshal(filtered)
  696. if err != nil {
  697. return changed, err
  698. }
  699. req.Messages[i].Content = newRaw
  700. changed = true
  701. }
  702. return changed, nil
  703. }