gateway_upstream_request.go 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "fmt"
  6. "net/http"
  7. "net/url"
  8. "strconv"
  9. "strings"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  12. "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
  13. "github.com/google/uuid"
  14. "github.com/tidwall/gjson"
  15. "github.com/gin-gonic/gin"
  16. )
  17. func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, []byte, error) {
  18. body = stripDeferredToolCacheControl(body)
  19. if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
  20. req, err := s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream)
  21. return req, body, err
  22. }
  23. // 确定目标URL
  24. targetURL := claudeAPIURL
  25. if account.Type == AccountTypeAPIKey {
  26. baseURL := account.GetBaseURL()
  27. if baseURL != "" {
  28. validatedURL, err := s.validateUpstreamBaseURL(baseURL)
  29. if err != nil {
  30. return nil, nil, err
  31. }
  32. targetURL = validatedURL + "/v1/messages?beta=true"
  33. }
  34. } else if account.IsCustomBaseURLEnabled() {
  35. customURL := account.GetCustomBaseURL()
  36. if customURL == "" {
  37. return nil, nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
  38. }
  39. validatedURL, err := s.validateUpstreamBaseURL(customURL)
  40. if err != nil {
  41. return nil, nil, err
  42. }
  43. targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages", account)
  44. }
  45. clientHeaders := http.Header{}
  46. if c != nil && c.Request != nil {
  47. clientHeaders = c.Request.Header
  48. }
  49. // OAuth账号:应用统一指纹和metadata重写(受设置开关控制)
  50. var fingerprint *Fingerprint
  51. enableFP, enableMPT := true, false
  52. if s.settingService != nil {
  53. enableFP, enableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx)
  54. }
  55. if account.IsOAuth() && s.identityService != nil {
  56. // 1. 获取或创建指纹(包含随机生成的ClientID)
  57. fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
  58. if err != nil {
  59. logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err)
  60. // 失败时降级为透传原始headers
  61. } else {
  62. if enableFP {
  63. fingerprint = fp
  64. }
  65. // 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid)
  66. // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
  67. // 当 metadata 透传开启时跳过重写
  68. if !enableMPT {
  69. accountUUID := account.GetExtraString("account_uuid")
  70. if accountUUID != "" && fp.ClientID != "" {
  71. if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
  72. body = newBody
  73. }
  74. }
  75. }
  76. }
  77. }
  78. // Mimicry may override the cached User-Agent later, even without a fingerprint.
  79. if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, fingerprint); billingUA != "" {
  80. body = syncBillingHeaderVersion(body, billingUA)
  81. }
  82. // === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)===
  83. //
  84. // 顺序约束:
  85. // 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径会忽略客户端 beta,
  86. // 与原“OAuth + mimicClaudeCode 跳过白名单透传”行为对齐)
  87. // 2) 按 finalBeta 做能力维度 body sanitize(如 context-management beta 缺失 →
  88. // strip body.context_management,与 Bedrock 路径对称)
  89. // 3) CCH 签名(必须使用 strip 后的 body,否则 hash 与最终 body 不一致 →
  90. // 被 Anthropic 判 third-party)
  91. // 4) NewRequest(body 至此最终敲定)
  92. // 5) 透传白名单 / fingerprint / mimic header / 写入 finalBeta
  93. policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID)
  94. effectiveDropSet := mergeDropSets(policyFilterSet)
  95. finalBetaHeader, finalBetaShouldSet := s.computeFinalAnthropicBeta(
  96. tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet,
  97. )
  98. // 账号覆写了 anthropic-beta 时,覆写值即最终上游值(由下方 ApplyHeaderOverrides 写入):
  99. // body 能力净化必须以覆写值为准,否则 header/body 不对称会被上游 400。
  100. if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
  101. finalBetaHeader, finalBetaShouldSet = beta, true
  102. }
  103. // 能力维度 body sanitize:与最终 anthropic-beta header 对称
  104. if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
  105. body = sanitized
  106. }
  107. // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的
  108. // base 取值同源(GetBaseURL),仅实际上游为 ollama.com 且映射后出站模型
  109. // 为 DeepSeek 系时压到 cap,详见 helper 注释。
  110. body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body)
  111. req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
  112. if err != nil {
  113. return nil, nil, err
  114. }
  115. // 设置认证头(保持原始大小写)
  116. if tokenType == "oauth" {
  117. setHeaderRaw(req.Header, "authorization", "Bearer "+token)
  118. } else {
  119. // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方
  120. // targetURL 的 base 取值),其余保持 extra/default 行为。
  121. setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL())
  122. }
  123. // 白名单透传 headers
  124. // OAuth mimicry 路径:跳过客户端 header 透传,与 Parrot 对齐。
  125. // Parrot 的 build_upstream_headers 只发 9 个精确 header,不透传任何客户端 header。
  126. // 透传客户端 header 会引入不一致的 x-stainless-* / anthropic-beta / user-agent /
  127. // x-claude-code-session-id 等值,和我们注入的伪装 header 冲突,被 Anthropic 判 third-party。
  128. if tokenType != "oauth" || !mimicClaudeCode {
  129. for key, values := range clientHeaders {
  130. lowerKey := strings.ToLower(key)
  131. if allowedHeaders[lowerKey] {
  132. wireKey := resolveWireCasing(key)
  133. for _, v := range values {
  134. addHeaderRaw(req.Header, wireKey, v)
  135. }
  136. }
  137. }
  138. }
  139. // OAuth账号:应用缓存的指纹到请求头(覆盖白名单透传的头)
  140. if fingerprint != nil {
  141. s.identityService.ApplyFingerprint(req, fingerprint)
  142. }
  143. // 确保必要的headers存在(保持原始大小写)
  144. if getHeaderRaw(req.Header, "content-type") == "" {
  145. setHeaderRaw(req.Header, "content-type", "application/json")
  146. }
  147. if getHeaderRaw(req.Header, "anthropic-version") == "" {
  148. setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
  149. }
  150. if tokenType == "oauth" {
  151. applyClaudeOAuthHeaderDefaults(req)
  152. }
  153. // OAuth + mimic Claude Code:强制注入 CLI 指纹相关 header
  154. // (user-agent/x-stainless-*/x-app/Accept/x-stainless-helper-method/x-client-request-id)
  155. if tokenType == "oauth" && mimicClaudeCode {
  156. applyClaudeCodeMimicHeaders(req, reqStream)
  157. }
  158. // 写入最终 anthropic-beta header
  159. // 注:透传分支白名单可能写入了客户端 anthropic-beta,无条件 Del 一次再按 finalBeta
  160. // 决定是否 set,确保 dropSet 过滤后的结果一定覆盖客户端原始值。
  161. deleteHeaderAllForms(req.Header, "anthropic-beta")
  162. if finalBetaShouldSet {
  163. setHeaderRaw(req.Header, "anthropic-beta", finalBetaHeader)
  164. }
  165. // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
  166. if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
  167. if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
  168. if parsed := ParseMetadataUserID(uid); parsed != nil {
  169. setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
  170. }
  171. }
  172. }
  173. // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)。
  174. // 放在所有 header 逻辑之后,确保配置值对同名头拥有最终决定权。
  175. account.ApplyHeaderOverrides(req.Header)
  176. // === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
  177. s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
  178. "url": req.URL.String(),
  179. "token_type": tokenType,
  180. "mimic_claude_code": strconv.FormatBool(mimicClaudeCode),
  181. "fingerprint_applied": strconv.FormatBool(fingerprint != nil),
  182. "enable_fp": strconv.FormatBool(enableFP),
  183. "enable_mpt": strconv.FormatBool(enableMPT),
  184. })
  185. // Always capture a compact fingerprint line for later error diagnostics.
  186. // We only print it when needed (or when the explicit debug flag is enabled).
  187. if c != nil && tokenType == "oauth" {
  188. c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
  189. }
  190. if s.debugClaudeMimicEnabled() {
  191. logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode)
  192. }
  193. return req, body, nil
  194. }
  195. // vertexSupportedBetaTokens 是 Vertex AI 的 Anthropic 端点接受的 anthropic-beta
  196. // 白名单。Vertex 对任何未知 token 直接 HTTP 400,故采用白名单(与 Bedrock 的
  197. // bedrockSupportedBetaTokens 同思路)而非黑名单:未来 Claude Code 新增的、Vertex 尚未
  198. // 支持的 token 天然被剥离。当 Vertex 新增支持某 beta 时在此补充。
  199. //
  200. // 明确排除(issue #3358 中 Vertex 报 400 的 token):advisor-tool-2026-03-01、
  201. // prompt-caching-scope-2026-01-05、redact-thinking-2026-02-12、
  202. // thinking-token-count-2026-05-13;以及 claude-code-20250219 / oauth-2025-04-20 等
  203. // 客户端身份 beta——Vertex service_account 走 Bearer 鉴权,不需要它们。
  204. var vertexSupportedBetaTokens = map[string]bool{
  205. "context-1m-2025-08-07": true,
  206. "context-management-2025-06-27": true,
  207. "fine-grained-tool-streaming-2025-05-14": true,
  208. "interleaved-thinking-2025-05-14": true,
  209. }
  210. // filterVertexBetaTokens 解析 client 的 anthropic-beta header,先剔除 drop 集合中的
  211. // token(BetaPolicy filter + 默认 drop),再只保留 Vertex 支持的 token,去重后逗号拼接。
  212. // 返回最终 header(可能为空字符串)。
  213. func filterVertexBetaTokens(header string, drop map[string]struct{}) string {
  214. tokens := parseAnthropicBetaHeader(header)
  215. if len(tokens) == 0 {
  216. return ""
  217. }
  218. out := make([]string, 0, len(tokens))
  219. seen := make(map[string]bool, len(tokens))
  220. for _, t := range tokens {
  221. if _, dropped := drop[t]; dropped {
  222. continue
  223. }
  224. if !vertexSupportedBetaTokens[t] {
  225. continue
  226. }
  227. if seen[t] {
  228. continue
  229. }
  230. seen[t] = true
  231. out = append(out, t)
  232. }
  233. return strings.Join(out, ",")
  234. }
  235. func (s *GatewayService) buildUpstreamRequestAnthropicVertex(
  236. ctx context.Context,
  237. c *gin.Context,
  238. account *Account,
  239. body []byte,
  240. token string,
  241. modelID string,
  242. reqStream bool,
  243. ) (*http.Request, error) {
  244. vertexBody, err := buildVertexAnthropicRequestBody(body)
  245. if err != nil {
  246. return nil, err
  247. }
  248. // 计算最终 outgoing anthropic-beta。Vertex AI 的 Anthropic 端点只接受一小撮
  249. // beta token,未知 token 会直接 HTTP 400——近期 Claude Code CLI 透传的
  250. // advisor-tool-2026-03-01 / prompt-caching-scope-2026-01-05 /
  251. // redact-thinking-2026-02-12 / thinking-token-count-2026-05-13 都不被 Vertex 接受
  252. // (issue #3358)。这里复用 BetaPolicy 的 block 检查(与 Bedrock 的
  253. // resolveBedrockBetaTokensForRequest 对称),再按 vertexSupportedBetaTokens 白名单
  254. // 剥离其余 token,使该路径与 Anthropic 直连 / Bedrock 路径行为一致。
  255. clientBeta := ""
  256. if c != nil && c.Request != nil {
  257. clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
  258. }
  259. policy := s.evaluateBetaPolicy(ctx, clientBeta, account, modelID)
  260. if policy.blockErr != nil {
  261. return nil, policy.blockErr
  262. }
  263. finalBeta := filterVertexBetaTokens(clientBeta, mergeDropSets(policy.filterSet))
  264. // 能力维度 sanitize:基于最终 beta(而非原始 client 值)决定是否保留 body 中的
  265. // context_management,与 Anthropic 直连 / Bedrock 路径对称。
  266. if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(vertexBody, finalBeta); changed {
  267. vertexBody = sanitized
  268. }
  269. fullURL, err := buildVertexAnthropicURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, reqStream)
  270. if err != nil {
  271. return nil, err
  272. }
  273. req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(vertexBody))
  274. if err != nil {
  275. return nil, err
  276. }
  277. if c != nil && c.Request != nil {
  278. for key, values := range c.Request.Header {
  279. lowerKey := strings.ToLower(strings.TrimSpace(key))
  280. if !allowedHeaders[lowerKey] || lowerKey == "anthropic-version" {
  281. continue
  282. }
  283. wireKey := resolveWireCasing(key)
  284. for _, v := range values {
  285. addHeaderRaw(req.Header, wireKey, v)
  286. }
  287. }
  288. }
  289. req.Header.Del("authorization")
  290. req.Header.Del("x-api-key")
  291. req.Header.Del("x-goog-api-key")
  292. req.Header.Del("cookie")
  293. req.Header.Del("anthropic-version")
  294. setHeaderRaw(req.Header, "authorization", "Bearer "+token)
  295. setHeaderRaw(req.Header, "content-type", "application/json")
  296. // 覆盖上面白名单 loop 写入的原始 client anthropic-beta,使用过滤后的最终值。
  297. // finalBeta 为空(全部被剥离)时不下发该 header,与 Vertex 无 beta 请求一致。
  298. deleteHeaderAllForms(req.Header, "anthropic-beta")
  299. if finalBeta != "" {
  300. setHeaderRaw(req.Header, "anthropic-beta", finalBeta)
  301. }
  302. s.debugLogGatewaySnapshot("UPSTREAM_FORWARD_VERTEX_ANTHROPIC", req.Header, vertexBody, map[string]string{
  303. "url": req.URL.String(),
  304. "token_type": "service_account",
  305. "model": modelID,
  306. "stream": strconv.FormatBool(reqStream),
  307. })
  308. return req, nil
  309. }
  310. // getBetaHeader 处理anthropic-beta header
  311. // 对于OAuth账号,需要确保包含oauth-2025-04-20
  312. func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string) string {
  313. // 如果客户端传了anthropic-beta
  314. if clientBetaHeader != "" {
  315. // 已包含oauth beta则直接返回
  316. if strings.Contains(clientBetaHeader, claude.BetaOAuth) {
  317. return clientBetaHeader
  318. }
  319. // 需要添加oauth beta
  320. parts := strings.Split(clientBetaHeader, ",")
  321. for i, p := range parts {
  322. parts[i] = strings.TrimSpace(p)
  323. }
  324. // 在claude-code-20250219后面插入oauth beta
  325. claudeCodeIdx := -1
  326. for i, p := range parts {
  327. if p == claude.BetaClaudeCode {
  328. claudeCodeIdx = i
  329. break
  330. }
  331. }
  332. if claudeCodeIdx >= 0 {
  333. // 在claude-code后面插入
  334. newParts := make([]string, 0, len(parts)+1)
  335. newParts = append(newParts, parts[:claudeCodeIdx+1]...)
  336. newParts = append(newParts, claude.BetaOAuth)
  337. newParts = append(newParts, parts[claudeCodeIdx+1:]...)
  338. return strings.Join(newParts, ",")
  339. }
  340. // 没有claude-code,放在第一位
  341. return claude.BetaOAuth + "," + clientBetaHeader
  342. }
  343. // OAuth 真实客户端透传且客户端没传 beta 时,根据模型生成默认值。
  344. // Haiku 的透传默认值不补 claude-code beta;mimic 路径不会调用本分支。
  345. if strings.Contains(strings.ToLower(modelID), "haiku") {
  346. return claude.HaikuBetaHeader
  347. }
  348. return claude.DefaultBetaHeader
  349. }
  350. func requestNeedsBetaFeatures(body []byte) bool {
  351. tools := gjson.GetBytes(body, "tools")
  352. if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
  353. return true
  354. }
  355. thinkingType := gjson.GetBytes(body, "thinking.type").String()
  356. if strings.EqualFold(thinkingType, "enabled") || strings.EqualFold(thinkingType, "adaptive") {
  357. return true
  358. }
  359. return false
  360. }
  361. func defaultAPIKeyBetaHeader(body []byte) string {
  362. modelID := gjson.GetBytes(body, "model").String()
  363. if strings.Contains(strings.ToLower(modelID), "haiku") {
  364. return claude.APIKeyHaikuBetaHeader
  365. }
  366. return claude.APIKeyBetaHeader
  367. }
  368. func applyClaudeOAuthHeaderDefaults(req *http.Request) {
  369. if req == nil {
  370. return
  371. }
  372. if getHeaderRaw(req.Header, "Accept") == "" {
  373. setHeaderRaw(req.Header, "Accept", "application/json")
  374. }
  375. for key, value := range claude.DefaultHeaders {
  376. if value == "" {
  377. continue
  378. }
  379. if getHeaderRaw(req.Header, key) == "" {
  380. setHeaderRaw(req.Header, resolveWireCasing(key), value)
  381. }
  382. }
  383. }
  384. func mergeAnthropicBeta(required []string, incoming string) string {
  385. seen := make(map[string]struct{}, len(required)+8)
  386. out := make([]string, 0, len(required)+8)
  387. add := func(v string) {
  388. v = strings.TrimSpace(v)
  389. if v == "" {
  390. return
  391. }
  392. if _, ok := seen[v]; ok {
  393. return
  394. }
  395. seen[v] = struct{}{}
  396. out = append(out, v)
  397. }
  398. for _, r := range required {
  399. add(r)
  400. }
  401. for _, p := range strings.Split(incoming, ",") {
  402. add(p)
  403. }
  404. return strings.Join(out, ",")
  405. }
  406. func mergeAnthropicBetaDropping(required []string, incoming string, drop map[string]struct{}) string {
  407. merged := mergeAnthropicBeta(required, incoming)
  408. if merged == "" || len(drop) == 0 {
  409. return merged
  410. }
  411. out := make([]string, 0, 8)
  412. for _, p := range strings.Split(merged, ",") {
  413. p = strings.TrimSpace(p)
  414. if p == "" {
  415. continue
  416. }
  417. if _, ok := drop[p]; ok {
  418. continue
  419. }
  420. out = append(out, p)
  421. }
  422. return strings.Join(out, ",")
  423. }
  424. // computeFinalAnthropicBeta 计算发往上游的最终 anthropic-beta header 值。
  425. //
  426. // 设计动机:将原本在 buildUpstreamRequest 内联在一起、依赖 req.Header 的
  427. // anthropic-beta 计算逻辑抽成纯函数。这样调用方可以在 NewRequest 之前
  428. // 就提前拿到最终 beta header,进而能按它对 body 做能力维度 sanitize 后再做
  429. // CCH 签名——一举修复了以下之前由顺序依赖导致的能力维度 sanitize
  430. // 无法部署的问题(签名与最终 body 不一致可以被判 third-party)。
  431. //
  432. // 返回 (value, shouldSet):
  433. // - shouldSet=false 意为“不主动设置 anthropic-beta header”,与原代码“
  434. // API-key 账号 + 客户端未传 anthropic-beta + InjectBetaForAPIKey 未开启或
  435. // requestNeedsBetaFeatures=false”的行为对齐。
  436. // - shouldSet=true 时 value 可能为空字符串(例如客户端透传的 beta 被 dropSet
  437. // 全部过滤掉),这与原代码中 setHeaderRaw 的结果一致。
  438. //
  439. // clientHeaders 是客户端原始 HTTP header(通常为 c.Request.Header);nil 时按“客户端
  440. // 未传”处理。body 是已经 metadata 重写 / billing version sync 之后但未 sanitize 上游
  441. // 不兼容字段之前的版本。
  442. func (s *GatewayService) computeFinalAnthropicBeta(
  443. tokenType string,
  444. mimicClaudeCode bool,
  445. modelID string,
  446. clientHeaders http.Header,
  447. body []byte,
  448. effectiveDropSet map[string]struct{},
  449. ) (string, bool) {
  450. clientBeta := ""
  451. if clientHeaders != nil {
  452. clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta")
  453. }
  454. if tokenType == "oauth" {
  455. if mimicClaudeCode {
  456. // mimic 路径跳过白名单透传,incomingBeta 始终为空;所有模型都必须
  457. // 携带完整 Claude Code beta 集合,避免 Haiku 被识别为第三方客户端。
  458. return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), "", effectiveDropSet), true
  459. }
  460. // 真 Claude Code 客户端透传路径
  461. return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true
  462. }
  463. // API-key accounts
  464. if clientBeta != "" {
  465. return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true
  466. }
  467. if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
  468. if requestNeedsBetaFeatures(body) {
  469. if beta := defaultAPIKeyBetaHeader(body); beta != "" {
  470. return beta, true
  471. }
  472. }
  473. }
  474. return "", false
  475. }
  476. // computeFinalCountTokensAnthropicBeta 是 count_tokens 路径上 anthropic-beta header 的
  477. // 计算纯函数。语义与 computeFinalAnthropicBeta 对齐,但备份了 count_tokens 独有的
  478. // 两条特殊规则:
  479. //
  480. // - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting;
  481. // count_tokens 另外保留客户端 beta,而 messages mimic 会忽略客户端 beta。
  482. // - OAuth 透传 + 客户端未传 anthropic-beta:补齐 CountTokensBetaHeader
  483. // - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting(如果未含)
  484. //
  485. // 返回语义同 computeFinalAnthropicBeta。
  486. func (s *GatewayService) computeFinalCountTokensAnthropicBeta(
  487. tokenType string,
  488. mimicClaudeCode bool,
  489. modelID string,
  490. clientHeaders http.Header,
  491. body []byte,
  492. effectiveDropSet map[string]struct{},
  493. ) (string, bool) {
  494. clientBeta := ""
  495. if clientHeaders != nil {
  496. clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta")
  497. }
  498. if tokenType == "oauth" {
  499. if mimicClaudeCode {
  500. // 与原代码严格等价:original buildCountTokensRequest 在 count_tokens mimic
  501. // 分支上**不**会跳过白名单透传(与 messages mimic 路径不同),所以
  502. // incomingBeta = req.Header[anthropic-beta] = 客户端透传过来的 client beta。
  503. // 重构后直接从 clientHeaders 拿同一个值,保持行为一致。
  504. requiredBetas := append(claude.FullClaudeCodeMimicryBetas(), claude.BetaTokenCounting)
  505. return mergeAnthropicBetaDropping(requiredBetas, clientBeta, effectiveDropSet), true
  506. }
  507. if clientBeta == "" {
  508. return claude.CountTokensBetaHeader, true
  509. }
  510. beta := s.getBetaHeader(modelID, clientBeta)
  511. if !strings.Contains(beta, claude.BetaTokenCounting) {
  512. beta = beta + "," + claude.BetaTokenCounting
  513. }
  514. return stripBetaTokensWithSet(beta, effectiveDropSet), true
  515. }
  516. // API-key accounts
  517. if clientBeta != "" {
  518. return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true
  519. }
  520. if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
  521. if requestNeedsBetaFeatures(body) {
  522. if beta := defaultAPIKeyBetaHeader(body); beta != "" {
  523. return beta, true
  524. }
  525. }
  526. }
  527. return "", false
  528. }
  529. // stripBetaTokens removes the given beta tokens from a comma-separated header value.
  530. func stripBetaTokens(header string, tokens []string) string {
  531. if header == "" || len(tokens) == 0 {
  532. return header
  533. }
  534. return stripBetaTokensWithSet(header, buildBetaTokenSet(tokens))
  535. }
  536. func stripBetaTokensWithSet(header string, drop map[string]struct{}) string {
  537. if header == "" || len(drop) == 0 {
  538. return header
  539. }
  540. parts := strings.Split(header, ",")
  541. out := make([]string, 0, len(parts))
  542. for _, p := range parts {
  543. p = strings.TrimSpace(p)
  544. if p == "" {
  545. continue
  546. }
  547. if _, ok := drop[p]; ok {
  548. continue
  549. }
  550. out = append(out, p)
  551. }
  552. if len(out) == len(parts) {
  553. return header // no change, avoid allocation
  554. }
  555. return strings.Join(out, ",")
  556. }
  557. // BetaBlockedError indicates a request was blocked by a beta policy rule.
  558. type BetaBlockedError struct {
  559. Message string
  560. }
  561. func (e *BetaBlockedError) Error() string { return e.Message }
  562. // betaPolicyResult holds the evaluated result of beta policy rules for a single request.
  563. type betaPolicyResult struct {
  564. blockErr *BetaBlockedError // non-nil if a block rule matched
  565. filterSet map[string]struct{} // tokens to filter (may be nil)
  566. }
  567. // evaluateBetaPolicy loads settings once and evaluates all rules against the given request.
  568. func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account, model string) betaPolicyResult {
  569. if s.settingService == nil {
  570. return betaPolicyResult{}
  571. }
  572. settings, err := s.settingService.GetBetaPolicySettings(ctx)
  573. if err != nil || settings == nil {
  574. return betaPolicyResult{}
  575. }
  576. isOAuth := account.IsOAuth()
  577. isBedrock := account.IsBedrock()
  578. var result betaPolicyResult
  579. for _, rule := range settings.Rules {
  580. if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
  581. continue
  582. }
  583. effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model)
  584. switch effectiveAction {
  585. case BetaPolicyActionBlock:
  586. if result.blockErr == nil && betaHeader != "" && containsBetaToken(betaHeader, rule.BetaToken) {
  587. msg := effectiveErrMsg
  588. if msg == "" {
  589. msg = "beta feature " + rule.BetaToken + " is not allowed"
  590. }
  591. result.blockErr = &BetaBlockedError{Message: msg}
  592. }
  593. case BetaPolicyActionFilter:
  594. if result.filterSet == nil {
  595. result.filterSet = make(map[string]struct{})
  596. }
  597. result.filterSet[rule.BetaToken] = struct{}{}
  598. }
  599. }
  600. return result
  601. }
  602. // mergeDropSets merges the static defaultDroppedBetasSet with dynamic policy filter tokens.
  603. // Returns defaultDroppedBetasSet directly when policySet is empty (zero allocation).
  604. func mergeDropSets(policySet map[string]struct{}, extra ...string) map[string]struct{} {
  605. if len(policySet) == 0 && len(extra) == 0 {
  606. return defaultDroppedBetasSet
  607. }
  608. m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(policySet)+len(extra))
  609. for t := range defaultDroppedBetasSet {
  610. m[t] = struct{}{}
  611. }
  612. for t := range policySet {
  613. m[t] = struct{}{}
  614. }
  615. for _, t := range extra {
  616. m[t] = struct{}{}
  617. }
  618. return m
  619. }
  620. // betaPolicyFilterSetKey is the gin.Context key for caching the policy filter set within a request.
  621. const betaPolicyFilterSetKey = "betaPolicyFilterSet"
  622. // getBetaPolicyFilterSet returns the beta policy filter set, using the gin context cache if available.
  623. // In the /v1/messages path, Forward() evaluates the policy first and caches the result;
  624. // buildUpstreamRequest reuses it (zero extra DB calls). In the count_tokens path, this
  625. // evaluates on demand (one DB call).
  626. func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Context, account *Account, model string) map[string]struct{} {
  627. if c != nil {
  628. if v, ok := c.Get(betaPolicyFilterSetKey); ok {
  629. if fs, ok := v.(map[string]struct{}); ok {
  630. return fs
  631. }
  632. }
  633. }
  634. return s.evaluateBetaPolicy(ctx, "", account, model).filterSet
  635. }
  636. // betaPolicyScopeMatches checks whether a rule's scope matches the current account type.
  637. func betaPolicyScopeMatches(scope string, isOAuth bool, isBedrock bool) bool {
  638. switch scope {
  639. case BetaPolicyScopeAll:
  640. return true
  641. case BetaPolicyScopeOAuth:
  642. return isOAuth
  643. case BetaPolicyScopeAPIKey:
  644. return !isOAuth && !isBedrock
  645. case BetaPolicyScopeBedrock:
  646. return isBedrock
  647. default:
  648. return true // unknown scope → match all (fail-open)
  649. }
  650. }
  651. // matchModelWhitelist checks if a model matches any pattern in the whitelist.
  652. // Reuses matchModelPattern from group.go which supports exact and wildcard prefix matching.
  653. func matchModelWhitelist(model string, whitelist []string) bool {
  654. for _, pattern := range whitelist {
  655. if matchModelPattern(pattern, model) {
  656. return true
  657. }
  658. }
  659. return false
  660. }
  661. // resolveRuleAction determines the effective action and error message for a rule given the request model.
  662. // When ModelWhitelist is empty, the rule's primary Action/ErrorMessage applies unconditionally.
  663. // When non-empty, Action applies to matching models; FallbackAction/FallbackErrorMessage applies to others.
  664. func resolveRuleAction(rule BetaPolicyRule, model string) (action, errorMessage string) {
  665. if len(rule.ModelWhitelist) == 0 {
  666. return rule.Action, rule.ErrorMessage
  667. }
  668. if matchModelWhitelist(model, rule.ModelWhitelist) {
  669. return rule.Action, rule.ErrorMessage
  670. }
  671. if rule.FallbackAction != "" {
  672. return rule.FallbackAction, rule.FallbackErrorMessage
  673. }
  674. return BetaPolicyActionPass, "" // default fallback: pass (fail-open)
  675. }
  676. // droppedBetaSet returns claude.DroppedBetas as a set, with optional extra tokens.
  677. func droppedBetaSet(extra ...string) map[string]struct{} {
  678. m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(extra))
  679. for t := range defaultDroppedBetasSet {
  680. m[t] = struct{}{}
  681. }
  682. for _, t := range extra {
  683. m[t] = struct{}{}
  684. }
  685. return m
  686. }
  687. // containsBetaToken checks if a comma-separated header value contains the given token.
  688. func containsBetaToken(header, token string) bool {
  689. if header == "" || token == "" {
  690. return false
  691. }
  692. for _, p := range strings.Split(header, ",") {
  693. if strings.TrimSpace(p) == token {
  694. return true
  695. }
  696. }
  697. return false
  698. }
  699. func filterBetaTokens(tokens []string, filterSet map[string]struct{}) []string {
  700. if len(tokens) == 0 || len(filterSet) == 0 {
  701. return tokens
  702. }
  703. kept := make([]string, 0, len(tokens))
  704. for _, token := range tokens {
  705. if _, filtered := filterSet[token]; !filtered {
  706. kept = append(kept, token)
  707. }
  708. }
  709. return kept
  710. }
  711. func (s *GatewayService) resolveBedrockBetaTokensForRequest(
  712. ctx context.Context,
  713. account *Account,
  714. betaHeader string,
  715. body []byte,
  716. modelID string,
  717. ) ([]string, error) {
  718. // 1. 对原始 header 中的 beta token 做 block 检查(快速失败)
  719. policy := s.evaluateBetaPolicy(ctx, betaHeader, account, modelID)
  720. if policy.blockErr != nil {
  721. return nil, policy.blockErr
  722. }
  723. // 2. 解析 header + body 自动注入 + Bedrock 转换/过滤
  724. betaTokens := ResolveBedrockBetaTokens(betaHeader, body, modelID)
  725. // 3. 对最终 token 列表再做 block 检查,捕获通过 body 自动注入绕过 header block 的情况。
  726. // 例如:管理员 block 了 interleaved-thinking,客户端不在 header 中带该 token,
  727. // 但请求体中包含 thinking 字段 → autoInjectBedrockBetaTokens 会自动补齐 →
  728. // 如果不做此检查,block 规则会被绕过。
  729. if blockErr := s.checkBetaPolicyBlockForTokens(ctx, betaTokens, account, modelID); blockErr != nil {
  730. return nil, blockErr
  731. }
  732. return filterBetaTokens(betaTokens, policy.filterSet), nil
  733. }
  734. // checkBetaPolicyBlockForTokens 检查 token 列表中是否有被管理员 block 规则命中的 token。
  735. // 用于补充 evaluateBetaPolicy 对 header 的检查,覆盖 body 自动注入的 token。
  736. func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, tokens []string, account *Account, model string) *BetaBlockedError {
  737. if s.settingService == nil || len(tokens) == 0 {
  738. return nil
  739. }
  740. settings, err := s.settingService.GetBetaPolicySettings(ctx)
  741. if err != nil || settings == nil {
  742. return nil
  743. }
  744. isOAuth := account.IsOAuth()
  745. isBedrock := account.IsBedrock()
  746. tokenSet := buildBetaTokenSet(tokens)
  747. for _, rule := range settings.Rules {
  748. effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model)
  749. if effectiveAction != BetaPolicyActionBlock {
  750. continue
  751. }
  752. if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
  753. continue
  754. }
  755. if _, present := tokenSet[rule.BetaToken]; present {
  756. msg := effectiveErrMsg
  757. if msg == "" {
  758. msg = "beta feature " + rule.BetaToken + " is not allowed"
  759. }
  760. return &BetaBlockedError{Message: msg}
  761. }
  762. }
  763. return nil
  764. }
  765. func buildBetaTokenSet(tokens []string) map[string]struct{} {
  766. m := make(map[string]struct{}, len(tokens))
  767. for _, t := range tokens {
  768. if t == "" {
  769. continue
  770. }
  771. m[t] = struct{}{}
  772. }
  773. return m
  774. }
  775. var defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas)
  776. // applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
  777. // This mirrors opencode-anthropic-auth behavior: do not trust downstream
  778. // headers when using Claude Code-scoped OAuth credentials.
  779. func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
  780. if req == nil {
  781. return
  782. }
  783. // Start with the standard defaults (fill missing).
  784. applyClaudeOAuthHeaderDefaults(req)
  785. // Then force key headers to match Claude Code fingerprint regardless of what the client sent.
  786. // 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App")
  787. for key, value := range claude.DefaultHeaders {
  788. if value == "" {
  789. continue
  790. }
  791. setHeaderRaw(req.Header, resolveWireCasing(key), value)
  792. }
  793. // Real Claude CLI uses Accept: application/json (even for streaming).
  794. setHeaderRaw(req.Header, "Accept", "application/json")
  795. if isStream {
  796. setHeaderRaw(req.Header, "x-stainless-helper-method", "stream")
  797. }
  798. // Real Claude CLI 每个请求都会生成一个新的 UUID 放在 x-client-request-id。
  799. // 上游会以此作为会话/请求指纹的一部分,缺失或重复都可能触发第三方判定。
  800. if getHeaderRaw(req.Header, "x-client-request-id") == "" {
  801. setHeaderRaw(req.Header, "x-client-request-id", uuid.NewString())
  802. }
  803. }
  804. func truncateForLog(b []byte, maxBytes int) string {
  805. if maxBytes <= 0 {
  806. maxBytes = 2048
  807. }
  808. if len(b) > maxBytes {
  809. b = b[:maxBytes]
  810. }
  811. s := string(b)
  812. // 保持一行,避免污染日志格式
  813. s = strings.ReplaceAll(s, "\n", "\\n")
  814. s = strings.ReplaceAll(s, "\r", "\\r")
  815. return s
  816. }
  817. // buildCustomRelayURL 构建自定义中继转发 URL
  818. // 在 path 后附加 beta=true 和可选的 proxy 查询参数
  819. func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string {
  820. u := strings.TrimRight(baseURL, "/") + path + "?beta=true"
  821. if account.ProxyID != nil && account.Proxy != nil {
  822. proxyURL := account.Proxy.URL()
  823. if proxyURL != "" {
  824. u += "&proxy=" + url.QueryEscape(proxyURL)
  825. }
  826. }
  827. return u
  828. }
  829. func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
  830. if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
  831. normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
  832. if err != nil {
  833. return "", fmt.Errorf("invalid base_url: %w", err)
  834. }
  835. return normalized, nil
  836. }
  837. normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
  838. AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
  839. RequireAllowlist: true,
  840. AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
  841. })
  842. if err != nil {
  843. return "", fmt.Errorf("invalid base_url: %w", err)
  844. }
  845. return normalized, nil
  846. }