Merge pull request #2662 from touwaeriol/feat/bedrock-cc-compat

feat(bedrock): add Claude Code compatibility for AWS Bedrock
This commit is contained in:
Wesley Liddick 2026-05-22 17:32:11 +08:00 committed by GitHub
commit 9f91a8af17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 352 additions and 86 deletions

View File

@ -744,8 +744,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
if channelMapping.Mapped {
parsedReq.Model = channelMapping.MappedModel
parsedReq.Body = h.gatewayService.ReplaceModelInBody(parsedReq.Body, channelMapping.MappedModel)
body = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
}
// Bedrock CC 兼容:渠道模型映射后,清理 Anthropic API 专有字段、注入 Bedrock 必需字段
parsedReq.Body = h.gatewayService.ApplyBedrockCCCompat(c.Request.Context(), parsedReq.Body, parsedReq.Model, account, apiKey.GroupID)
body = parsedReq.Body
// 转发请求 - 根据账号平台分流
c.Set("parsed_request", parsedReq)

View File

@ -9,6 +9,7 @@ import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@ -209,6 +210,7 @@ func PrepareBedrockRequestBodyWithTokens(body []byte, modelID string, betaTokens
if err != nil {
return nil, fmt.Errorf("inject anthropic_beta: %w", err)
}
logger.LegacyPrintf("service.gateway", "[Bedrock] Injected beta tokens: %v (model=%s ccCompat=%v)", betaTokens, modelID, ccCompat)
}
// 移除 model 字段Bedrock 通过 URL 指定模型)
@ -456,17 +458,17 @@ func parseAnthropicBetaHeader(header string) []string {
}
// bedrockSupportedBetaTokens 是 Bedrock Invoke 支持的 beta 头白名单
// 参考: litellm/litellm/llms/bedrock/common_utils.py (anthropic_beta_headers_config.json)
// 参考: AWS Bedrock 官方文档 + litellm anthropic_beta_headers_config.json
// 更新策略: 当 AWS Bedrock 新增支持的 beta token 时需同步更新此白名单
var bedrockSupportedBetaTokens = map[string]bool{
"computer-use-2025-01-24": true,
"computer-use-2025-11-24": true,
"context-1m-2025-08-07": true,
"context-management-2025-06-27": true,
"compact-2026-01-12": true,
"interleaved-thinking-2025-05-14": true,
"tool-search-tool-2025-10-19": true,
"tool-examples-2025-10-29": true,
"computer-use-2025-01-24": true,
"computer-use-2025-11-24": true,
"context-1m-2025-08-07": true,
// "context-management-2025-06-27": false, // 无官方文档支持
"compact-2026-01-12": true, // 官方支持,仅 InvokeModel APIOpus 4.6+
// "interleaved-thinking-2025-05-14": false, // 无官方文档支持
"tool-search-tool-2025-10-19": true,
"tool-examples-2025-10-29": true,
}
// bedrockBetaTokenTransforms 定义 Bedrock Invoke 特有的 beta 头转换规则
@ -494,11 +496,8 @@ func autoInjectBedrockBetaTokens(tokens []string, body []byte, modelID string) [
}
}
// 检测 thinking / interleaved thinking
// 请求体中有 "thinking" 字段 → 需要 interleaved-thinking beta
if gjson.GetBytes(body, "thinking").Exists() {
inject("interleaved-thinking-2025-05-14")
}
// 注意thinking 字段不再自动注入 interleaved-thinking-2025-05-14
// 因为该 beta token 未在 AWS Bedrock 官方文档中确认支持
// 检测 computer_use 工具
// tools 中有 type="computer_20xxxxxx" 的工具 → 需要 computer-use beta
@ -702,3 +701,71 @@ func sanitizeIDField(body []byte, id, path string) []byte {
}
return body
}
const defaultCCMaxTokens = 81920
// sanitizeBedrockCCFields 处理 Claude Code 发送的 Bedrock 不兼容字段:
// - 移除 service_tierAnthropic API 专有Bedrock 不支持)
// - 移除 interface_geoAnthropic API 专有Bedrock 不支持)
// - 移除 context_managementAnthropic API 专有Bedrock 不支持CC v2.1.87+ 默认携带)
// - 注入 max_tokens 默认值 81920CC 可能省略Bedrock 要求必须提供)
// - 注入 anthropic_versionCC 通过 HTTP 头发送Bedrock 需要放在请求体中)
func sanitizeBedrockCCFields(body []byte) []byte {
if gjson.GetBytes(body, "service_tier").Exists() {
body, _ = sjson.DeleteBytes(body, "service_tier")
}
if gjson.GetBytes(body, "interface_geo").Exists() {
body, _ = sjson.DeleteBytes(body, "interface_geo")
}
if gjson.GetBytes(body, "context_management").Exists() {
body, _ = sjson.DeleteBytes(body, "context_management")
}
if !gjson.GetBytes(body, "max_tokens").Exists() {
body, _ = sjson.SetBytes(body, "max_tokens", defaultCCMaxTokens)
}
if !gjson.GetBytes(body, "anthropic_version").Exists() {
body, _ = sjson.SetBytes(body, "anthropic_version", "bedrock-2023-05-31")
}
return body
}
// sanitizeBedrockCCBetaTokens 清理请求体中的 anthropic_beta 字段,只保留 Bedrock 支持的 beta token
// CC 可能在请求体中注入了 Bedrock 不支持的 beta token如 prompt-caching 等),导致 ValidationException
func sanitizeBedrockCCBetaTokens(body []byte, modelID string) []byte {
betaField := gjson.GetBytes(body, "anthropic_beta")
if !betaField.Exists() {
return body
}
var tokens []string
if betaField.IsArray() {
for _, t := range betaField.Array() {
if t.Type == gjson.String {
tokens = append(tokens, t.String())
}
}
}
originalTokens := append([]string(nil), tokens...) // 保存原始 tokens 用于日志
// 复用现有的 Bedrock beta token 过滤逻辑(自动注入 + 白名单过滤 + 转换)
// 即使 tokens 为空,也要执行自动注入(根据 body 内容补充必要的 beta token
tokens = autoInjectBedrockBetaTokens(tokens, body, modelID)
tokens = filterBedrockBetaTokens(tokens)
if len(tokens) == 0 {
// 所有 token 都被过滤掉,删除 anthropic_beta 字段
body, _ = sjson.DeleteBytes(body, "anthropic_beta")
logger.LegacyPrintf("service.gateway", "[Bedrock CC Compat] Removed all beta tokens: original=%v", originalTokens)
} else {
// 更新为过滤后的 token 列表
body, _ = sjson.SetBytes(body, "anthropic_beta", tokens)
if len(originalTokens) > 0 {
logger.LegacyPrintf("service.gateway", "[Bedrock CC Compat] Filtered beta tokens: original=%v final=%v", originalTokens, tokens)
} else {
logger.LegacyPrintf("service.gateway", "[Bedrock CC Compat] Auto-injected beta tokens: %v", tokens)
}
}
return body
}

View File

@ -216,7 +216,7 @@ func TestPrepareBedrockRequestBody_FullIntegration(t *testing.T) {
]
}`
betaHeader := "interleaved-thinking-2025-05-14, context-1m-2025-08-07, compact-2026-01-12"
betaHeader := "context-1m-2025-08-07, compact-2026-01-12"
result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", betaHeader)
require.NoError(t, err)
@ -228,10 +228,9 @@ func TestPrepareBedrockRequestBody_FullIntegration(t *testing.T) {
// anthropic_beta 应包含所有 beta tokens
betaArr := gjson.GetBytes(result, "anthropic_beta").Array()
require.Len(t, betaArr, 3)
assert.Equal(t, "interleaved-thinking-2025-05-14", betaArr[0].String())
assert.Equal(t, "context-1m-2025-08-07", betaArr[1].String())
assert.Equal(t, "compact-2026-01-12", betaArr[2].String())
require.Len(t, betaArr, 2)
assert.Equal(t, "context-1m-2025-08-07", betaArr[0].String())
assert.Equal(t, "compact-2026-01-12", betaArr[1].String())
// output_format 应被移除schema 内联到最后一条 user message
assert.False(t, gjson.GetBytes(result, "output_format").Exists())
@ -264,29 +263,29 @@ func TestPrepareBedrockRequestBody_BetaHeader(t *testing.T) {
})
t.Run("single beta token", func(t *testing.T) {
result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "interleaved-thinking-2025-05-14")
result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "context-1m-2025-08-07")
require.NoError(t, err)
arr := gjson.GetBytes(result, "anthropic_beta").Array()
require.Len(t, arr, 1)
assert.Equal(t, "interleaved-thinking-2025-05-14", arr[0].String())
assert.Equal(t, "context-1m-2025-08-07", arr[0].String())
})
t.Run("multiple beta tokens with spaces", func(t *testing.T) {
result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "interleaved-thinking-2025-05-14 , context-1m-2025-08-07 ")
result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "context-1m-2025-08-07 , compact-2026-01-12 ")
require.NoError(t, err)
arr := gjson.GetBytes(result, "anthropic_beta").Array()
require.Len(t, arr, 2)
assert.Equal(t, "interleaved-thinking-2025-05-14", arr[0].String())
assert.Equal(t, "context-1m-2025-08-07", arr[1].String())
assert.Equal(t, "context-1m-2025-08-07", arr[0].String())
assert.Equal(t, "compact-2026-01-12", arr[1].String())
})
t.Run("json array beta header", func(t *testing.T) {
result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", `["interleaved-thinking-2025-05-14","context-1m-2025-08-07"]`)
result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", `["context-1m-2025-08-07","compact-2026-01-12"]`)
require.NoError(t, err)
arr := gjson.GetBytes(result, "anthropic_beta").Array()
require.Len(t, arr, 2)
assert.Equal(t, "interleaved-thinking-2025-05-14", arr[0].String())
assert.Equal(t, "context-1m-2025-08-07", arr[1].String())
assert.Equal(t, "context-1m-2025-08-07", arr[0].String())
assert.Equal(t, "compact-2026-01-12", arr[1].String())
})
}
@ -301,15 +300,15 @@ func TestParseAnthropicBetaHeader(t *testing.T) {
func TestFilterBedrockBetaTokens(t *testing.T) {
t.Run("supported tokens pass through", func(t *testing.T) {
tokens := []string{"interleaved-thinking-2025-05-14", "context-1m-2025-08-07", "compact-2026-01-12"}
tokens := []string{"context-1m-2025-08-07", "compact-2026-01-12", "computer-use-2025-11-24"}
result := filterBedrockBetaTokens(tokens)
assert.Equal(t, tokens, result)
})
t.Run("unsupported tokens are filtered out", func(t *testing.T) {
tokens := []string{"interleaved-thinking-2025-05-14", "output-128k-2025-02-19", "files-api-2025-04-14", "structured-outputs-2025-11-13"}
tokens := []string{"context-1m-2025-08-07", "output-128k-2025-02-19", "files-api-2025-04-14", "structured-outputs-2025-11-13"}
result := filterBedrockBetaTokens(tokens)
assert.Equal(t, []string{"interleaved-thinking-2025-05-14"}, result)
assert.Equal(t, []string{"context-1m-2025-08-07"}, result)
})
t.Run("advanced-tool-use transforms to tool-search-tool", func(t *testing.T) {
@ -361,11 +360,11 @@ func TestPrepareBedrockRequestBody_BetaFiltering(t *testing.T) {
t.Run("unsupported beta tokens are filtered", func(t *testing.T) {
result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1",
"interleaved-thinking-2025-05-14, output-128k-2025-02-19, files-api-2025-04-14")
"compact-2026-01-12, output-128k-2025-02-19, files-api-2025-04-14")
require.NoError(t, err)
arr := gjson.GetBytes(result, "anthropic_beta").Array()
require.Len(t, arr, 1)
assert.Equal(t, "interleaved-thinking-2025-05-14", arr[0].String())
assert.Equal(t, "compact-2026-01-12", arr[0].String())
})
t.Run("advanced-tool-use transformed in full pipeline", func(t *testing.T) {
@ -498,22 +497,17 @@ func TestResolveBedrockModelID(t *testing.T) {
}
func TestAutoInjectBedrockBetaTokens(t *testing.T) {
t.Run("inject interleaved-thinking when thinking present", func(t *testing.T) {
t.Run("no auto-inject for thinking (interleaved-thinking not supported)", func(t *testing.T) {
body := []byte(`{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[{"role":"user","content":"hi"}]}`)
result := autoInjectBedrockBetaTokens(nil, body, "us.anthropic.claude-opus-4-6-v1")
assert.Contains(t, result, "interleaved-thinking-2025-05-14")
// interleaved-thinking-2025-05-14 已从白名单移除,不应自动注入
assert.Empty(t, result)
})
t.Run("no duplicate when already present", func(t *testing.T) {
body := []byte(`{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[{"role":"user","content":"hi"}]}`)
result := autoInjectBedrockBetaTokens([]string{"interleaved-thinking-2025-05-14"}, body, "us.anthropic.claude-opus-4-6-v1")
count := 0
for _, t := range result {
if t == "interleaved-thinking-2025-05-14" {
count++
}
}
assert.Equal(t, 1, count)
result := autoInjectBedrockBetaTokens([]string{"context-1m-2025-08-07"}, body, "us.anthropic.claude-opus-4-6-v1")
assert.Equal(t, []string{"context-1m-2025-08-07"}, result)
})
t.Run("inject computer-use when computer tool present", func(t *testing.T) {
@ -574,7 +568,8 @@ func TestAutoInjectBedrockBetaTokens(t *testing.T) {
result := autoInjectBedrockBetaTokens(existing, body, "us.anthropic.claude-opus-4-6-v1")
assert.Contains(t, result, "context-1m-2025-08-07")
assert.Contains(t, result, "compact-2026-01-12")
assert.Contains(t, result, "interleaved-thinking-2025-05-14")
// interleaved-thinking 不再自动注入
assert.NotContains(t, result, "interleaved-thinking-2025-05-14")
})
}
@ -588,27 +583,21 @@ func TestResolveBedrockBetaTokens(t *testing.T) {
t.Run("unsupported client beta tokens are filtered out", func(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"hi"}]}`)
result := ResolveBedrockBetaTokens("interleaved-thinking-2025-05-14,files-api-2025-04-14", body, "us.anthropic.claude-opus-4-6-v1")
assert.Equal(t, []string{"interleaved-thinking-2025-05-14"}, result)
result := ResolveBedrockBetaTokens("context-1m-2025-08-07,files-api-2025-04-14", body, "us.anthropic.claude-opus-4-6-v1")
assert.Equal(t, []string{"context-1m-2025-08-07"}, result)
})
}
func TestPrepareBedrockRequestBody_AutoBetaInjection(t *testing.T) {
t.Run("thinking in body auto-injects beta without header", func(t *testing.T) {
t.Run("thinking in body does not auto-inject beta (not supported)", func(t *testing.T) {
input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100,"thinking":{"type":"enabled","budget_tokens":10000}}`
result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "")
require.NoError(t, err)
arr := gjson.GetBytes(result, "anthropic_beta").Array()
found := false
for _, v := range arr {
if v.String() == "interleaved-thinking-2025-05-14" {
found = true
}
}
assert.True(t, found, "interleaved-thinking should be auto-injected")
// interleaved-thinking 已从白名单移除,不应自动注入
assert.False(t, gjson.GetBytes(result, "anthropic_beta").Exists())
})
t.Run("header tokens merged with auto-injected tokens", func(t *testing.T) {
t.Run("header tokens preserved without auto-injection", func(t *testing.T) {
input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100,"thinking":{"type":"enabled","budget_tokens":10000}}`
result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "context-1m-2025-08-07")
require.NoError(t, err)
@ -618,7 +607,8 @@ func TestPrepareBedrockRequestBody_AutoBetaInjection(t *testing.T) {
names[i] = v.String()
}
assert.Contains(t, names, "context-1m-2025-08-07")
assert.Contains(t, names, "interleaved-thinking-2025-05-14")
// interleaved-thinking 不再自动注入
assert.NotContains(t, names, "interleaved-thinking-2025-05-14")
})
}
@ -887,3 +877,133 @@ func TestPrepareBedrockRequestBodyWithTokens_CCCompat(t *testing.T) {
assert.Equal(t, "toolu_01_Ab", gjson.GetBytes(result, "messages.0.content.0.id").String())
})
}
func TestSanitizeBedrockCCFields(t *testing.T) {
t.Run("removes service_tier and interface_geo", func(t *testing.T) {
body := []byte(`{"model":"claude-opus-4-6","service_tier":"standard","interface_geo":"us","messages":[]}`)
result := sanitizeBedrockCCFields(body)
assert.False(t, gjson.GetBytes(result, "service_tier").Exists())
assert.False(t, gjson.GetBytes(result, "interface_geo").Exists())
assert.True(t, gjson.GetBytes(result, "messages").Exists())
})
t.Run("removes context_management", func(t *testing.T) {
body := []byte(`{"model":"claude-opus-4-6","context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[]}`)
result := sanitizeBedrockCCFields(body)
assert.False(t, gjson.GetBytes(result, "context_management").Exists())
assert.True(t, gjson.GetBytes(result, "messages").Exists())
})
t.Run("injects max_tokens when missing", func(t *testing.T) {
body := []byte(`{"model":"claude-opus-4-6","messages":[]}`)
result := sanitizeBedrockCCFields(body)
assert.Equal(t, int64(defaultCCMaxTokens), gjson.GetBytes(result, "max_tokens").Int())
})
t.Run("preserves existing max_tokens", func(t *testing.T) {
body := []byte(`{"model":"claude-opus-4-6","max_tokens":4096,"messages":[]}`)
result := sanitizeBedrockCCFields(body)
assert.Equal(t, int64(4096), gjson.GetBytes(result, "max_tokens").Int())
})
t.Run("injects anthropic_version when missing", func(t *testing.T) {
body := []byte(`{"model":"claude-opus-4-6","messages":[]}`)
result := sanitizeBedrockCCFields(body)
assert.Equal(t, "bedrock-2023-05-31", gjson.GetBytes(result, "anthropic_version").String())
})
t.Run("preserves existing anthropic_version", func(t *testing.T) {
body := []byte(`{"model":"claude-opus-4-6","anthropic_version":"bedrock-2023-05-31","messages":[]}`)
result := sanitizeBedrockCCFields(body)
assert.Equal(t, "bedrock-2023-05-31", gjson.GetBytes(result, "anthropic_version").String())
})
t.Run("no-op when fields already clean", func(t *testing.T) {
body := []byte(`{"model":"claude-opus-4-6","max_tokens":81920,"anthropic_version":"bedrock-2023-05-31","messages":[]}`)
result := sanitizeBedrockCCFields(body)
assert.Equal(t, int64(defaultCCMaxTokens), gjson.GetBytes(result, "max_tokens").Int())
assert.Equal(t, "bedrock-2023-05-31", gjson.GetBytes(result, "anthropic_version").String())
assert.False(t, gjson.GetBytes(result, "service_tier").Exists())
assert.False(t, gjson.GetBytes(result, "interface_geo").Exists())
assert.False(t, gjson.GetBytes(result, "context_management").Exists())
})
t.Run("full CC request sanitization", func(t *testing.T) {
body := []byte(`{
"model":"claude-opus-4-6",
"service_tier":"standard",
"interface_geo":"us",
"context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},
"messages":[{"role":"user","content":"hello"}],
"thinking":{"type":"enabled"}
}`)
result := sanitizeBedrockCCFields(body)
assert.False(t, gjson.GetBytes(result, "service_tier").Exists())
assert.False(t, gjson.GetBytes(result, "interface_geo").Exists())
assert.False(t, gjson.GetBytes(result, "context_management").Exists())
assert.Equal(t, int64(defaultCCMaxTokens), gjson.GetBytes(result, "max_tokens").Int())
assert.Equal(t, "bedrock-2023-05-31", gjson.GetBytes(result, "anthropic_version").String())
assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
})
}
func TestSanitizeBedrockCCBetaTokens(t *testing.T) {
t.Run("filters unsupported beta tokens", func(t *testing.T) {
input := `{"anthropic_beta":["prompt-caching-2024-07-31","context-1m-2025-08-07","unsupported-feature"],"messages":[]}`
result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
beta := gjson.GetBytes(result, "anthropic_beta")
assert.True(t, beta.Exists())
assert.True(t, beta.IsArray())
tokens := beta.Array()
assert.Equal(t, 1, len(tokens))
assert.Equal(t, "context-1m-2025-08-07", tokens[0].String())
})
t.Run("removes anthropic_beta if all tokens filtered", func(t *testing.T) {
input := `{"anthropic_beta":["prompt-caching-2024-07-31","unsupported-feature"],"messages":[]}`
result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
assert.False(t, gjson.GetBytes(result, "anthropic_beta").Exists())
})
t.Run("thinking alone does not auto-inject beta tokens", func(t *testing.T) {
input := `{"anthropic_beta":[],"thinking":{"type":"enabled"},"messages":[]}`
result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
assert.False(t, gjson.GetBytes(result, "anthropic_beta").Exists())
})
t.Run("auto-injects computer-use beta token", func(t *testing.T) {
input := `{"anthropic_beta":[],"tools":[{"type":"computer_20250124","name":"computer"}],"messages":[]}`
result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
beta := gjson.GetBytes(result, "anthropic_beta")
assert.True(t, beta.Exists())
tokens := beta.Array()
assert.Equal(t, 1, len(tokens))
assert.Equal(t, "computer-use-2025-11-24", tokens[0].String())
})
t.Run("transforms advanced-tool-use to tool-search-tool", func(t *testing.T) {
input := `{"anthropic_beta":["advanced-tool-use-2025-11-20"],"messages":[]}`
result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
beta := gjson.GetBytes(result, "anthropic_beta")
tokens := beta.Array()
assert.Equal(t, 2, len(tokens)) // tool-search-tool + tool-examples (auto-associated)
assert.Contains(t, []string{tokens[0].String(), tokens[1].String()}, "tool-search-tool-2025-10-19")
assert.Contains(t, []string{tokens[0].String(), tokens[1].String()}, "tool-examples-2025-10-29")
})
t.Run("no-op when anthropic_beta not present", func(t *testing.T) {
input := `{"messages":[]}`
result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
assert.False(t, gjson.GetBytes(result, "anthropic_beta").Exists())
})
t.Run("preserves supported beta tokens", func(t *testing.T) {
input := `{"anthropic_beta":["computer-use-2025-11-24","context-1m-2025-08-07"],"messages":[]}`
result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
beta := gjson.GetBytes(result, "anthropic_beta")
tokens := beta.Array()
assert.Equal(t, 2, len(tokens))
assert.Contains(t, []string{tokens[0].String(), tokens[1].String()}, "computer-use-2025-11-24")
assert.Contains(t, []string{tokens[0].String(), tokens[1].String()}, "context-1m-2025-08-07")
})
}

View File

@ -248,16 +248,14 @@ func (c *Channel) IsWebSearchEmulationEnabled(platform string) bool {
return ok && enabled
}
// IsBedrockCCCompatEnabled 返回该渠道是否为指定平台启用了 Bedrock CC 兼容模式。
// IsBedrockCCCompatEnabled 返回该渠道是否启用了 Bedrock CC 兼容模式。
// 一旦启用,该渠道下所有请求都会应用 CC 兼容转换,不区分账号 platform。
func (c *Channel) IsBedrockCCCompatEnabled(platform string) bool {
if c == nil || c.FeaturesConfig == nil {
return false
}
bcc, ok := c.FeaturesConfig[featureKeyBedrockCCCompat].(map[string]any)
if !ok {
return false
}
enabled, ok := bcc[platform].(bool)
// 直接检查 bedrock_cc_compat 开关,不再检查 platform 子字段
enabled, ok := c.FeaturesConfig[featureKeyBedrockCCCompat].(bool)
return ok && enabled
}

View File

@ -0,0 +1,73 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestChannel_IsBedrockCCCompatEnabled_Enabled(t *testing.T) {
c := &Channel{
FeaturesConfig: map[string]any{
featureKeyBedrockCCCompat: true,
},
}
require.True(t, c.IsBedrockCCCompatEnabled("bedrock"))
}
func TestChannel_IsBedrockCCCompatEnabled_AppliesToAllPlatforms(t *testing.T) {
c := &Channel{
FeaturesConfig: map[string]any{
featureKeyBedrockCCCompat: true,
},
}
require.True(t, c.IsBedrockCCCompatEnabled("anthropic"))
require.True(t, c.IsBedrockCCCompatEnabled("openai"))
require.True(t, c.IsBedrockCCCompatEnabled(""))
}
func TestChannel_IsBedrockCCCompatEnabled_Disabled(t *testing.T) {
c := &Channel{
FeaturesConfig: map[string]any{
featureKeyBedrockCCCompat: false,
},
}
require.False(t, c.IsBedrockCCCompatEnabled("bedrock"))
}
func TestChannel_IsBedrockCCCompatEnabled_NilFeaturesConfig(t *testing.T) {
c := &Channel{FeaturesConfig: nil}
require.False(t, c.IsBedrockCCCompatEnabled("bedrock"))
}
func TestChannel_IsBedrockCCCompatEnabled_NilChannel(t *testing.T) {
var c *Channel
require.False(t, c.IsBedrockCCCompatEnabled("bedrock"))
}
func TestChannel_IsBedrockCCCompatEnabled_WrongType(t *testing.T) {
c := &Channel{
FeaturesConfig: map[string]any{
featureKeyBedrockCCCompat: "yes",
},
}
require.False(t, c.IsBedrockCCCompatEnabled("bedrock"))
}
func TestChannel_IsBedrockCCCompatEnabled_OldMapFormat(t *testing.T) {
c := &Channel{
FeaturesConfig: map[string]any{
featureKeyBedrockCCCompat: map[string]any{"bedrock": true},
},
}
require.False(t, c.IsBedrockCCCompatEnabled("bedrock"))
}
func TestChannel_IsBedrockCCCompatEnabled_MissingKey(t *testing.T) {
c := &Channel{
FeaturesConfig: map[string]any{
"other_feature": true,
},
}
require.False(t, c.IsBedrockCCCompatEnabled("bedrock"))
}

View File

@ -4352,13 +4352,6 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
return s.handleWebSearchEmulation(ctx, c, account, parsed)
}
// Bedrock CC 兼容:在转发之前对请求体做 CC 兼容处理。
// 只修改 body 内容thinking 类型、tool_use ID不影响后续的透传/Bedrock 转发路径。
if account != nil && s.isBedrockCCCompatEnabled(ctx, account, parsed.GroupID) {
parsed.Body = sanitizeBedrockThinking(parsed.Body, parsed.Model)
parsed.Body = sanitizeBedrockToolUseIDs(parsed.Body)
}
if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() {
passthroughBody := parsed.Body
passthroughModel := parsed.Model
@ -5644,6 +5637,19 @@ func writeAnthropicPassthroughResponseHeaders(dst http.Header, src http.Header,
}
}
// ApplyBedrockCCCompat 应用 Bedrock CC 兼容转换(渠道级模型映射后调用)
// 清理 Anthropic API 专有字段、注入 Bedrock 必需字段、修复 thinking/tool_use ID
func (s *GatewayService) ApplyBedrockCCCompat(ctx context.Context, body []byte, model string, account *Account, groupID *int64) []byte {
if !s.isBedrockCCCompatEnabled(ctx, account, groupID) {
return body
}
body = sanitizeBedrockCCFields(body)
body = sanitizeBedrockThinking(body, model)
body = sanitizeBedrockToolUseIDs(body)
body = sanitizeBedrockCCBetaTokens(body, model)
return body
}
// isBedrockCCCompatEnabled 检查渠道是否启用了 Bedrock CC 兼容模式
func (s *GatewayService) isBedrockCCCompatEnabled(ctx context.Context, account *Account, groupID *int64) bool {
if groupID == nil || s.channelService == nil {

View File

@ -126,17 +126,17 @@ func TestResolveBedrockBetaTokensForRequest_FiltersAfterBedrockTransform(t *test
}
}
// TestResolveBedrockBetaTokensForRequest_BlocksBodyAutoInjectedThinking 验证:
// 管理员 block 了 interleaved-thinking,客户端不在 header 中带该 token
// 但请求体包含 thinking 字段 → 自动注入后应被 block。
func TestResolveBedrockBetaTokensForRequest_BlocksBodyAutoInjectedThinking(t *testing.T) {
// TestResolveBedrockBetaTokensForRequest_BlocksBodyAutoInjectedComputerUse 验证:
// 管理员 block 了 computer-use,客户端不在 header 中带该 token
// 但请求体包含 computer_use 工具 → 自动注入后应被 block。
func TestResolveBedrockBetaTokensForRequest_BlocksBodyAutoInjectedComputerUse(t *testing.T) {
settings := &BetaPolicySettings{
Rules: []BetaPolicyRule{
{
BetaToken: "interleaved-thinking-2025-05-14",
BetaToken: "computer-use-2025-11-24",
Action: BetaPolicyActionBlock,
Scope: BetaPolicyScopeAll,
ErrorMessage: "thinking is blocked",
ErrorMessage: "computer use is blocked",
},
},
}
@ -155,18 +155,18 @@ func TestResolveBedrockBetaTokensForRequest_BlocksBodyAutoInjectedThinking(t *te
}
account := &Account{Platform: PlatformAnthropic, Type: AccountTypeBedrock}
// header 中不带 beta token但 body 中有 thinking 字段
// header 中不带 beta token但 body 中有 computer_use 工具
_, err = svc.resolveBedrockBetaTokensForRequest(
context.Background(),
account,
"", // 空 header
[]byte(`{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[{"role":"user","content":"hi"}]}`),
[]byte(`{"tools":[{"type":"computer_20250124","name":"computer"}],"messages":[{"role":"user","content":"hi"}]}`),
"us.anthropic.claude-opus-4-6-v1",
)
if err == nil {
t.Fatal("expected body-injected interleaved-thinking to be blocked")
t.Fatal("expected body-injected computer-use to be blocked")
}
if err.Error() != "thinking is blocked" {
if err.Error() != "computer use is blocked" {
t.Fatalf("unexpected error: %v", err)
}
}
@ -222,10 +222,10 @@ func TestResolveBedrockBetaTokensForRequest_PassesWhenNoBlockRuleMatches(t *test
settings := &BetaPolicySettings{
Rules: []BetaPolicyRule{
{
BetaToken: "computer-use-2025-11-24",
BetaToken: "context-1m-2025-08-07",
Action: BetaPolicyActionBlock,
Scope: BetaPolicyScopeAll,
ErrorMessage: "computer use is blocked",
ErrorMessage: "context is blocked",
},
},
}
@ -244,12 +244,12 @@ func TestResolveBedrockBetaTokensForRequest_PassesWhenNoBlockRuleMatches(t *test
}
account := &Account{Platform: PlatformAnthropic, Type: AccountTypeBedrock}
// body 中有 thinking会注入 interleaved-thinking但 block 规则只针对 computer-use
// body 中有 computer_use 工具(会注入 computer-use token但 block 规则只针对 context-1m
tokens, err := svc.resolveBedrockBetaTokensForRequest(
context.Background(),
account,
"",
[]byte(`{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[{"role":"user","content":"hi"}]}`),
[]byte(`{"tools":[{"type":"computer_20250124","name":"computer"}],"messages":[{"role":"user","content":"hi"}]}`),
"us.anthropic.claude-opus-4-6-v1",
)
if err != nil {
@ -257,11 +257,11 @@ func TestResolveBedrockBetaTokensForRequest_PassesWhenNoBlockRuleMatches(t *test
}
found := false
for _, token := range tokens {
if token == "interleaved-thinking-2025-05-14" {
if token == "computer-use-2025-11-24" {
found = true
}
}
if !found {
t.Fatal("expected interleaved-thinking token to be present")
t.Fatal("expected computer-use token to be present")
}
}