feat(bedrock): add Claude Code compatibility transformations for Bedrock accounts
Add channel-level Bedrock CC compatibility toggle (similar to web_search_emulation) that fixes 4 types of Bedrock 400 errors seen with Claude Code: 1. thinking.type "enabled" → "adaptive" for Opus 4.7+ (only supports adaptive) 2. Add default budget_tokens when missing for older models 3. Replace illegal characters in tool_use IDs to match Bedrock's ^[a-zA-Z0-9_-]+$ pattern 4. anthropic_version / invalid beta flag (already handled elsewhere) Transformations run in Forward() before any forwarding path, so both native Bedrock accounts and apikey passthrough accounts pointing to Bedrock relays benefit. Includes channel-level toggle UI and unit tests.
This commit is contained in:
parent
73b43bbb8a
commit
4fd21994c5
@ -15,6 +15,9 @@ import (
|
|||||||
|
|
||||||
const defaultBedrockRegion = "us-east-1"
|
const defaultBedrockRegion = "us-east-1"
|
||||||
|
|
||||||
|
// featureKeyBedrockCCCompat is the key used in Channel.FeaturesConfig for Bedrock CC compatibility.
|
||||||
|
const featureKeyBedrockCCCompat = "bedrock_cc_compat"
|
||||||
|
|
||||||
var bedrockCrossRegionPrefixes = []string{"us.", "eu.", "apac.", "jp.", "au.", "us-gov.", "global."}
|
var bedrockCrossRegionPrefixes = []string{"us.", "eu.", "apac.", "jp.", "au.", "us-gov.", "global."}
|
||||||
|
|
||||||
// BedrockCrossRegionPrefix 根据 AWS Region 返回 Bedrock 跨区域推理的模型 ID 前缀
|
// BedrockCrossRegionPrefix 根据 AWS Region 返回 Bedrock 跨区域推理的模型 ID 前缀
|
||||||
@ -179,13 +182,16 @@ func BuildBedrockURL(region, modelID string, stream bool) string {
|
|||||||
// 3. 移除 Bedrock 不支持的字段(model, stream, output_format, output_config)
|
// 3. 移除 Bedrock 不支持的字段(model, stream, output_format, output_config)
|
||||||
// 4. 移除工具定义中的 custom 字段(Claude Code 会发送 custom: {defer_loading: true})
|
// 4. 移除工具定义中的 custom 字段(Claude Code 会发送 custom: {defer_loading: true})
|
||||||
// 5. 清理 cache_control 中 Bedrock 不支持的字段(scope, ttl)
|
// 5. 清理 cache_control 中 Bedrock 不支持的字段(scope, ttl)
|
||||||
|
// 6. 修复 thinking 字段兼容性(Opus 4.7 仅支持 adaptive,enabled 需要 budget_tokens)
|
||||||
|
// 7. 清理 tool_use.id / tool_use_id 中 Bedrock 不接受的字符
|
||||||
func PrepareBedrockRequestBody(body []byte, modelID string, betaHeader string) ([]byte, error) {
|
func PrepareBedrockRequestBody(body []byte, modelID string, betaHeader string) ([]byte, error) {
|
||||||
betaTokens := ResolveBedrockBetaTokens(betaHeader, body, modelID)
|
betaTokens := ResolveBedrockBetaTokens(betaHeader, body, modelID)
|
||||||
return PrepareBedrockRequestBodyWithTokens(body, modelID, betaTokens)
|
return PrepareBedrockRequestBodyWithTokens(body, modelID, betaTokens, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrepareBedrockRequestBodyWithTokens prepares a Bedrock request using pre-resolved beta tokens.
|
// PrepareBedrockRequestBodyWithTokens prepares a Bedrock request using pre-resolved beta tokens.
|
||||||
func PrepareBedrockRequestBodyWithTokens(body []byte, modelID string, betaTokens []string) ([]byte, error) {
|
// ccCompat 启用 CC 兼容模式时额外处理 thinking 类型转换和 tool_use.id 清理。
|
||||||
|
func PrepareBedrockRequestBodyWithTokens(body []byte, modelID string, betaTokens []string, ccCompat bool) ([]byte, error) {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// 注入 anthropic_version(Bedrock 要求)
|
// 注入 anthropic_version(Bedrock 要求)
|
||||||
@ -235,6 +241,12 @@ func PrepareBedrockRequestBodyWithTokens(body []byte, modelID string, betaTokens
|
|||||||
// 清理 cache_control 中 Bedrock 不支持的字段
|
// 清理 cache_control 中 Bedrock 不支持的字段
|
||||||
body = sanitizeBedrockCacheControl(body, modelID)
|
body = sanitizeBedrockCacheControl(body, modelID)
|
||||||
|
|
||||||
|
// CC 兼容模式:修复 CC 发送的 Bedrock 不兼容字段
|
||||||
|
if ccCompat {
|
||||||
|
body = sanitizeBedrockThinking(body, modelID)
|
||||||
|
body = sanitizeBedrockToolUseIDs(body)
|
||||||
|
}
|
||||||
|
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -605,3 +617,88 @@ func filterBedrockBetaTokens(tokens []string) []string {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bedrockToolUseIDRe 匹配 Bedrock 允许的 tool_use ID 字符(字母、数字、下划线、连字符)
|
||||||
|
var bedrockToolUseIDRe = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
||||||
|
|
||||||
|
// isBedrockOpus47OrNewer 判断 Bedrock 模型 ID 是否为 Claude Opus 4.7 或更新版本
|
||||||
|
// Opus 4.7 仅支持 thinking.type: "adaptive",不支持 "enabled"
|
||||||
|
func isBedrockOpus47OrNewer(modelID string) bool {
|
||||||
|
lower := strings.ToLower(modelID)
|
||||||
|
if !strings.Contains(lower, "opus") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
matches := claudeVersionRe.FindStringSubmatch(lower)
|
||||||
|
if matches == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
major, _ := strconv.Atoi(matches[1])
|
||||||
|
minor, _ := strconv.Atoi(matches[2])
|
||||||
|
return major > 4 || (major == 4 && minor >= 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultThinkingBudgetTokens = 10000
|
||||||
|
|
||||||
|
// sanitizeBedrockThinking 修复 thinking 字段的 Bedrock 兼容性问题:
|
||||||
|
// - Opus 4.7+: 仅支持 "adaptive",将 "enabled" 转换为 "adaptive" 并移除 budget_tokens
|
||||||
|
// - 其他模型: "enabled" 必须带 budget_tokens,缺失时补充默认值
|
||||||
|
func sanitizeBedrockThinking(body []byte, modelID string) []byte {
|
||||||
|
thinking := gjson.GetBytes(body, "thinking")
|
||||||
|
if !thinking.Exists() || !thinking.IsObject() {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
thinkingType := thinking.Get("type").String()
|
||||||
|
if thinkingType == "" {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
if isBedrockOpus47OrNewer(modelID) {
|
||||||
|
if thinkingType == "enabled" {
|
||||||
|
body, _ = sjson.SetBytes(body, "thinking.type", "adaptive")
|
||||||
|
body, _ = sjson.DeleteBytes(body, "thinking.budget_tokens")
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
if thinkingType == "enabled" && !thinking.Get("budget_tokens").Exists() {
|
||||||
|
body, _ = sjson.SetBytes(body, "thinking.budget_tokens", defaultThinkingBudgetTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeBedrockToolUseIDs 清理 messages 中 tool_use.id 和 tool_result.tool_use_id
|
||||||
|
// 的非法字符。Bedrock 要求 ID 匹配 '^[a-zA-Z0-9_-]+$'。
|
||||||
|
func sanitizeBedrockToolUseIDs(body []byte) []byte {
|
||||||
|
messages := gjson.GetBytes(body, "messages")
|
||||||
|
if !messages.Exists() || !messages.IsArray() {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
for mi, msg := range messages.Array() {
|
||||||
|
content := msg.Get("content")
|
||||||
|
if !content.Exists() || !content.IsArray() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for ci, block := range content.Array() {
|
||||||
|
switch block.Get("type").String() {
|
||||||
|
case "tool_use":
|
||||||
|
body = sanitizeIDField(body, block.Get("id").String(), fmt.Sprintf("messages.%d.content.%d.id", mi, ci))
|
||||||
|
case "tool_result":
|
||||||
|
body = sanitizeIDField(body, block.Get("tool_use_id").String(), fmt.Sprintf("messages.%d.content.%d.tool_use_id", mi, ci))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeIDField(body []byte, id, path string) []byte {
|
||||||
|
if id == "" {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
sanitized := bedrockToolUseIDRe.ReplaceAllString(id, "_")
|
||||||
|
if sanitized != id {
|
||||||
|
body, _ = sjson.SetBytes(body, path, sanitized)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|||||||
@ -657,3 +657,233 @@ func TestAdjustBedrockModelRegionPrefix(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsBedrockOpus47OrNewer(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
modelID string
|
||||||
|
expect bool
|
||||||
|
}{
|
||||||
|
{"us.anthropic.claude-opus-4-7-v1", true},
|
||||||
|
{"us.anthropic.claude-opus-4-6-v1", false},
|
||||||
|
{"us.anthropic.claude-opus-4-5-20251101-v1:0", false},
|
||||||
|
{"us.anthropic.claude-opus-5-0-v1", true},
|
||||||
|
// Sonnet 4.7 is not Opus → false
|
||||||
|
{"us.anthropic.claude-sonnet-4-7-v1", false},
|
||||||
|
{"us.anthropic.claude-sonnet-4-6", false},
|
||||||
|
// Haiku is not Opus
|
||||||
|
{"us.anthropic.claude-haiku-4-5-20251001-v1:0", false},
|
||||||
|
// Non-Claude models
|
||||||
|
{"amazon.nova-pro-v1", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.modelID, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expect, isBedrockOpus47OrNewer(tt.modelID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeBedrockThinking(t *testing.T) {
|
||||||
|
t.Run("opus 4.7 converts enabled to adaptive", func(t *testing.T) {
|
||||||
|
input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
|
||||||
|
assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("opus 4.7 keeps adaptive unchanged", func(t *testing.T) {
|
||||||
|
input := `{"thinking":{"type":"adaptive"},"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
|
||||||
|
assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("opus 4.6 enabled without budget_tokens gets default", func(t *testing.T) {
|
||||||
|
input := `{"thinking":{"type":"enabled"},"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-6-v1")
|
||||||
|
assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
assert.Equal(t, int64(defaultThinkingBudgetTokens), gjson.GetBytes(result, "thinking.budget_tokens").Int())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("opus 4.6 enabled with budget_tokens unchanged", func(t *testing.T) {
|
||||||
|
input := `{"thinking":{"type":"enabled","budget_tokens":20000},"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-6-v1")
|
||||||
|
assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
assert.Equal(t, int64(20000), gjson.GetBytes(result, "thinking.budget_tokens").Int())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no thinking field unchanged", func(t *testing.T) {
|
||||||
|
input := `{"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
|
||||||
|
assert.JSONEq(t, input, string(result))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sonnet 4.6 enabled without budget_tokens gets default", func(t *testing.T) {
|
||||||
|
input := `{"thinking":{"type":"enabled"},"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-sonnet-4-6")
|
||||||
|
assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
assert.Equal(t, int64(defaultThinkingBudgetTokens), gjson.GetBytes(result, "thinking.budget_tokens").Int())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeBedrockToolUseIDs(t *testing.T) {
|
||||||
|
t.Run("clean IDs unchanged", func(t *testing.T) {
|
||||||
|
input := `{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01AbCdEf","name":"bash","input":{}}]}]}`
|
||||||
|
result := sanitizeBedrockToolUseIDs([]byte(input))
|
||||||
|
assert.Equal(t, "toolu_01AbCdEf", gjson.GetBytes(result, "messages.0.content.0.id").String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dots in tool_use ID replaced with underscores", func(t *testing.T) {
|
||||||
|
input := `{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu.01.Ab","name":"bash","input":{}}]}]}`
|
||||||
|
result := sanitizeBedrockToolUseIDs([]byte(input))
|
||||||
|
assert.Equal(t, "toolu_01_Ab", gjson.GetBytes(result, "messages.0.content.0.id").String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("special chars in tool_use ID sanitized", func(t *testing.T) {
|
||||||
|
input := `{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu:01@Ab#Cd","name":"bash","input":{}}]}]}`
|
||||||
|
result := sanitizeBedrockToolUseIDs([]byte(input))
|
||||||
|
id := gjson.GetBytes(result, "messages.0.content.0.id").String()
|
||||||
|
assert.Regexp(t, `^[a-zA-Z0-9_-]+$`, id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tool_result tool_use_id sanitized", func(t *testing.T) {
|
||||||
|
input := `{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu.01.Ab","content":"ok"}]}]}`
|
||||||
|
result := sanitizeBedrockToolUseIDs([]byte(input))
|
||||||
|
assert.Equal(t, "toolu_01_Ab", gjson.GetBytes(result, "messages.0.content.0.tool_use_id").String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("mixed clean and dirty IDs", func(t *testing.T) {
|
||||||
|
input := `{"messages":[
|
||||||
|
{"role":"assistant","content":[{"type":"tool_use","id":"clean_id-123","name":"a","input":{}}]},
|
||||||
|
{"role":"user","content":[{"type":"tool_result","tool_use_id":"dirty.id@456","content":"ok"}]},
|
||||||
|
{"role":"assistant","content":[{"type":"tool_use","id":"also.dirty","name":"b","input":{}}]}
|
||||||
|
]}`
|
||||||
|
result := sanitizeBedrockToolUseIDs([]byte(input))
|
||||||
|
assert.Equal(t, "clean_id-123", gjson.GetBytes(result, "messages.0.content.0.id").String())
|
||||||
|
assert.Equal(t, "dirty_id_456", gjson.GetBytes(result, "messages.1.content.0.tool_use_id").String())
|
||||||
|
assert.Equal(t, "also_dirty", gjson.GetBytes(result, "messages.2.content.0.id").String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no messages unchanged", func(t *testing.T) {
|
||||||
|
input := `{"system":[{"type":"text","text":"hi"}]}`
|
||||||
|
result := sanitizeBedrockToolUseIDs([]byte(input))
|
||||||
|
assert.JSONEq(t, input, string(result))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("string content skipped", func(t *testing.T) {
|
||||||
|
input := `{"messages":[{"role":"user","content":"plain text"}]}`
|
||||||
|
result := sanitizeBedrockToolUseIDs([]byte(input))
|
||||||
|
assert.JSONEq(t, input, string(result))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty ID skipped", func(t *testing.T) {
|
||||||
|
input := `{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"","name":"a","input":{}}]}]}`
|
||||||
|
result := sanitizeBedrockToolUseIDs([]byte(input))
|
||||||
|
assert.Equal(t, "", gjson.GetBytes(result, "messages.0.content.0.id").String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeBedrockThinking_EdgeCases(t *testing.T) {
|
||||||
|
t.Run("opus 4.7 enabled without budget_tokens converts to adaptive", func(t *testing.T) {
|
||||||
|
input := `{"thinking":{"type":"enabled"},"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
|
||||||
|
assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("thinking type disabled unchanged", func(t *testing.T) {
|
||||||
|
input := `{"thinking":{"type":"disabled"},"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
|
||||||
|
assert.Equal(t, "disabled", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("thinking type empty string unchanged", func(t *testing.T) {
|
||||||
|
input := `{"thinking":{"type":""},"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
|
||||||
|
assert.JSONEq(t, input, string(result))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("thinking is not an object unchanged", func(t *testing.T) {
|
||||||
|
input := `{"thinking":true,"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
|
||||||
|
assert.JSONEq(t, input, string(result))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("opus 4.7 adaptive with budget_tokens preserved", func(t *testing.T) {
|
||||||
|
input := `{"thinking":{"type":"adaptive","budget_tokens":5000},"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
|
||||||
|
assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
assert.Equal(t, int64(5000), gjson.GetBytes(result, "thinking.budget_tokens").Int())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Forward() passes parsed.Model (standard names like "claude-opus-4-7")
|
||||||
|
t.Run("standard model name opus 4.7 converts enabled to adaptive", func(t *testing.T) {
|
||||||
|
input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "claude-opus-4-7")
|
||||||
|
assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("standard model name opus 4.6 keeps enabled", func(t *testing.T) {
|
||||||
|
input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}`
|
||||||
|
result := sanitizeBedrockThinking([]byte(input), "claude-opus-4-6")
|
||||||
|
assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
assert.Equal(t, int64(10000), gjson.GetBytes(result, "thinking.budget_tokens").Int())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBedrockOpus47OrNewer_EdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
modelID string
|
||||||
|
expect bool
|
||||||
|
}{
|
||||||
|
{"anthropic.claude-opus-4-7-v1", true},
|
||||||
|
{"us.anthropic.claude-opus-4-7-20270101-v1:0", true},
|
||||||
|
{"", false},
|
||||||
|
// Forward() passes parsed.Model (standard names), not Bedrock IDs
|
||||||
|
{"claude-opus-4-7", true},
|
||||||
|
{"claude-opus-4-6", false},
|
||||||
|
{"claude-sonnet-4-7", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.modelID, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expect, isBedrockOpus47OrNewer(tt.modelID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareBedrockRequestBodyWithTokens_CCCompat(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"model":"claude-opus-4-6",
|
||||||
|
"stream":true,
|
||||||
|
"max_tokens":16384,
|
||||||
|
"thinking":{"type":"enabled"},
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","content":[{"type":"tool_use","id":"toolu.01.Ab","name":"bash","input":{}}]},
|
||||||
|
{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu.01.Ab","content":"ok"}]}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
t.Run("ccCompat=false skips thinking and toolUseID sanitization", func(t *testing.T) {
|
||||||
|
result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), "us.anthropic.claude-opus-4-6-v1", nil, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
|
||||||
|
assert.Equal(t, "toolu.01.Ab", gjson.GetBytes(result, "messages.0.content.0.id").String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ccCompat=true applies thinking fix and toolUseID sanitization (opus 4.6)", func(t *testing.T) {
|
||||||
|
result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), "us.anthropic.claude-opus-4-6-v1", nil, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
assert.Equal(t, int64(defaultThinkingBudgetTokens), gjson.GetBytes(result, "thinking.budget_tokens").Int())
|
||||||
|
assert.Equal(t, "toolu_01_Ab", gjson.GetBytes(result, "messages.0.content.0.id").String())
|
||||||
|
assert.Equal(t, "toolu_01_Ab", gjson.GetBytes(result, "messages.1.content.0.tool_use_id").String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ccCompat=true converts thinking to adaptive for opus 4.7", func(t *testing.T) {
|
||||||
|
result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), "us.anthropic.claude-opus-4-7-v1", nil, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
|
||||||
|
assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
|
||||||
|
assert.Equal(t, "toolu_01_Ab", gjson.GetBytes(result, "messages.0.content.0.id").String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -248,6 +248,19 @@ func (c *Channel) IsWebSearchEmulationEnabled(platform string) bool {
|
|||||||
return ok && enabled
|
return ok && enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsBedrockCCCompatEnabled 返回该渠道是否为指定平台启用了 Bedrock CC 兼容模式。
|
||||||
|
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)
|
||||||
|
return ok && enabled
|
||||||
|
}
|
||||||
|
|
||||||
// deepCopyFeaturesConfig creates a deep copy of FeaturesConfig to prevent cache pollution.
|
// deepCopyFeaturesConfig creates a deep copy of FeaturesConfig to prevent cache pollution.
|
||||||
func deepCopyFeaturesConfig(src map[string]any) map[string]any {
|
func deepCopyFeaturesConfig(src map[string]any) map[string]any {
|
||||||
dst := make(map[string]any, len(src))
|
dst := make(map[string]any, len(src))
|
||||||
|
|||||||
@ -4352,6 +4352,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
return s.handleWebSearchEmulation(ctx, c, account, parsed)
|
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() {
|
if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() {
|
||||||
passthroughBody := parsed.Body
|
passthroughBody := parsed.Body
|
||||||
passthroughModel := parsed.Model
|
passthroughModel := parsed.Model
|
||||||
@ -5637,6 +5644,18 @@ func writeAnthropicPassthroughResponseHeaders(dst http.Header, src http.Header,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBedrockCCCompatEnabled 检查渠道是否启用了 Bedrock CC 兼容模式
|
||||||
|
func (s *GatewayService) isBedrockCCCompatEnabled(ctx context.Context, account *Account, groupID *int64) bool {
|
||||||
|
if groupID == nil || s.channelService == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ch, err := s.channelService.GetChannelForGroup(ctx, *groupID)
|
||||||
|
if err != nil || ch == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ch.IsBedrockCCCompatEnabled(account.Platform)
|
||||||
|
}
|
||||||
|
|
||||||
// forwardBedrock 转发请求到 AWS Bedrock
|
// forwardBedrock 转发请求到 AWS Bedrock
|
||||||
func (s *GatewayService) forwardBedrock(
|
func (s *GatewayService) forwardBedrock(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@ -5669,7 +5688,7 @@ func (s *GatewayService) forwardBedrock(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
bedrockBody, err := PrepareBedrockRequestBodyWithTokens(body, mappedModel, betaTokens)
|
bedrockBody, err := PrepareBedrockRequestBodyWithTokens(body, mappedModel, betaTokens, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("prepare bedrock request body: %w", err)
|
return nil, fmt.Errorf("prepare bedrock request body: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2358,6 +2358,8 @@ export default {
|
|||||||
webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation',
|
webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation',
|
||||||
codexImageGenerationBridge: 'Codex Image Generation Bridge',
|
codexImageGenerationBridge: 'Codex Image Generation Bridge',
|
||||||
codexImageGenerationBridgeHint: 'When enabled, Codex /responses text requests in OpenAI groups may be automatically given the image_generation tool. Keep off unless the routed accounts support image generation.',
|
codexImageGenerationBridgeHint: 'When enabled, Codex /responses text requests in OpenAI groups may be automatically given the image_generation tool. Keep off unless the routed accounts support image generation.',
|
||||||
|
bedrockCCCompat: 'Bedrock CC Compatibility',
|
||||||
|
bedrockCCCompatHint: '⚠️ When enabled, requests to Bedrock accounts in this channel will be transformed for Claude Code compatibility (thinking type conversion, tool_use ID sanitization).',
|
||||||
basicSettings: 'Basic Settings',
|
basicSettings: 'Basic Settings',
|
||||||
addPlatform: 'Add Platform',
|
addPlatform: 'Add Platform',
|
||||||
noPlatforms: 'Click "Add Platform" to start configuring the channel',
|
noPlatforms: 'Click "Add Platform" to start configuring the channel',
|
||||||
|
|||||||
@ -2435,6 +2435,8 @@ export default {
|
|||||||
webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关',
|
webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关',
|
||||||
codexImageGenerationBridge: 'Codex 图片生成桥接',
|
codexImageGenerationBridge: 'Codex 图片生成桥接',
|
||||||
codexImageGenerationBridgeHint: '开启后,OpenAI 分组的 Codex /responses 文本请求可能会被自动注入 image_generation 工具。仅在路由账号支持图片生成时开启。',
|
codexImageGenerationBridgeHint: '开启后,OpenAI 分组的 Codex /responses 文本请求可能会被自动注入 image_generation 工具。仅在路由账号支持图片生成时开启。',
|
||||||
|
bedrockCCCompat: 'Bedrock CC 兼容',
|
||||||
|
bedrockCCCompatHint: '⚠️ 开启后,该渠道下 Bedrock 账号的请求将进行 Claude Code 兼容处理(thinking 类型转换、tool_use ID 清理)',
|
||||||
basicSettings: '基础设置',
|
basicSettings: '基础设置',
|
||||||
addPlatform: '添加平台',
|
addPlatform: '添加平台',
|
||||||
noPlatforms: '点击"添加平台"开始配置渠道',
|
noPlatforms: '点击"添加平台"开始配置渠道',
|
||||||
|
|||||||
@ -354,6 +354,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bedrock CC Compatibility (Anthropic only) -->
|
||||||
|
<div v-if="section.platform === 'anthropic'" class="border-t border-gray-200 pt-3 dark:border-dark-600">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.channels.form.bedrockCCCompat') }}
|
||||||
|
</label>
|
||||||
|
<p class="mt-0.5 text-[11px] text-amber-600 dark:text-amber-400">
|
||||||
|
{{ t('admin.channels.form.bedrockCCCompatHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="section.bedrock_cc_compat" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Model Mapping -->
|
<!-- Model Mapping -->
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 flex items-center justify-between">
|
<div class="mb-1 flex items-center justify-between">
|
||||||
@ -669,6 +684,7 @@ interface PlatformSection {
|
|||||||
model_pricing: PricingFormEntry[]
|
model_pricing: PricingFormEntry[]
|
||||||
web_search_emulation: boolean
|
web_search_emulation: boolean
|
||||||
codex_image_generation_bridge: boolean
|
codex_image_generation_bridge: boolean
|
||||||
|
bedrock_cc_compat: boolean
|
||||||
account_stats_pricing_rules: FormPricingRule[]
|
account_stats_pricing_rules: FormPricingRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -765,6 +781,7 @@ function addPlatformSection(platform: GroupPlatform) {
|
|||||||
model_pricing: [],
|
model_pricing: [],
|
||||||
web_search_emulation: false,
|
web_search_emulation: false,
|
||||||
codex_image_generation_bridge: false,
|
codex_image_generation_bridge: false,
|
||||||
|
bedrock_cc_compat: false,
|
||||||
account_stats_pricing_rules: [],
|
account_stats_pricing_rules: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -1125,6 +1142,19 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
|
|||||||
delete featuresConfig.codex_image_generation_bridge
|
delete featuresConfig.codex_image_generation_bridge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bedrockCCCompat: Record<string, boolean> = {}
|
||||||
|
for (const section of form.platforms) {
|
||||||
|
if (!section.enabled) continue
|
||||||
|
if (section.platform === 'anthropic') {
|
||||||
|
bedrockCCCompat[section.platform] = !!section.bedrock_cc_compat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(bedrockCCCompat).length > 0) {
|
||||||
|
featuresConfig.bedrock_cc_compat = bedrockCCCompat
|
||||||
|
} else {
|
||||||
|
delete featuresConfig.bedrock_cc_compat
|
||||||
|
}
|
||||||
|
|
||||||
return { group_ids, model_pricing, model_mapping, features_config: featuresConfig }
|
return { group_ids, model_pricing, model_mapping, features_config: featuresConfig }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1175,6 +1205,8 @@ function apiToForm(channel: Channel): PlatformSection[] {
|
|||||||
const webSearchEnabled = wsEmulation?.[platform] === true
|
const webSearchEnabled = wsEmulation?.[platform] === true
|
||||||
const codexImageGenerationBridge = fc?.codex_image_generation_bridge as Record<string, boolean> | undefined
|
const codexImageGenerationBridge = fc?.codex_image_generation_bridge as Record<string, boolean> | undefined
|
||||||
const codexImageGenerationBridgeEnabled = codexImageGenerationBridge?.[platform] === true
|
const codexImageGenerationBridgeEnabled = codexImageGenerationBridge?.[platform] === true
|
||||||
|
const bedrockCCCompat = fc?.bedrock_cc_compat as Record<string, boolean> | undefined
|
||||||
|
const bedrockCCCompatEnabled = bedrockCCCompat?.[platform] === true
|
||||||
|
|
||||||
sections.push({
|
sections.push({
|
||||||
platform,
|
platform,
|
||||||
@ -1185,6 +1217,7 @@ function apiToForm(channel: Channel): PlatformSection[] {
|
|||||||
model_pricing: pricing,
|
model_pricing: pricing,
|
||||||
web_search_emulation: webSearchEnabled,
|
web_search_emulation: webSearchEnabled,
|
||||||
codex_image_generation_bridge: codexImageGenerationBridgeEnabled,
|
codex_image_generation_bridge: codexImageGenerationBridgeEnabled,
|
||||||
|
bedrock_cc_compat: bedrockCCCompatEnabled,
|
||||||
account_stats_pricing_rules: [],
|
account_stats_pricing_rules: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user