diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 5a9a33880e..bd579fb063 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -6830,6 +6830,48 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex 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, @@ -6844,14 +6886,27 @@ func (s *GatewayService) buildUpstreamRequestAnthropicVertex( return nil, err } - // 能力维度 sanitize:Vertex 路径上 anthropic-beta header 原样透传客户端值 - // (下面白名单跳过 anthropic-version 但保留 anthropic-beta),依此决定是否 - // 保留 body 中的 context_management,与 Anthropic 直连 / Bedrock 路径对称。 + // 计算最终 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") - if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(vertexBody, clientBeta); changed { - vertexBody = sanitized - } + 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 { @@ -6883,6 +6938,13 @@ func (s *GatewayService) buildUpstreamRequestAnthropicVertex( 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",