| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927 |
- package service
- import (
- "bytes"
- "context"
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
- "github.com/google/uuid"
- "github.com/tidwall/gjson"
- "github.com/gin-gonic/gin"
- )
- 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) {
- body = stripDeferredToolCacheControl(body)
- if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
- req, err := s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream)
- return req, body, err
- }
- // 确定目标URL
- targetURL := claudeAPIURL
- if account.Type == AccountTypeAPIKey {
- baseURL := account.GetBaseURL()
- if baseURL != "" {
- validatedURL, err := s.validateUpstreamBaseURL(baseURL)
- if err != nil {
- return nil, nil, err
- }
- targetURL = validatedURL + "/v1/messages?beta=true"
- }
- } else if account.IsCustomBaseURLEnabled() {
- customURL := account.GetCustomBaseURL()
- if customURL == "" {
- return nil, nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
- }
- validatedURL, err := s.validateUpstreamBaseURL(customURL)
- if err != nil {
- return nil, nil, err
- }
- targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages", account)
- }
- clientHeaders := http.Header{}
- if c != nil && c.Request != nil {
- clientHeaders = c.Request.Header
- }
- // OAuth账号:应用统一指纹和metadata重写(受设置开关控制)
- var fingerprint *Fingerprint
- enableFP, enableMPT := true, false
- if s.settingService != nil {
- enableFP, enableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx)
- }
- if account.IsOAuth() && s.identityService != nil {
- // 1. 获取或创建指纹(包含随机生成的ClientID)
- fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
- if err != nil {
- logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err)
- // 失败时降级为透传原始headers
- } else {
- if enableFP {
- fingerprint = fp
- }
- // 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid)
- // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
- // 当 metadata 透传开启时跳过重写
- if !enableMPT {
- accountUUID := account.GetExtraString("account_uuid")
- if accountUUID != "" && fp.ClientID != "" {
- if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
- body = newBody
- }
- }
- }
- }
- }
- // Mimicry may override the cached User-Agent later, even without a fingerprint.
- if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, fingerprint); billingUA != "" {
- body = syncBillingHeaderVersion(body, billingUA)
- }
- // === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)===
- //
- // 顺序约束:
- // 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径会忽略客户端 beta,
- // 与原“OAuth + mimicClaudeCode 跳过白名单透传”行为对齐)
- // 2) 按 finalBeta 做能力维度 body sanitize(如 context-management beta 缺失 →
- // strip body.context_management,与 Bedrock 路径对称)
- // 3) CCH 签名(必须使用 strip 后的 body,否则 hash 与最终 body 不一致 →
- // 被 Anthropic 判 third-party)
- // 4) NewRequest(body 至此最终敲定)
- // 5) 透传白名单 / fingerprint / mimic header / 写入 finalBeta
- policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID)
- effectiveDropSet := mergeDropSets(policyFilterSet)
- finalBetaHeader, finalBetaShouldSet := s.computeFinalAnthropicBeta(
- tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet,
- )
- // 账号覆写了 anthropic-beta 时,覆写值即最终上游值(由下方 ApplyHeaderOverrides 写入):
- // body 能力净化必须以覆写值为准,否则 header/body 不对称会被上游 400。
- if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
- finalBetaHeader, finalBetaShouldSet = beta, true
- }
- // 能力维度 body sanitize:与最终 anthropic-beta header 对称
- if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
- body = sanitized
- }
- // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的
- // base 取值同源(GetBaseURL),仅实际上游为 ollama.com 且映射后出站模型
- // 为 DeepSeek 系时压到 cap,详见 helper 注释。
- body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body)
- req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
- if err != nil {
- return nil, nil, err
- }
- // 设置认证头(保持原始大小写)
- if tokenType == "oauth" {
- setHeaderRaw(req.Header, "authorization", "Bearer "+token)
- } else {
- // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方
- // targetURL 的 base 取值),其余保持 extra/default 行为。
- setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL())
- }
- // 白名单透传 headers
- // OAuth mimicry 路径:跳过客户端 header 透传,与 Parrot 对齐。
- // Parrot 的 build_upstream_headers 只发 9 个精确 header,不透传任何客户端 header。
- // 透传客户端 header 会引入不一致的 x-stainless-* / anthropic-beta / user-agent /
- // x-claude-code-session-id 等值,和我们注入的伪装 header 冲突,被 Anthropic 判 third-party。
- if tokenType != "oauth" || !mimicClaudeCode {
- for key, values := range clientHeaders {
- lowerKey := strings.ToLower(key)
- if allowedHeaders[lowerKey] {
- wireKey := resolveWireCasing(key)
- for _, v := range values {
- addHeaderRaw(req.Header, wireKey, v)
- }
- }
- }
- }
- // OAuth账号:应用缓存的指纹到请求头(覆盖白名单透传的头)
- if fingerprint != nil {
- s.identityService.ApplyFingerprint(req, fingerprint)
- }
- // 确保必要的headers存在(保持原始大小写)
- if getHeaderRaw(req.Header, "content-type") == "" {
- setHeaderRaw(req.Header, "content-type", "application/json")
- }
- if getHeaderRaw(req.Header, "anthropic-version") == "" {
- setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
- }
- if tokenType == "oauth" {
- applyClaudeOAuthHeaderDefaults(req)
- }
- // OAuth + mimic Claude Code:强制注入 CLI 指纹相关 header
- // (user-agent/x-stainless-*/x-app/Accept/x-stainless-helper-method/x-client-request-id)
- if tokenType == "oauth" && mimicClaudeCode {
- applyClaudeCodeMimicHeaders(req, reqStream)
- }
- // 写入最终 anthropic-beta header
- // 注:透传分支白名单可能写入了客户端 anthropic-beta,无条件 Del 一次再按 finalBeta
- // 决定是否 set,确保 dropSet 过滤后的结果一定覆盖客户端原始值。
- deleteHeaderAllForms(req.Header, "anthropic-beta")
- if finalBetaShouldSet {
- setHeaderRaw(req.Header, "anthropic-beta", finalBetaHeader)
- }
- // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
- if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
- if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
- if parsed := ParseMetadataUserID(uid); parsed != nil {
- setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
- }
- }
- }
- // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)。
- // 放在所有 header 逻辑之后,确保配置值对同名头拥有最终决定权。
- account.ApplyHeaderOverrides(req.Header)
- // === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
- s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
- "url": req.URL.String(),
- "token_type": tokenType,
- "mimic_claude_code": strconv.FormatBool(mimicClaudeCode),
- "fingerprint_applied": strconv.FormatBool(fingerprint != nil),
- "enable_fp": strconv.FormatBool(enableFP),
- "enable_mpt": strconv.FormatBool(enableMPT),
- })
- // Always capture a compact fingerprint line for later error diagnostics.
- // We only print it when needed (or when the explicit debug flag is enabled).
- if c != nil && tokenType == "oauth" {
- c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
- }
- if s.debugClaudeMimicEnabled() {
- logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode)
- }
- return req, body, nil
- }
- // vertexSupportedBetaTokens 是 Vertex AI 的 Anthropic 端点接受的 anthropic-beta
- // 白名单。Vertex 对任何未知 token 直接 HTTP 400,故采用白名单(与 Bedrock 的
- // bedrockSupportedBetaTokens 同思路)而非黑名单:未来 Claude Code 新增的、Vertex 尚未
- // 支持的 token 天然被剥离。当 Vertex 新增支持某 beta 时在此补充。
- //
- // 明确排除(issue #3358 中 Vertex 报 400 的 token):advisor-tool-2026-03-01、
- // prompt-caching-scope-2026-01-05、redact-thinking-2026-02-12、
- // thinking-token-count-2026-05-13;以及 claude-code-20250219 / oauth-2025-04-20 等
- // 客户端身份 beta——Vertex service_account 走 Bearer 鉴权,不需要它们。
- var vertexSupportedBetaTokens = map[string]bool{
- "context-1m-2025-08-07": true,
- "context-management-2025-06-27": true,
- "fine-grained-tool-streaming-2025-05-14": true,
- "interleaved-thinking-2025-05-14": true,
- }
- // filterVertexBetaTokens 解析 client 的 anthropic-beta header,先剔除 drop 集合中的
- // token(BetaPolicy filter + 默认 drop),再只保留 Vertex 支持的 token,去重后逗号拼接。
- // 返回最终 header(可能为空字符串)。
- func filterVertexBetaTokens(header string, drop map[string]struct{}) string {
- tokens := parseAnthropicBetaHeader(header)
- if len(tokens) == 0 {
- return ""
- }
- out := make([]string, 0, len(tokens))
- seen := make(map[string]bool, len(tokens))
- for _, t := range tokens {
- if _, dropped := drop[t]; dropped {
- continue
- }
- if !vertexSupportedBetaTokens[t] {
- continue
- }
- if seen[t] {
- continue
- }
- seen[t] = true
- out = append(out, t)
- }
- return strings.Join(out, ",")
- }
- func (s *GatewayService) buildUpstreamRequestAnthropicVertex(
- ctx context.Context,
- c *gin.Context,
- account *Account,
- body []byte,
- token string,
- modelID string,
- reqStream bool,
- ) (*http.Request, error) {
- vertexBody, err := buildVertexAnthropicRequestBody(body)
- if err != nil {
- return nil, err
- }
- // 计算最终 outgoing anthropic-beta。Vertex AI 的 Anthropic 端点只接受一小撮
- // beta token,未知 token 会直接 HTTP 400——近期 Claude Code CLI 透传的
- // advisor-tool-2026-03-01 / prompt-caching-scope-2026-01-05 /
- // redact-thinking-2026-02-12 / thinking-token-count-2026-05-13 都不被 Vertex 接受
- // (issue #3358)。这里复用 BetaPolicy 的 block 检查(与 Bedrock 的
- // resolveBedrockBetaTokensForRequest 对称),再按 vertexSupportedBetaTokens 白名单
- // 剥离其余 token,使该路径与 Anthropic 直连 / Bedrock 路径行为一致。
- clientBeta := ""
- if c != nil && c.Request != nil {
- clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
- }
- policy := s.evaluateBetaPolicy(ctx, clientBeta, account, modelID)
- if policy.blockErr != nil {
- return nil, policy.blockErr
- }
- finalBeta := filterVertexBetaTokens(clientBeta, mergeDropSets(policy.filterSet))
- // 能力维度 sanitize:基于最终 beta(而非原始 client 值)决定是否保留 body 中的
- // context_management,与 Anthropic 直连 / Bedrock 路径对称。
- if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(vertexBody, finalBeta); changed {
- vertexBody = sanitized
- }
- fullURL, err := buildVertexAnthropicURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, reqStream)
- if err != nil {
- return nil, err
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(vertexBody))
- if err != nil {
- return nil, err
- }
- if c != nil && c.Request != nil {
- for key, values := range c.Request.Header {
- lowerKey := strings.ToLower(strings.TrimSpace(key))
- if !allowedHeaders[lowerKey] || lowerKey == "anthropic-version" {
- continue
- }
- wireKey := resolveWireCasing(key)
- for _, v := range values {
- addHeaderRaw(req.Header, wireKey, v)
- }
- }
- }
- req.Header.Del("authorization")
- req.Header.Del("x-api-key")
- req.Header.Del("x-goog-api-key")
- req.Header.Del("cookie")
- req.Header.Del("anthropic-version")
- setHeaderRaw(req.Header, "authorization", "Bearer "+token)
- setHeaderRaw(req.Header, "content-type", "application/json")
- // 覆盖上面白名单 loop 写入的原始 client anthropic-beta,使用过滤后的最终值。
- // finalBeta 为空(全部被剥离)时不下发该 header,与 Vertex 无 beta 请求一致。
- deleteHeaderAllForms(req.Header, "anthropic-beta")
- if finalBeta != "" {
- setHeaderRaw(req.Header, "anthropic-beta", finalBeta)
- }
- s.debugLogGatewaySnapshot("UPSTREAM_FORWARD_VERTEX_ANTHROPIC", req.Header, vertexBody, map[string]string{
- "url": req.URL.String(),
- "token_type": "service_account",
- "model": modelID,
- "stream": strconv.FormatBool(reqStream),
- })
- return req, nil
- }
- // getBetaHeader 处理anthropic-beta header
- // 对于OAuth账号,需要确保包含oauth-2025-04-20
- func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string) string {
- // 如果客户端传了anthropic-beta
- if clientBetaHeader != "" {
- // 已包含oauth beta则直接返回
- if strings.Contains(clientBetaHeader, claude.BetaOAuth) {
- return clientBetaHeader
- }
- // 需要添加oauth beta
- parts := strings.Split(clientBetaHeader, ",")
- for i, p := range parts {
- parts[i] = strings.TrimSpace(p)
- }
- // 在claude-code-20250219后面插入oauth beta
- claudeCodeIdx := -1
- for i, p := range parts {
- if p == claude.BetaClaudeCode {
- claudeCodeIdx = i
- break
- }
- }
- if claudeCodeIdx >= 0 {
- // 在claude-code后面插入
- newParts := make([]string, 0, len(parts)+1)
- newParts = append(newParts, parts[:claudeCodeIdx+1]...)
- newParts = append(newParts, claude.BetaOAuth)
- newParts = append(newParts, parts[claudeCodeIdx+1:]...)
- return strings.Join(newParts, ",")
- }
- // 没有claude-code,放在第一位
- return claude.BetaOAuth + "," + clientBetaHeader
- }
- // OAuth 真实客户端透传且客户端没传 beta 时,根据模型生成默认值。
- // Haiku 的透传默认值不补 claude-code beta;mimic 路径不会调用本分支。
- if strings.Contains(strings.ToLower(modelID), "haiku") {
- return claude.HaikuBetaHeader
- }
- return claude.DefaultBetaHeader
- }
- func requestNeedsBetaFeatures(body []byte) bool {
- tools := gjson.GetBytes(body, "tools")
- if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
- return true
- }
- thinkingType := gjson.GetBytes(body, "thinking.type").String()
- if strings.EqualFold(thinkingType, "enabled") || strings.EqualFold(thinkingType, "adaptive") {
- return true
- }
- return false
- }
- func defaultAPIKeyBetaHeader(body []byte) string {
- modelID := gjson.GetBytes(body, "model").String()
- if strings.Contains(strings.ToLower(modelID), "haiku") {
- return claude.APIKeyHaikuBetaHeader
- }
- return claude.APIKeyBetaHeader
- }
- func applyClaudeOAuthHeaderDefaults(req *http.Request) {
- if req == nil {
- return
- }
- if getHeaderRaw(req.Header, "Accept") == "" {
- setHeaderRaw(req.Header, "Accept", "application/json")
- }
- for key, value := range claude.DefaultHeaders {
- if value == "" {
- continue
- }
- if getHeaderRaw(req.Header, key) == "" {
- setHeaderRaw(req.Header, resolveWireCasing(key), value)
- }
- }
- }
- func mergeAnthropicBeta(required []string, incoming string) string {
- seen := make(map[string]struct{}, len(required)+8)
- out := make([]string, 0, len(required)+8)
- add := func(v string) {
- v = strings.TrimSpace(v)
- if v == "" {
- return
- }
- if _, ok := seen[v]; ok {
- return
- }
- seen[v] = struct{}{}
- out = append(out, v)
- }
- for _, r := range required {
- add(r)
- }
- for _, p := range strings.Split(incoming, ",") {
- add(p)
- }
- return strings.Join(out, ",")
- }
- func mergeAnthropicBetaDropping(required []string, incoming string, drop map[string]struct{}) string {
- merged := mergeAnthropicBeta(required, incoming)
- if merged == "" || len(drop) == 0 {
- return merged
- }
- out := make([]string, 0, 8)
- for _, p := range strings.Split(merged, ",") {
- p = strings.TrimSpace(p)
- if p == "" {
- continue
- }
- if _, ok := drop[p]; ok {
- continue
- }
- out = append(out, p)
- }
- return strings.Join(out, ",")
- }
- // computeFinalAnthropicBeta 计算发往上游的最终 anthropic-beta header 值。
- //
- // 设计动机:将原本在 buildUpstreamRequest 内联在一起、依赖 req.Header 的
- // anthropic-beta 计算逻辑抽成纯函数。这样调用方可以在 NewRequest 之前
- // 就提前拿到最终 beta header,进而能按它对 body 做能力维度 sanitize 后再做
- // CCH 签名——一举修复了以下之前由顺序依赖导致的能力维度 sanitize
- // 无法部署的问题(签名与最终 body 不一致可以被判 third-party)。
- //
- // 返回 (value, shouldSet):
- // - shouldSet=false 意为“不主动设置 anthropic-beta header”,与原代码“
- // API-key 账号 + 客户端未传 anthropic-beta + InjectBetaForAPIKey 未开启或
- // requestNeedsBetaFeatures=false”的行为对齐。
- // - shouldSet=true 时 value 可能为空字符串(例如客户端透传的 beta 被 dropSet
- // 全部过滤掉),这与原代码中 setHeaderRaw 的结果一致。
- //
- // clientHeaders 是客户端原始 HTTP header(通常为 c.Request.Header);nil 时按“客户端
- // 未传”处理。body 是已经 metadata 重写 / billing version sync 之后但未 sanitize 上游
- // 不兼容字段之前的版本。
- func (s *GatewayService) computeFinalAnthropicBeta(
- tokenType string,
- mimicClaudeCode bool,
- modelID string,
- clientHeaders http.Header,
- body []byte,
- effectiveDropSet map[string]struct{},
- ) (string, bool) {
- clientBeta := ""
- if clientHeaders != nil {
- clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta")
- }
- if tokenType == "oauth" {
- if mimicClaudeCode {
- // mimic 路径跳过白名单透传,incomingBeta 始终为空;所有模型都必须
- // 携带完整 Claude Code beta 集合,避免 Haiku 被识别为第三方客户端。
- return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), "", effectiveDropSet), true
- }
- // 真 Claude Code 客户端透传路径
- return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true
- }
- // API-key accounts
- if clientBeta != "" {
- return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true
- }
- if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
- if requestNeedsBetaFeatures(body) {
- if beta := defaultAPIKeyBetaHeader(body); beta != "" {
- return beta, true
- }
- }
- }
- return "", false
- }
- // computeFinalCountTokensAnthropicBeta 是 count_tokens 路径上 anthropic-beta header 的
- // 计算纯函数。语义与 computeFinalAnthropicBeta 对齐,但备份了 count_tokens 独有的
- // 两条特殊规则:
- //
- // - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting;
- // count_tokens 另外保留客户端 beta,而 messages mimic 会忽略客户端 beta。
- // - OAuth 透传 + 客户端未传 anthropic-beta:补齐 CountTokensBetaHeader
- // - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting(如果未含)
- //
- // 返回语义同 computeFinalAnthropicBeta。
- func (s *GatewayService) computeFinalCountTokensAnthropicBeta(
- tokenType string,
- mimicClaudeCode bool,
- modelID string,
- clientHeaders http.Header,
- body []byte,
- effectiveDropSet map[string]struct{},
- ) (string, bool) {
- clientBeta := ""
- if clientHeaders != nil {
- clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta")
- }
- if tokenType == "oauth" {
- if mimicClaudeCode {
- // 与原代码严格等价:original buildCountTokensRequest 在 count_tokens mimic
- // 分支上**不**会跳过白名单透传(与 messages mimic 路径不同),所以
- // incomingBeta = req.Header[anthropic-beta] = 客户端透传过来的 client beta。
- // 重构后直接从 clientHeaders 拿同一个值,保持行为一致。
- requiredBetas := append(claude.FullClaudeCodeMimicryBetas(), claude.BetaTokenCounting)
- return mergeAnthropicBetaDropping(requiredBetas, clientBeta, effectiveDropSet), true
- }
- if clientBeta == "" {
- return claude.CountTokensBetaHeader, true
- }
- beta := s.getBetaHeader(modelID, clientBeta)
- if !strings.Contains(beta, claude.BetaTokenCounting) {
- beta = beta + "," + claude.BetaTokenCounting
- }
- return stripBetaTokensWithSet(beta, effectiveDropSet), true
- }
- // API-key accounts
- if clientBeta != "" {
- return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true
- }
- if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
- if requestNeedsBetaFeatures(body) {
- if beta := defaultAPIKeyBetaHeader(body); beta != "" {
- return beta, true
- }
- }
- }
- return "", false
- }
- // stripBetaTokens removes the given beta tokens from a comma-separated header value.
- func stripBetaTokens(header string, tokens []string) string {
- if header == "" || len(tokens) == 0 {
- return header
- }
- return stripBetaTokensWithSet(header, buildBetaTokenSet(tokens))
- }
- func stripBetaTokensWithSet(header string, drop map[string]struct{}) string {
- if header == "" || len(drop) == 0 {
- return header
- }
- parts := strings.Split(header, ",")
- out := make([]string, 0, len(parts))
- for _, p := range parts {
- p = strings.TrimSpace(p)
- if p == "" {
- continue
- }
- if _, ok := drop[p]; ok {
- continue
- }
- out = append(out, p)
- }
- if len(out) == len(parts) {
- return header // no change, avoid allocation
- }
- return strings.Join(out, ",")
- }
- // BetaBlockedError indicates a request was blocked by a beta policy rule.
- type BetaBlockedError struct {
- Message string
- }
- func (e *BetaBlockedError) Error() string { return e.Message }
- // betaPolicyResult holds the evaluated result of beta policy rules for a single request.
- type betaPolicyResult struct {
- blockErr *BetaBlockedError // non-nil if a block rule matched
- filterSet map[string]struct{} // tokens to filter (may be nil)
- }
- // evaluateBetaPolicy loads settings once and evaluates all rules against the given request.
- func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account, model string) betaPolicyResult {
- if s.settingService == nil {
- return betaPolicyResult{}
- }
- settings, err := s.settingService.GetBetaPolicySettings(ctx)
- if err != nil || settings == nil {
- return betaPolicyResult{}
- }
- isOAuth := account.IsOAuth()
- isBedrock := account.IsBedrock()
- var result betaPolicyResult
- for _, rule := range settings.Rules {
- if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
- continue
- }
- effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model)
- switch effectiveAction {
- case BetaPolicyActionBlock:
- if result.blockErr == nil && betaHeader != "" && containsBetaToken(betaHeader, rule.BetaToken) {
- msg := effectiveErrMsg
- if msg == "" {
- msg = "beta feature " + rule.BetaToken + " is not allowed"
- }
- result.blockErr = &BetaBlockedError{Message: msg}
- }
- case BetaPolicyActionFilter:
- if result.filterSet == nil {
- result.filterSet = make(map[string]struct{})
- }
- result.filterSet[rule.BetaToken] = struct{}{}
- }
- }
- return result
- }
- // mergeDropSets merges the static defaultDroppedBetasSet with dynamic policy filter tokens.
- // Returns defaultDroppedBetasSet directly when policySet is empty (zero allocation).
- func mergeDropSets(policySet map[string]struct{}, extra ...string) map[string]struct{} {
- if len(policySet) == 0 && len(extra) == 0 {
- return defaultDroppedBetasSet
- }
- m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(policySet)+len(extra))
- for t := range defaultDroppedBetasSet {
- m[t] = struct{}{}
- }
- for t := range policySet {
- m[t] = struct{}{}
- }
- for _, t := range extra {
- m[t] = struct{}{}
- }
- return m
- }
- // betaPolicyFilterSetKey is the gin.Context key for caching the policy filter set within a request.
- const betaPolicyFilterSetKey = "betaPolicyFilterSet"
- // getBetaPolicyFilterSet returns the beta policy filter set, using the gin context cache if available.
- // In the /v1/messages path, Forward() evaluates the policy first and caches the result;
- // buildUpstreamRequest reuses it (zero extra DB calls). In the count_tokens path, this
- // evaluates on demand (one DB call).
- func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Context, account *Account, model string) map[string]struct{} {
- if c != nil {
- if v, ok := c.Get(betaPolicyFilterSetKey); ok {
- if fs, ok := v.(map[string]struct{}); ok {
- return fs
- }
- }
- }
- return s.evaluateBetaPolicy(ctx, "", account, model).filterSet
- }
- // betaPolicyScopeMatches checks whether a rule's scope matches the current account type.
- func betaPolicyScopeMatches(scope string, isOAuth bool, isBedrock bool) bool {
- switch scope {
- case BetaPolicyScopeAll:
- return true
- case BetaPolicyScopeOAuth:
- return isOAuth
- case BetaPolicyScopeAPIKey:
- return !isOAuth && !isBedrock
- case BetaPolicyScopeBedrock:
- return isBedrock
- default:
- return true // unknown scope → match all (fail-open)
- }
- }
- // matchModelWhitelist checks if a model matches any pattern in the whitelist.
- // Reuses matchModelPattern from group.go which supports exact and wildcard prefix matching.
- func matchModelWhitelist(model string, whitelist []string) bool {
- for _, pattern := range whitelist {
- if matchModelPattern(pattern, model) {
- return true
- }
- }
- return false
- }
- // resolveRuleAction determines the effective action and error message for a rule given the request model.
- // When ModelWhitelist is empty, the rule's primary Action/ErrorMessage applies unconditionally.
- // When non-empty, Action applies to matching models; FallbackAction/FallbackErrorMessage applies to others.
- func resolveRuleAction(rule BetaPolicyRule, model string) (action, errorMessage string) {
- if len(rule.ModelWhitelist) == 0 {
- return rule.Action, rule.ErrorMessage
- }
- if matchModelWhitelist(model, rule.ModelWhitelist) {
- return rule.Action, rule.ErrorMessage
- }
- if rule.FallbackAction != "" {
- return rule.FallbackAction, rule.FallbackErrorMessage
- }
- return BetaPolicyActionPass, "" // default fallback: pass (fail-open)
- }
- // droppedBetaSet returns claude.DroppedBetas as a set, with optional extra tokens.
- func droppedBetaSet(extra ...string) map[string]struct{} {
- m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(extra))
- for t := range defaultDroppedBetasSet {
- m[t] = struct{}{}
- }
- for _, t := range extra {
- m[t] = struct{}{}
- }
- return m
- }
- // containsBetaToken checks if a comma-separated header value contains the given token.
- func containsBetaToken(header, token string) bool {
- if header == "" || token == "" {
- return false
- }
- for _, p := range strings.Split(header, ",") {
- if strings.TrimSpace(p) == token {
- return true
- }
- }
- return false
- }
- func filterBetaTokens(tokens []string, filterSet map[string]struct{}) []string {
- if len(tokens) == 0 || len(filterSet) == 0 {
- return tokens
- }
- kept := make([]string, 0, len(tokens))
- for _, token := range tokens {
- if _, filtered := filterSet[token]; !filtered {
- kept = append(kept, token)
- }
- }
- return kept
- }
- func (s *GatewayService) resolveBedrockBetaTokensForRequest(
- ctx context.Context,
- account *Account,
- betaHeader string,
- body []byte,
- modelID string,
- ) ([]string, error) {
- // 1. 对原始 header 中的 beta token 做 block 检查(快速失败)
- policy := s.evaluateBetaPolicy(ctx, betaHeader, account, modelID)
- if policy.blockErr != nil {
- return nil, policy.blockErr
- }
- // 2. 解析 header + body 自动注入 + Bedrock 转换/过滤
- betaTokens := ResolveBedrockBetaTokens(betaHeader, body, modelID)
- // 3. 对最终 token 列表再做 block 检查,捕获通过 body 自动注入绕过 header block 的情况。
- // 例如:管理员 block 了 interleaved-thinking,客户端不在 header 中带该 token,
- // 但请求体中包含 thinking 字段 → autoInjectBedrockBetaTokens 会自动补齐 →
- // 如果不做此检查,block 规则会被绕过。
- if blockErr := s.checkBetaPolicyBlockForTokens(ctx, betaTokens, account, modelID); blockErr != nil {
- return nil, blockErr
- }
- return filterBetaTokens(betaTokens, policy.filterSet), nil
- }
- // checkBetaPolicyBlockForTokens 检查 token 列表中是否有被管理员 block 规则命中的 token。
- // 用于补充 evaluateBetaPolicy 对 header 的检查,覆盖 body 自动注入的 token。
- func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, tokens []string, account *Account, model string) *BetaBlockedError {
- if s.settingService == nil || len(tokens) == 0 {
- return nil
- }
- settings, err := s.settingService.GetBetaPolicySettings(ctx)
- if err != nil || settings == nil {
- return nil
- }
- isOAuth := account.IsOAuth()
- isBedrock := account.IsBedrock()
- tokenSet := buildBetaTokenSet(tokens)
- for _, rule := range settings.Rules {
- effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model)
- if effectiveAction != BetaPolicyActionBlock {
- continue
- }
- if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
- continue
- }
- if _, present := tokenSet[rule.BetaToken]; present {
- msg := effectiveErrMsg
- if msg == "" {
- msg = "beta feature " + rule.BetaToken + " is not allowed"
- }
- return &BetaBlockedError{Message: msg}
- }
- }
- return nil
- }
- func buildBetaTokenSet(tokens []string) map[string]struct{} {
- m := make(map[string]struct{}, len(tokens))
- for _, t := range tokens {
- if t == "" {
- continue
- }
- m[t] = struct{}{}
- }
- return m
- }
- var defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas)
- // applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
- // This mirrors opencode-anthropic-auth behavior: do not trust downstream
- // headers when using Claude Code-scoped OAuth credentials.
- func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
- if req == nil {
- return
- }
- // Start with the standard defaults (fill missing).
- applyClaudeOAuthHeaderDefaults(req)
- // Then force key headers to match Claude Code fingerprint regardless of what the client sent.
- // 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App")
- for key, value := range claude.DefaultHeaders {
- if value == "" {
- continue
- }
- setHeaderRaw(req.Header, resolveWireCasing(key), value)
- }
- // Real Claude CLI uses Accept: application/json (even for streaming).
- setHeaderRaw(req.Header, "Accept", "application/json")
- if isStream {
- setHeaderRaw(req.Header, "x-stainless-helper-method", "stream")
- }
- // Real Claude CLI 每个请求都会生成一个新的 UUID 放在 x-client-request-id。
- // 上游会以此作为会话/请求指纹的一部分,缺失或重复都可能触发第三方判定。
- if getHeaderRaw(req.Header, "x-client-request-id") == "" {
- setHeaderRaw(req.Header, "x-client-request-id", uuid.NewString())
- }
- }
- func truncateForLog(b []byte, maxBytes int) string {
- if maxBytes <= 0 {
- maxBytes = 2048
- }
- if len(b) > maxBytes {
- b = b[:maxBytes]
- }
- s := string(b)
- // 保持一行,避免污染日志格式
- s = strings.ReplaceAll(s, "\n", "\\n")
- s = strings.ReplaceAll(s, "\r", "\\r")
- return s
- }
- // buildCustomRelayURL 构建自定义中继转发 URL
- // 在 path 后附加 beta=true 和可选的 proxy 查询参数
- func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string {
- u := strings.TrimRight(baseURL, "/") + path + "?beta=true"
- if account.ProxyID != nil && account.Proxy != nil {
- proxyURL := account.Proxy.URL()
- if proxyURL != "" {
- u += "&proxy=" + url.QueryEscape(proxyURL)
- }
- }
- return u
- }
- func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
- if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
- normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
- if err != nil {
- return "", fmt.Errorf("invalid base_url: %w", err)
- }
- return normalized, nil
- }
- normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
- AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
- RequireAllowlist: true,
- AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
- })
- if err != nil {
- return "", fmt.Errorf("invalid base_url: %w", err)
- }
- return normalized, nil
- }
|