diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 3db725f7..4420a87c 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -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) diff --git a/backend/internal/service/bedrock_request.go b/backend/internal/service/bedrock_request.go index 35a1be85..8a1fb317 100644 --- a/backend/internal/service/bedrock_request.go +++ b/backend/internal/service/bedrock_request.go @@ -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 +} diff --git a/backend/internal/service/bedrock_request_test.go b/backend/internal/service/bedrock_request_test.go index b6252849..98942ba4 100644 --- a/backend/internal/service/bedrock_request_test.go +++ b/backend/internal/service/bedrock_request_test.go @@ -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") + }) +} diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 4e5c9b65..88ed2df7 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -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 } diff --git a/backend/internal/service/channel_bedrock_cc_test.go b/backend/internal/service/channel_bedrock_cc_test.go new file mode 100644 index 00000000..1d0476f6 --- /dev/null +++ b/backend/internal/service/channel_bedrock_cc_test.go @@ -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")) +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 28e31438..1b1b9c5e 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -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 { diff --git a/backend/internal/service/gateway_service_bedrock_beta_test.go b/backend/internal/service/gateway_service_bedrock_beta_test.go index 8920ee08..fa2feda1 100644 --- a/backend/internal/service/gateway_service_bedrock_beta_test.go +++ b/backend/internal/service/gateway_service_bedrock_beta_test.go @@ -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") } }