From 8490a818678780e8301147c13696af94dea7dd07 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Tue, 29 Sep 2026 04:42:30 +0800 Subject: [PATCH 1/2] feat: support Claude Sonnet 5.5 --- backend/internal/domain/constants.go | 2 + backend/internal/domain/constants_test.go | 7 +- .../pkg/apicompat/anthropic_responses_test.go | 58 +++++++++ .../anthropic_to_responses_response.go | 6 +- .../responses_to_anthropic_request.go | 36 +++++- backend/internal/pkg/apicompat/types.go | 2 +- backend/internal/pkg/claude/constants.go | 6 + .../pkg/claude/constants_model_test.go | 13 ++ backend/internal/pkg/claude/effort_catalog.go | 16 ++- .../pkg/claude/effort_catalog_test.go | 19 +++ backend/internal/service/bedrock_request.go | 49 ++++++-- .../internal/service/bedrock_request_test.go | 58 +++++++++ backend/internal/service/billing_service.go | 13 +- .../internal/service/billing_service_test.go | 20 +++ ...teway_anthropic_apikey_passthrough_test.go | 104 +++++++++++++++ .../service/gateway_anthropic_passthrough.go | 1 + backend/internal/service/gateway_bedrock.go | 3 + .../service/gateway_claude_oauth_body.go | 2 +- .../internal/service/gateway_count_tokens.go | 17 ++- backend/internal/service/gateway_forward.go | 14 ++- .../gateway_forward_as_chat_completions.go | 4 +- .../service/gateway_forward_as_responses.go | 8 +- .../gateway_forward_as_responses_test.go | 107 ++++++++-------- backend/internal/service/gateway_request.go | 106 ++++++++++++++-- .../internal/service/gateway_request_test.go | 27 ++++ .../gateway_sonnet55_toolset_beta_test.go | 118 ++++++++++++++++++ .../service/gateway_upstream_request.go | 43 ++++++- .../service/openai_codex_models_service.go | 5 +- .../openai_codex_models_service_test.go | 6 + ...teway_chat_completions_anthropic_native.go | 2 +- ...penai_gateway_messages_anthropic_native.go | 1 + ...enai_gateway_responses_anthropic_native.go | 7 +- backend/internal/service/pricing_service.go | 12 ++ .../model_prices_and_context_window.json | 27 ++++ .../account/AccountStatusIndicator.vue | 1 + .../__tests__/AccountStatusIndicator.spec.ts | 7 +- frontend/src/components/keys/UseKeyModal.vue | 13 ++ .../keys/__tests__/UseKeyModal.spec.ts | 16 ++- .../__tests__/useModelWhitelist.spec.ts | 11 ++ frontend/src/composables/useModelWhitelist.ts | 5 +- 40 files changed, 855 insertions(+), 117 deletions(-) create mode 100644 backend/internal/service/gateway_sonnet55_toolset_beta_test.go diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index e82c1df1dc..4833bae7d4 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -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", diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index de7d882955..076688d6c0 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -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] diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 00ca2daadd..d8437954d3 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -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"} { diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index f0712ecb74..186ee3baa3 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -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 } diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 69928591d3..34d9051887 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -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} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 00b6e85c4d..859cb1dcb4 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -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 } diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 9cdcfc16b8..6c2aeebba3 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -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", diff --git a/backend/internal/pkg/claude/constants_model_test.go b/backend/internal/pkg/claude/constants_model_test.go index 941a72d486..4a58d540d8 100644 --- a/backend/internal/pkg/claude/constants_model_test.go +++ b/backend/internal/pkg/claude/constants_model_test.go @@ -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") +} diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go index 760281aa2e..a3039faac7 100644 --- a/backend/internal/pkg/claude/effort_catalog.go +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -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 } diff --git a/backend/internal/pkg/claude/effort_catalog_test.go b/backend/internal/pkg/claude/effort_catalog_test.go index 9bcd5206ee..e8f8e2f15a 100644 --- a/backend/internal/pkg/claude/effort_catalog_test.go +++ b/backend/internal/pkg/claude/effort_catalog_test.go @@ -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) + } +} diff --git a/backend/internal/service/bedrock_request.go b/backend/internal/service/bedrock_request.go index d8f48541de..4fc7fd7213 100644 --- a/backend/internal/service/bedrock_request.go +++ b/backend/internal/service/bedrock_request.go @@ -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") diff --git a/backend/internal/service/bedrock_request_test.go b/backend/internal/service/bedrock_request_test.go index 71c954f44c..20b8eff337 100644 --- a/backend/internal/service/bedrock_request_test.go +++ b/backend/internal/service/bedrock_request_test.go @@ -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":[]}`) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index c85420b5d5..d41187e151 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -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") { diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index e79e68391f..58145f07c5 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -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) + }) + } } } diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 1925404467..8f5d6e82ee 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -1812,3 +1812,107 @@ 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} + if typ == AccountTypeAPIKey { + model = "public-sonnet" + account.Credentials = map[string]any{"model_mapping": map[string]any{model: "claude-sonnet-5-5"}} + } else if typ == 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()) + }) + } +} diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index d9393e5a0c..31df1cb77c 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -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 } diff --git a/backend/internal/service/gateway_bedrock.go b/backend/internal/service/gateway_bedrock.go index 80e2afcfac..97ee44341d 100644 --- a/backend/internal/service/gateway_bedrock.go +++ b/backend/internal/service/gateway_bedrock.go @@ -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) diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index fbdeb683ea..9fa4c1e134 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -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 diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 7a0578f08c..2a5f4a0401 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -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)) diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index f292773765..c47e90967e 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -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 } diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index ac1d393602..0b74135c4c 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -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) diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 44ed3329c4..7f33bdae71 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -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 diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index 5f74dfac59..5c6be51765 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -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()) + } } } } diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index da4e5d6017..1ec4db19da 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -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 { diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index 27b71c31fe..242b02110b 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -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", diff --git a/backend/internal/service/gateway_sonnet55_toolset_beta_test.go b/backend/internal/service/gateway_sonnet55_toolset_beta_test.go new file mode 100644 index 0000000000..2338a34b95 --- /dev/null +++ b/backend/internal/service/gateway_sonnet55_toolset_beta_test.go @@ -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)) +} diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index ff88e71003..b6e91a52a8 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -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") diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index f4b77f061e..e5a9312023 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -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 } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index d2ce58a038..c58fdc49e7 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -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) diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index 4019530426..a8839e6f49 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -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 } diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 1634a556fc..0026046a58 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -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...) diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index dba295ad63..c858039ba1 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -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 diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 0b873a698b..e640929be3 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -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 // 系列名称 diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 2ba65b23c2..9f9ba8ee26 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -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, diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 2130e257ac..44602d43ee 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -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', diff --git a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts index e38f43aa6a..31d1920807 100644 --- a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts +++ b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts @@ -51,7 +51,7 @@ function makeAccount(overrides: Partial): 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') }) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 242817d1cc..26e76415a0 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -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') { diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index 7d2a939ed1..ff5115e3f9 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -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: '
' }, Icon: { template: '' } } } @@ -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 () => { diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index bb73136e0a..0185996235 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -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') diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index e99695652e..d84fec73f7 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -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' }, From 2cc459e24fb58b6a8875d952deeb984a5136ec37 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Tue, 29 Sep 2026 05:20:04 +0800 Subject: [PATCH 2/2] style: satisfy Sonnet 5.5 lint checks --- .../service/gateway_anthropic_apikey_passthrough_test.go | 5 +++-- backend/internal/service/gateway_request.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 8f5d6e82ee..8b7c2d4a09 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -1830,10 +1830,11 @@ func TestSonnet55RejectsUnsupportedParametersBeforeMimicry(t *testing.T) { for _, count := range []bool{false, true} { model := "claude-sonnet-5-5" account := &Account{ID: 1, Platform: PlatformAnthropic, Type: typ} - if typ == AccountTypeAPIKey { + switch typ { + case AccountTypeAPIKey: model = "public-sonnet" account.Credentials = map[string]any{"model_mapping": map[string]any{model: "claude-sonnet-5-5"}} - } else if typ == AccountTypeBedrock { + case AccountTypeBedrock: account.Credentials = map[string]any{"aws_region": "eu-west-1"} } body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hello"}],` + field + `}`) diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 1ec4db19da..678d7d3d3b 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -736,7 +736,7 @@ func FilterThinkingBlocksForRetry(body []byte, mappedModel string) []byte { // Disable top-level thinking mode for retry to avoid structural/signature constraints upstream. thinkingMode := gjson.Get(jsonStr, "thinking.type").String() deleteTopLevelThinking := gjson.Get(jsonStr, "thinking").Exists() && - !(claude.IsSonnet55(mappedModel) && thinkingMode == "between_tools") + (!claude.IsSonnet55(mappedModel) || thinkingMode != "between_tools") for i := 0; i < len(messages); i++ { msgMap, ok := messages[i].(map[string]any)