Merge pull request #2662 from touwaeriol/feat/bedrock-cc-compat
feat(bedrock): add Claude Code compatibility for AWS Bedrock
This commit is contained in:
commit
9f91a8af17
@ -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)
|
||||
|
||||
@ -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 API(Opus 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_tier(Anthropic API 专有,Bedrock 不支持)
|
||||
// - 移除 interface_geo(Anthropic API 专有,Bedrock 不支持)
|
||||
// - 移除 context_management(Anthropic API 专有,Bedrock 不支持,CC v2.1.87+ 默认携带)
|
||||
// - 注入 max_tokens 默认值 81920(CC 可能省略,Bedrock 要求必须提供)
|
||||
// - 注入 anthropic_version(CC 通过 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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
73
backend/internal/service/channel_bedrock_cc_test.go
Normal file
73
backend/internal/service/channel_bedrock_cc_test.go
Normal 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"))
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user