上游 Anthropic 在 body 含 `context_management` 但最终发出去的
`anthropic-beta` header 不含 `context-management-2025-06-27` 时会拒收:
{
"type": "invalid_request_error",
"message": "context_management: Extra inputs are not permitted"
}
(HTTP 400, request_id 形如 req_011C...)
该 400 在 haiku 路径上触发,因为三个 beta header 构造器有意排除了
context-management beta:
- HaikuBetaHeader (messages, OAuth / mimic CC)
- APIKeyHaikuBetaHeader (messages, API-key)
- CountTokensBetaHeader (count_tokens, 所有认证类型)
但 body 中仍然带着 `context_management` 字段,原因有二:
1. normalizeClaudeOAuthRequestBody 在 thinking_enabled / thinking_adaptive
打开时为 `clear_thinking_20251015` 主动注入;
2. 客户端 (Claude Code CLI >= 2.1.87) 原样发送, 网关透传时一并转发。
修复方案: 能力维度对称约束
==========================
对齐已有的 Bedrock 模式
(`backend/internal/service/bedrock_request.go` 中的
`sanitizeBedrockFieldsForBetaTokens`):
根据 **最终** 发出的 `anthropic-beta` header 决定是否保留
`body.context_management`, 而不是按 model 名或路由分类来决定。
新增纯函数:
sanitizeAnthropicBodyForBetaTokens(body, betaHeader) (body, changed)
如果 `betaHeader` 不含 `context-management-2025-06-27`, 用 sjson 把 body
字段 strip 掉; 否则原样返回。
在所有 Anthropic / Anthropic-兼容 上游出口都接入:
| 路径 | sanitize 接入点 |
|--------------------------------------------|-------------------------------------------------------|
| /v1/messages OAuth mimic CC | buildUpstreamRequest |
| /v1/messages OAuth 真 CC 透传 | buildUpstreamRequest |
| /v1/messages API-key | buildUpstreamRequest |
| /v1/messages API-key passthrough | buildUpstreamRequestAnthropicAPIKeyPassthrough |
| /v1/messages Vertex / service-account | buildUpstreamRequestAnthropicVertex |
| /v1/messages/count_tokens (全部 4 条路径) | buildCountTokensRequest, |
| | buildCountTokensRequestAnthropicAPIKeyPassthrough |
| Antigravity Anthropic-兼容 上游 | AntigravityGatewayService.ForwardUpstream |
| Bedrock | (已由 sanitizeBedrockFieldsForBetaTokens 处理) |
为什么要重排 (而不是加一行调用)
================================
sanitize 必须 **在** `signBillingHeaderCCH` 之前运行。CCH 对整个 body
取 xxHash64 摘要后写入 billing header 里 5 位十六进制的 `cch` 字段;
如果先签名再 strip, 上游对发出去的 body 重算 hash 会和 `cch` 不一致,
请求被判为 third-party。这就要求在 `http.NewRequest` 之前算出最终的
`anthropic-beta` header, 所以把原本内联在 builder 里的 beta 计算逻辑
抽成了两个纯函数:
- computeFinalAnthropicBeta (messages 路径: mimic 不透传
客户端 beta)
- computeFinalCountTokensAnthropicBeta (count_tokens 路径: mimic 不
跳过白名单透传)
两者逐位保留原行为:
- mimic 路径在 messages 上跳过客户端 beta, 在 count_tokens 上合并
- API-key 路径尊重 `InjectBetaForAPIKey` 开关
- dropSet (`defaultDroppedBetasSet` + BetaPolicy filter) 应用在主路径,
passthrough / Vertex 路径有意不应用 —— 这条原有的不对称行为本 PR
不动。
一条语义测试 (`TestSanitizeMustBeBeforeCCHSigning_HashConsistency`) 把
顺序约束文档化并强制守住: 它证明 `sanitize -> signBillingHeaderCCH`
产生的 `cch` 与最终 body 一致, 而 `signBillingHeaderCCH -> sanitize`
产生的 `cch` 会被上游 hash 重算判失败。
为什么是能力维度 (而不是 haiku 模型名匹配)
==========================================
最朴素的"按 model 名 strip"方案
(`strings.Contains(modelID, "haiku") -> DeleteBytes "context_management"`)
有四个真实失败模式:
1. 过度删除。CLI >= 2.1.87 的真 Claude Code 客户端在 haiku 上同时
发送 body 字段 **和** `anthropic-beta: context-management-2025-06-27`。
一律 strip 会让该用户的 `clear_thinking_20251015` 静默失效。
2. 别名漂移。未来的 haiku 别名 (`claude-3-haiku-...`,
`claude-haiku-...` 等) 改变匹配面; 任何新别名都会悄悄绕过 strip。
3. count_tokens 漏覆盖。count_tokens 有自己的 builder 和不同的 beta
header 集合; 在一个地方做 model 名检查会漏掉这条路径。
4. API-key passthrough 早退。passthrough builder 在 model 名 strip
之前就 return 了, strip 根本不执行。
能力维度沿着 header 端到端走, 上述 4 个 case 都由构造方式保证正确,
不依赖任何 modelID 匹配。
防御项
======
- 当 `sjson.DeleteBytes` 在 `gjson` 刚验证过字段存在的 body 上失败时,
`sanitizeAnthropicBodyForBetaTokens` 会记 warning 日志 —— 这种情况
现实中仅在请求中途被破坏时发生, 日志把此前会静默发生的 body / header
不一致暴露出来。
- `header_util.go` 新增 `deleteHeaderAllForms`: 在白名单透传已经写入
canonical 大小写的 `Anthropic-Beta` 之后再覆盖, 否则会同时留下两条。
测试
====
`backend/internal/service` 下新增 44 个测试:
- 纯函数: anthropicBetaTokensContains x 5, sanitize keep/strip x 6,
computeFinal{Anthropic,CountTokens}AnthropicBeta x 12
- normalize 回归 x 5
- buildUpstreamRequest 端到端 x 4
(OAuth mimic haiku strip / mimic sonnet preserve /
真 CC haiku 带客户端 beta preserve / API-key haiku strip)
- buildCountTokensRequest 端到端 x 2
- buildUpstreamRequestAnthropicAPIKeyPassthrough x 2 (strip / preserve)
- buildCountTokensRequestAnthropicAPIKeyPassthrough x 2 (strip / preserve)
- buildUpstreamRequestAnthropicVertex x 2 (strip / preserve, 含 outgoing
`anthropic-beta` header 对称断言)
- CCH 顺序语义测试 x 1
unit 套件全过 (本机 88s), `golangci-lint` 0 issues。
已知局限 (本 PR 范围外)
========================
- Vertex 路径用透传过来的客户端 `anthropic-beta` header 作为 sanitize
依据, 而不是 Vertex 侧的能力矩阵。最坏情况是过度 strip (= 当前 main
的行为, 主路径本来什么都不 strip); 不是 regression。完整的 Vertex
能力模型属于单独的 PR。
- Vertex builder 仍然不应用 BetaPolicy filter / dropSet。这是该 builder
早 return 的既有架构决策, 本 PR 不动。
- count_tokens mimic 在 haiku 上仍然注入 `context-management-2025-06-27`
(因为原 count_tokens mimic 逻辑并不像 messages mimic 那样排除它)。
本 PR 逐位保留 main 的行为; 是否要让它与 messages mimic 的排除策略
统一是另一个问题。
- `sanitizeAnthropicBodyForBetaTokens` 目前只处理
`context_management <-> context-management-2025-06-27` 这一对。如果
Anthropic 后续推出更多 beta-gated body 字段, 可以在后续 PR 重构为
`{body 路径 -> required beta token}` 注册表的形式。
668 lines
34 KiB
Go
668 lines
34 KiB
Go
//go:build unit
|
||
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/require"
|
||
"github.com/tidwall/gjson"
|
||
)
|
||
|
||
// ============================================================================
|
||
// 背景
|
||
// ============================================================================
|
||
//
|
||
// Anthropic 上游对 body.context_management 字段实施 Pydantic schema 校验:
|
||
// 当且仅当 anthropic-beta header 含 context-management-2025-06-27 时接受。
|
||
// 否则报:
|
||
// "context_management: Extra inputs are not permitted"
|
||
//
|
||
// 本仓采用能力维度对称约束(与 Bedrock 路径的 sanitizeBedrockFieldsForBetaTokens
|
||
// 对称):在所有 Anthropic 直连出口,按最终 anthropic-beta header 是否含上述 token
|
||
// 决定 body 是否保留同名字段。
|
||
//
|
||
// 本文件覆盖:
|
||
// 1) sanitizeAnthropicBodyForBetaTokens 纯函数
|
||
// 2) anthropicBetaTokensContains 解析辅助函数
|
||
// 3) computeFinalAnthropicBeta / computeFinalCountTokensAnthropicBeta 各路径
|
||
// 4) normalizeClaudeOAuthRequestBody 的 context_management 补齐行为(不再按 model 短路)
|
||
|
||
// ============================================================================
|
||
// anthropicBetaTokensContains
|
||
// ============================================================================
|
||
|
||
func TestAnthropicBetaTokensContains_EmptyInputs(t *testing.T) {
|
||
require.False(t, anthropicBetaTokensContains("", "context-management-2025-06-27"))
|
||
require.False(t, anthropicBetaTokensContains("oauth-2025-04-20", ""))
|
||
}
|
||
|
||
func TestAnthropicBetaTokensContains_SingleToken(t *testing.T) {
|
||
require.True(t, anthropicBetaTokensContains("context-management-2025-06-27", "context-management-2025-06-27"))
|
||
}
|
||
|
||
func TestAnthropicBetaTokensContains_MultiTokenComma(t *testing.T) {
|
||
header := "oauth-2025-04-20,context-management-2025-06-27,interleaved-thinking-2025-05-14"
|
||
require.True(t, anthropicBetaTokensContains(header, "context-management-2025-06-27"))
|
||
require.True(t, anthropicBetaTokensContains(header, "oauth-2025-04-20"))
|
||
require.False(t, anthropicBetaTokensContains(header, "fast-mode-2026-02-01"))
|
||
}
|
||
|
||
func TestAnthropicBetaTokensContains_ToleratesWhitespace(t *testing.T) {
|
||
header := "oauth-2025-04-20 , context-management-2025-06-27 , interleaved-thinking-2025-05-14"
|
||
require.True(t, anthropicBetaTokensContains(header, "context-management-2025-06-27"))
|
||
}
|
||
|
||
func TestAnthropicBetaTokensContains_SubstringNotMatched(t *testing.T) {
|
||
// 严格 token 比较,不应被子串误匹配
|
||
require.False(t, anthropicBetaTokensContains("context-management-2025-06-27-rev2", "context-management-2025-06-27"),
|
||
"必须按 token 边界匹配,不允许 prefix 子串误命中")
|
||
}
|
||
|
||
// ============================================================================
|
||
// sanitizeAnthropicBodyForBetaTokens
|
||
// ============================================================================
|
||
|
||
func TestSanitizeAnthropicBodyForBetaTokens_NoFieldNoChange(t *testing.T) {
|
||
body := []byte(`{"model":"claude-haiku-4-5","messages":[]}`)
|
||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
|
||
require.False(t, changed)
|
||
require.Equal(t, string(body), string(out))
|
||
}
|
||
|
||
func TestSanitizeAnthropicBodyForBetaTokens_FieldKeptWhenBetaPresent(t *testing.T) {
|
||
body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
|
||
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
|
||
"oauth-2025-04-20,context-management-2025-06-27,interleaved-thinking-2025-05-14")
|
||
require.False(t, changed)
|
||
require.True(t, gjson.GetBytes(out, "context_management").Exists())
|
||
require.Equal(t, "clear_thinking_20251015",
|
||
gjson.GetBytes(out, "context_management.edits.0.type").String())
|
||
}
|
||
|
||
func TestSanitizeAnthropicBodyForBetaTokens_FieldStrippedWhenBetaMissing(t *testing.T) {
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
|
||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,interleaved-thinking-2025-05-14")
|
||
require.True(t, changed)
|
||
require.False(t, gjson.GetBytes(out, "context_management").Exists(),
|
||
"header 不含 context-management beta 时必须 strip 同名字段")
|
||
}
|
||
|
||
func TestSanitizeAnthropicBodyForBetaTokens_FieldStrippedWhenBetaEmpty(t *testing.T) {
|
||
body := []byte(`{"context_management":{"edits":[]},"messages":[]}`)
|
||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "")
|
||
require.True(t, changed)
|
||
require.False(t, gjson.GetBytes(out, "context_management").Exists())
|
||
}
|
||
|
||
func TestSanitizeAnthropicBodyForBetaTokens_EmptyBody(t *testing.T) {
|
||
out, changed := sanitizeAnthropicBodyForBetaTokens([]byte{}, "")
|
||
require.False(t, changed)
|
||
require.Empty(t, out)
|
||
|
||
out, changed = sanitizeAnthropicBodyForBetaTokens(nil, "")
|
||
require.False(t, changed)
|
||
require.Empty(t, out)
|
||
}
|
||
|
||
// ★ 关键回归断言:能力维度 sanitize 解决了 "真 CC + haiku" 路径的过度删除问题。
|
||
// 真实 Claude Code CLI 2.1.87+ 客户端 header 含 context-management beta;
|
||
// 即使 model 是 haiku,sanitize 也不应剥离功能字段。
|
||
func TestSanitizeAnthropicBodyForBetaTokens_HaikuRealCCClientPreservesField(t *testing.T) {
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[]}`)
|
||
// 真 Claude Code CLI 2.1.87+ 客户端 header 含 context-management beta
|
||
clientBeta := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27"
|
||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta)
|
||
require.False(t, changed,
|
||
"真 CC 客户端 header 含 context-management beta 时,haiku body 字段必须保留(功能不丢)")
|
||
require.True(t, gjson.GetBytes(out, "context_management").Exists())
|
||
}
|
||
|
||
// ============================================================================
|
||
// computeFinalAnthropicBeta — 关键路径
|
||
// ============================================================================
|
||
|
||
func newTestGatewayServiceForBeta(injectBetaForAPIKey bool) *GatewayService {
|
||
cfg := &config.Config{}
|
||
cfg.Gateway.InjectBetaForAPIKey = injectBetaForAPIKey
|
||
return &GatewayService{cfg: cfg}
|
||
}
|
||
|
||
func TestComputeFinalAnthropicBeta_OAuthMimic_NonHaiku_IncludesContextManagement(t *testing.T) {
|
||
s := newTestGatewayServiceForBeta(false)
|
||
final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", http.Header{}, []byte(`{}`), nil)
|
||
require.True(t, ok)
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
|
||
"OAuth mimic non-haiku 必须注入完整 CC mimicry beta,含 context-management-2025-06-27")
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaOAuth))
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaClaudeCode))
|
||
}
|
||
|
||
func TestComputeFinalAnthropicBeta_OAuthMimic_Haiku_ExcludesContextManagement(t *testing.T) {
|
||
s := newTestGatewayServiceForBeta(false)
|
||
final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil)
|
||
require.True(t, ok)
|
||
require.False(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
|
||
"OAuth mimic haiku 仅注入 oauth + interleaved-thinking,不含 context-management")
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaOAuth))
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaInterleavedThinking))
|
||
}
|
||
|
||
func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBeta(t *testing.T) {
|
||
// mimic 路径下原代码白名单透传被跳过,client beta 应被忽略
|
||
s := newTestGatewayServiceForBeta(false)
|
||
hdr := http.Header{}
|
||
hdr.Set("anthropic-beta", "custom-experimental-beta")
|
||
final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
|
||
require.True(t, ok)
|
||
require.False(t, strings.Contains(final, "custom-experimental-beta"),
|
||
"mimic 路径必须忽略客户端 anthropic-beta header")
|
||
}
|
||
|
||
func TestComputeFinalAnthropicBeta_OAuthTransparent_NonHaiku_PreservesClientContextManagement(t *testing.T) {
|
||
// 真 CC 客户端透传:客户端 header 中的 context-management beta 必须保留
|
||
s := newTestGatewayServiceForBeta(false)
|
||
hdr := http.Header{}
|
||
hdr.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27")
|
||
final, ok := s.computeFinalAnthropicBeta("oauth", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
|
||
require.True(t, ok)
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement))
|
||
}
|
||
|
||
func TestComputeFinalAnthropicBeta_OAuthTransparent_Haiku_RealCCPreservesContextManagement(t *testing.T) {
|
||
// haiku 透传 + 客户端带 context-management beta → 必须保留
|
||
// (能力维度核心场景:避免 model-name 误删客户端透传的功能 beta)
|
||
s := newTestGatewayServiceForBeta(false)
|
||
hdr := http.Header{}
|
||
hdr.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27,interleaved-thinking-2025-05-14")
|
||
final, ok := s.computeFinalAnthropicBeta("oauth", false, "claude-haiku-4-5", hdr, []byte(`{}`), nil)
|
||
require.True(t, ok)
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
|
||
"真 CC + haiku + 客户端带 context-management beta → 透传必须保留")
|
||
}
|
||
|
||
func TestComputeFinalAnthropicBeta_APIKey_PassesClientBetaThroughDropSet(t *testing.T) {
|
||
s := newTestGatewayServiceForBeta(false)
|
||
hdr := http.Header{}
|
||
hdr.Set("anthropic-beta", "oauth-2025-04-20,custom-beta")
|
||
final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
|
||
require.True(t, ok)
|
||
require.True(t, anthropicBetaTokensContains(final, "oauth-2025-04-20"))
|
||
require.True(t, anthropicBetaTokensContains(final, "custom-beta"))
|
||
}
|
||
|
||
func TestComputeFinalAnthropicBeta_APIKey_NoClientBetaInjectOff_ShouldNotSet(t *testing.T) {
|
||
s := newTestGatewayServiceForBeta(false)
|
||
final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-sonnet-4-6", http.Header{}, []byte(`{}`), nil)
|
||
require.False(t, ok, "API-key + 客户端未传 + InjectBetaForAPIKey 关 → 不应主动设置 anthropic-beta")
|
||
require.Equal(t, "", final)
|
||
}
|
||
|
||
// ============================================================================
|
||
// computeFinalCountTokensAnthropicBeta
|
||
// ============================================================================
|
||
|
||
func TestComputeFinalCountTokensAnthropicBeta_OAuthMimic_AlwaysIncludesContextManagement(t *testing.T) {
|
||
// count_tokens 路径下 mimic 不按 haiku 排除:始终注入完整 mimicry beta
|
||
s := newTestGatewayServiceForBeta(false)
|
||
final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil)
|
||
require.True(t, ok)
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
|
||
"count_tokens + mimic 即使 haiku 也注入 context-management beta(与 messages 不同)")
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting),
|
||
"count_tokens 路径必须含 token-counting beta")
|
||
}
|
||
|
||
// 重构等价性回归:
|
||
// 原 main buildCountTokensRequest 在 count_tokens mimic 分支上不跳过白名单透传
|
||
// (与 messages mimic 不同),incomingBeta 取自客户端透传。重构后必须从 clientHeaders
|
||
// 拿同一个值并 merge,否则会丢失客户端 beta。
|
||
func TestComputeFinalCountTokensAnthropicBeta_OAuthMimic_PreservesClientBeta(t *testing.T) {
|
||
s := newTestGatewayServiceForBeta(false)
|
||
hdr := http.Header{}
|
||
hdr.Set("anthropic-beta", "custom-experimental-beta,context-1m-2025-08-07")
|
||
final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", hdr, []byte(`{}`), nil)
|
||
require.True(t, ok)
|
||
require.True(t, anthropicBetaTokensContains(final, "custom-experimental-beta"),
|
||
"count_tokens mimic 不同于 messages mimic:原代码会保留客户端透传的 beta")
|
||
require.True(t, anthropicBetaTokensContains(final, "context-1m-2025-08-07"),
|
||
"客户端透传的其他 beta token 同样需要保留")
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
|
||
"同时 FullClaudeCodeMimicryBetas 不打折扣")
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting),
|
||
"同时补齐 token-counting beta")
|
||
}
|
||
|
||
// messages mimic 路径反向验证:原代码会跳过白名单透传,
|
||
// 客户端 beta 不会进入 mimic 计算。重构后 messages computeFinalAnthropicBeta
|
||
// mimic 分支依然不该使用 clientBeta。
|
||
func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBetaExplicit(t *testing.T) {
|
||
s := newTestGatewayServiceForBeta(false)
|
||
hdr := http.Header{}
|
||
hdr.Set("anthropic-beta", "custom-experimental-beta")
|
||
final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
|
||
require.True(t, ok)
|
||
require.False(t, anthropicBetaTokensContains(final, "custom-experimental-beta"),
|
||
"messages mimic 原代码跳过白名单透传 → 客户端 beta 不进入计算。"+
|
||
"与 count_tokens mimic 是不同的设计,不能合并为同一函数。")
|
||
}
|
||
|
||
func TestComputeFinalCountTokensAnthropicBeta_OAuthTransparent_NoClientBetaInjectsDefault(t *testing.T) {
|
||
// 真 CC 客户端透传 + 客户端未传 anthropic-beta → 用 CountTokensBetaHeader 兜底
|
||
s := newTestGatewayServiceForBeta(false)
|
||
final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", false, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil)
|
||
require.True(t, ok)
|
||
require.Equal(t, claude.CountTokensBetaHeader, final)
|
||
// CountTokensBetaHeader 不含 context-management beta
|
||
require.False(t, anthropicBetaTokensContains(final, claude.BetaContextManagement))
|
||
}
|
||
|
||
func TestComputeFinalCountTokensAnthropicBeta_OAuthTransparent_AppendsBetaTokenCounting(t *testing.T) {
|
||
s := newTestGatewayServiceForBeta(false)
|
||
hdr := http.Header{}
|
||
hdr.Set("anthropic-beta", "oauth-2025-04-20,context-management-2025-06-27")
|
||
final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
|
||
require.True(t, ok)
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting),
|
||
"客户端未带 token-counting beta 时必须补齐")
|
||
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
|
||
"客户端带的 context-management beta 必须保留")
|
||
}
|
||
|
||
// ============================================================================
|
||
// normalizeClaudeOAuthRequestBody — 回归:context_management 补齐恢复原行为
|
||
// ============================================================================
|
||
//
|
||
// 重构后该函数不再按 model 名短路:thinking=enabled/adaptive 时补齐 context_management,
|
||
// 与 model 无关。strip 责任移交 sanitizeAnthropicBodyForBetaTokens(在
|
||
// buildUpstreamRequest 层按最终 beta header 执行)。
|
||
|
||
func TestNormalizeClaudeOAuthRequestBody_InjectsContextManagement_ThinkingEnabled(t *testing.T) {
|
||
body := []byte(`{"model":"claude-sonnet-4-6","thinking":{"type":"enabled","budget_tokens":1000},"messages":[]}`)
|
||
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{})
|
||
require.True(t, gjson.GetBytes(out, "context_management").Exists())
|
||
require.Equal(t, "clear_thinking_20251015",
|
||
gjson.GetBytes(out, "context_management.edits.0.type").String())
|
||
}
|
||
|
||
func TestNormalizeClaudeOAuthRequestBody_InjectsContextManagement_ThinkingAdaptive(t *testing.T) {
|
||
body := []byte(`{"model":"claude-opus-4-7","thinking":{"type":"adaptive"},"messages":[]}`)
|
||
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-4-7", claudeOAuthNormalizeOptions{})
|
||
require.True(t, gjson.GetBytes(out, "context_management").Exists())
|
||
}
|
||
|
||
func TestNormalizeClaudeOAuthRequestBody_HaikuStillInjects_StripDeferredToSanitize(t *testing.T) {
|
||
// haiku + thinking=enabled:normalize 阶段仍按 CLI mimicry 行为补齐字段;
|
||
// strip 由 buildUpstreamRequest 层的 sanitize 兜底(如果 final beta 不含 token)。
|
||
body := []byte(`{"model":"claude-haiku-4-5","thinking":{"type":"enabled","budget_tokens":1000},"messages":[]}`)
|
||
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-haiku-4-5", claudeOAuthNormalizeOptions{})
|
||
require.True(t, gjson.GetBytes(out, "context_management").Exists(),
|
||
"normalize 不再按 model 名短路;strip 责任移交 sanitize 层")
|
||
}
|
||
|
||
func TestNormalizeClaudeOAuthRequestBody_PreservesClientContextManagement(t *testing.T) {
|
||
body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"custom_strategy"}]},"thinking":{"type":"enabled","budget_tokens":1000},"messages":[]}`)
|
||
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-4-7", claudeOAuthNormalizeOptions{})
|
||
require.Equal(t, "custom_strategy",
|
||
gjson.GetBytes(out, "context_management.edits.0.type").String(),
|
||
"客户端透传的 context_management 内容必须原样保留")
|
||
}
|
||
|
||
func TestNormalizeClaudeOAuthRequestBody_NoThinking_NoInject(t *testing.T) {
|
||
body := []byte(`{"model":"claude-sonnet-4-6","messages":[]}`)
|
||
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{})
|
||
require.False(t, gjson.GetBytes(out, "context_management").Exists())
|
||
}
|
||
|
||
// ============================================================================
|
||
// passthrough 集成测试:buildUpstreamRequest-
|
||
// AnthropicAPIKeyPassthrough 与 buildCountTokensRequestAnthropicAPIKeyPassthrough
|
||
// 路径上 sanitize 是否生效。
|
||
// ============================================================================
|
||
|
||
// passthrough 集成测试不设 base_url,避开 validateUpstreamBaseURL 对 cfg.Security 的依赖。
|
||
// targetURL 会走默认 claudeAPIURL,sanitize 逻辑与 baseURL 是否存在无关。
|
||
func newAnthropicAPIKeyPassthroughAccountForBetaTest() *Account {
|
||
return &Account{
|
||
ID: 501,
|
||
Name: "anthropic-apikey-passthrough-ctxmgmt-test",
|
||
Platform: PlatformAnthropic,
|
||
Type: AccountTypeAPIKey,
|
||
Credentials: map[string]any{
|
||
"api_key": "upstream-key",
|
||
},
|
||
Extra: map[string]any{"anthropic_passthrough": true},
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
}
|
||
}
|
||
|
||
func readUpstreamBodyForTest(t *testing.T, req *http.Request) []byte {
|
||
t.Helper()
|
||
require.NotNil(t, req.Body)
|
||
b, err := io.ReadAll(req.Body)
|
||
require.NoError(t, err)
|
||
return b
|
||
}
|
||
|
||
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_StripsContextManagementWhenClientHeaderMissingBeta(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||
// 客户端仅带 oauth beta,不带 context-management-2025-06-27
|
||
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20")
|
||
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
|
||
svc := &GatewayService{cfg: &config.Config{}}
|
||
req, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
|
||
)
|
||
require.NoError(t, err)
|
||
require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
|
||
"API-key passthrough + 客户端未带 context-management beta → strip body 字段")
|
||
}
|
||
|
||
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_PreservesContextManagementWhenClientHeaderHasBeta(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,context-management-2025-06-27")
|
||
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
|
||
svc := &GatewayService{cfg: &config.Config{}}
|
||
req, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
|
||
)
|
||
require.NoError(t, err)
|
||
require.True(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
|
||
"API-key passthrough + 客户端带 context-management beta → 字段保留(不过度删除)")
|
||
}
|
||
|
||
func TestBuildCountTokensRequestAnthropicAPIKeyPassthrough_StripsContextManagementWhenClientHeaderMissingBeta(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
|
||
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,token-counting-2024-11-01")
|
||
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[]},"messages":[]}`)
|
||
svc := &GatewayService{cfg: &config.Config{}}
|
||
req, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(
|
||
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
|
||
)
|
||
require.NoError(t, err)
|
||
require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
|
||
"count_tokens passthrough + 客户端未带 context-management beta → strip")
|
||
}
|
||
|
||
// ============================================================================
|
||
// 集成测试:buildUpstreamRequest
|
||
// 全路径验证上游 outgoing body 与 anthropic-beta header 严格对称。
|
||
// 这个测试能挡住未来某人忘调 sanitize / 将 sanitize 挪到 CCH 之后 等 regression。
|
||
// ============================================================================
|
||
|
||
func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsContextManagementEndToEnd(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||
|
||
account := &Account{ID: 401, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
|
||
Credentials: map[string]any{"access_token": "oauth-tok"},
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
}
|
||
// haiku + mimic CC → final beta = HaikuBetaHeader(不含 context-management)→
|
||
// body 必须 strip。
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
|
||
svc := &GatewayService{cfg: &config.Config{}}
|
||
req, err := svc.buildUpstreamRequest(
|
||
context.Background(), c, account, body,
|
||
"oauth-tok", "oauth", "claude-haiku-4-5", false, true, // mimicClaudeCode=true
|
||
)
|
||
require.NoError(t, err)
|
||
|
||
outBody := readUpstreamBodyForTest(t, req)
|
||
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
|
||
|
||
require.False(t, gjson.GetBytes(outBody, "context_management").Exists(),
|
||
"OAuth mimic + haiku 端到端:outgoing body 不应含 context_management")
|
||
require.False(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
|
||
"对称约束:outgoing anthropic-beta header 也不带 context-management beta")
|
||
}
|
||
|
||
func TestBuildUpstreamRequest_OAuthMimicNonHaiku_PreservesContextManagementEndToEnd(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||
|
||
account := &Account{ID: 402, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
|
||
Credentials: map[string]any{"access_token": "oauth-tok"},
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
}
|
||
// sonnet + mimic CC → final beta = FullClaudeCodeMimicryBetas(含 context-management)→
|
||
// body 保留。
|
||
body := []byte(`{"model":"claude-sonnet-4-6","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
|
||
svc := &GatewayService{cfg: &config.Config{}}
|
||
req, err := svc.buildUpstreamRequest(
|
||
context.Background(), c, account, body,
|
||
"oauth-tok", "oauth", "claude-sonnet-4-6", false, true,
|
||
)
|
||
require.NoError(t, err)
|
||
|
||
outBody := readUpstreamBodyForTest(t, req)
|
||
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
|
||
|
||
require.True(t, gjson.GetBytes(outBody, "context_management").Exists(),
|
||
"OAuth mimic + non-haiku:outgoing body 必须保留 context_management。")
|
||
require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
|
||
"对称约束:outgoing anthropic-beta header 同时含 context-management beta")
|
||
}
|
||
|
||
func TestBuildUpstreamRequest_OAuthTransparentHaikuWithRealCCBeta_PreservesField(t *testing.T) {
|
||
// 端到端验证:真 CC 客户端 + haiku + 客户端 header 带 context-management beta
|
||
// → final beta 透传 → 不应该过度删除 body 字段
|
||
gin.SetMode(gin.TestMode)
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||
c.Request.Header.Set("Anthropic-Beta",
|
||
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27")
|
||
|
||
account := &Account{ID: 403, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
|
||
Credentials: map[string]any{"access_token": "oauth-tok"},
|
||
Status: StatusActive, Schedulable: true,
|
||
}
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[]}`)
|
||
svc := &GatewayService{cfg: &config.Config{}}
|
||
req, err := svc.buildUpstreamRequest(
|
||
context.Background(), c, account, body,
|
||
"oauth-tok", "oauth", "claude-haiku-4-5", false, false, // mimicClaudeCode=false(真 CC)
|
||
)
|
||
require.NoError(t, err)
|
||
|
||
outBody := readUpstreamBodyForTest(t, req)
|
||
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
|
||
|
||
require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
|
||
"真 CC 透传路径:客户端 header 中的 context-management beta 必须保留")
|
||
require.True(t, gjson.GetBytes(outBody, "context_management").Exists(),
|
||
"回归保护:真 CC + haiku + 客户端带 beta token 时,clear_thinking_20251015 功能不能静默失效")
|
||
}
|
||
|
||
// CCH 顺序语义测试:sanitize 必须在 signBillingHeaderCCH 之前,
|
||
// 否则签名的 hash 与最终发送的 body 不一致,被 Anthropic 判 third-party。
|
||
//
|
||
// 该测试不走 buildUpstreamRequest 完整路径(需要 mock SettingService 成本高),
|
||
// 而是直接验证两个顺序产生的 cch 不同,证明二者不可交换。
|
||
// 测试名本身是语义约束的文档化 marker。
|
||
func TestSanitizeMustBeBeforeCCHSigning_HashConsistency(t *testing.T) {
|
||
// 构造 body:含 context_management + cch=00000 占位符
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92; cch=00000;"}],"messages":[]}`)
|
||
|
||
// 最终发送场景:final beta 不含 context-management beta → sanitize 会 strip
|
||
finalBeta := "oauth-2025-04-20,interleaved-thinking-2025-05-14"
|
||
|
||
extractCCH := func(t *testing.T, b []byte) string {
|
||
t.Helper()
|
||
m := regexp.MustCompile(`\bcch=([0-9a-fA-F]{5})\b`).FindSubmatch(b)
|
||
require.NotNil(t, m, "body 里找不到 cch=<5hex> :%s", string(b))
|
||
return string(m[1])
|
||
}
|
||
|
||
// === 正确顺序:sanitize → signBillingHeaderCCH ===
|
||
// 1. strip context_management
|
||
sanitizedFirst, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBeta)
|
||
require.True(t, changed)
|
||
require.False(t, gjson.GetBytes(sanitizedFirst, "context_management").Exists())
|
||
// 2. 基于“strip 后的 body”算 hash
|
||
correctFinal := signBillingHeaderCCH(sanitizedFirst)
|
||
correctCCH := extractCCH(t, correctFinal)
|
||
require.NotEqual(t, "00000", correctCCH, "placeholder 应被替换")
|
||
|
||
// === 错误顺序:signBillingHeaderCCH → sanitize(未来 regression 场景)===
|
||
// 1. 先基于“含 context_management 的 body”算 hash → cch=H_with
|
||
signedFirst := signBillingHeaderCCH(body)
|
||
wrongCCH := extractCCH(t, signedFirst)
|
||
require.NotEqual(t, "00000", wrongCCH)
|
||
// 2. 后 strip context_management → body 变化但 cch 仍是 H_with
|
||
wrongFinal, _ := sanitizeAnthropicBodyForBetaTokens(signedFirst, finalBeta)
|
||
wrongFinalCCH := extractCCH(t, wrongFinal)
|
||
|
||
// === 关键断言 ===
|
||
// 上游验证逻辑:将 outgoing body 的 cch 还原为 00000、重算 hash、与 cch 字段比较。
|
||
// 模拟上游验证:用发送 body 算出“期望的 cch”,与发送 body 里的 cch 字段比。
|
||
recomputeExpected := func(b []byte, currentCCH string) string {
|
||
t.Helper()
|
||
// 把 cch=<currentCCH> 还原为 cch=00000
|
||
re := regexp.MustCompile(`(\bcch=)` + currentCCH + `(\b)`)
|
||
restored := re.ReplaceAll(b, []byte("${1}00000${2}"))
|
||
return extractCCH(t, signBillingHeaderCCH(restored))
|
||
}
|
||
|
||
// 正确顺序:发送 body 的 cch == 重算 hash → 上游验证过
|
||
require.Equal(t, correctCCH, recomputeExpected(correctFinal, correctCCH),
|
||
"正确顺序:final body 里的 cch 与重算 hash 一致 → 上游验证通过")
|
||
|
||
// 错误顺序:发送 body 的 cch 是“含 ctx 算的”,但最终 body 不含 ctx → 重算 hash 不同
|
||
require.NotEqual(t, wrongFinalCCH, recomputeExpected(wrongFinal, wrongFinalCCH),
|
||
"错误顺序:final body 里的 cch 是基于含 ctx 的 body 算的,"+
|
||
"但发送 body 已 strip ctx → 上游重算 hash 与 cch 不一致 → 被判 third-party。"+
|
||
"这是 buildUpstreamRequest / buildCountTokensRequest 里 sanitize 必须在 "+
|
||
"signBillingHeaderCCH 之前的原因。")
|
||
}
|
||
|
||
// count_tokens 主路径 E2E 集成测试
|
||
func TestBuildCountTokensRequest_OAuthMimicHaiku_PreservesContextManagementEndToEnd(t *testing.T) {
|
||
// count_tokens 路径下 mimic 不按 haiku 排除,始终注入 BetaContextManagement
|
||
// → sanitize 看到最终 beta header 含 context-management beta → 字段保留。
|
||
gin.SetMode(gin.TestMode)
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
|
||
|
||
account := &Account{ID: 411, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
|
||
Credentials: map[string]any{"access_token": "oauth-tok"},
|
||
Status: StatusActive, Schedulable: true,
|
||
}
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
|
||
svc := &GatewayService{cfg: &config.Config{}}
|
||
req, err := svc.buildCountTokensRequest(
|
||
context.Background(), c, account, body,
|
||
"oauth-tok", "oauth", "claude-haiku-4-5", true, // mimicClaudeCode=true
|
||
)
|
||
require.NoError(t, err)
|
||
|
||
outBody := readUpstreamBodyForTest(t, req)
|
||
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
|
||
|
||
require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
|
||
"count_tokens mimic 始终注入 context-management beta")
|
||
require.True(t, gjson.GetBytes(outBody, "context_management").Exists(),
|
||
"对称约束:final beta 含 token 时 body 字段保留")
|
||
require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaTokenCounting),
|
||
"count_tokens 路径必须含 token-counting beta")
|
||
}
|
||
|
||
func TestBuildCountTokensRequest_APIKeyHaiku_StripsContextManagementEndToEnd(t *testing.T) {
|
||
// API-key + haiku + 客户端 header 不带 context-management beta → final beta 不含 → strip
|
||
gin.SetMode(gin.TestMode)
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
|
||
c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
|
||
|
||
account := &Account{ID: 412, Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
|
||
Credentials: map[string]any{"api_key": "sk-ant-xxx"},
|
||
Status: StatusActive, Schedulable: true,
|
||
}
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[]},"messages":[]}`)
|
||
svc := &GatewayService{cfg: &config.Config{}}
|
||
req, err := svc.buildCountTokensRequest(
|
||
context.Background(), c, account, body,
|
||
"sk-ant-xxx", "apikey", "claude-haiku-4-5", false,
|
||
)
|
||
require.NoError(t, err)
|
||
|
||
outBody := readUpstreamBodyForTest(t, req)
|
||
require.False(t, gjson.GetBytes(outBody, "context_management").Exists(),
|
||
"count_tokens API-key + 客户端未带 beta token → body strip")
|
||
}
|
||
|
||
// count_tokens passthrough preserve 测试
|
||
func TestBuildCountTokensRequestAnthropicAPIKeyPassthrough_PreservesContextManagementWhenClientHeaderHasBeta(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
|
||
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,context-management-2025-06-27,token-counting-2024-11-01")
|
||
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
|
||
svc := &GatewayService{cfg: &config.Config{}}
|
||
req, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(
|
||
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
|
||
)
|
||
require.NoError(t, err)
|
||
require.True(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
|
||
"count_tokens passthrough + 客户端带 context-management beta → 字段保留")
|
||
}
|
||
|
||
func TestBuildUpstreamRequest_APIKeyHaikuWithContextManagement_StripsField(t *testing.T) {
|
||
// API-key + haiku + body 带 context_management + 客户端 header 未带 context-management beta
|
||
// → final beta 不含 → body 字段被 strip
|
||
gin.SetMode(gin.TestMode)
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||
c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
|
||
|
||
account := &Account{ID: 404, Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
|
||
Credentials: map[string]any{"api_key": "sk-ant-xxx"},
|
||
Status: StatusActive, Schedulable: true,
|
||
}
|
||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[]},"messages":[]}`)
|
||
svc := &GatewayService{cfg: &config.Config{}}
|
||
req, err := svc.buildUpstreamRequest(
|
||
context.Background(), c, account, body,
|
||
"sk-ant-xxx", "apikey", "claude-haiku-4-5", false, false,
|
||
)
|
||
require.NoError(t, err)
|
||
|
||
outBody := readUpstreamBodyForTest(t, req)
|
||
require.False(t, gjson.GetBytes(outBody, "context_management").Exists(),
|
||
"API-key + haiku + 客户端未带 beta token → body 字段必须被 strip")
|
||
}
|