gateway_forward.go 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "log/slog"
  9. "net/http"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  15. "github.com/tidwall/gjson"
  16. "github.com/gin-gonic/gin"
  17. )
  18. // 重试相关常量
  19. const (
  20. // 最大尝试次数(包含首次请求)。过多重试会导致请求堆积与资源耗尽。
  21. maxRetryAttempts = 5
  22. // 指数退避:第 N 次失败后的等待 = retryBaseDelay * 2^(N-1),并且上限为 retryMaxDelay。
  23. retryBaseDelay = 300 * time.Millisecond
  24. retryMaxDelay = 3 * time.Second
  25. // 最大重试耗时(包含请求本身耗时 + 退避等待时间)。
  26. // 用于防止极端情况下 goroutine 长时间堆积导致资源耗尽。
  27. maxRetryElapsed = 10 * time.Second
  28. )
  29. func (s *GatewayService) shouldRetryUpstreamError(account *Account, statusCode int) bool {
  30. // OAuth/Setup Token 账号:仅 403 重试
  31. if account.IsOAuth() {
  32. return statusCode == 403
  33. }
  34. // API Key 账号:未配置的错误码重试
  35. return !account.ShouldHandleErrorCode(statusCode)
  36. }
  37. // shouldFailoverUpstreamError determines whether an upstream error should trigger account failover.
  38. func (s *GatewayService) shouldFailoverUpstreamError(statusCode int) bool {
  39. switch statusCode {
  40. case 401, 403, 429, 529:
  41. return true
  42. default:
  43. return statusCode >= 500
  44. }
  45. }
  46. func retryBackoffDelay(attempt int) time.Duration {
  47. // attempt 从 1 开始,表示第 attempt 次请求刚失败,需要等待后进行第 attempt+1 次请求。
  48. if attempt <= 0 {
  49. return retryBaseDelay
  50. }
  51. delay := retryBaseDelay * time.Duration(1<<(attempt-1))
  52. if delay > retryMaxDelay {
  53. return retryMaxDelay
  54. }
  55. return delay
  56. }
  57. func sleepWithContext(ctx context.Context, d time.Duration) error {
  58. if d <= 0 {
  59. return nil
  60. }
  61. timer := time.NewTimer(d)
  62. defer func() {
  63. if !timer.Stop() {
  64. select {
  65. case <-timer.C:
  66. default:
  67. }
  68. }
  69. }()
  70. select {
  71. case <-ctx.Done():
  72. return ctx.Err()
  73. case <-timer.C:
  74. return nil
  75. }
  76. }
  77. // Forward 转发请求到Claude API
  78. func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (result *ForwardResult, err error) {
  79. startTime := time.Now()
  80. if parsed == nil {
  81. return nil, fmt.Errorf("parse request: empty request")
  82. }
  83. // Anthropic Fast is requested with speed=fast rather than OpenAI's
  84. // service_tier. Attach it at this shared boundary so passthrough, OAuth and
  85. // partial-stream results all use the same billing and usage-log path.
  86. defer func() {
  87. if result != nil {
  88. if tier := anthropicSpeedServiceTier(account, parsed.Speed, anthropicSpeedModel(parsed, result)); tier != nil {
  89. result.ServiceTier = tier
  90. }
  91. }
  92. }()
  93. beginUpstreamResponseModelObservation(c)
  94. // Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应
  95. if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.GroupID, parsed.Body.Bytes()) {
  96. return s.handleWebSearchEmulation(ctx, c, account, parsed)
  97. }
  98. if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() {
  99. passthroughBody := parsed.Body.Bytes()
  100. passthroughModel := parsed.Model
  101. if passthroughModel != "" {
  102. if mappedModel := account.GetMappedModel(passthroughModel); mappedModel != passthroughModel {
  103. passthroughBody = s.replaceModelInBody(passthroughBody, mappedModel)
  104. logger.LegacyPrintf("service.gateway", "Passthrough model mapping: %s -> %s (account: %s)", parsed.Model, mappedModel, account.Name)
  105. passthroughModel = mappedModel
  106. }
  107. }
  108. return s.forwardAnthropicAPIKeyPassthroughWithInput(ctx, c, account, anthropicPassthroughForwardInput{
  109. Body: passthroughBody,
  110. Parsed: parsed,
  111. RequestModel: passthroughModel,
  112. OriginalModel: parsed.Model,
  113. RequestStream: parsed.Stream,
  114. StartTime: startTime,
  115. })
  116. }
  117. if account != nil && account.IsBedrock() {
  118. return s.forwardBedrock(ctx, c, account, parsed, startTime)
  119. }
  120. // Beta policy: evaluate once; block check + cache filter set for buildUpstreamRequest.
  121. // Always overwrite the cache to prevent stale values from a previous retry with a different account.
  122. if account.Platform == PlatformAnthropic && c != nil {
  123. policy := s.evaluateBetaPolicy(ctx, c.GetHeader("anthropic-beta"), account, parsed.Model)
  124. if policy.blockErr != nil {
  125. return nil, policy.blockErr
  126. }
  127. filterSet := policy.filterSet
  128. if filterSet == nil {
  129. filterSet = map[string]struct{}{}
  130. }
  131. c.Set(betaPolicyFilterSetKey, filterSet)
  132. }
  133. body := parsed.Body.Bytes()
  134. replaceBody := func(next []byte) error {
  135. if err := parsed.ReplaceBody(next); err != nil {
  136. return fmt.Errorf("rewrite request body: %w", err)
  137. }
  138. body = parsed.Body.Bytes()
  139. return nil
  140. }
  141. reqModel := parsed.Model
  142. reqStream := parsed.Stream
  143. originalModel := reqModel
  144. // === DEBUG: 打印客户端原始请求(headers + body 摘要)===
  145. if c != nil {
  146. s.debugLogGatewaySnapshot("CLIENT_ORIGINAL", c.Request.Header, body, map[string]string{
  147. "account": fmt.Sprintf("%d(%s)", account.ID, account.Name),
  148. "account_type": string(account.Type),
  149. "model": reqModel,
  150. "stream": strconv.FormatBool(reqStream),
  151. })
  152. }
  153. // Claude Code 客户端判定:UA 匹配 claude-cli/* 且携带 metadata.user_id。
  154. // 真正的 Claude Code 客户端自带完整的 system prompt、cache_control 断点和 header,
  155. // 不需要代理做任何 body 级别的 mimicry;强行替换反而会破坏客户端的缓存策略
  156. // (长 system prompt 被替换为 ~45 tokens 的短 prompt,低于 Anthropic 1024 token
  157. // 最低缓存门槛,导致系统级缓存失效)。
  158. //
  159. // 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。
  160. var clientUserAgent string
  161. if c != nil {
  162. clientUserAgent = c.GetHeader("User-Agent")
  163. }
  164. isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(clientUserAgent, parsed.MetadataUserID)
  165. // 补充判定:上游 API 网关(如 new-api)转发真实 Claude Code 流量时,
  166. // UA 会变成 Go-http-client 但 body 保留了完整的 Claude Code 特征
  167. // (billing attribution block + metadata.user_id)。此时如果仍走 mimicry
  168. // 重写 system prompt,会破坏 Anthropic prompt cache 的前缀匹配——
  169. // 导致 messages 级缓存永远 miss、cache_creation 每轮全量重写。
  170. // 通过检查 body 中的 billing attribution block 来识别被代理的真实 CC 流量。
  171. if !isClaudeCode && parsed.MetadataUserID != "" {
  172. isClaudeCode = systemHasBillingAttributionBlock(body)
  173. }
  174. shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
  175. if shouldMimicClaudeCode {
  176. // 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code
  177. // 风格的 system prompt)。原因:第三方工具(opencode 等)会发 "You are Claude
  178. // Code..." system prompt 但缺少 billing attribution block,导致 Anthropic
  179. // 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。
  180. // Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。
  181. systemRewritten := false
  182. systemRaw, _ := parsed.SystemValue()
  183. systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx)
  184. if systemPromptInjectionEnabled {
  185. if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil {
  186. return nil, err
  187. }
  188. systemRewritten = true
  189. }
  190. // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为);
  191. // 未重写时(注入开关关闭)剥离客户端 cache_control,与原有行为一致。
  192. // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。
  193. normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten}
  194. if s.identityService != nil && c != nil {
  195. fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
  196. if err == nil && fp != nil {
  197. // metadata 透传开启时跳过 metadata 注入
  198. _, mimicMPT, _ := s.settingService.GetGatewayForwardingSettings(ctx)
  199. if !mimicMPT {
  200. if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
  201. normalizeOpts.injectMetadata = true
  202. normalizeOpts.metadataUserID = metadataUserID
  203. }
  204. }
  205. }
  206. }
  207. var normalizedBody []byte
  208. normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
  209. if err := replaceBody(normalizedBody); err != nil {
  210. return nil, err
  211. }
  212. // D/E/F: 可选 messages cache 策略 + 工具名混淆 + tools[-1] 断点
  213. // 与 forward_as_chat_completions / forward_as_responses 路径对齐,
  214. // 原生 /v1/messages 路径也走同一套可配置字段级改写。
  215. if err := replaceBody(s.rewriteMessageCacheControlIfEnabled(ctx, body)); err != nil {
  216. return nil, err
  217. }
  218. if rw := buildToolNameRewriteFromBody(body); rw != nil {
  219. if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil {
  220. return nil, err
  221. }
  222. if c != nil {
  223. c.Set(toolNameRewriteKey, rw)
  224. }
  225. } else {
  226. if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil {
  227. return nil, err
  228. }
  229. }
  230. }
  231. // 客户端 dateline 归一化:仅对 Anthropic OAuth/SetupToken 账号生效。
  232. // 抹除 "Today's date is …" 语句里可能被注入的隐写指纹(4 种撇号 × 2 种日期
  233. // 分隔符),还原为 ASCII 撇号 + "-" 分隔符。运行在 mimicry 分支之外,
  234. // 保证真实 Claude Code 客户端注入的指纹同样被清洗。
  235. if next, ok := s.normalizeClientDatelineIfEnabled(ctx, account, body); ok {
  236. if err := replaceBody(next); err != nil {
  237. return nil, err
  238. }
  239. }
  240. // 强制执行 cache_control 块数量限制(最多 4 个)
  241. if err := replaceBody(enforceCacheControlLimit(body)); err != nil {
  242. return nil, err
  243. }
  244. // 应用模型映射:
  245. // - APIKey 账号:使用账号级别的显式映射(如果配置),否则透传原始模型名
  246. // - OAuth/SetupToken 账号:使用 Anthropic 标准映射(短ID → 长ID)
  247. mappedModel := reqModel
  248. mappingSource := ""
  249. if account.Type == AccountTypeAPIKey {
  250. mappedModel = account.GetMappedModel(reqModel)
  251. if mappedModel != reqModel {
  252. mappingSource = "account"
  253. }
  254. }
  255. if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
  256. if candidate, matched := account.ResolveMappedModel(reqModel); matched {
  257. mappedModel = candidate
  258. mappingSource = "account"
  259. } else {
  260. normalized := normalizeVertexAnthropicModelID(claude.NormalizeModelID(reqModel))
  261. if normalized != reqModel {
  262. mappedModel = normalized
  263. mappingSource = "vertex"
  264. }
  265. }
  266. }
  267. if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
  268. normalized := claude.NormalizeModelID(reqModel)
  269. if normalized != reqModel {
  270. mappedModel = normalized
  271. mappingSource = "prefix"
  272. }
  273. }
  274. if mappedModel != reqModel {
  275. // 替换请求体中的模型名
  276. if err := replaceBody(s.replaceModelInBody(body, mappedModel)); err != nil {
  277. return nil, err
  278. }
  279. reqModel = mappedModel
  280. parsed.Model = mappedModel
  281. logger.LegacyPrintf("service.gateway", "Model mapping applied: %s -> %s (account: %s, source=%s)", originalModel, mappedModel, account.Name, mappingSource)
  282. }
  283. if s.shouldInjectAnthropicCacheTTL1h(ctx, account) {
  284. if err := replaceBody(injectAnthropicCacheControlTTL1h(body)); err != nil {
  285. return nil, err
  286. }
  287. }
  288. // 获取凭证
  289. token, tokenType, err := s.GetAccessToken(ctx, account)
  290. if err != nil {
  291. return nil, err
  292. }
  293. // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
  294. proxyURL := ""
  295. if account.ProxyID != nil && account.Proxy != nil {
  296. if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
  297. proxyURL = account.Proxy.URL()
  298. }
  299. }
  300. // 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析)
  301. tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account)
  302. // 调试日志:记录即将转发的账号信息
  303. logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s",
  304. account.ID, account.Name, account.Platform, account.Type, tlsProfile, proxyURL)
  305. // Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
  306. if err := replaceBody(StripEmptyTextBlocks(body)); err != nil {
  307. return nil, err
  308. }
  309. // Pre-filter: strip web-search history blocks the upstream cannot accept
  310. // (emulation-synthesized server_tool_use / web_search_tool_result always;
  311. // genuine ones additionally for passback-required upstreams). See
  312. // FilterWebSearchHistoryBlocks. reqModel 此时已是映射后的模型 ID。
  313. if err := replaceBody(FilterWebSearchHistoryBlocks(body, reqModel)); err != nil {
  314. return nil, err
  315. }
  316. // Pre-filter: remove thinking blocks with missing/invalid signatures before forwarding.
  317. // Clients (e.g. Claude Code) sometimes send multi-turn conversations where a historical
  318. // assistant message contains a thinking block that is missing the required "signature" field,
  319. // causing upstream to reject the request with 400 "thinking.signature: Field required".
  320. // FilterThinkingBlocks removes only the invalid blocks; thinking blocks with valid signatures
  321. // are preserved. This avoids relying solely on the post-error retry path, which can time out
  322. // (maxRetryElapsed = 10s) for long conversations before the retry budget is exhausted.
  323. //
  324. // 仅 anthropic-strict 模型族执行此过滤;passback-required 上游 (DeepSeek/Kimi/GLM 等)
  325. // 要求历史 thinking block 原样回传,过滤反而制造 400。reqModel 此时已是映射后的模型 ID。
  326. if err := replaceBody(FilterThinkingBlocks(body, reqModel)); err != nil {
  327. return nil, err
  328. }
  329. // Chinese LLM thinking.type 协议差异补正(如 MiniMax 只接受 adaptive;Anthropic-SDK
  330. // 客户端默认发 enabled)。仅对 passback-required 上游生效(claude-* 不会进来)。
  331. if ResolveThinkingProtocol(reqModel) == ThinkingProtocolPassbackRequired {
  332. if rewritten, applied := NormalizeChineseLLMThinking(body, reqModel); applied {
  333. if err := replaceBody(rewritten); err != nil {
  334. return nil, err
  335. }
  336. logger.LegacyPrintf("service.gateway", "Account %d: rewrote thinking.type for %s (Anthropic-SDK default 'enabled' -> vendor-specific)", account.ID, reqModel)
  337. }
  338. }
  339. // 重试循环
  340. var resp *http.Response
  341. lastWireBody := body
  342. retryStart := time.Now()
  343. for attempt := 1; attempt <= maxRetryAttempts; attempt++ {
  344. // 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取)
  345. upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
  346. upstreamReq, wireBody, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
  347. releaseUpstreamCtx()
  348. if err != nil {
  349. return nil, err
  350. }
  351. // 记录本次实际发送的 wire body;只有请求成功后才写回 ParsedRequest,避免 400 retry 基于已签名 CCH 再改写。
  352. lastWireBody = wireBody
  353. // 发送请求
  354. resp, err = s.httpUpstream.DoWithTLS(WithAccountTrafficRequest(upstreamReq, account), proxyURL, account.ID, account.Concurrency, tlsProfile)
  355. if err != nil {
  356. if resp != nil && resp.Body != nil {
  357. _ = resp.Body.Close()
  358. }
  359. return nil, s.handleUpstreamTransportError(ctx, c, account, err, OpsUpstreamErrorEvent{
  360. UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
  361. })
  362. }
  363. // 优先检测thinking block签名错误(400)并重试一次
  364. if resp.StatusCode == 400 {
  365. respBody, readErr := s.readUpstreamErrorBody(resp)
  366. if readErr == nil {
  367. _ = resp.Body.Close()
  368. if s.shouldRectifySignatureError(ctx, account, respBody, reqModel) {
  369. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  370. ProxyID: opsUpstreamProxyID(account),
  371. ProxyName: opsUpstreamProxyName(account),
  372. Platform: account.Platform,
  373. AccountID: account.ID,
  374. AccountName: account.Name,
  375. UpstreamStatusCode: resp.StatusCode,
  376. UpstreamRequestID: resp.Header.Get("x-request-id"),
  377. UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
  378. Kind: "signature_error",
  379. Message: extractUpstreamErrorMessage(respBody),
  380. Detail: func() string {
  381. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  382. return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
  383. }
  384. return ""
  385. }(),
  386. })
  387. looksLikeToolSignatureError := func(msg string) bool {
  388. m := strings.ToLower(msg)
  389. return strings.Contains(m, "tool_use") ||
  390. strings.Contains(m, "tool_result") ||
  391. strings.Contains(m, "functioncall") ||
  392. strings.Contains(m, "function_call") ||
  393. strings.Contains(m, "functionresponse") ||
  394. strings.Contains(m, "function_response")
  395. }
  396. // 避免在重试预算已耗尽时再发起额外请求
  397. if time.Since(retryStart) >= maxRetryElapsed {
  398. resp.Body = io.NopCloser(bytes.NewReader(respBody))
  399. break
  400. }
  401. logger.LegacyPrintf("service.gateway", "[warn] Account %d: thinking blocks have invalid signature, retrying with filtered blocks", account.ID)
  402. // Conservative two-stage fallback:
  403. // 1) Disable thinking + thinking->text (preserve content)
  404. // 2) Only if upstream still errors AND error message points to tool/function signature issues:
  405. // also downgrade tool_use/tool_result blocks to text.
  406. filteredBody := FilterThinkingBlocksForRetry(body, reqModel)
  407. retryCtx, releaseRetryCtx := detachStreamUpstreamContext(ctx, reqStream)
  408. retryReq, retryWireBody, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
  409. releaseRetryCtx()
  410. if buildErr == nil {
  411. retryResp, retryErr := s.httpUpstream.DoWithTLS(WithAccountTrafficRequest(retryReq, account), proxyURL, account.ID, account.Concurrency, tlsProfile)
  412. if retryErr == nil {
  413. if retryResp.StatusCode < 400 {
  414. // 重试请求被上游接受后同步 ParsedRequest,保证 usage/日志看到真实请求体。
  415. lastWireBody = retryWireBody
  416. if err := replaceBody(retryWireBody); err != nil {
  417. _ = retryResp.Body.Close()
  418. return nil, err
  419. }
  420. logger.LegacyPrintf("service.gateway", "Account %d: thinking block retry succeeded (blocks downgraded)", account.ID)
  421. resp = retryResp
  422. break
  423. }
  424. retryRespBody, retryReadErr := s.readUpstreamErrorBody(retryResp)
  425. _ = retryResp.Body.Close()
  426. if retryReadErr == nil && retryResp.StatusCode == 400 && s.isSignatureErrorPattern(ctx, account, retryRespBody) {
  427. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  428. ProxyID: opsUpstreamProxyID(account),
  429. ProxyName: opsUpstreamProxyName(account),
  430. Platform: account.Platform,
  431. AccountID: account.ID,
  432. AccountName: account.Name,
  433. UpstreamStatusCode: retryResp.StatusCode,
  434. UpstreamRequestID: retryResp.Header.Get("x-request-id"),
  435. UpstreamURL: safeUpstreamURL(retryReq.URL.String()),
  436. Kind: "signature_retry_thinking",
  437. Message: extractUpstreamErrorMessage(retryRespBody),
  438. Detail: func() string {
  439. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  440. return truncateString(string(retryRespBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
  441. }
  442. return ""
  443. }(),
  444. })
  445. msg2 := extractUpstreamErrorMessage(retryRespBody)
  446. if looksLikeToolSignatureError(msg2) && time.Since(retryStart) < maxRetryElapsed {
  447. logger.LegacyPrintf("service.gateway", "Account %d: signature retry still failing and looks tool-related, retrying with tool blocks downgraded", account.ID)
  448. filteredBody2 := FilterSignatureSensitiveBlocksForRetry(body, reqModel)
  449. retryCtx2, releaseRetryCtx2 := detachStreamUpstreamContext(ctx, reqStream)
  450. retryReq2, retryWireBody2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
  451. releaseRetryCtx2()
  452. if buildErr2 == nil {
  453. retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(WithAccountTrafficRequest(retryReq2, account), proxyURL, account.ID, account.Concurrency, tlsProfile)
  454. if retryErr2 == nil {
  455. if retryResp2.StatusCode < 400 {
  456. // 二阶段工具块降级成功时也必须更新当前 body。
  457. lastWireBody = retryWireBody2
  458. if err := replaceBody(retryWireBody2); err != nil {
  459. _ = retryResp2.Body.Close()
  460. return nil, err
  461. }
  462. }
  463. resp = retryResp2
  464. break
  465. }
  466. if retryResp2 != nil && retryResp2.Body != nil {
  467. _ = retryResp2.Body.Close()
  468. }
  469. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  470. ProxyID: opsUpstreamProxyID(account),
  471. ProxyName: opsUpstreamProxyName(account),
  472. Platform: account.Platform,
  473. AccountID: account.ID,
  474. AccountName: account.Name,
  475. UpstreamStatusCode: 0,
  476. UpstreamURL: safeUpstreamURL(retryReq2.URL.String()),
  477. Kind: "signature_retry_tools_request_error",
  478. Message: sanitizeUpstreamErrorMessage(retryErr2.Error()),
  479. })
  480. logger.LegacyPrintf("service.gateway", "Account %d: tool-downgrade signature retry failed: %v", account.ID, retryErr2)
  481. } else {
  482. logger.LegacyPrintf("service.gateway", "Account %d: tool-downgrade signature retry build failed: %v", account.ID, buildErr2)
  483. }
  484. }
  485. }
  486. // Fall back to the original retry response context.
  487. resp = &http.Response{
  488. StatusCode: retryResp.StatusCode,
  489. Header: retryResp.Header.Clone(),
  490. Body: io.NopCloser(bytes.NewReader(retryRespBody)),
  491. }
  492. break
  493. }
  494. if retryResp != nil && retryResp.Body != nil {
  495. _ = retryResp.Body.Close()
  496. }
  497. logger.LegacyPrintf("service.gateway", "Account %d: signature error retry failed: %v", account.ID, retryErr)
  498. } else {
  499. logger.LegacyPrintf("service.gateway", "Account %d: signature error retry build request failed: %v", account.ID, buildErr)
  500. }
  501. // Retry failed: restore original response body and continue handling.
  502. resp.Body = io.NopCloser(bytes.NewReader(respBody))
  503. break
  504. }
  505. // 不是签名错误(或整流器已关闭),继续检查 budget 约束
  506. errMsg := extractUpstreamErrorMessage(respBody)
  507. if isThinkingBudgetConstraintError(errMsg) && s.settingService.IsBudgetRectifierEnabled(ctx) {
  508. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  509. ProxyID: opsUpstreamProxyID(account),
  510. ProxyName: opsUpstreamProxyName(account),
  511. Platform: account.Platform,
  512. AccountID: account.ID,
  513. AccountName: account.Name,
  514. UpstreamStatusCode: resp.StatusCode,
  515. UpstreamRequestID: resp.Header.Get("x-request-id"),
  516. UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
  517. Kind: "budget_constraint_error",
  518. Message: errMsg,
  519. Detail: func() string {
  520. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  521. return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
  522. }
  523. return ""
  524. }(),
  525. })
  526. rectifiedBody, applied := RectifyThinkingBudget(body)
  527. if applied && time.Since(retryStart) < maxRetryElapsed {
  528. logger.LegacyPrintf("service.gateway", "Account %d: detected budget_tokens constraint error, retrying with rectified budget (budget_tokens=%d, max_tokens=%d)", account.ID, BudgetRectifyBudgetTokens, BudgetRectifyMaxTokens)
  529. budgetRetryCtx, releaseBudgetRetryCtx := detachStreamUpstreamContext(ctx, reqStream)
  530. budgetRetryReq, budgetWireBody, buildErr := s.buildUpstreamRequest(budgetRetryCtx, c, account, rectifiedBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
  531. releaseBudgetRetryCtx()
  532. if buildErr == nil {
  533. budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(WithAccountTrafficRequest(budgetRetryReq, account), proxyURL, account.ID, account.Concurrency, tlsProfile)
  534. if retryErr == nil {
  535. if budgetRetryResp.StatusCode < 400 {
  536. // budget 修正请求成功后,ParsedRequest 也要描述被接受的修正版。
  537. lastWireBody = budgetWireBody
  538. if err := replaceBody(budgetWireBody); err != nil {
  539. _ = budgetRetryResp.Body.Close()
  540. return nil, err
  541. }
  542. }
  543. resp = budgetRetryResp
  544. break
  545. }
  546. if budgetRetryResp != nil && budgetRetryResp.Body != nil {
  547. _ = budgetRetryResp.Body.Close()
  548. }
  549. logger.LegacyPrintf("service.gateway", "Account %d: budget rectifier retry failed: %v", account.ID, retryErr)
  550. } else {
  551. logger.LegacyPrintf("service.gateway", "Account %d: budget rectifier retry build failed: %v", account.ID, buildErr)
  552. }
  553. }
  554. }
  555. resp.Body = io.NopCloser(bytes.NewReader(respBody))
  556. }
  557. }
  558. // 检查是否需要通用重试(排除400,因为400已经在上面特殊处理过了)
  559. if resp.StatusCode >= 400 && resp.StatusCode != 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
  560. if attempt < maxRetryAttempts {
  561. elapsed := time.Since(retryStart)
  562. if elapsed >= maxRetryElapsed {
  563. break
  564. }
  565. delay := retryBackoffDelay(attempt)
  566. remaining := maxRetryElapsed - elapsed
  567. if delay > remaining {
  568. delay = remaining
  569. }
  570. if delay <= 0 {
  571. break
  572. }
  573. respBody, _ := s.readUpstreamErrorBody(resp)
  574. _ = resp.Body.Close()
  575. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  576. ProxyID: opsUpstreamProxyID(account),
  577. ProxyName: opsUpstreamProxyName(account),
  578. Platform: account.Platform,
  579. AccountID: account.ID,
  580. AccountName: account.Name,
  581. UpstreamStatusCode: resp.StatusCode,
  582. UpstreamRequestID: resp.Header.Get("x-request-id"),
  583. UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
  584. Kind: "retry",
  585. Message: extractUpstreamErrorMessage(respBody),
  586. Detail: func() string {
  587. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  588. return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
  589. }
  590. return ""
  591. }(),
  592. })
  593. logger.LegacyPrintf("service.gateway", "Account %d: upstream error %d, retry %d/%d after %v (elapsed=%v/%v)",
  594. account.ID, resp.StatusCode, attempt, maxRetryAttempts, delay, elapsed, maxRetryElapsed)
  595. if err := sleepWithContext(ctx, delay); err != nil {
  596. return nil, err
  597. }
  598. continue
  599. }
  600. // 最后一次尝试也失败,跳出循环处理重试耗尽
  601. break
  602. }
  603. // 不需要重试(成功或不可重试的错误),跳出循环
  604. // DEBUG: 输出响应 headers(用于检测 rate limit 信息)
  605. if account.Platform == PlatformGemini && resp.StatusCode < 400 && s.cfg != nil && s.cfg.Gateway.GeminiDebugResponseHeaders {
  606. logger.LegacyPrintf("service.gateway", "[DEBUG] Gemini API Response Headers for account %d:", account.ID)
  607. for k, v := range resp.Header {
  608. logger.LegacyPrintf("service.gateway", "[DEBUG] %s: %v", k, v)
  609. }
  610. }
  611. break
  612. }
  613. if resp == nil || resp.Body == nil {
  614. return nil, errors.New("upstream request failed: empty response")
  615. }
  616. defer func() { _ = resp.Body.Close() }()
  617. // 处理重试耗尽的情况
  618. if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
  619. if s.shouldFailoverUpstreamError(resp.StatusCode) {
  620. respBody, _ := s.readUpstreamErrorBody(resp)
  621. _ = resp.Body.Close()
  622. resp.Body = io.NopCloser(bytes.NewReader(respBody))
  623. // 调试日志:打印重试耗尽后的错误响应
  624. logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s",
  625. account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000))
  626. s.handleRetryExhaustedSideEffects(ctx, resp, account)
  627. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  628. ProxyID: opsUpstreamProxyID(account),
  629. ProxyName: opsUpstreamProxyName(account),
  630. Platform: account.Platform,
  631. AccountID: account.ID,
  632. AccountName: account.Name,
  633. UpstreamStatusCode: resp.StatusCode,
  634. UpstreamRequestID: resp.Header.Get("x-request-id"),
  635. Kind: "retry_exhausted_failover",
  636. Message: extractUpstreamErrorMessage(respBody),
  637. Detail: func() string {
  638. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  639. return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
  640. }
  641. return ""
  642. }(),
  643. })
  644. return nil, &UpstreamFailoverError{
  645. StatusCode: resp.StatusCode,
  646. ResponseBody: respBody,
  647. RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
  648. }
  649. }
  650. return s.handleRetryExhaustedError(ctx, resp, c, account)
  651. }
  652. // 处理可切换账号的错误
  653. if resp.StatusCode >= 400 && s.shouldFailoverUpstreamError(resp.StatusCode) {
  654. respBody, _ := s.readUpstreamErrorBody(resp)
  655. _ = resp.Body.Close()
  656. resp.Body = io.NopCloser(bytes.NewReader(respBody))
  657. // 调试日志:打印上游错误响应
  658. logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (failover): Account=%d(%s) Status=%d RequestID=%s Body=%s",
  659. account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000))
  660. s.handleFailoverSideEffects(ctx, resp, account, reqModel)
  661. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  662. ProxyID: opsUpstreamProxyID(account),
  663. ProxyName: opsUpstreamProxyName(account),
  664. Platform: account.Platform,
  665. AccountID: account.ID,
  666. UpstreamStatusCode: resp.StatusCode,
  667. UpstreamRequestID: resp.Header.Get("x-request-id"),
  668. Kind: "failover",
  669. Message: extractUpstreamErrorMessage(respBody),
  670. Detail: func() string {
  671. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  672. return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
  673. }
  674. return ""
  675. }(),
  676. })
  677. return nil, &UpstreamFailoverError{
  678. StatusCode: resp.StatusCode,
  679. ResponseBody: respBody,
  680. RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
  681. }
  682. }
  683. if resp.StatusCode >= 400 {
  684. // 可选:对部分 400 触发 failover(默认关闭以保持语义)
  685. if resp.StatusCode == 400 && s.cfg != nil && s.cfg.Gateway.FailoverOn400 {
  686. respBody, readErr := s.readUpstreamErrorBody(resp)
  687. if readErr != nil {
  688. // ReadAll failed, fall back to normal error handling without consuming the stream
  689. return s.handleErrorResponse(ctx, resp, c, account, reqModel)
  690. }
  691. _ = resp.Body.Close()
  692. resp.Body = io.NopCloser(bytes.NewReader(respBody))
  693. if s.shouldFailoverOn400(respBody) {
  694. upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
  695. upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
  696. upstreamDetail := ""
  697. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  698. maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
  699. if maxBytes <= 0 {
  700. maxBytes = 2048
  701. }
  702. upstreamDetail = truncateString(string(respBody), maxBytes)
  703. }
  704. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  705. ProxyID: opsUpstreamProxyID(account),
  706. ProxyName: opsUpstreamProxyName(account),
  707. Platform: account.Platform,
  708. AccountID: account.ID,
  709. AccountName: account.Name,
  710. UpstreamStatusCode: resp.StatusCode,
  711. UpstreamRequestID: resp.Header.Get("x-request-id"),
  712. Kind: "failover_on_400",
  713. Message: upstreamMsg,
  714. Detail: upstreamDetail,
  715. })
  716. if s.cfg.Gateway.LogUpstreamErrorBody {
  717. logger.LegacyPrintf("service.gateway",
  718. "Account %d: 400 error, attempting failover: %s",
  719. account.ID,
  720. truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
  721. )
  722. } else {
  723. logger.LegacyPrintf("service.gateway", "Account %d: 400 error, attempting failover", account.ID)
  724. }
  725. s.handleFailoverSideEffects(ctx, resp, account, reqModel)
  726. return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
  727. }
  728. }
  729. return s.handleErrorResponse(ctx, resp, c, account, reqModel)
  730. }
  731. // 处理正常响应
  732. if !bytes.Equal(lastWireBody, body) {
  733. // 成功后再同步最终 wire body,避免失败重试从已签名 CCH 的 body 继续派生。
  734. if err := replaceBody(lastWireBody); err != nil {
  735. return nil, err
  736. }
  737. }
  738. // 触发上游接受回调(提前释放串行锁,不等流完成)
  739. if parsed.OnUpstreamAccepted != nil {
  740. parsed.OnUpstreamAccepted()
  741. }
  742. var usage *ClaudeUsage
  743. var firstTokenMs *int
  744. var clientDisconnect bool
  745. if reqStream {
  746. writerSizeBeforeStream := c.Writer.Size()
  747. streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, shouldMimicClaudeCode)
  748. if err != nil {
  749. var sseErr *sseStreamErrorEventError
  750. if errors.As(err, &sseErr) {
  751. // 上游 HTTP 200 + SSE 流体内出现 event:error 帧。
  752. body := []byte(sseErr.RawData)
  753. semanticStatus := http.StatusForbidden
  754. if c.Writer.Size() == writerSizeBeforeStream && gjson.GetBytes(body, "error.type").String() == "overloaded_error" {
  755. semanticStatus = 529
  756. syntheticResp := &http.Response{
  757. StatusCode: semanticStatus,
  758. Header: resp.Header.Clone(),
  759. Body: io.NopCloser(bytes.NewReader(body)),
  760. }
  761. s.handleFailoverSideEffects(ctx, syntheticResp, account, reqModel)
  762. }
  763. upstreamMsg := sanitizeUpstreamErrorMessage(
  764. strings.TrimSpace(extractUpstreamErrorMessage(body)),
  765. )
  766. upstreamDetail := ""
  767. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  768. maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
  769. if maxBytes <= 0 {
  770. maxBytes = 2048
  771. }
  772. upstreamDetail = truncateString(sseErr.RawData, maxBytes)
  773. }
  774. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  775. ProxyID: opsUpstreamProxyID(account),
  776. ProxyName: opsUpstreamProxyName(account),
  777. Platform: account.Platform,
  778. AccountID: account.ID,
  779. AccountName: account.Name,
  780. UpstreamStatusCode: semanticStatus,
  781. UpstreamRequestID: resp.Header.Get("x-request-id"),
  782. Kind: "stream_error",
  783. Message: upstreamMsg,
  784. Detail: upstreamDetail,
  785. })
  786. logger.LegacyPrintf("service.gateway",
  787. "[Forward] SSE error event in stream: Account=%d(%s) RequestID=%s Body=%s",
  788. account.ID, account.Name, resp.Header.Get("x-request-id"),
  789. truncateString(sseErr.RawData, 1000),
  790. )
  791. return nil, &UpstreamFailoverError{
  792. StatusCode: semanticStatus,
  793. ResponseBody: body,
  794. }
  795. }
  796. // 流中断(缺失 terminal 事件、读错误、数据间隔超时等)时保留已观测到的
  797. // usage 与错误一起返回,handler 在错误处理完成后照常提交 usage 记录。
  798. if partial := partialStreamUsageResult(c, resp, streamResult, originalModel, mappedModel, startTime, err); partial != nil {
  799. return partial, err
  800. }
  801. return nil, err
  802. }
  803. usage = streamResult.usage
  804. firstTokenMs = streamResult.firstTokenMs
  805. clientDisconnect = streamResult.clientDisconnect
  806. } else {
  807. usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel)
  808. if err != nil {
  809. return nil, err
  810. }
  811. }
  812. return &ForwardResult{
  813. RequestID: resp.Header.Get("x-request-id"),
  814. UpstreamHeaders: resp.Header,
  815. Usage: *usage,
  816. Model: originalModel, // 使用原始模型用于计费和日志
  817. UpstreamModel: mappedModel,
  818. UpstreamResponseModel: observedUpstreamResponseModel(c),
  819. UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
  820. UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
  821. Stream: reqStream,
  822. Duration: time.Since(startTime),
  823. FirstTokenMs: firstTokenMs,
  824. ClientDisconnect: clientDisconnect,
  825. }, nil
  826. }
  827. func anthropicSpeedModel(parsed *ParsedRequest, result *ForwardResult) string {
  828. if result != nil {
  829. if upstreamModel := strings.TrimSpace(result.UpstreamModel); upstreamModel != "" {
  830. return upstreamModel
  831. }
  832. }
  833. if parsed == nil {
  834. return ""
  835. }
  836. return parsed.Model
  837. }
  838. // anthropicSpeedServiceTier 把 Anthropic 的 speed=fast 归一成可计费的 "fast" tier。
  839. //
  840. // Fast mode 目前只在 Claude Opus 5 / Opus 4.8 上存在,且不支持 Bedrock 等第三方
  841. // 承载(Opus 4.7 的 fast mode 已被移除,传 speed=fast 会直接报错)。这里按模型和
  842. // 平台收紧,避免上游根本没跑 fast 时仍然按 2x 计费——宁可漏收也不能多收。
  843. //
  844. // 这里只决定请求侧的档位;上游响应里的 usage.speed 由 UpstreamResponseServiceTier
  845. // 带回,用量记录时经 ResolveBillingServiceTier 只降不升(usage.speed=standard 则按
  846. // 标准价计费)。
  847. func anthropicSpeedServiceTier(account *Account, speed, model string) *string {
  848. if account == nil || account.Platform != PlatformAnthropic || speed != "fast" {
  849. return nil
  850. }
  851. if account.IsBedrock() || !modelSupportsAnthropicFastMode(model) {
  852. return nil
  853. }
  854. tier := "fast"
  855. return &tier
  856. }
  857. // modelSupportsAnthropicFastMode 判断模型是否属于支持 fast mode 的 Opus 5 / Opus 4.8。
  858. func modelSupportsAnthropicFastMode(model string) bool {
  859. modelLower := strings.ToLower(strings.TrimSpace(model))
  860. if !strings.Contains(modelLower, "opus") {
  861. return false
  862. }
  863. // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。
  864. if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") {
  865. return true
  866. }
  867. return strings.Contains(modelLower, "4.8") || strings.Contains(modelLower, "4-8")
  868. }
  869. // ResolveChannelMapping 委托渠道服务解析模型映射
  870. func (s *GatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult {
  871. if s.channelService == nil {
  872. return ChannelMappingResult{MappedModel: model}
  873. }
  874. return s.channelService.ResolveChannelMapping(ctx, groupID, model)
  875. }
  876. // ReplaceModelInBody 替换请求体中的模型名(导出供 handler 使用)
  877. func (s *GatewayService) ReplaceModelInBody(body []byte, newModel string) []byte {
  878. return ReplaceModelInBody(body, newModel)
  879. }
  880. // IsModelRestricted 检查模型是否被渠道限制
  881. func (s *GatewayService) IsModelRestricted(ctx context.Context, groupID int64, model string) bool {
  882. if s.channelService == nil {
  883. return false
  884. }
  885. return s.channelService.IsModelRestricted(ctx, groupID, model)
  886. }
  887. // ResolveChannelMappingAndRestrict 解析渠道映射。
  888. // 模型限制检查已移至调度阶段(checkChannelPricingRestriction),restricted 始终返回 false。
  889. func (s *GatewayService) ResolveChannelMappingAndRestrict(ctx context.Context, groupID *int64, model string) (ChannelMappingResult, bool) {
  890. if s.channelService == nil {
  891. return ChannelMappingResult{MappedModel: model}, false
  892. }
  893. return s.channelService.ResolveChannelMappingAndRestrict(ctx, groupID, model)
  894. }
  895. // checkChannelPricingRestriction 根据渠道计费基准检查模型是否受定价列表限制。
  896. // 供调度阶段预检查(requested / channel_mapped)。
  897. // upstream 需逐账号检查,此处返回 false。
  898. func (s *GatewayService) checkChannelPricingRestriction(ctx context.Context, groupID *int64, requestedModel string) bool {
  899. if groupID == nil || s.channelService == nil || requestedModel == "" {
  900. return false
  901. }
  902. mapping := s.channelService.ResolveChannelMapping(ctx, *groupID, requestedModel)
  903. billingModel := billingModelForRestriction(mapping.BillingModelSource, requestedModel, mapping.MappedModel)
  904. if billingModel == "" {
  905. return false
  906. }
  907. return s.channelService.IsModelRestricted(ctx, *groupID, billingModel)
  908. }
  909. // billingModelForRestriction 根据计费基准确定限制检查使用的模型。
  910. // upstream 返回空(需逐账号检查)。
  911. func billingModelForRestriction(source, requestedModel, channelMappedModel string) string {
  912. switch source {
  913. case BillingModelSourceRequested:
  914. return requestedModel
  915. case BillingModelSourceUpstream:
  916. return ""
  917. case BillingModelSourceResponse:
  918. // The response is not available during dispatch; use mapped pricing
  919. // for restriction prechecks and decide billing after the response.
  920. return channelMappedModel
  921. case BillingModelSourceChannelMapped:
  922. return channelMappedModel
  923. default:
  924. return channelMappedModel
  925. }
  926. }
  927. // isUpstreamModelRestrictedByChannel 检查账号映射后的上游模型是否受渠道定价限制。
  928. // 仅在 BillingModelSource="upstream" 且 RestrictModels=true 时由调度循环调用。
  929. func (s *GatewayService) isUpstreamModelRestrictedByChannel(ctx context.Context, groupID int64, account *Account, requestedModel string) bool {
  930. if s.channelService == nil {
  931. return false
  932. }
  933. upstreamModel := resolveAccountUpstreamModel(account, requestedModel)
  934. if upstreamModel == "" {
  935. return false
  936. }
  937. return s.channelService.IsModelRestricted(ctx, groupID, upstreamModel)
  938. }
  939. // resolveAccountUpstreamModel 确定账号将请求模型映射为什么上游模型。
  940. func resolveAccountUpstreamModel(account *Account, requestedModel string) string {
  941. if account.Platform == PlatformAntigravity {
  942. return mapAntigravityModel(account, requestedModel)
  943. }
  944. return account.GetMappedModel(requestedModel)
  945. }
  946. // needsUpstreamChannelRestrictionCheck 判断是否需要在调度循环中逐账号检查上游模型的渠道限制。
  947. func (s *GatewayService) needsUpstreamChannelRestrictionCheck(ctx context.Context, groupID *int64) bool {
  948. if groupID == nil || s.channelService == nil {
  949. return false
  950. }
  951. ch, err := s.channelService.GetChannelForGroup(ctx, *groupID)
  952. if err != nil {
  953. slog.Warn("failed to check channel upstream restriction", "group_id", *groupID, "error", err)
  954. return false
  955. }
  956. if ch == nil || !ch.RestrictModels {
  957. return false
  958. }
  959. return ch.BillingModelSource == BillingModelSourceUpstream
  960. }
  961. // isStickyAccountUpstreamRestricted 检查粘性会话命中的账号是否受 upstream 渠道限制。
  962. // 合并 needsUpstreamChannelRestrictionCheck + isUpstreamModelRestrictedByChannel 两步调用,
  963. // 供 sticky session 条件链使用,避免内联多个函数调用导致行过长。
  964. func (s *GatewayService) isStickyAccountUpstreamRestricted(ctx context.Context, groupID *int64, account *Account, requestedModel string) bool {
  965. if groupID == nil {
  966. return false
  967. }
  968. if !s.needsUpstreamChannelRestrictionCheck(ctx, groupID) {
  969. return false
  970. }
  971. return s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel)
  972. }