Merge pull request #7683 from StarryKira/codex/sonnet-5-5-upstream

feat: support Claude Sonnet 5.5
This commit is contained in:
Wesley Liddick
2026-09-29 15:42:21 +08:00
committed by GitHub
40 changed files with 856 additions and 117 deletions
+2
View File
@@ -190,6 +190,8 @@ var DefaultBedrockModelMapping = map[string]string{
"claude-opus-4-1": "us.anthropic.claude-opus-4-1-20250805-v1:0",
"claude-opus-4-20250514": "us.anthropic.claude-opus-4-20250514-v1:0",
// Claude Sonnet
// Sonnet 5.5 is available on bedrock-runtime through Global inference only.
"claude-sonnet-5-5": "global.anthropic.claude-sonnet-5-5",
"claude-sonnet-5": "us.anthropic.claude-sonnet-5-v1",
"claude-sonnet-4-6-thinking": "us.anthropic.claude-sonnet-4-6",
"claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6",
+4 -3
View File
@@ -109,9 +109,10 @@ func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
cases := map[string]string{
"claude-fable-5-1": "anthropic.claude-fable-5-1",
"claude-fable-5": "anthropic.claude-fable-5",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
"claude-fable-5-1": "anthropic.claude-fable-5-1",
"claude-fable-5": "anthropic.claude-fable-5",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
"claude-sonnet-5-5": "global.anthropic.claude-sonnet-5-5",
}
for from, want := range cases {
got, ok := DefaultBedrockModelMapping[from]
@@ -1882,6 +1882,64 @@ func TestOpus55SignedThinkingResponsesRoundTrip(t *testing.T) {
require.Error(t, err)
}
func TestSonnet55ResponsesThinkingAndSampling(t *testing.T) {
for _, effort := range []string{"", "low", "medium", "high", "xhigh", "max", "none"} {
req := &ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: effort}}
out, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err, effort)
require.Zero(t, out.Thinking.BudgetTokens)
if effort == "none" {
require.Equal(t, "between_tools", out.Thinking.Type)
require.Equal(t, "low", out.OutputConfig.Effort)
} else {
require.Equal(t, "adaptive", out.Thinking.Type)
if effort == "" {
effort = "high"
}
require.Equal(t, effort, out.OutputConfig.Effort)
}
}
for _, choice := range []string{`"required"`, `{"type":"function","name":"lookup"}`} {
_, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), ToolChoice: json.RawMessage(choice)})
require.ErrorContains(t, err, "forced tool_choice")
}
temperature := 0.7
_, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature})
require.ErrorContains(t, err, "temperature")
topP := 0.5
_, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), TopP: &topP})
require.ErrorContains(t, err, "top_p")
_, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "minimal"}})
require.ErrorContains(t, err, "reasoning effort")
temperature, topP = 1, 0.99
_, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature, TopP: &topP})
require.NoError(t, err)
}
func TestSonnet55SignedThinkingResponsesRoundTrip(t *testing.T) {
block := AnthropicContentBlock{Type: "thinking", Thinking: "", Signature: "signed-sonnet-block"}
response := AnthropicToResponsesResponse(&AnthropicResponse{Model: "claude-sonnet-5-5", Content: []AnthropicContentBlock{block, {Type: "text", Text: "progress"}, {Type: "tool_use", ID: "toolu_1", Name: "lookup", Input: json.RawMessage(`{}`)}}})
require.Len(t, response.Output, 3)
require.NotEmpty(t, response.Output[0].EncryptedContent)
require.Equal(t, "message", response.Output[1].Type)
raw, err := json.Marshal(response.Output)
require.NoError(t, err)
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(raw, &items))
items = append(items, ResponsesInputItem{Type: "function_call_output", CallID: response.Output[2].CallID, Output: "ok"})
raw, err = json.Marshal(items)
require.NoError(t, err)
converted, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: raw})
require.NoError(t, err)
require.Len(t, converted.Messages, 2)
var blocks []AnthropicContentBlock
require.NoError(t, json.Unmarshal(converted.Messages[0].Content, &blocks))
require.Equal(t, block, blocks[0])
require.Equal(t, "text", blocks[1].Type)
require.Equal(t, "tool_use", blocks[2].Type)
}
func TestGPT6ChatSamplingAndCacheFields(t *testing.T) {
temperature := 0.7
for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} {
@@ -53,7 +53,7 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse {
for _, block := range resp.Content {
switch block.Type {
case "thinking", "redacted_thinking":
if claude.IsOpus55(resp.Model) && (block.Signature != "" || block.Data != "") {
if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model)) && (block.Signature != "" || block.Data != "") {
item := ResponsesOutput{Type: "reasoning", ID: generateItemID(), EncryptedContent: encodeAnthropicThinking(block)}
if block.Thinking != "" {
item.Summary = []ResponsesSummary{{Type: "summary_text", Text: block.Thinking}}
@@ -72,7 +72,7 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse {
})
}
case "text":
if claude.IsOpus55(resp.Model) && block.Text != "" {
if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model)) && block.Text != "" {
outputs = append(outputs, ResponsesOutput{Type: "message", ID: generateItemID(), Role: "assistant", Status: "completed", Content: []ResponsesContentPart{{Type: "output_text", Text: block.Text}}})
continue
}
@@ -285,7 +285,7 @@ func ResponsesEventToSSE(evt ResponsesStreamEvent) (string, error) {
func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if evt.Message != nil {
state.ResponseID = evt.Message.ID
state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model)
state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model) || claude.IsSonnet55(evt.Message.Model)
if state.Model == "" {
state.Model = evt.Message.Model
}
@@ -14,7 +14,9 @@ import (
// enables Anthropic platform groups to accept OpenAI Responses API requests
// by converting them to the native /v1/messages format before forwarding upstream.
func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) {
system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, claude.IsOpus55(req.Model))
isOpus55 := claude.IsOpus55(req.Model)
isSonnet55 := claude.IsSonnet55(req.Model)
system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, isOpus55 || isSonnet55)
if err != nil {
return nil, err
}
@@ -54,9 +56,11 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro
out.ToolChoice = tc
}
// Opus 5.5 always uses adaptive thinking. Resolve the upstream model before
// conversion, because client aliases need not identify a Claude model.
if claude.IsOpus55(req.Model) {
// The 5.5 models reject manual thinking and forced tool use. Sonnet 5.5
// additionally supports between_tools to disable up-front thinking.
// Resolve the upstream model before conversion: client aliases need not
// identify a Claude model.
if isOpus55 || isSonnet55 {
var choice struct {
Type string `json:"type"`
}
@@ -66,16 +70,36 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro
}
}
if choice.Type == "any" || choice.Type == "tool" {
return nil, fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none")
return nil, fmt.Errorf("%s does not support forced tool_choice; use auto or none", req.Model)
}
effort := "medium"
if isSonnet55 {
effort = "high"
if req.Temperature != nil && *req.Temperature != 1 {
return nil, fmt.Errorf("claude-sonnet-5-5 does not support non-default temperature")
}
if req.TopP != nil && (*req.TopP < 0.99 || *req.TopP > 1) {
return nil, fmt.Errorf("claude-sonnet-5-5 does not support non-default top_p")
}
}
if req.Reasoning != nil && req.Reasoning.Effort != "" {
effort = req.Reasoning.Effort
}
if isSonnet55 && effort == "none" {
// OpenAI's no-reasoning request maps to Sonnet 5.5's lowest
// thinking mode. between_tools still preserves signed progress
// blocks produced during tool use.
out.Thinking = &AnthropicThinking{Type: "between_tools"}
if out.OutputConfig == nil {
out.OutputConfig = &AnthropicOutputConfig{}
}
out.OutputConfig.Effort = "low"
return out, nil
}
switch effort {
case "low", "medium", "high", "xhigh", "max":
default:
return nil, fmt.Errorf("claude-opus-5-5 does not support reasoning effort %q; use low, medium, high, xhigh or max", effort)
return nil, fmt.Errorf("%s does not support reasoning effort %q; use low, medium, high, xhigh or max", req.Model, effort)
}
out.Thinking = &AnthropicThinking{Type: "adaptive"}
out.OutputConfig = &AnthropicOutputConfig{Effort: effort}
+1 -1
View File
@@ -42,7 +42,7 @@ type AnthropicOutputConfig struct {
// AnthropicThinking configures extended thinking in the Anthropic API.
type AnthropicThinking struct {
Type string `json:"type"` // "enabled" | "adaptive" | "disabled"
Type string `json:"type"` // "enabled" | "adaptive" | "disabled" | "between_tools"
BudgetTokens int `json:"budget_tokens,omitempty"` // max thinking tokens
}
+6
View File
@@ -190,6 +190,12 @@ var DefaultModels = []Model{
DisplayName: "Claude Opus 5",
CreatedAt: "2026-07-25T00:00:00Z",
},
{
ID: "claude-sonnet-5-5",
Type: "model",
DisplayName: "Claude Sonnet 5.5",
CreatedAt: "2026-09-28T00:00:00Z",
},
{
ID: "claude-sonnet-5",
Type: "model",
@@ -30,3 +30,16 @@ func TestDefaultModelsContainsOpus55(t *testing.T) {
}
t.Fatal("claude-opus-5-5 missing")
}
func TestDefaultModelsContainsSonnet55(t *testing.T) {
t.Parallel()
for _, model := range DefaultModels {
if model.ID == "claude-sonnet-5-5" {
require.Equal(t, "Claude Sonnet 5.5", model.DisplayName)
require.Equal(t, "2026-09-28T00:00:00Z", model.CreatedAt)
return
}
}
t.Fatal("claude-sonnet-5-5 missing from DefaultModels")
}
+14 -2
View File
@@ -19,6 +19,7 @@ var effortFamilies = []struct {
{family: "claude-mythos-5", levels: effortLowMediumHighXHighMax},
{family: "claude-fable-5", levels: effortLowMediumHighXHighMax},
{family: "claude-sonnet-4-6", levels: effortLowMediumHighMax},
{family: "claude-sonnet-5-5", levels: effortLowMediumHighXHighMax},
{family: "claude-sonnet-5", levels: effortLowMediumHighXHighMax},
{family: "claude-opus-4-8", levels: effortLowMediumHighXHighMax},
{family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax},
@@ -45,19 +46,30 @@ func IsOpus55(model string) bool {
return normalizeEffortModelID(model) == "claude-opus-5-5"
}
// IsSonnet55 identifies the fixed Sonnet 5.5 ID after provider/local suffix normalization.
func IsSonnet55(model string) bool {
return normalizeEffortModelID(model) == "claude-sonnet-5-5"
}
func normalizeEffortModelID(model string) string {
id := strings.ToLower(strings.TrimSpace(model))
id = strings.TrimPrefix(id, "models/")
if slash := strings.IndexByte(id, '/'); slash >= 0 {
id = strings.TrimPrefix(strings.TrimSpace(id[slash+1:]), "models/")
}
for _, prefix := range []string{"us.", "eu.", "apac.", "jp.", "au.", "us-gov.", "global."} {
id = strings.TrimPrefix(id, prefix)
}
id = strings.TrimPrefix(id, "anthropic.")
id = strings.TrimSuffix(id, "-thinking")
// OpenRouter uses a dotted minor version for this exact Opus 5.5 ID.
// Normalize it before effort, thinking, and billing family lookups.
// OpenRouter uses dotted minor versions for some models. Normalize them
// before effort, thinking, and billing family lookups.
if id == "claude-opus-5.5" {
id = "claude-opus-5-5"
}
if id == "claude-sonnet-5.5" {
id = "claude-sonnet-5-5"
}
if mapped, ok := ModelIDReverseOverrides[id]; ok {
id = mapped
}
@@ -17,6 +17,8 @@ func TestEffortLevelsForModel(t *testing.T) {
{model: "anthropic/claude-sonnet-4-6", want: []string{"low", "medium", "high", "max"}},
{model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "anthropic/claude-opus-5.5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "claude-sonnet-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "us.anthropic.claude-sonnet-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}},
{model: "claude-haiku-4-5-20251001", want: nil},
{model: "gpt-5.6", want: nil},
@@ -38,3 +40,20 @@ func TestIsOpus55OpenRouterExactAlias(t *testing.T) {
require.False(t, IsOpus55(model), model)
}
}
func TestIsSonnet55(t *testing.T) {
t.Parallel()
for _, model := range []string{
"claude-sonnet-5-5",
"anthropic/claude-sonnet-5.5",
"anthropic.claude-sonnet-5-5",
"us.anthropic.claude-sonnet-5-5",
"us-gov.anthropic.claude-sonnet-5-5",
"global.anthropic.claude-sonnet-5-5-thinking",
} {
require.True(t, IsSonnet55(model), model)
}
for _, model := range []string{"claude-sonnet-5", "claude-sonnet-5-5-preview", "claude-opus-5-5"} {
require.False(t, IsSonnet55(model), model)
}
}
+42 -7
View File
@@ -127,8 +127,17 @@ func normalizeBedrockModelID(modelID string) (normalized string, shouldAdjustReg
return "", false, false
}
if mapped, exists := domain.DefaultBedrockModelMapping[modelID]; exists {
// Sonnet 5.5 currently has only a global inference profile on
// bedrock-runtime. A caller's AWS region selects the endpoint, but must
// not rewrite the profile ID to a regional one that does not exist.
if mapped == "global.anthropic.claude-sonnet-5-5" {
return mapped, false, true
}
return mapped, true, true
}
if modelID == "global.anthropic.claude-sonnet-5-5" {
return modelID, false, true
}
if isRegionalBedrockModelID(modelID) {
return modelID, true, true
}
@@ -181,7 +190,7 @@ func BuildBedrockURL(region, modelID string, stream bool) string {
// PrepareBedrockRequestBody 处理请求体以适配 Bedrock API
// 1. 注入 anthropic_version
// 2. 注入 anthropic_beta(从客户端 anthropic-beta 头解析)
// 3. 移除 Bedrock 不支持的字段(model, stream, output_format, output_config)
// 3. 移除 Bedrock 不支持的字段(model, stream, output_format);Sonnet 5.5 保留 output_config.effort
// 4. 移除工具定义中的 custom 字段(Claude Code 会发送 custom: {defer_loading: true})
// 5. 清理 cache_control 中 Bedrock 不支持的字段(scope, ttl)
// 6. 修复 thinking 字段兼容性(Opus 4.7 仅支持 adaptive,enabled 需要 budget_tokens)
@@ -240,10 +249,20 @@ func PrepareBedrockRequestBodyWithTokens(body []byte, modelID string, betaTokens
// 参考 litellm: _convert_output_format_to_inline_schema()
body = convertOutputFormatToInlineSchema(body)
// 移除 output_config 字段(Bedrock Invoke 不支持)
body, err = sjson.DeleteBytes(body, "output_config")
// InvokeModel accepts output_config.effort for Sonnet 5.5. Keep just that
// field; output_config.format has already been inlined above, and older
// models retain the existing output_config stripping behavior.
if claude.IsSonnet55(modelID) {
if effort := gjson.GetBytes(body, "output_config.effort"); effort.Exists() {
body, err = sjson.SetRawBytes(body, "output_config", []byte(`{"effort":`+effort.Raw+`}`))
} else {
body, err = sjson.DeleteBytes(body, "output_config")
}
} else {
body, err = sjson.DeleteBytes(body, "output_config")
}
if err != nil {
return nil, fmt.Errorf("remove output_config field: %w", err)
return nil, fmt.Errorf("normalize output_config field: %w", err)
}
// 移除工具定义中的 custom 字段
@@ -270,17 +289,21 @@ func ResolveBedrockBetaTokens(betaHeader string, body []byte, modelID string) []
return filterBedrockBetaTokens(betaTokens)
}
// convertOutputFormatToInlineSchema 将 output_format 中的 JSON schema 内联到最后一条 user message
// convertOutputFormatToInlineSchema 将结构化输出的 JSON schema 内联到最后一条 user message
// Bedrock Invoke 不支持 output_format 参数,litellm 的做法是将 schema 追加到用户消息中
// 参考: litellm AmazonAnthropicClaudeMessagesConfig._convert_output_format_to_inline_schema()
func convertOutputFormatToInlineSchema(body []byte) []byte {
outputFormat := gjson.GetBytes(body, "output_format")
outputFormat := gjson.GetBytes(body, "output_config.format")
if !outputFormat.Exists() {
outputFormat = gjson.GetBytes(body, "output_format")
}
if !outputFormat.Exists() || !outputFormat.IsObject() {
return body
}
// 先从请求体中移除 output_format
// 先从请求体中移除两个版本的结构化输出字段。
body, _ = sjson.DeleteBytes(body, "output_format")
body, _ = sjson.DeleteBytes(body, "output_config.format")
schema := outputFormat.Get("schema")
if !schema.Exists() {
@@ -707,6 +730,7 @@ func isBedrockFable5(modelID string) bool {
const defaultThinkingBudgetTokens = 10000
// sanitizeBedrockThinking 修复 thinking 字段的 Bedrock 兼容性问题:
// - Sonnet 5.5: enabled 改为 adaptive;disabled 改为 between_tools
// - Fable 5: 仅使用 always-on adaptive thinking,不支持手动 budget_tokens
// - Opus 4.7+: 仅支持 "adaptive",将 "enabled" 转换为 "adaptive" 并移除 budget_tokens
// - 其他模型: "enabled" 必须带 budget_tokens,缺失时补充默认值
@@ -731,6 +755,17 @@ func sanitizeBedrockThinking(body []byte, modelID string) []byte {
return body
}
if claude.IsSonnet55(modelID) {
switch thinkingType {
case "enabled":
body, _ = sjson.SetBytes(body, "thinking.type", "adaptive")
body, _ = sjson.DeleteBytes(body, "thinking.budget_tokens")
case "disabled":
body, _ = sjson.SetBytes(body, "thinking.type", "between_tools")
}
return body
}
if isBedrockOpus47OrNewer(modelID) {
if thinkingType == "enabled" {
body, _ = sjson.SetBytes(body, "thinking.type", "adaptive")
@@ -482,6 +482,37 @@ func TestBedrockCrossRegionPrefix(t *testing.T) {
}
func TestResolveBedrockModelID(t *testing.T) {
t.Run("sonnet 5.5 uses the global inference profile in every region", func(t *testing.T) {
for _, region := range []string{"us-east-1", "eu-west-1", "ap-southeast-2"} {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeBedrock,
Credentials: map[string]any{
"aws_region": region,
},
}
modelID, ok := ResolveBedrockModelID(account, "claude-sonnet-5-5")
require.True(t, ok, region)
assert.Equal(t, "global.anthropic.claude-sonnet-5-5", modelID, region)
}
})
t.Run("explicit global sonnet 5.5 profile is not rewritten", func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeBedrock,
Credentials: map[string]any{
"aws_region": "eu-west-1",
"model_mapping": map[string]any{
"public-sonnet": "global.anthropic.claude-sonnet-5-5",
},
},
}
modelID, ok := ResolveBedrockModelID(account, "public-sonnet")
require.True(t, ok)
assert.Equal(t, "global.anthropic.claude-sonnet-5-5", modelID)
})
t.Run("default alias resolves and adjusts region", func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
@@ -779,6 +810,19 @@ func TestIsBedrockOpus47OrNewer(t *testing.T) {
}
func TestSanitizeBedrockThinking(t *testing.T) {
t.Run("sonnet 5.5 converts legacy enabled to adaptive", func(t *testing.T) {
input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}`
result := sanitizeBedrockThinking([]byte(input), "global.anthropic.claude-sonnet-5-5")
assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
})
t.Run("sonnet 5.5 converts legacy disabled to between_tools", func(t *testing.T) {
input := `{"thinking":{"type":"disabled"},"messages":[]}`
result := sanitizeBedrockThinking([]byte(input), "global.anthropic.claude-sonnet-5-5")
assert.Equal(t, "between_tools", gjson.GetBytes(result, "thinking.type").String())
})
t.Run("Fable 5 将 enabled 转换为 adaptive 并移除预算", func(t *testing.T) {
input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}`
result := sanitizeBedrockThinking([]byte(input), "anthropic.claude-fable-5")
@@ -1000,6 +1044,20 @@ func TestPrepareBedrockRequestBodyWithTokens_CCCompat(t *testing.T) {
})
}
func TestPrepareBedrockSonnet55PreservesEffort(t *testing.T) {
input := []byte(`{"model":"claude-sonnet-5-5","max_tokens":1024,"output_config":{"effort":"medium","format":{"type":"json_schema","schema":{"type":"object"}}},"messages":[{"role":"user","content":"hello"}]}`)
result, err := PrepareBedrockRequestBodyWithTokens(input, "global.anthropic.claude-sonnet-5-5", nil, false)
require.NoError(t, err)
assert.Equal(t, "medium", gjson.GetBytes(result, "output_config.effort").String())
assert.False(t, gjson.GetBytes(result, "output_config.format").Exists())
assert.Contains(t, gjson.GetBytes(result, "messages.0.content.1.text").String(), `"type":"object"`, "schema should still be included in the final user message")
assert.False(t, gjson.GetBytes(result, "model").Exists())
oldModel, err := PrepareBedrockRequestBodyWithTokens(input, "us.anthropic.claude-sonnet-4-6", nil, false)
require.NoError(t, err)
assert.False(t, gjson.GetBytes(oldModel, "output_config").Exists())
}
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":[]}`)
+12 -1
View File
@@ -430,7 +430,15 @@ func (s *BillingService) initFallbackPricing() {
CacheCreation1hPrice: 8e-6,
SupportsCacheBreakdown: true,
}
s.fallbackPrices["claude-sonnet-5-5"] = &ModelPricing{
InputPricePerToken: 2e-6,
OutputPricePerToken: 10e-6,
CacheCreationPricePerToken: 2.5e-6,
CacheReadPricePerToken: 0.2e-6,
CacheCreation5mPrice: 2.5e-6,
CacheCreation1hPrice: 4e-6,
SupportsCacheBreakdown: true,
}
// Claude Fable 5.x uses the same input/output and cache-write prices, while
// Fable 5.1 reduces cache reads from $1 to $0.25 per MTok.
s.fallbackPrices["claude-fable-5"] = &ModelPricing{
@@ -978,6 +986,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
if claude.IsOpus55(modelLower) {
return s.fallbackPrices["claude-opus-5-5"]
}
if claude.IsSonnet55(modelLower) {
return s.fallbackPrices["claude-sonnet-5-5"]
}
if strings.Contains(modelLower, "opus") {
// "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。
if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") {
@@ -2020,6 +2020,26 @@ func TestNewModelPricingCatalogFallbackAndContext(t *testing.T) {
}
})
}
for _, model := range []string{
"claude-sonnet-5-5",
"anthropic/claude-sonnet-5.5",
"us.anthropic.claude-sonnet-5-5",
} {
t.Run(source+"/"+model, func(t *testing.T) {
tokens := UsageTokens{
InputTokens: 100_000, OutputTokens: 500,
CacheReadTokens: 1000, CacheCreationTokens: 1000,
CacheCreation5mTokens: 400, CacheCreation1hTokens: 600,
}
cost, err := svc.CalculateCost(model, tokens, 1)
require.NoError(t, err)
require.InDelta(t, 100_000*2e-6, cost.InputCost, 1e-10)
require.InDelta(t, 400*2.5e-6+600*4e-6, cost.CacheCreationCost, 1e-10)
require.InDelta(t, 1000*0.2e-6, cost.CacheReadCost, 1e-10)
require.InDelta(t, 500*10e-6, cost.OutputCost, 1e-10)
require.False(t, cost.LongContextBillingApplied)
})
}
}
}
@@ -1812,3 +1812,108 @@ func TestOpus55ThinkingDefaultPreservesSignedHistory(t *testing.T) {
require.Equal(t, "omitted", gjson.GetBytes(out, "thinking.display").String())
require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(out, "messages").Raw)
}
func TestSonnet55RejectsUnsupportedParametersBeforeMimicry(t *testing.T) {
fields := []string{
`"thinking":{"type":"disabled"}`,
`"thinking":{"type":"enabled","budget_tokens":1024}`,
`"thinking":{"type":"between_tools"},"output_config":{"effort":"xhigh"}`,
`"thinking":{"type":"between_tools","display":"summarized"}`,
`"tool_choice":{"type":"any"}`,
`"tool_choice":{"type":"tool","name":"lookup"}`,
`"temperature":0.7`,
`"top_p":0.5`,
`"top_k":1`,
}
for _, field := range fields {
for _, typ := range []string{AccountTypeOAuth, AccountTypeAPIKey, AccountTypeBedrock, AccountTypeServiceAccount} {
for _, count := range []bool{false, true} {
model := "claude-sonnet-5-5"
account := &Account{ID: 1, Platform: PlatformAnthropic, Type: typ}
switch typ {
case AccountTypeAPIKey:
model = "public-sonnet"
account.Credentials = map[string]any{"model_mapping": map[string]any{model: "claude-sonnet-5-5"}}
case AccountTypeBedrock:
account.Credentials = map[string]any{"aws_region": "eu-west-1"}
}
body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hello"}],` + field + `}`)
parsed := &ParsedRequest{Model: model, Body: NewRequestBodyRef(body)}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
svc := &GatewayService{}
var err error
if count {
err = svc.ForwardCountTokens(context.Background(), c, account, parsed)
} else {
_, err = svc.Forward(context.Background(), c, account, parsed)
}
require.Error(t, err, field)
require.Equal(t, http.StatusBadRequest, rec.Code, field)
require.Contains(t, rec.Body.String(), "invalid_request_error", field)
}
}
}
}
func TestSonnet55PreservesSignedHistoryAndAcceptsDefaultParameters(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-5-5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"signed"},{"type":"redacted_thinking","data":"encrypted"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}],"thinking":{"type":"between_tools"},"output_config":{"effort":"high"},"tool_choice":{"type":"none"},"temperature":1,"top_p":0.99}`)
require.NoError(t, validateClaude55Request(body, "anthropic/claude-sonnet-5.5"))
require.JSONEq(t, string(body), string(FilterThinkingBlocks(body, "claude-sonnet-5-5")))
withoutThinking, _ := deleteJSONPathBytes(body, "thinking")
require.JSONEq(t, string(withoutThinking), string(FilterThinkingBlocks(withoutThinking, "claude-sonnet-5-5")))
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-5-5", claudeOAuthNormalizeOptions{})
require.Equal(t, "none", gjson.GetBytes(out, "tool_choice.type").String())
require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(out, "messages").Raw)
}
func TestSonnet55BedrockCCCompatTransformsBeforeValidation(t *testing.T) {
groupID := int64(55)
channels := &ChannelService{}
channels.cache.Store(&channelCache{
loadedAt: time.Now(),
channelByGroupID: map[int64]*Channel{
groupID: {Status: StatusActive, FeaturesConfig: map[string]any{featureKeyBedrockCCCompat: true}},
},
})
svc := &GatewayService{channelService: channels}
account := &Account{Platform: PlatformAnthropic, Type: AccountTypeBedrock,
Credentials: map[string]any{"aws_region": "eu-west-1"}}
for _, tc := range []struct {
name string
thinking string
expectedType string
wantErr bool
}{
{name: "enabled", thinking: `{"type":"enabled","budget_tokens":2048}`, expectedType: "adaptive"},
{name: "disabled", thinking: `{"type":"disabled"}`, expectedType: "between_tools"},
{name: "between_tools invalid effort", thinking: `{"type":"between_tools"}`, expectedType: "between_tools", wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-5-5","max_tokens":1024,"messages":[{"role":"user","content":"hello"}],"thinking":` + tc.thinking + `,"output_config":{"effort":"low"}}`)
if tc.wantErr {
body = []byte(`{"model":"claude-sonnet-5-5","max_tokens":1024,"messages":[{"role":"user","content":"hello"}],"thinking":` + tc.thinking + `,"output_config":{"effort":"xhigh"}}`)
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
converted := svc.ApplyBedrockCCCompat(c, body, "claude-sonnet-5-5", account, &groupID)
require.Equal(t, tc.expectedType, gjson.GetBytes(converted, "thinking.type").String())
if tc.name == "enabled" {
require.False(t, gjson.GetBytes(converted, "thinking.budget_tokens").Exists())
}
mapped, ok := ResolveBedrockModelID(account, "claude-sonnet-5-5")
require.True(t, ok)
err := validateClaude55Request(converted, mapped)
if tc.wantErr {
require.ErrorContains(t, err, "only low, medium or high effort")
return
}
require.NoError(t, err)
prepared, err := PrepareBedrockRequestBodyWithTokens(converted, mapped, nil, false)
require.NoError(t, err)
require.Equal(t, "low", gjson.GetBytes(prepared, "output_config.effort").String())
})
}
}
@@ -365,6 +365,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
account.ApplyHeaderOverrides(req.Header)
filterSonnet55ToolsetBetaHeader(req.Header, body, gjson.GetBytes(body, "model").String())
return req, body, nil
}
@@ -25,6 +25,9 @@ func (s *GatewayService) ApplyBedrockCCCompat(c *gin.Context, body []byte, model
if !s.isBedrockCCCompatEnabled(c.Request.Context(), account, groupID) {
return body
}
if resolved, ok := ResolveBedrockModelID(account, model); ok {
model = resolved
}
body = sanitizeBedrockCCFields(body)
body = sanitizeBedrockThinking(body, model)
body = sanitizeBedrockToolUseIDs(body)
@@ -304,7 +304,7 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu
// - 其他形态(auto/any/none)原样透传
// 如果 body 里完全没有 tools(空数组),tool_choice 没意义时才删除
if !gjson.GetBytes(out, "tools").IsArray() || len(gjson.GetBytes(out, "tools").Array()) == 0 {
if !claude.IsOpus55(modelID) && gjson.GetBytes(out, "tool_choice").Exists() {
if !isClaude55SignedThinkingModel(modelID) && gjson.GetBytes(out, "tool_choice").Exists() {
if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok {
out = next
modified = true
@@ -24,11 +24,17 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
}
validationModel := parsed.Model
if account != nil && account.Type == AccountTypeAPIKey {
validationModel = account.GetMappedModel(validationModel)
if account != nil {
if account.IsBedrock() {
if resolved, ok := ResolveBedrockModelID(account, validationModel); ok {
validationModel = resolved
}
} else if account.Type == AccountTypeAPIKey {
validationModel = account.GetMappedModel(validationModel)
}
}
if account != nil && account.Platform == PlatformAnthropic && !account.IsBedrock() && account.Type != AccountTypeServiceAccount {
if err := validateClaudeOpus55Request(parsed.Body.Bytes(), validationModel); err != nil {
if account != nil && account.Platform == PlatformAnthropic {
if err := validateClaude55Request(parsed.Body.Bytes(), validationModel); err != nil {
s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return err
}
@@ -447,6 +453,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
account.ApplyHeaderOverrides(req.Header)
filterSonnet55ToolsetBetaHeader(req.Header, body, gjson.GetBytes(body, "model").String())
return req, nil
}
@@ -527,6 +534,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
finalBetaHeader, finalBetaShouldSet = beta, true
}
finalBetaHeader = filterSonnet55ToolsetBeta(finalBetaHeader, body, modelID)
// 能力维度 body sanitize:与最终 anthropic-beta header 对称
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
@@ -598,6 +606,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
// 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)
account.ApplyHeaderOverrides(req.Header)
filterSonnet55ToolsetBetaHeader(req.Header, body, modelID)
if c != nil && tokenType == "oauth" {
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
+10 -4
View File
@@ -95,11 +95,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
// API-key mappings and OAuth native IDs are resolved before mimicry.
validationModel := parsed.Model
if account != nil && account.Type == AccountTypeAPIKey {
validationModel = account.GetMappedModel(validationModel)
if account != nil {
if account.IsBedrock() {
if resolved, ok := ResolveBedrockModelID(account, validationModel); ok {
validationModel = resolved
}
} else if account.Type == AccountTypeAPIKey {
validationModel = account.GetMappedModel(validationModel)
}
}
if account != nil && account.Platform == PlatformAnthropic && !account.IsBedrock() && account.Type != AccountTypeServiceAccount {
if err := validateClaudeOpus55Request(parsed.Body.Bytes(), validationModel); err != nil {
if account != nil && account.Platform == PlatformAnthropic {
if err := validateClaude55Request(parsed.Body.Bytes(), validationModel); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"type": "error", "error": gin.H{"type": "invalid_request_error", "message": err.Error()}})
return nil, err
}
@@ -66,14 +66,14 @@ func (s *GatewayService) ForwardAsChatCompletions(
mappedModel = normalized
}
}
if err := validateClaudeOpus55Request(body, mappedModel); err != nil {
if err := validateClaude55Request(body, mappedModel); err != nil {
writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return nil, err
}
responsesReq.Model = mappedModel
anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq)
if err != nil {
if claude.IsOpus55(mappedModel) {
if isClaude55SignedThinkingModel(mappedModel) {
writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
}
return nil, fmt.Errorf("convert responses to anthropic: %w", err)
@@ -76,14 +76,14 @@ func (s *GatewayService) ForwardAsResponses(
mappedModel = normalized
}
}
if err := validateClaudeOpus55Request(body, mappedModel); err != nil {
if err := validateClaude55Request(body, mappedModel); err != nil {
writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return nil, err
}
responsesReq.Model = mappedModel
anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq)
if err != nil {
if claude.IsOpus55(mappedModel) {
if isClaude55SignedThinkingModel(mappedModel) {
writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
}
return nil, fmt.Errorf("convert responses to anthropic: %w", err)
@@ -452,7 +452,7 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse(
}
// Convert to Responses format
if claude.IsOpus55(mappedModel) {
if isClaude55SignedThinkingModel(mappedModel) {
finalResp.Model = mappedModel
}
responsesResp := apicompat.AnthropicToResponsesResponse(finalResp)
@@ -514,7 +514,7 @@ func (s *GatewayService) handleResponsesStreamingResponse(
state := apicompat.NewAnthropicEventToResponsesState()
state.Model = originalModel
state.PreserveThinkingSignatures = claude.IsOpus55(mappedModel)
state.PreserveThinkingSignatures = isClaude55SignedThinkingModel(mappedModel)
clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping)
var usage ClaudeUsage
var firstTokenMs *int
@@ -453,7 +453,7 @@ func TestHandleResponsesStreamingResponse_CompactSSEFormat(t *testing.T) {
require.Contains(t, rec.Body.String(), `response.completed`)
}
func TestOpus55ResponsesSignedThinkingBufferedAndStreamed(t *testing.T) {
func TestClaude55ResponsesSignedThinkingBufferedAndStreamed(t *testing.T) {
payload := strings.Join([]string{
"event: message_start\n" + `data: {"type":"message_start","message":{"id":"msg_1","model":"claude-opus-5-5","content":[],"usage":{"input_tokens":10}}}`,
"event: content_block_start\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`,
@@ -465,60 +465,65 @@ func TestOpus55ResponsesSignedThinkingBufferedAndStreamed(t *testing.T) {
"event: message_delta\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`,
"event: message_stop\n" + `data: {"type":"message_stop"}`,
}, "\n\n") + "\n\n"
for _, stream := range []bool{false, true} {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
resp := &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(payload))}
svc := &GatewayService{}
var err error
if stream {
_, err = svc.handleResponsesStreamingResponse(resp, c, "public-opus", "claude-opus-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{})
} else {
_, err = svc.handleResponsesBufferedStreamingResponse(resp, c, "public-opus", "claude-opus-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{})
for _, model := range []string{"claude-opus-5-5", "claude-sonnet-5-5"} {
modelPayload := strings.ReplaceAll(payload, "claude-opus-5-5", model)
for _, stream := range []bool{false, true} {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
resp := &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(modelPayload))}
svc := &GatewayService{}
var err error
if stream {
_, err = svc.handleResponsesStreamingResponse(resp, c, "public-claude", model, nil, time.Now(), apicompat.ResponsesClientToolMapping{})
} else {
_, err = svc.handleResponsesBufferedStreamingResponse(resp, c, "public-claude", model, nil, time.Now(), apicompat.ResponsesClientToolMapping{})
}
require.NoError(t, err)
require.Contains(t, rec.Body.String(), "anthropic-thinking-v1:")
require.Contains(t, rec.Body.String(), "public-claude")
require.Contains(t, rec.Body.String(), `"text":"ok"`)
}
require.NoError(t, err)
require.Contains(t, rec.Body.String(), "anthropic-thinking-v1:")
require.Contains(t, rec.Body.String(), "public-opus")
require.Contains(t, rec.Body.String(), `"text":"ok"`)
}
}
func TestOpus55BridgeUsesMappedModelBeforeThinkingConversion(t *testing.T) {
for _, chat := range []bool{false, true} {
for _, forced := range []bool{false, true} {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := `{"model":"public-opus","input":"hello","reasoning":{"effort":"xhigh"}}`
if chat {
body = `{"model":"public-opus","messages":[{"role":"user","content":"hello"}],"reasoning_effort":"xhigh"}`
}
if forced {
body = body[:len(body)-1] + `,"tool_choice":"required","tools":[{"type":"function","name":"lookup","function":{"name":"lookup"}}]}`
}
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream()))}}
svc := &GatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "fixture-key", "model_mapping": map[string]any{"public-opus": "claude-opus-5-5"}}}
var err error
var result *ForwardResult
if chat {
result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, []byte(body), nil)
} else {
result, err = svc.ForwardAsResponses(context.Background(), c, account, []byte(body), nil)
}
if forced {
require.Error(t, err)
require.Equal(t, 400, rec.Code)
require.Nil(t, upstream.lastReq)
} else {
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "claude-opus-5-5", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "adaptive", gjson.GetBytes(upstream.lastBody, "thinking.type").String())
require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "output_config.effort").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "thinking.budget_tokens").Exists())
func TestClaude55BridgeUsesMappedModelBeforeThinkingConversion(t *testing.T) {
for _, model := range []string{"claude-opus-5-5", "claude-sonnet-5-5"} {
for _, chat := range []bool{false, true} {
for _, forced := range []bool{false, true} {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := `{"model":"public-claude","input":"hello","reasoning":{"effort":"xhigh"}}`
if chat {
body = `{"model":"public-claude","messages":[{"role":"user","content":"hello"}],"reasoning_effort":"xhigh"}`
}
if forced {
body = body[:len(body)-1] + `,"tool_choice":"required","tools":[{"type":"function","name":"lookup","function":{"name":"lookup"}}]}`
}
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream()))}}
svc := &GatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "fixture-key", "model_mapping": map[string]any{"public-claude": model}}}
var err error
var result *ForwardResult
if chat {
result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, []byte(body), nil)
} else {
result, err = svc.ForwardAsResponses(context.Background(), c, account, []byte(body), nil)
}
if forced {
require.Error(t, err)
require.Equal(t, 400, rec.Code)
require.Nil(t, upstream.lastReq)
} else {
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, upstream.lastReq)
require.Equal(t, model, gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "adaptive", gjson.GetBytes(upstream.lastBody, "thinking.type").String())
require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "output_config.effort").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "thinking.budget_tokens").Exists())
}
}
}
}
+95 -11
View File
@@ -223,7 +223,8 @@ func parseGatewayRequestCurrentBody(parsed *ParsedRequest, protocol string) erro
parsed.MetadataUserID = gjson.Get(jsonStr, "metadata.user_id").String()
thinkingType := gjson.Get(jsonStr, "thinking.type").String()
parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" || (protocol == domain.PlatformAnthropic && claude.IsOpus55(parsed.Model))
parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" || thinkingType == "between_tools" ||
(protocol == domain.PlatformAnthropic && isClaude55SignedThinkingModel(parsed.Model))
parsed.OutputEffort = strings.TrimSpace(gjson.Get(jsonStr, "output_config.effort").String())
if protocol == domain.PlatformAnthropic {
@@ -566,22 +567,60 @@ func StripEmptyTextBlocks(body []byte) []byte {
return out
}
// validateClaudeOpus55Request rejects settings that the upstream cannot honor.
// isClaude55SignedThinkingModel identifies models whose default thinking mode
// requires signed history to survive protocol conversion and request filtering.
func isClaude55SignedThinkingModel(model string) bool {
return claude.IsOpus55(model) || claude.IsSonnet55(model)
}
// validateClaude55Request rejects settings that the upstream cannot honor.
// Call before OAuth mimicry can remove tool_choice or alter thinking defaults.
func validateClaudeOpus55Request(body []byte, model string) error {
if !claude.IsOpus55(model) {
func validateClaude55Request(body []byte, model string) error {
if !isClaude55SignedThinkingModel(model) {
return nil
}
isSonnet55 := claude.IsSonnet55(model)
switch gjson.GetBytes(body, "thinking.type").String() {
case "disabled", "enabled":
if isSonnet55 {
return fmt.Errorf("claude-sonnet-5-5 requires adaptive thinking or thinking.type=between_tools; omit thinking or use one of those modes")
}
return fmt.Errorf("claude-opus-5-5 requires adaptive thinking; omit thinking or use thinking.type=adaptive and output_config.effort")
case "between_tools":
if !isSonnet55 {
return fmt.Errorf("claude-opus-5-5 requires adaptive thinking; thinking.type=between_tools is unsupported")
}
effort := gjson.GetBytes(body, "output_config.effort").String()
if effort == "xhigh" || effort == "max" {
return fmt.Errorf("claude-sonnet-5-5 thinking.type=between_tools supports only low, medium or high effort")
}
for _, field := range []string{"thinking.display", "thinking.budget_tokens", "thinking.block_binding"} {
if gjson.GetBytes(body, field).Exists() {
return fmt.Errorf("claude-sonnet-5-5 thinking.type=between_tools does not support %s", field)
}
}
}
modelName := "claude-opus-5-5"
if isSonnet55 {
modelName = "claude-sonnet-5-5"
}
if gjson.GetBytes(body, "tool_choice").String() == "required" {
return fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none")
return fmt.Errorf("%s does not support forced tool_choice; use auto or none", modelName)
}
switch gjson.GetBytes(body, "tool_choice.type").String() {
case "any", "tool", "function", "custom", "namespace":
return fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none")
return fmt.Errorf("%s does not support forced tool_choice; use auto or none", modelName)
}
if isSonnet55 {
if temperature := gjson.GetBytes(body, "temperature"); temperature.Exists() && (temperature.Type != gjson.Number || temperature.Float() != 1) {
return fmt.Errorf("claude-sonnet-5-5 does not support non-default temperature")
}
if topP := gjson.GetBytes(body, "top_p"); topP.Exists() && (topP.Type != gjson.Number || topP.Float() < 0.99 || topP.Float() > 1) {
return fmt.Errorf("claude-sonnet-5-5 does not support non-default top_p")
}
if gjson.GetBytes(body, "top_k").Exists() {
return fmt.Errorf("claude-sonnet-5-5 does not support top_k")
}
}
return nil
}
@@ -598,13 +637,13 @@ func validateClaudeOpus55Request(body []byte, model string) error {
//
// 策略 (anthropic-strict only):
// - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块
// - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块(避免 400)
// (blocks with missing/empty/dummy signatures that would cause 400 errors)
// - 当 thinking.type 是 "enabled"/"adaptive":移除缺失/无效 signature 的 thinking 块(避免 400)
// - 对要求签名历史完整的 5.5 模型,一旦有无效块,移除历史中的全部 thinking 块
func FilterThinkingBlocks(body []byte, mappedModel string) []byte {
if !ShouldPreFilterThinkingBlocks(mappedModel) {
return body
}
return filterThinkingBlocksInternal(body, claude.IsOpus55(mappedModel))
return filterThinkingBlocksInternal(body, isClaude55SignedThinkingModel(mappedModel))
}
// FilterThinkingBlocksForRetry strips thinking-related constructs for retry scenarios.
@@ -675,6 +714,9 @@ func FilterThinkingBlocksForRetry(body []byte, mappedModel string) []byte {
bytes.Contains(body, patternThinkingFieldSpaced)
if !hasEmptyContent && !hasEmptyTextBlock && !containsThinkingBlocks {
if topThinking := gjson.Get(jsonStr, "thinking"); topThinking.Exists() {
if claude.IsSonnet55(mappedModel) && topThinking.Get("type").String() == "between_tools" {
return body
}
if out, err := sjson.DeleteBytes(body, "thinking"); err == nil {
out = removeThinkingDependentContextStrategies(out)
return out
@@ -692,7 +734,9 @@ func FilterThinkingBlocksForRetry(body []byte, mappedModel string) []byte {
modified := false
// Disable top-level thinking mode for retry to avoid structural/signature constraints upstream.
deleteTopLevelThinking := gjson.Get(jsonStr, "thinking").Exists()
thinkingMode := gjson.Get(jsonStr, "thinking.type").String()
deleteTopLevelThinking := gjson.Get(jsonStr, "thinking").Exists() &&
(!claude.IsSonnet55(mappedModel) || thinkingMode != "between_tools")
for i := 0; i < len(messages); i++ {
msgMap, ok := messages[i].(map[string]any)
@@ -1340,7 +1384,9 @@ func FilterSignatureSensitiveBlocksForRetry(body []byte, mappedModel string) []b
// filterThinkingBlocksInternal removes invalid thinking blocks from request
// 策略:
// - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块
// - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块
// - 当 thinking.type 是 "enabled"/"adaptive":移除缺失/无效 signature 的 thinking 块
// - 对 5.5 模型,只要历史中有无效 thinking,就移除所有 thinking/redacted_thinking,
// 避免留下不连续的签名历史
func filterThinkingBlocksInternal(body []byte, alwaysThinking bool) []byte {
// Fast path: if body doesn't contain "thinking", skip parsing
if !bytes.Contains(body, []byte(`"type":"thinking"`)) &&
@@ -1436,6 +1482,44 @@ func filterThinkingBlocksInternal(body []byte, alwaysThinking bool) []byte {
if !filtered {
return body
}
if alwaysThinking {
for _, msg := range messages {
msgMap, ok := msg.(map[string]any)
if !ok {
continue
}
content, ok := msgMap["content"].([]any)
if !ok {
continue
}
cleaned := make([]any, 0, len(content))
for _, block := range content {
blockMap, ok := block.(map[string]any)
if !ok {
cleaned = append(cleaned, block)
continue
}
blockType, _ := blockMap["type"].(string)
if blockType == "thinking" || blockType == "redacted_thinking" {
continue
}
if blockType == "" {
if _, hasThinking := blockMap["thinking"]; hasThinking {
continue
}
}
cleaned = append(cleaned, block)
}
if len(cleaned) == 0 {
placeholder := "(content removed)"
if role, _ := msgMap["role"].(string); role == "assistant" {
placeholder = "(assistant content removed)"
}
cleaned = append(cleaned, map[string]any{"type": "text", "text": placeholder})
}
msgMap["content"] = cleaned
}
}
newBody, err := json.Marshal(req)
if err != nil {
@@ -386,6 +386,33 @@ func TestFilterThinkingBlocksForRetry_DisablesThinkingAndPreservesAsText(t *test
require.Equal(t, "Let me think...", first["text"])
}
func TestClaude55FilterThinkingBlocksRemovesSignedChainAfterInvalidBlock(t *testing.T) {
input := []byte(`{"model":"claude-sonnet-5-5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"old","signature":""},{"type":"text","text":"answer"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"later","signature":"valid"},{"type":"redacted_thinking","data":"encrypted"},{"type":"text","text":"later answer"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"only thought","signature":"valid"}]}]}`)
for _, model := range []string{"claude-sonnet-5-5", "claude-opus-5-5"} {
t.Run(model, func(t *testing.T) {
out := FilterThinkingBlocks(input, model)
require.False(t, gjson.GetBytes(out, "messages.0.content.#(type==thinking)").Exists())
require.Equal(t, "answer", gjson.GetBytes(out, "messages.0.content.0.text").String())
require.Len(t, gjson.GetBytes(out, "messages.1.content").Array(), 1)
require.Equal(t, "later answer", gjson.GetBytes(out, "messages.1.content.0.text").String())
require.Equal(t, "(assistant content removed)", gjson.GetBytes(out, "messages.2.content.0.text").String())
})
}
}
func TestSonnet55RetryKeepsBetweenToolsMode(t *testing.T) {
for _, messages := range []string{
`[{"role":"user","content":"hi"}]`,
`[{"role":"assistant","content":[{"type":"thinking","thinking":"old","signature":"invalid"},{"type":"text","text":"answer"}]}]`,
} {
input := []byte(`{"model":"claude-sonnet-5-5","thinking":{"type":"between_tools"},"output_config":{"effort":"low"},"messages":` + messages + `}`)
out := FilterThinkingBlocksForRetry(input, "claude-sonnet-5-5")
require.Equal(t, "between_tools", gjson.GetBytes(out, "thinking.type").String())
require.Equal(t, "low", gjson.GetBytes(out, "output_config.effort").String())
require.NoError(t, validateClaude55Request(out, "claude-sonnet-5-5"))
}
}
func TestFilterThinkingBlocksForRetry_DisablesThinkingEvenWithoutThinkingBlocks(t *testing.T) {
input := []byte(`{
"model":"claude-3-5-sonnet-20241022",
@@ -0,0 +1,118 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestSonnet55ToolsetDropsLegacyStreamingBeta(t *testing.T) {
svc := &GatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{InjectBetaForAPIKey: true}}}
for _, tc := range []struct {
name string
model string
toolType string
wantDrop bool
}{
{"computer toolset", "claude-sonnet-5-5", "computer_toolset_20260801", true},
{"browser toolset", "claude-sonnet-5-5", "browser_toolset_20260801", true},
{"legacy computer", "claude-sonnet-5-5", "computer_20251124", false},
{"older model", "claude-sonnet-5", "computer_toolset_20260801", false},
} {
t.Run(tc.name, func(t *testing.T) {
body := []byte(`{"model":"` + tc.model + `","tools":[{"type":"` + tc.toolType + `"}],"messages":[{"role":"user","content":"hi"}]}`)
headers := http.Header{}
headers.Set("anthropic-beta", claude.BetaFineGrainedToolStreaming+",context-1m-2025-08-07")
for _, tokens := range []struct {
name string
count bool
clientBeta bool
}{
{"explicit messages", false, true},
{"injected messages", false, false},
{"explicit count_tokens", true, true},
{"injected count_tokens", true, false},
} {
t.Run(tokens.name, func(t *testing.T) {
requestHeaders := http.Header{}
if tokens.clientBeta {
requestHeaders = headers
}
var got string
var set bool
if tokens.count {
got, set = svc.computeFinalCountTokensAnthropicBeta("apikey", false, tc.model, requestHeaders, body, nil)
} else {
got, set = svc.computeFinalAnthropicBeta("apikey", false, tc.model, requestHeaders, body, nil)
}
require.True(t, set)
require.Equal(t, !tc.wantDrop, containsBetaToken(got, claude.BetaFineGrainedToolStreaming))
if tokens.clientBeta {
require.True(t, containsBetaToken(got, claude.BetaContext1M))
}
})
}
})
}
}
func TestSonnet55ToolsetBetaFilteredAfterAccountOverrideAndOnVertex(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-5-5","max_tokens":128,"tools":[{"type":"browser_toolset_20260801"}],"messages":[{"role":"user","content":"hi"}]}`)
account := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
Credentials: map[string]any{
credKeyHeaderOverrideEnabled: true,
credKeyHeaderOverrides: map[string]any{"anthropic-beta": claude.BetaFineGrainedToolStreaming + ",context-1m-2025-08-07"},
}}
svc := &GatewayService{cfg: &config.Config{}}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
for _, build := range []struct {
name string
fn func() (*http.Request, error)
}{
{"normal", func() (*http.Request, error) {
req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "key", "apikey", "claude-sonnet-5-5", false, false)
return req, err
}},
{"passthrough", func() (*http.Request, error) {
req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "key")
return req, err
}},
} {
t.Run(build.name, func(t *testing.T) {
req, err := build.fn()
require.NoError(t, err)
header := getHeaderRaw(req.Header, "anthropic-beta")
require.False(t, containsBetaToken(header, claude.BetaFineGrainedToolStreaming))
require.True(t, containsBetaToken(header, claude.BetaContext1M))
})
}
vertexContext := newVertexBetaTestContext(t, claude.BetaFineGrainedToolStreaming+","+claude.BetaContext1M)
req, _, err := svc.buildUpstreamRequest(context.Background(), vertexContext, newVertexServiceAccount(9001), body,
"vertex-token", "service_account", "claude-sonnet-5-5@20260928", false, false)
require.NoError(t, err)
vertexHeader := getHeaderRaw(req.Header, "anthropic-beta")
require.False(t, containsBetaToken(vertexHeader, claude.BetaFineGrainedToolStreaming))
require.True(t, containsBetaToken(vertexHeader, claude.BetaContext1M))
nativeAccount := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
Credentials: map[string]any{
credKeyHeaderOverrideEnabled: true,
credKeyHeaderOverrides: map[string]any{"anthropic-beta": claude.BetaFineGrainedToolStreaming + ",context-1m-2025-08-07"},
}}
native := &OpenAIGatewayService{}
nativeReq, _, err := native.buildNativeAnthropicUpstreamRequest(context.Background(), c, nativeAccount, body,
"key", "https://api.anthropic.com/v1/messages")
require.NoError(t, err)
nativeHeader := getHeaderRaw(nativeReq.Header, "anthropic-beta")
require.False(t, containsBetaToken(nativeHeader, claude.BetaFineGrainedToolStreaming))
require.True(t, containsBetaToken(nativeHeader, claude.BetaContext1M))
}
@@ -116,6 +116,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
finalBetaHeader, finalBetaShouldSet = beta, true
}
finalBetaHeader = filterSonnet55ToolsetBeta(finalBetaHeader, body, modelID)
// 能力维度 body sanitize:与最终 anthropic-beta header 对称
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
@@ -200,6 +201,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)。
// 放在所有 header 逻辑之后,确保配置值对同名头拥有最终决定权。
account.ApplyHeaderOverrides(req.Header)
filterSonnet55ToolsetBetaHeader(req.Header, body, modelID)
// === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
@@ -294,7 +296,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicVertex(
if policy.blockErr != nil {
return nil, policy.blockErr
}
finalBeta := filterVertexBetaTokens(clientBeta, mergeDropSets(policy.filterSet))
finalBeta := filterSonnet55ToolsetBeta(filterVertexBetaTokens(clientBeta, mergeDropSets(policy.filterSet)), vertexBody, modelID)
// 能力维度 sanitize:基于最终 beta(而非原始 client 值)决定是否保留 body 中的
// context_management,与 Anthropic 直连 / Bedrock 路径对称。
@@ -415,6 +417,39 @@ func defaultAPIKeyBetaHeader(body []byte) string {
return claude.APIKeyBetaHeader
}
// The legacy streaming beta is incompatible with the stable computer and
// browser toolsets. The per-tool eager_input_streaming field remains available.
func filterSonnet55ToolsetBeta(header string, body []byte, modelID string) string {
if at := strings.IndexByte(modelID, '@'); at >= 0 {
modelID = modelID[:at] // Vertex's dated model ID
}
if !claude.IsSonnet55(modelID) {
return header
}
for _, tool := range gjson.GetBytes(body, "tools").Array() {
switch tool.Get("type").String() {
case "computer_toolset_20260801", "browser_toolset_20260801":
if !containsBetaToken(header, claude.BetaFineGrainedToolStreaming) {
return header
}
return stripBetaTokensWithSet(header, map[string]struct{}{claude.BetaFineGrainedToolStreaming: {}})
}
}
return header
}
func filterSonnet55ToolsetBetaHeader(headers http.Header, body []byte, modelID string) {
header := getHeaderRaw(headers, "anthropic-beta")
filtered := filterSonnet55ToolsetBeta(header, body, modelID)
if filtered == header {
return
}
deleteHeaderAllForms(headers, "anthropic-beta")
if filtered != "" {
setHeaderRaw(headers, "anthropic-beta", filtered)
}
}
func applyClaudeOAuthHeaderDefaults(req *http.Request) {
if req == nil {
return
@@ -501,7 +536,8 @@ func (s *GatewayService) computeFinalAnthropicBeta(
clientHeaders http.Header,
body []byte,
effectiveDropSet map[string]struct{},
) (string, bool) {
) (beta string, shouldSet bool) {
defer func() { beta = filterSonnet55ToolsetBeta(beta, body, modelID) }()
clientBeta := ""
if clientHeaders != nil {
clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta")
@@ -553,7 +589,8 @@ func (s *GatewayService) computeFinalCountTokensAnthropicBeta(
clientHeaders http.Header,
body []byte,
effectiveDropSet map[string]struct{},
) (string, bool) {
) (beta string, shouldSet bool) {
defer func() { beta = filterSonnet55ToolsetBeta(beta, body, modelID) }()
clientBeta := ""
if clientHeaders != nil {
clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta")
@@ -486,7 +486,7 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr
}
if isClaudeCodexModel(modelID) {
if claude.IsOpus55(modelID) {
if claude.IsOpus55(modelID) || claude.IsSonnet55(modelID) {
descriptor.ContextWindow = 1_000_000
descriptor.MaxContextWindow = 1_000_000
}
@@ -495,6 +495,9 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr
descriptor.SupportsParallelToolCalls = true
if levels := configuredCodexClaudeReasoningLevels(modelID); len(levels) > 0 {
defaultReasoningLevel := claudeCodexDefaultReasoningLevel(levels)
if claude.IsSonnet55(modelID) {
defaultReasoningLevel = "high"
}
descriptor.DefaultReasoningLevel = &defaultReasoningLevel
descriptor.SupportedReasoningLevels = levels
}
@@ -317,6 +317,12 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t
claudeOpus5 := newConfiguredCodexModelDescriptor("claude-opus-5")
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeOpus5.SupportedReasoningLevels))
claudeSonnet55 := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-5-5")
require.Equal(t, "Claude Sonnet 5.5", claudeSonnet55.DisplayName)
require.Equal(t, int64(1_000_000), claudeSonnet55.ContextWindow)
require.Equal(t, int64(1_000_000), claudeSonnet55.MaxContextWindow)
require.Equal(t, "high", *claudeSonnet55.DefaultReasoningLevel)
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeSonnet55.SupportedReasoningLevels))
providerQualifiedClaude := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-4-6")
require.Equal(t, "Claude Sonnet 4.6", providerQualifiedClaude.DisplayName)
@@ -66,7 +66,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic(
// Resolve the mapped model before choosing its thinking/tool protocol.
billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
if err := validateClaudeOpus55Request(body, upstreamModel); err != nil {
if err := validateClaude55Request(body, upstreamModel); err != nil {
writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return nil, err
}
@@ -214,6 +214,7 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest(
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
account.ApplyHeaderOverrides(req.Header)
filterSonnet55ToolsetBetaHeader(req.Header, body, gjson.GetBytes(body, "model").String())
payloads := append([][]byte{body}, sessionBodies...)
applyOpenCodeSessionHeader(c, account, targetURL, req.Header, payloads...)
@@ -20,7 +20,6 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
@@ -68,7 +67,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic(
// Resolve the mapped model before choosing its thinking/tool protocol.
billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
if err := validateClaudeOpus55Request(body, upstreamModel); err != nil {
if err := validateClaude55Request(body, upstreamModel); err != nil {
writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return nil, err
}
@@ -272,7 +271,7 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic(
}
}
if claude.IsOpus55(upstreamModel) {
if isClaude55SignedThinkingModel(upstreamModel) {
finalResp.Model = upstreamModel
}
responsesResp := apicompat.AnthropicToResponsesResponse(finalResp)
@@ -333,7 +332,7 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic(
state := apicompat.NewAnthropicEventToResponsesState()
state.Model = originalModel
state.PreserveThinkingSignatures = claude.IsOpus55(upstreamModel)
state.PreserveThinkingSignatures = isClaude55SignedThinkingModel(upstreamModel)
clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping)
var usage ClaudeUsage
@@ -116,6 +116,12 @@ var (
CacheCreationInputTokenCostPriority: 10e-6, CacheReadInputTokenCostPriority: 0.4e-6,
SupportsServiceTier: true, LiteLLMProvider: "anthropic", Mode: "chat", SupportsPromptCaching: true,
}
claudeSonnet55FallbackPricing = &LiteLLMModelPricing{
InputCostPerToken: 2e-6, OutputCostPerToken: 10e-6,
CacheCreationInputTokenCost: 2.5e-6, CacheCreationInputTokenCostAbove1hr: 4e-6,
CacheReadInputTokenCost: 0.2e-6,
LiteLLMProvider: "anthropic", Mode: "chat", SupportsPromptCaching: true,
}
openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{
InputCostPerToken: 5e-06,
InputCostPerTokenPriority: 1e-05,
@@ -1379,6 +1385,12 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing {
}
return claudeOpus55FallbackPricing
}
if claude.IsSonnet55(model) {
if pricing, ok := s.pricingData["claude-sonnet-5-5"]; ok {
return pricing
}
return claudeSonnet55FallbackPricing
}
// modelFamily 定义一个模型系列的匹配和定价查找规则。
type modelFamily struct {
name string // 系列名称
@@ -867,6 +867,33 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"claude-sonnet-5-5": {
"cache_creation_input_token_cost": 2.5e-06,
"cache_creation_input_token_cost_above_1hr": 4e-06,
"cache_read_input_token_cost": 2e-07,
"input_cost_per_token": 2e-06,
"litellm_provider": "anthropic",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1e-05,
"source": "https://platform.claude.com/docs/en/models/sonnet-5-5/overview",
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true
},
"codex-auto-review": {
"cache_read_input_token_cost": 2e-08,
"input_cost_per_token": 2e-07,
@@ -235,6 +235,7 @@ const formatScopeName = (scope: string): string => {
'claude-sonnet-4-6': 'CSon46',
'claude-sonnet-4-5': 'CSon45',
'claude-sonnet-4-5-thinking': 'CSon45T',
'claude-sonnet-5-5': 'CSon55',
'claude-sonnet-5': 'CSon5',
// Gemini 2.5 系列
'gemini-2.5-flash': 'G25F',
@@ -51,7 +51,7 @@ function makeAccount(overrides: Partial<Account>): Account {
}
describe('AccountStatusIndicator', () => {
it('Claude 5 模型限流时显示 Opus 和 Sonnet 的短别名', () => {
it('Claude 5 系列模型限流时显示 Opus 和 Sonnet 的短别名', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
@@ -64,6 +64,10 @@ describe('AccountStatusIndicator', () => {
'claude-sonnet-5': {
rate_limited_at: '2026-07-28T00:00:00Z',
rate_limit_reset_at: '2099-07-28T00:00:00Z'
},
'claude-sonnet-5-5': {
rate_limited_at: '2026-09-28T00:00:00Z',
rate_limit_reset_at: '2099-09-28T00:00:00Z'
}
}
}
@@ -78,6 +82,7 @@ describe('AccountStatusIndicator', () => {
expect(wrapper.text()).toContain('COpus5')
expect(wrapper.text()).toContain('CSon5')
expect(wrapper.text()).toContain('CSon55')
expect(wrapper.text()).not.toContain('claude-sonnet-5')
})
@@ -1914,6 +1914,19 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
xhigh: { effort: 'xhigh' },
max: { effort: 'max' }
}
},
'claude-sonnet-5-5': {
name: 'Claude Sonnet 5.5',
limit: { context: 1000000, output: 128000 },
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
options: { thinking: { type: 'adaptive' }, effort: 'high' },
variants: {
low: { effort: 'low' },
medium: { effort: 'medium' },
high: { effort: 'high' },
xhigh: { effort: 'xhigh' },
max: { effort: 'max' }
}
}
}
} else if (platform === 'antigravity-claude') {
@@ -690,7 +690,7 @@ describe('UseKeyModal', () => {
})
})
it('exports Opus 5.5 only on the Anthropic provider with adaptive defaults', async () => {
it('exports Claude 5.5 models on the Anthropic provider with adaptive defaults', async () => {
const wrapper = mount(UseKeyModal, {
props: { show: true, apiKey: 'sk-test', baseUrl: 'https://example.com/v1', platform: 'anthropic' },
global: { stubs: { BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }, Icon: { template: '<span />' } } }
@@ -699,11 +699,15 @@ describe('UseKeyModal', () => {
expect(tab).toBeDefined()
await tab!.trigger('click')
await nextTick()
const model = JSON.parse(wrapper.find('pre code').text()).provider.anthropic.models['claude-opus-5-5']
expect(model.limit).toEqual({ context: 1000000, output: 128000 })
expect(model.options).toEqual({ thinking: { type: 'adaptive' }, effort: 'medium' })
expect(model.variants.xhigh.effort).toBe('xhigh')
expect(model.variants).not.toHaveProperty('none')
const models = JSON.parse(wrapper.find('pre code').text()).provider.anthropic.models
expect(models['claude-opus-5-5'].limit).toEqual({ context: 1000000, output: 128000 })
expect(models['claude-opus-5-5'].options).toEqual({ thinking: { type: 'adaptive' }, effort: 'medium' })
expect(models['claude-opus-5-5'].variants.xhigh.effort).toBe('xhigh')
expect(models['claude-opus-5-5'].variants).not.toHaveProperty('none')
expect(models['claude-sonnet-5-5'].limit).toEqual({ context: 1000000, output: 128000 })
expect(models['claude-sonnet-5-5'].options).toEqual({ thinking: { type: 'adaptive' }, effort: 'high' })
expect(models['claude-sonnet-5-5'].variants.xhigh.effort).toBe('xhigh')
expect(models['claude-sonnet-5-5'].variants).not.toHaveProperty('none')
})
it('renders Claude Fable 5 OpenCode config with adaptive thinking', async () => {
@@ -54,10 +54,21 @@ describe('useModelWhitelist', () => {
expect(getModelsByPlatform('antigravity')).toContain('claude-fable-5')
expect(getModelsByPlatform('claude')).toContain('claude-opus-5-5')
expect(getModelsByPlatform('antigravity')).not.toContain('claude-opus-5-5')
expect(getModelsByPlatform('claude')).toContain('claude-sonnet-5-5')
expect(getModelsByPlatform('antigravity')).not.toContain('claude-sonnet-5-5')
expect(getModelsByPlatform('claude')).toContain('claude-opus-4-8')
expect(getModelsByPlatform('antigravity')).toContain('claude-opus-4-8')
})
it('Claude Sonnet 5.5 预设使用各平台的官方模型 ID', () => {
expect(getPresetMappingsByPlatform('claude')).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'Sonnet 5.5', from: 'claude-sonnet-5-5', to: 'claude-sonnet-5-5' })
]))
expect(getPresetMappingsByPlatform('bedrock')).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'Sonnet 5.5', from: 'claude-sonnet-5-5', to: 'global.anthropic.claude-sonnet-5-5' })
]))
})
it('xAI 模型列表包含 Grok 4.5 官方模型和别名', () => {
const models = getModelsByPlatform('grok')
@@ -36,8 +36,9 @@ export const claudeModels = [
'claude-opus-4-8',
'claude-opus-5-5',
'claude-opus-5',
'claude-sonnet-4-6',
'claude-sonnet-5-5',
'claude-sonnet-5',
'claude-sonnet-4-6',
'claude-fable-5-1',
'claude-fable-5'
]
@@ -283,6 +284,7 @@ export const allModels = allModelsList.map(m => ({ value: m, label: m }))
const anthropicPresetMappings = [
{ label: 'Fable 5.1', from: 'claude-fable-5-1', to: 'claude-fable-5-1', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Fable 5', from: 'claude-fable-5', to: 'claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Sonnet 5.5', from: 'claude-sonnet-5-5', to: 'claude-sonnet-5-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 5', from: 'claude-sonnet-5', to: 'claude-sonnet-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
@@ -395,6 +397,7 @@ const bedrockPresetMappings = [
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'us.anthropic.claude-opus-4-7-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'us.anthropic.claude-opus-4-8-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Opus 5', from: 'claude-opus-5', to: 'us.anthropic.claude-opus-5-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Sonnet 5.5', from: 'claude-sonnet-5-5', to: 'global.anthropic.claude-sonnet-5-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 5', from: 'claude-sonnet-5', to: 'us.anthropic.claude-sonnet-5-v1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'us.anthropic.claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
{ label: 'Opus 4.5', from: 'claude-opus-4-5-thinking', to: 'us.anthropic.claude-opus-4-5-20251101-v1:0', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },