From c4c2e660704dbde070f445beac3808cc829880c1 Mon Sep 17 00:00:00 2001 From: lyy0709 <65712338+lyy0709@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:21:53 +0800 Subject: [PATCH] feat(models): support GPT-6 Sol, Luna and Claude Opus 5.5 --- .../gpt6-sol-luna-opus55-model-selector.jpg | Bin 0 -> 35773 bytes .../internal/handler/gateway_models_test.go | 31 ++++ .../pkg/apicompat/anthropic_responses_test.go | 66 ++++++++ .../pkg/apicompat/anthropic_to_responses.go | 4 +- .../anthropic_to_responses_response.go | 53 ++++++- .../apicompat/chatcompletions_to_responses.go | 40 +++-- ...esponses_to_anthropic_instructions_test.go | 6 +- .../responses_to_anthropic_request.go | 60 ++++++- ...esponses_to_anthropic_tool_pairing_test.go | 2 +- backend/internal/pkg/apicompat/types.go | 19 ++- backend/internal/pkg/claude/constants.go | 6 + .../pkg/claude/constants_model_test.go | 12 ++ backend/internal/pkg/claude/effort_catalog.go | 6 + backend/internal/pkg/openai/constants.go | 20 +++ backend/internal/pkg/openai/constants_test.go | 10 ++ backend/internal/service/billing_service.go | 65 +++++++- .../internal/service/billing_service_test.go | 106 +++++++++++++ ...teway_anthropic_apikey_passthrough_test.go | 42 +++++ .../service/gateway_claude_oauth_body.go | 4 +- .../internal/service/gateway_count_tokens.go | 11 ++ backend/internal/service/gateway_forward.go | 11 ++ .../gateway_forward_as_chat_completions.go | 28 ++-- .../service/gateway_forward_as_responses.go | 34 ++-- .../gateway_forward_as_responses_test.go | 74 +++++++++ backend/internal/service/gateway_request.go | 34 +++- .../service/openai_codex_models_service.go | 18 ++- .../openai_codex_models_service_test.go | 19 +++ .../service/openai_codex_transform.go | 4 + .../service/openai_compat_prompt_cache_key.go | 6 +- .../openai_compat_prompt_cache_key_test.go | 9 ++ .../openai_gateway_chat_completions.go | 5 + ...teway_chat_completions_anthropic_native.go | 19 ++- .../openai_gateway_chat_completions_raw.go | 6 + .../openai_gateway_chat_completions_test.go | 113 +++++++++++++ .../service/openai_gateway_forward.go | 2 +- .../service/openai_gateway_messages.go | 1 + .../service/openai_gateway_request_body.go | 57 ++++++- ...enai_gateway_responses_anthropic_native.go | 26 ++- .../internal/service/openai_model_alias.go | 11 ++ .../openai_passthrough_normalization_test.go | 6 +- backend/internal/service/pricing_service.go | 64 ++++++++ .../model_prices_and_context_window.json | 148 ++++++++++++++++++ .../account/AccountStatusIndicator.vue | 1 + frontend/src/components/keys/UseKeyModal.vue | 51 ++++++ .../keys/__tests__/UseKeyModal.spec.ts | 20 ++- .../__tests__/useModelWhitelist.spec.ts | 4 + frontend/src/composables/useModelWhitelist.ts | 6 +- 47 files changed, 1227 insertions(+), 113 deletions(-) create mode 100644 assets/gpt6-sol-luna-opus55-model-selector.jpg diff --git a/assets/gpt6-sol-luna-opus55-model-selector.jpg b/assets/gpt6-sol-luna-opus55-model-selector.jpg new file mode 100644 index 0000000000000000000000000000000000000000..71e177a556d5bff4b8bb9ab302e3e3536981b04a GIT binary patch literal 35773 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 literal 0 HcmV?d00001 diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 81df742e9b..5231f4c095 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -1479,3 +1479,34 @@ func TestGatewayModels_CodexGeminiGroupListsAntigravityGeminiMappings(t *testing require.True(t, strings.HasPrefix(slug, "gemini-"), "unexpected non-gemini model on gemini group: %s", slug) } } + +func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t *testing.T) { + for _, tc := range []struct { + name string + selected []string + restricted bool + want []string + }{ + {"selected and ordered", []string{"gpt-6-luna", "gpt-6-sol"}, false, []string{"gpt-6-luna", "gpt-6-sol"}}, + {"group excludes new models", []string{"gpt-5.6-sol"}, false, []string{"gpt-5.6-sol"}}, + {"account restricts new models", []string{"gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol"}, true, []string{"gpt-5.6-sol"}}, + } { + t.Run(tc.name, func(t *testing.T) { + groupID := int64(25) + account := service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey} + if tc.restricted { + account.Credentials = map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-sol"}} + } + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{groupID: {account}}}) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: tc.selected}}}) + h.Models(c) + require.Equal(t, http.StatusOK, rec.Code) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, tc.want, modelIDsForTest(got.Data)) + }) + } +} diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 539ccda0d7..d823393cb2 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -1819,6 +1819,72 @@ func TestAnthropicEventToResponses_CacheTokensFromMessageDelta(t *testing.T) { assert.Equal(t, 11, completed.Response.Usage.InputTokensDetails.CachedTokens) } +func TestOpus55ResponsesAdaptiveThinkingAndToolChoice(t *testing.T) { + for _, effort := range []string{"", "low", "medium", "high", "xhigh", "max"} { + req := &ResponsesRequest{Model: "claude-opus-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: effort}} + out, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + require.Equal(t, "adaptive", out.Thinking.Type) + require.Zero(t, out.Thinking.BudgetTokens) + if effort == "" { + effort = "medium" + } + require.Equal(t, effort, out.OutputConfig.Effort) + } + for _, choice := range []string{`"required"`, `{"type":"function","name":"lookup"}`} { + _, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5-5", Input: json.RawMessage(`"hello"`), ToolChoice: json.RawMessage(choice)}) + require.ErrorContains(t, err, "forced tool_choice") + } + _, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "none"}}) + require.ErrorContains(t, err, "reasoning effort") + old, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "xhigh"}}) + require.NoError(t, err) + require.Equal(t, "max", old.OutputConfig.Effort) + require.Equal(t, "enabled", old.Thinking.Type) +} + +func TestOpus55SignedThinkingResponsesRoundTrip(t *testing.T) { + block := AnthropicContentBlock{Type: "thinking", Thinking: "", Signature: "upstream-signed-block"} + response := AnthropicToResponsesResponse(&AnthropicResponse{Model: "claude-opus-5-5", Content: []AnthropicContentBlock{block, {Type: "tool_use", ID: "toolu_1", Name: "lookup", Input: json.RawMessage(`{}`)}}}) + require.Len(t, response.Output, 2) + require.NotEmpty(t, response.Output[0].EncryptedContent) + 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[1].CallID, Output: "ok"}) + raw, err = json.Marshal(items) + require.NoError(t, err) + converted, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-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, "tool_use", blocks[1].Type) + // Arbitrary OpenAI ciphertext must never be treated as an Anthropic signature. + _, _, err = convertResponsesInputToAnthropic("", json.RawMessage(`[{"type":"reasoning","encrypted_content":"anthropic-thinking-v1:!"}]`), true) + require.Error(t, err) +} + +func TestGPT6ChatSamplingAndCacheFields(t *testing.T) { + temperature := 0.7 + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + for _, effort := range []string{"", "none", "medium", "max"} { + out, err := ChatCompletionsToResponses(&ChatCompletionsRequest{Model: model, ReasoningEffort: effort, Temperature: &temperature, TopP: &temperature, PromptCacheOptions: json.RawMessage(`{"mode":"explicit","ttl":"30m"}`), Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`[{"type":"text","text":"hello","prompt_cache_breakpoint":{"mode":"explicit"}}]`)}}}) + require.NoError(t, err) + if effort == "none" { + require.NotNil(t, out.Temperature) + } else { + require.Nil(t, out.Temperature) + require.Nil(t, out.TopP) + } + require.JSONEq(t, `{"mode":"explicit","ttl":"30m"}`, string(out.PromptCacheOptions)) + require.Contains(t, string(out.Input), "prompt_cache_breakpoint") + } + } +} + func TestMessageStartSSE_StopReasonIsJSONNull(t *testing.T) { state := NewResponsesEventToAnthropicState() state.Model = "grok-4.5" diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index dfbe7b5e05..23acc0bea8 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) // AnthropicToResponses converts an Anthropic Messages request directly into @@ -470,7 +472,7 @@ func boolPtr(v bool) *bool { // All gpt-5.x models are reasoning-only; the Responses API returns // "Unsupported parameter: temperature" if these fields are present. func isReasoningModel(model string) bool { - return strings.HasPrefix(model, "gpt-5") + return strings.HasPrefix(model, "gpt-5") || openai.IsGPT6SolOrLunaModelSpelling(model) } // normalizeToolParameters ensures the tool parameter schema is valid for diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index f270050d43..f4c52fcef6 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -2,16 +2,32 @@ package apicompat import ( "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" ) // --------------------------------------------------------------------------- // Non-streaming: AnthropicResponse → ResponsesResponse // --------------------------------------------------------------------------- +const anthropicThinkingEnvelopePrefix = "anthropic-thinking-v1:" + +func encodeAnthropicThinking(block AnthropicContentBlock) string { + // Only fields belonging to the signed thinking block are retained. + payload, _ := json.Marshal(struct { + Type string `json:"type"` + Thinking string `json:"thinking"` + Signature string `json:"signature,omitempty"` + Data string `json:"data,omitempty"` + }{block.Type, block.Thinking, block.Signature, block.Data}) + return anthropicThinkingEnvelopePrefix + base64.RawStdEncoding.EncodeToString(payload) +} + // AnthropicToResponsesResponse converts an Anthropic Messages response into a // Responses API response. This is the reverse of ResponsesToAnthropic and // enables Anthropic upstream responses to be returned in OpenAI Responses format. @@ -35,7 +51,15 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { for _, block := range resp.Content { switch block.Type { - case "thinking": + case "thinking", "redacted_thinking": + if claude.IsOpus55(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}} + } + outputs = append(outputs, item) + continue + } if block.Thinking != "" { outputs = append(outputs, ResponsesOutput{ Type: "reasoning", @@ -47,6 +71,10 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { }) } case "text": + if claude.IsOpus55(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 + } if block.Text != "" { msgParts = append(msgParts, ResponsesContentPart{ Type: "output_text", @@ -164,9 +192,11 @@ type AnthropicEventToResponsesState struct { CurrentName string // Content of the currently open item, folded into Outputs when it closes. - CurrentContent []ResponsesContentPart // message - CurrentArgs string // function_call - CurrentSummary string // reasoning + CurrentContent []ResponsesContentPart // message + CurrentArgs string // function_call + CurrentSummary string // reasoning + CurrentThinking AnthropicContentBlock + PreserveThinkingSignatures bool // Outputs accumulates every closed output item so that response.completed // can carry the full output list. The OpenAI SDK's get_final_response() @@ -248,6 +278,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) if state.Model == "" { state.Model = evt.Message.Model } @@ -279,7 +310,7 @@ func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *Anthropi var events []ResponsesStreamEvent switch evt.ContentBlock.Type { - case "thinking": + case "thinking", "redacted_thinking": // 开新 item 前必须先关掉在开的那个,与下面的 tool_use 分支一致。 // 一个 message item 在它的 text 块 content_block_stop 时是刻意保持打开的 // (同一 item 里可能还有后续 text 块),所以 thinking 块到来时它仍然开着: @@ -292,6 +323,8 @@ func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *Anthropi state.CurrentItemID = generateItemID() state.CurrentItemType = "reasoning" + state.CurrentThinking = *evt.ContentBlock + state.CurrentSummary = evt.ContentBlock.Thinking state.ContentIndex = 0 events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{ @@ -409,7 +442,10 @@ func anthToResHandleContentBlockDelta(evt *AnthropicStreamEvent, state *Anthropi })} case "signature_delta": - // Anthropic signature deltas have no Responses equivalent; skip + // Keep signatures in the opaque bridge envelope, never in visible text. + if state.PreserveThinkingSignatures { + state.CurrentThinking.Signature += evt.Delta.Signature + } return nil } @@ -552,6 +588,10 @@ func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []Response } item.Arguments = args case "reasoning": + if state.PreserveThinkingSignatures && (state.CurrentThinking.Signature != "" || state.CurrentThinking.Data != "") { + state.CurrentThinking.Thinking = state.CurrentSummary + item.EncryptedContent = encodeAnthropicThinking(state.CurrentThinking) + } if state.CurrentSummary != "" { item.Summary = []ResponsesSummary{{Type: "summary_text", Text: state.CurrentSummary}} } @@ -566,6 +606,7 @@ func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []Response state.CurrentContent = nil state.CurrentArgs = "" state.CurrentSummary = "" + state.CurrentThinking = AnthropicContentBlock{} state.TextAccum = "" state.OutputIndex++ state.ContentIndex = 0 diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index f72b5faf6d..773b12b69a 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) type chatMessageContent struct { @@ -27,18 +29,19 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } out := &ResponsesRequest{ - Model: req.Model, - Instructions: req.Instructions, - Input: inputJSON, - Stream: true, // upstream always streams - Include: []string{"reasoning.encrypted_content"}, - ServiceTier: req.ServiceTier, - ParallelToolCalls: req.ParallelToolCalls, + Model: req.Model, + Instructions: req.Instructions, + Input: inputJSON, + Stream: true, // upstream always streams + Include: []string{"reasoning.encrypted_content"}, + ServiceTier: req.ServiceTier, + PromptCacheOptions: req.PromptCacheOptions, + ParallelToolCalls: req.ParallelToolCalls, } // Reasoning models (gpt-5.x) do not accept sampling parameters. // See isReasoningModel in anthropic_to_responses.go. - if !isReasoningModel(req.Model) { + if !isReasoningModel(req.Model) || (openai.IsGPT6SolOrLunaModelSpelling(req.Model) && req.ReasoningEffort == "none") { out.Temperature = req.Temperature out.TopP = req.TopP } @@ -365,26 +368,29 @@ func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesCont for _, p := range parts { switch p.Type { case "text": - if p.Text != "" { + if p.Text != "" || len(p.PromptCacheBreakpoint) > 0 { responseParts = append(responseParts, ResponsesContentPart{ - Type: "input_text", - Text: p.Text, + PromptCacheBreakpoint: p.PromptCacheBreakpoint, + Type: "input_text", + Text: p.Text, }) } case "image_url": if p.ImageURL != nil && p.ImageURL.URL != "" && !isEmptyBase64DataURI(p.ImageURL.URL) { responseParts = append(responseParts, ResponsesContentPart{ - Type: "input_image", - ImageURL: p.ImageURL.URL, + PromptCacheBreakpoint: p.PromptCacheBreakpoint, + Type: "input_image", + ImageURL: p.ImageURL.URL, }) } case "file": if p.File != nil && (p.File.FileData != "" || p.File.FileID != "") { responseParts = append(responseParts, ResponsesContentPart{ - Type: "input_file", - Filename: p.File.Filename, - FileData: p.File.FileData, - FileID: p.File.FileID, + PromptCacheBreakpoint: p.PromptCacheBreakpoint, + Type: "input_file", + Filename: p.File.Filename, + FileData: p.File.FileData, + FileID: p.File.FileID, }) } } diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go index b63787cd44..4c113fefb2 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go @@ -80,7 +80,7 @@ func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) { {"role":"user","content":"review this code"} ]` - system, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + system, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input), false) require.NoError(t, err) var systemText string @@ -97,7 +97,7 @@ func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) { {"role":"user","content":"hi"} ]` - _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input), false) require.NoError(t, err) for _, m := range messages { @@ -116,7 +116,7 @@ func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) { {"role":"user","content":"hello"} ]` - system, _, err := convertResponsesInputToAnthropic("Main instruction.", json.RawMessage(input)) + system, _, err := convertResponsesInputToAnthropic("Main instruction.", json.RawMessage(input), false) require.NoError(t, err) var systemText string diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 484fa98dba..06d9669d29 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -1,9 +1,12 @@ package apicompat import ( + "encoding/base64" "encoding/json" "fmt" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" ) // ResponsesToAnthropicRequest converts a Responses API request into an @@ -11,7 +14,7 @@ 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) + system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, claude.IsOpus55(req.Model)) if err != nil { return nil, err } @@ -51,6 +54,34 @@ 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) { + var choice struct { + Type string `json:"type"` + } + if len(out.ToolChoice) > 0 { + if err := json.Unmarshal(out.ToolChoice, &choice); err != nil { + return nil, fmt.Errorf("invalid tool_choice: %w", err) + } + } + 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") + } + effort := "medium" + if req.Reasoning != nil && req.Reasoning.Effort != "" { + effort = req.Reasoning.Effort + } + 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) + } + out.Thinking = &AnthropicThinking{Type: "adaptive"} + out.OutputConfig = &AnthropicOutputConfig{Effort: effort} + return out, nil + } + // reasoning.effort → output_config.effort + thinking if req.Reasoning != nil && req.Reasoning.Effort != "" { effort := mapResponsesEffortToAnthropic(req.Reasoning.Effort) @@ -100,7 +131,7 @@ func mapResponsesEffortToAnthropic(effort string) string { // convertResponsesInputToAnthropic extracts system prompt and messages from // a Responses API instructions + input array. Returns the system as raw JSON // (for Anthropic's polymorphic system field) and a list of Anthropic messages. -func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMessage) (json.RawMessage, []AnthropicMessage, error) { +func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMessage, preserveThinking bool) (json.RawMessage, []AnthropicMessage, error) { var systemParts []string if strings.TrimSpace(instructions) != "" { systemParts = append(systemParts, strings.TrimSpace(instructions)) @@ -165,11 +196,26 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess }) case item.Type == "reasoning": - // Anthropic 无法摄入 OpenAI 的 reasoning:encrypted_content 是不透明的, - // 而 thinking 块的重放需要 Anthropic 自己签发的 signature,无法伪造。 - // Codex 常见形态(只带 summary + encrypted_content)本来就会被丢弃, - // 这里让带 content 数组的形态保持同样行为——否则 reasoning_text 块会被 - // 原样塞进 Anthropic 请求体,上游直接回 400。 + // Only decode marked Anthropic bridge envelopes, not arbitrary + // OpenAI ciphertext. The upstream remains responsible for signature validation. + if preserveThinking && strings.HasPrefix(item.EncryptedContent, anthropicThinkingEnvelopePrefix) { + raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(item.EncryptedContent, anthropicThinkingEnvelopePrefix)) + if err != nil { + return nil, nil, fmt.Errorf("invalid Anthropic thinking envelope: %w", err) + } + var block AnthropicContentBlock + if err := json.Unmarshal(raw, &block); err != nil { + return nil, nil, fmt.Errorf("invalid Anthropic thinking block: %w", err) + } + if (block.Type != "thinking" || block.Signature == "") && (block.Type != "redacted_thinking" || block.Data == "") { + return nil, nil, fmt.Errorf("invalid Anthropic signed thinking block") + } + content, err := json.Marshal([]AnthropicContentBlock{block}) + if err != nil { + return nil, nil, err + } + messages = append(messages, AnthropicMessage{Role: "assistant", Content: content}) + } case item.Role == "user": content, err := convertResponsesUserToAnthropicContent(item.Content) diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go index 42dd2d0cc5..db87f2058c 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go @@ -58,7 +58,7 @@ func hasToolResult(blocks []AnthropicContentBlock, toolUseID string) bool { func convertAnthropic(t *testing.T, input string) []AnthropicMessage { t.Helper() - _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input), false) require.NoError(t, err) assertAnthropicPairing(t, messages) return messages diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 5d89769b15..44a5b4c79c 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -66,6 +66,7 @@ type AnthropicContentBlock struct { // Signature carries provider encrypted reasoning (e.g. xAI encrypted_content) // so multi-turn Claude clients can round-trip it back on subsequent turns. Signature string `json:"signature,omitempty"` + Data string `json:"data,omitempty"` // redacted_thinking // type=image Source *AnthropicImageSource `json:"source,omitempty"` @@ -226,6 +227,7 @@ type AnthropicDelta struct { // ResponsesRequest is the request body for POST /v1/responses. type ResponsesRequest struct { + PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"` Model string `json:"model"` Instructions string `json:"instructions,omitempty"` Input json.RawMessage `json:"input"` // string or []ResponsesInputItem @@ -309,9 +311,10 @@ func (i *ResponsesInputItem) UnmarshalJSON(data []byte) error { // ResponsesContentPart is a typed content part in a Responses message. type ResponsesContentPart struct { - Type string `json:"type"` // "input_text" | "output_text" | "input_image" | "input_file" - Text string `json:"text,omitempty"` - ImageURL string `json:"image_url,omitempty"` // data URI for input_image + PromptCacheBreakpoint json.RawMessage `json:"prompt_cache_breakpoint,omitempty"` + Type string `json:"type"` // "input_text" | "output_text" | "input_image" | "input_file" + Text string `json:"text,omitempty"` + ImageURL string `json:"image_url,omitempty"` // data URI for input_image // input_file fields. Filename string `json:"filename,omitempty"` @@ -655,6 +658,7 @@ type ResponsesStreamEvent struct { // ChatCompletionsRequest is the request body for POST /v1/chat/completions. type ChatCompletionsRequest struct { + PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"` Model string `json:"model"` Messages []ChatMessage `json:"messages"` Instructions string `json:"instructions,omitempty"` // OpenAI Responses API compat @@ -698,10 +702,11 @@ type ChatMessage struct { // ChatContentPart is a typed content part in a multi-modal message. type ChatContentPart struct { - Type string `json:"type"` // "text" | "image_url" | "file" - Text string `json:"text,omitempty"` - ImageURL *ChatImageURL `json:"image_url,omitempty"` - File *ChatFile `json:"file,omitempty"` + PromptCacheBreakpoint json.RawMessage `json:"prompt_cache_breakpoint,omitempty"` + Type string `json:"type"` // "text" | "image_url" | "file" + Text string `json:"text,omitempty"` + ImageURL *ChatImageURL `json:"image_url,omitempty"` + File *ChatFile `json:"file,omitempty"` } // ChatImageURL contains the URL for an image content part. diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index ee2efcd29f..aa97f07c57 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -171,6 +171,12 @@ var DefaultModels = []Model{ DisplayName: "Claude Opus 4.8", CreatedAt: "2026-05-29T00:00:00Z", }, + { + ID: "claude-opus-5-5", + Type: "model", + DisplayName: "Claude Opus 5.5", + CreatedAt: "2026-09-22T00:00:00Z", + }, { ID: "claude-opus-5", Type: "model", diff --git a/backend/internal/pkg/claude/constants_model_test.go b/backend/internal/pkg/claude/constants_model_test.go index 6cfa265bdc..941a72d486 100644 --- a/backend/internal/pkg/claude/constants_model_test.go +++ b/backend/internal/pkg/claude/constants_model_test.go @@ -18,3 +18,15 @@ func TestDefaultModelsContainsClaudeFable51(t *testing.T) { } t.Fatal("claude-fable-5-1 missing from DefaultModels") } + +func TestDefaultModelsContainsOpus55(t *testing.T) { + for _, model := range DefaultModels { + if model.ID == "claude-opus-5-5" { + if model.DisplayName != "Claude Opus 5.5" || model.CreatedAt != "2026-09-22T00:00:00Z" { + t.Fatalf("unexpected Opus 5.5 descriptor: %+v", model) + } + return + } + } + t.Fatal("claude-opus-5-5 missing") +} diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go index ab587a4c01..00fd337917 100644 --- a/backend/internal/pkg/claude/effort_catalog.go +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -24,6 +24,7 @@ var effortFamilies = []struct { {family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax}, {family: "claude-opus-4-6", levels: effortLowMediumHighMax}, {family: "claude-opus-4-5", levels: effortLowMediumHigh}, + {family: "claude-opus-5-5", levels: effortLowMediumHighXHighMax}, {family: "claude-opus-5", levels: effortLowMediumHighXHighMax}, } @@ -39,6 +40,11 @@ func EffortLevelsForModel(model string) []string { return nil } +// IsOpus55 identifies the fixed Opus 5.5 ID after provider/local suffix normalization. +func IsOpus55(model string) bool { + return normalizeEffortModelID(model) == "claude-opus-5-5" +} + func normalizeEffortModelID(model string) string { id := strings.ToLower(strings.TrimSpace(model)) id = strings.TrimPrefix(id, "models/") diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index 5c7cad3674..01f244390d 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -23,6 +23,8 @@ var DefaultModels = []Model{ {ID: "gpt-5.6", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 (Sol)"}, {ID: "gpt-5.6-terra", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Terra"}, {ID: "gpt-5.6-luna", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Luna"}, + {ID: "gpt-6-sol", Object: "model", Created: 1790035200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Sol"}, + {ID: "gpt-6-luna", Object: "model", Created: 1790035200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Luna"}, {ID: "gpt-6-astra", Object: "model", Created: 1788480000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Astra"}, {ID: "gpt-5.5", Object: "model", Created: 1776873600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.5"}, {ID: "gpt-5.4", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4"}, @@ -157,3 +159,21 @@ func CodexBaseInstructionsForModel(model string) string { } return latestCodexInstructions() } + +// IsGPT6SolOrLunaModelSpelling recognizes official IDs and existing local effort/compact suffixes. +func IsGPT6SolOrLunaModelSpelling(model string) bool { + canonical := CanonicalizeOpenAIModelAliasSpelling(model) + for _, base := range []string{"gpt-6-sol", "gpt-6-luna"} { + if canonical == base { + return true + } + suffix, ok := strings.CutPrefix(canonical, base+"-") + if ok { + switch suffix { + case "none", "low", "medium", "high", "xhigh", "max", "openai-compact": + return true + } + } + } + return false +} diff --git a/backend/internal/pkg/openai/constants_test.go b/backend/internal/pkg/openai/constants_test.go index 1cc7003083..523202d8b3 100644 --- a/backend/internal/pkg/openai/constants_test.go +++ b/backend/internal/pkg/openai/constants_test.go @@ -32,3 +32,13 @@ func TestDefaultModelsIncludeGPTImage25(t *testing.T) { require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-flare") require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-sunburst") } + +func TestGPT6SolLunaModelIdentity(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + require.Contains(t, DefaultModelIDs(), model) + require.True(t, IsGPT6SolOrLunaModelSpelling(model)) + } + require.False(t, IsGPT6SolOrLunaModelSpelling("gpt-6-astra")) + require.False(t, IsGPT6SolOrLunaModelSpelling("gpt-6-solitude")) + require.False(t, IsGPT6SolOrLunaModelSpelling("gpt-6-luna-preview")) +} diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 1be36ba528..9011f1ce4c 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -12,6 +12,8 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -419,6 +421,16 @@ func (s *BillingService) initFallbackPricing() { s.fallbackPrices["claude-opus-4.8"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.7"], 2) s.fallbackPrices["claude-opus-5"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.8"], 2) + s.fallbackPrices["claude-opus-5-5"] = &ModelPricing{ + InputPricePerToken: 4e-6, + OutputPricePerToken: 20e-6, + CacheCreationPricePerToken: 5e-6, + CacheReadPricePerToken: 0.2e-6, + CacheCreation5mPrice: 5e-6, + CacheCreation1hPrice: 8e-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{ @@ -529,6 +541,35 @@ func (s *BillingService) initFallbackPricing() { LongContextOutputMultiplier: 1.5, } + // GPT-6 Sol/Luna official rates, 2026-09-22. + s.fallbackPrices["gpt-6-sol"] = &ModelPricing{ + InputPricePerToken: 2e-6, + InputPricePerTokenPriority: 4e-6, + OutputPricePerToken: 10e-6, + OutputPricePerTokenPriority: 20e-6, + CacheCreationPricePerToken: 2.5e-6, + CacheCreationPricePerTokenPriority: 5e-6, + CacheReadPricePerToken: 0.2e-6, + CacheReadPricePerTokenPriority: 0.4e-6, + CacheCreationPriceExplicit: true, + LongContextInputThreshold: 272_000, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 1.5, + } + s.fallbackPrices["gpt-6-luna"] = &ModelPricing{ + InputPricePerToken: 0.1e-6, + InputPricePerTokenPriority: 0.2e-6, + OutputPricePerToken: 0.5e-6, + OutputPricePerTokenPriority: 1e-6, + CacheCreationPricePerToken: 0.125e-6, + CacheCreationPricePerTokenPriority: 0.25e-6, + CacheReadPricePerToken: 0.01e-6, + CacheReadPricePerTokenPriority: 0.02e-6, + CacheCreationPriceExplicit: true, + LongContextInputThreshold: 272_000, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 1.5, + } // OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。 s.fallbackPrices["gpt-5.6-sol"] = &ModelPricing{ InputPricePerToken: 5e-6, @@ -934,6 +975,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { if strings.Contains(modelLower, "fable-5") || strings.Contains(modelLower, "fable5") { return s.fallbackPrices["claude-fable-5"] } + if claude.IsOpus55(modelLower) { + return s.fallbackPrices["claude-opus-5-5"] + } if strings.Contains(modelLower, "opus") { // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。 if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") { @@ -1113,6 +1157,8 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { // OpenAI(GPT-5 / Codex 族):仅匹配已知型号,避免未知 OpenAI 型号误计价。 if normalized := normalizeKnownOpenAICodexModel(modelLower); normalized != "" { switch normalized { + case "gpt-6-sol", "gpt-6-luna": + return s.fallbackPrices[normalized] case "gpt-6-astra": return s.fallbackPrices["gpt-6-astra"] case "gpt-5.6-sol": @@ -1270,6 +1316,7 @@ func (s *BillingService) getModelPricingAt(model string, pricingAt time.Time) (* InputPricePerTokenPriority: litellmPricing.InputCostPerTokenPriority, OutputPricePerToken: litellmPricing.OutputCostPerToken, OutputPricePerTokenPriority: litellmPricing.OutputCostPerTokenPriority, + CacheCreationPriceExplicit: openai.IsGPT6SolOrLunaModelSpelling(model) && litellmPricing.CacheCreationInputTokenCostExplicit, CacheCreationPricePerToken: litellmPricing.CacheCreationInputTokenCost, CacheCreationPricePerTokenPriority: litellmPricing.CacheCreationInputTokenCostPriority, CacheReadPricePerToken: litellmPricing.CacheReadInputTokenCost, @@ -1793,15 +1840,20 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing return &cloned } normalized := normalizeKnownOpenAICodexModel(model) - isGPT56 := isOpenAIGPT56Model(normalized) - needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || + usesCacheWritePremium := isOpenAIGPT56Model(normalized) || openai.IsGPT6SolOrLunaModelSpelling(normalized) + needsCacheCreationPolicy := usesCacheWritePremium && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) fastRatio := openAIModelFastPricingRatio(normalized) - if !needsCacheCreationPolicy && fastRatio <= 0 { + needsOpus55FastMultiplier := claude.IsOpus55(model) && pricing.FastMultiplier == nil + if !needsCacheCreationPolicy && fastRatio <= 0 && !needsOpus55FastMultiplier { return pricing } cloned := *pricing - if isGPT56 && !cloned.CacheCreationPriceExplicit { + if needsOpus55FastMultiplier { + multiplier := 2.0 + cloned.FastMultiplier = &multiplier + } + if usesCacheWritePremium && !cloned.CacheCreationPriceExplicit { if cloned.CacheCreationPricePerToken <= 0 { cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25 } @@ -1811,6 +1863,9 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing } if fastRatio > 0 { enforceOpenAIFastPricingRatio(&cloned, fastRatio) + if openai.IsGPT6SolOrLunaModelSpelling(normalized) && cloned.CacheCreationPriceExplicit { + cloned.CacheCreationPricePerTokenPriority = cloned.CacheCreationPricePerToken * fastRatio + } } return &cloned } @@ -1820,7 +1875,7 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing // 档的模型(如 gpt-5.5-pro、gpt-5.4-mini/nano)返回 0。 func openAIModelFastPricingRatio(normalized string) float64 { switch normalized { - case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra": + case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna": return 2.0 case "gpt-5.5": return 2.5 diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index ed83a97308..12e133df79 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -6,6 +6,7 @@ import ( "bytes" "log" "math" + "os" "strings" "testing" @@ -1931,3 +1932,108 @@ func TestComputeTokenBreakdown_NonExplicitZeroImagePrice_FallsBackToOutput(t *te // textOutputTokens = 200 - 50 = 150 require.InDelta(t, 150*15e-6, bd.OutputCost, 1e-12) } + +func TestNewModelPricingCatalogFallbackAndContext(t *testing.T) { + data, err := os.ReadFile("../../resources/model-pricing/model_prices_and_context_window.json") + require.NoError(t, err) + catalog := &PricingService{} + catalog.pricingData, err = catalog.parsePricingData(data) + require.NoError(t, err) + sources := map[string]*BillingService{ + "billing fallback": newTestBillingService(), + "pricing fallback": NewBillingService(&config.Config{}, &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gpt-6": {InputCostPerToken: 10e-6, OutputCostPerToken: 50e-6}, + "claude-opus-5": {InputCostPerToken: 5e-6, OutputCostPerToken: 25e-6}, + }}), + "catalog": NewBillingService(&config.Config{}, catalog), + } + for source, svc := range sources { + for _, tc := range []struct { + model string + input, output, write, read float64 + }{ + {"gpt-6-sol", 2e-6, 10e-6, 2.5e-6, 0.2e-6}, + {"gpt-6-luna", 0.1e-6, 0.5e-6, 0.125e-6, 0.01e-6}, + } { + t.Run(source+"/"+tc.model, func(t *testing.T) { + for _, n := range []int{271999, 272000, 272001} { + for tier, mult := range map[string]float64{"": 1, "priority": 2, "fast": 2, "flex": 0.5} { + tokens := UsageTokens{InputTokens: n - 3000, CacheReadTokens: 2000, CacheCreationTokens: 1000, OutputTokens: 500} + cost, err := svc.CalculateCostWithServiceTier(tc.model, tokens, 1, tier) + require.NoError(t, err) + im, om := 1.0, 1.0 + if n > 272000 { + im = 2 + om = 1.5 + } + require.InDelta(t, float64(tokens.InputTokens)*tc.input*im*mult, cost.InputCost, 1e-10) + require.InDelta(t, 1000*tc.write*im*mult, cost.CacheCreationCost, 1e-10) + require.InDelta(t, 2000*tc.read*im*mult, cost.CacheReadCost, 1e-10) + require.InDelta(t, 500*tc.output*om*mult, cost.OutputCost, 1e-10) + require.InDelta(t, cost.InputCost+cost.OutputCost+cost.CacheCreationCost+cost.CacheReadCost, cost.TotalCost, 1e-10) + require.Equal(t, n > 272000, cost.LongContextBillingApplied) + } + } + }) + } + t.Run(source+"/opus", func(t *testing.T) { + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 500, CacheReadTokens: 1000, CacheCreationTokens: 1000, CacheCreation5mTokens: 400, CacheCreation1hTokens: 600} + for tier, mult := range map[string]float64{"": 1, "fast": 2} { + cost, err := svc.CalculateCostWithServiceTier("claude-opus-5-5", tokens, 1, tier) + require.NoError(t, err) + require.InDelta(t, 1.2*mult, cost.InputCost, 1e-10) + require.InDelta(t, (400*5e-6+600*8e-6)*mult, cost.CacheCreationCost, 1e-10) + require.InDelta(t, 1000*0.2e-6*mult, cost.CacheReadCost, 1e-10) + require.InDelta(t, 500*20e-6*mult, cost.OutputCost, 1e-10) + require.False(t, cost.LongContextBillingApplied) + } + }) + } +} + +func TestNewModelPricingChannelOverridesAndFamilyIsolation(t *testing.T) { + svc := newTestBillingService() + for _, model := range []string{"gpt-6-sol", "gpt-6-luna", "claude-opus-5-5"} { + t.Run(model, func(t *testing.T) { + zero := 0.0 + prices, err := svc.GetModelPricingWithChannel(model, &ChannelModelPricing{InputPrice: &zero, OutputPrice: &zero, CacheWritePrice: &zero, CacheReadPrice: &zero}) + require.NoError(t, err) + cost := svc.computeTokenBreakdown(prices, UsageTokens{InputTokens: 300000, CacheReadTokens: 1000, CacheCreationTokens: 1000, OutputTokens: 1000}, 1, "priority", true) + require.Zero(t, cost.TotalCost) + fresh, err := svc.GetModelPricing(model) + require.NoError(t, err) + require.Positive(t, fresh.InputPricePerToken) + }) + } + prices, err := svc.GetModelPricing("claude-opus-5") + require.NoError(t, err) + require.Equal(t, 5e-6, prices.InputPricePerToken) + prices, err = svc.GetModelPricing("gpt-6") + require.NoError(t, err) + require.Equal(t, 10e-6, prices.InputPricePerToken) + require.Equal(t, "gpt-6-sol", normalizeKnownOpenAICodexModel("openai/gpt-6-sol-max")) + require.Equal(t, "gpt-6-luna", normalizeKnownOpenAICodexModel("gpt-6-luna-openai-compact")) +} + +func TestNewModelPricingExplicitZeroCacheWrite(t *testing.T) { + svc := &PricingService{} + var err error + svc.pricingData, err = svc.parsePricingData([]byte(`{"gpt-6-sol":{"litellm_provider":"openai","input_cost_per_token":0.000002,"output_cost_per_token":0.00001,"input_cost_per_token_flex":0.000001,"cache_creation_input_token_cost":0}}`)) + require.NoError(t, err) + billing := NewBillingService(&config.Config{}, svc) + for _, tier := range []string{"", "priority", "flex"} { + cost, err := billing.CalculateCostWithServiceTier("gpt-6-sol", UsageTokens{CacheCreationTokens: 1000}, 1, tier) + require.NoError(t, err) + require.Zero(t, cost.CacheCreationCost) + } +} + +func TestNewModelPricingAliasesRetainExplicitOverrides(t *testing.T) { + zero := &LiteLLMModelPricing{} + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gpt-6-sol": zero, "gpt-6-luna": zero, "claude-opus-5-5": zero, + }} + for _, model := range []string{"gpt-6-sol-max", "openai/gpt-6-luna-openai-compact", "claude-opus-5-5-thinking"} { + require.Same(t, zero, svc.GetModelPricing(model)) + } +} diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 34eb98d0b7..ff416f2c21 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -1769,3 +1769,45 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_Non2xxRecordsOllamaActivity(t _, ok := deferred.lastUsedUpdates.Load(int64(604)) require.True(t, ok, "Anthropic passthrough non-2xx on Ollama account must record activity via handleErrorResponse") } + +func TestOpus55RejectsUnsupportedParametersBeforeMimicry(t *testing.T) { + for _, typ := range []string{AccountTypeOAuth, AccountTypeAPIKey} { + for _, field := range []string{`"thinking":{"type":"disabled"}`, `"thinking":{"type":"enabled","budget_tokens":1024}`, `"tool_choice":{"type":"any"}`, `"tool_choice":{"type":"tool","name":"lookup"}`} { + for _, count := range []bool{false, true} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + model := "claude-opus-5-5" + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: typ} + if typ == AccountTypeAPIKey { + model = "public-opus" + account.Credentials = map[string]any{"model_mapping": map[string]any{model: "claude-opus-5-5"}} + } + body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hello"}],` + field + `}`) + parsed := &ParsedRequest{Model: model, Body: NewRequestBodyRef(body)} + 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) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "invalid_request_error") + } + } + } +} + +func TestOpus55ThinkingDefaultPreservesSignedHistory(t *testing.T) { + body := []byte(`{"model":"claude-opus-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"}]}],"tool_choice":{"type":"none"},"thinking":{"type":"adaptive","display":"omitted"}}`) + require.Equal(t, string(body), string(FilterThinkingBlocks(body, "claude-opus-5-5"))) + withoutThinking, _ := deleteJSONPathBytes(body, "thinking") + require.Equal(t, string(withoutThinking), string(FilterThinkingBlocks(withoutThinking, "claude-opus-5-5"))) + out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-5-5", claudeOAuthNormalizeOptions{}) + require.Equal(t, "none", gjson.GetBytes(out, "tool_choice.type").String()) + require.False(t, gjson.GetBytes(out, "output_config.effort").Exists(), "omission uses the official medium default") + require.Equal(t, "omitted", gjson.GetBytes(out, "thinking.display").String()) + require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(out, "messages").Raw) +} diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index b879611dee..4ad72588db 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -263,7 +263,7 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu // temperature:真实 Claude Code CLI 总是发送 temperature(默认 1,客户端可覆盖)。 // 之前的实现直接 delete 会导致 payload 缺字段,与真实 CLI 字节级不一致。 // 策略:客户端传了什么就透传;没传则补默认 1。 - if !gjson.GetBytes(out, "temperature").Exists() { + if !gjson.GetBytes(out, "temperature").Exists() && !claude.IsOpus55(modelID) { if next, ok := setJSONValueBytes(out, "temperature", 1); ok { out = next modified = true @@ -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 gjson.GetBytes(out, "tool_choice").Exists() { + if !claude.IsOpus55(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 6a17f50b17..3cffa2201a 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -23,6 +23,17 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, return fmt.Errorf("parse request: empty request") } + validationModel := parsed.Model + if account != nil && 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 { + s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return err + } + } + if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() { passthroughBody := parsed.Body.Bytes() if reqModel := parsed.Model; reqModel != "" { diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 3839198484..f292773765 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -93,6 +93,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if parsed == nil { return nil, fmt.Errorf("parse request: empty request") } + // 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 && account.Platform == PlatformAnthropic && !account.IsBedrock() && account.Type != AccountTypeServiceAccount { + if err := validateClaudeOpus55Request(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 + } + } // Anthropic Fast is requested with speed=fast rather than OpenAI's // service_tier. Attach it at this shared boundary so passthrough, OAuth and // partial-stream results all use the same billing and usage-log path. diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index f305146e58..ac1d393602 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -50,16 +50,7 @@ func (s *GatewayService) ForwardAsChatCompletions( return nil, fmt.Errorf("convert chat completions to responses: %w", err) } - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) - if err != nil { - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 3. Force upstream streaming - anthropicReq.Stream = true - reqStream := true - - // 4. Model mapping + // Resolve the final upstream model before model-specific conversion. mappedModel := originalModel if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { mappedModel = account.GetMappedModel(originalModel) @@ -75,7 +66,22 @@ func (s *GatewayService) ForwardAsChatCompletions( mappedModel = normalized } } - anthropicReq.Model = mappedModel + if err := validateClaudeOpus55Request(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) { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + } + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } + + // 3. Force upstream streaming + anthropicReq.Stream = true + reqStream := true logger.L().Debug("gateway forward_as_chat_completions: model mapping applied", zap.Int64("account_id", account.ID), diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 999b544adc..44ed3329c4 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -60,16 +60,7 @@ func (s *GatewayService) ForwardAsResponses( clientStream := responsesReq.Stream // 3. Convert Responses → Anthropic - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) - if err != nil { - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 3. Force upstream streaming (Anthropic works best with streaming) - anthropicReq.Stream = true - reqStream := true - - // 4. Model mapping + // Resolve the final upstream model before model-specific conversion. mappedModel := originalModel if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { mappedModel = account.GetMappedModel(originalModel) @@ -85,7 +76,22 @@ func (s *GatewayService) ForwardAsResponses( mappedModel = normalized } } - anthropicReq.Model = mappedModel + if err := validateClaudeOpus55Request(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) { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + } + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } + + // 3. Force upstream streaming (Anthropic works best with streaming) + anthropicReq.Stream = true + reqStream := true logger.L().Debug("gateway forward_as_responses: model mapping applied", zap.Int64("account_id", account.ID), @@ -412,6 +418,8 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( finalResp.Content[idx].Text += event.Delta.Text case "thinking_delta": finalResp.Content[idx].Thinking += event.Delta.Thinking + case "signature_delta": + finalResp.Content[idx].Signature += event.Delta.Signature case "input_json_delta": finalResp.Content[idx].Input = appendRawJSON(finalResp.Content[idx].Input, event.Delta.PartialJSON) } @@ -444,6 +452,9 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( } // Convert to Responses format + if claude.IsOpus55(mappedModel) { + finalResp.Model = mappedModel + } responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) responsesResp.Model = originalModel // Use original model name @@ -503,6 +514,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( state := apicompat.NewAnthropicEventToResponsesState() state.Model = originalModel + state.PreserveThinkingSignatures = claude.IsOpus55(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 972c6bc1a6..5f74dfac59 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -3,7 +3,10 @@ package service import ( + "context" "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/tidwall/gjson" "io" "net/http" "net/http/httptest" @@ -449,3 +452,74 @@ func TestHandleResponsesStreamingResponse_CompactSSEFormat(t *testing.T) { require.Equal(t, 6, result.Usage.OutputTokens) require.Contains(t, rec.Body.String(), `response.completed`) } + +func TestOpus55ResponsesSignedThinkingBufferedAndStreamed(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":""}}`, + "event: content_block_delta\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"signed"}}`, + "event: content_block_stop\n" + `data: {"type":"content_block_stop","index":0}`, + "event: content_block_start\n" + `data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`, + "event: content_block_delta\n" + `data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ok"}}`, + "event: content_block_stop\n" + `data: {"type":"content_block_stop","index":1}`, + "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{}) + } + 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()) + } + } + } +} diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 72f48ad40a..da4e5d6017 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -223,7 +223,7 @@ 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" + parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" || (protocol == domain.PlatformAnthropic && claude.IsOpus55(parsed.Model)) parsed.OutputEffort = strings.TrimSpace(gjson.Get(jsonStr, "output_config.effort").String()) if protocol == domain.PlatformAnthropic { @@ -566,6 +566,26 @@ func StripEmptyTextBlocks(body []byte) []byte { return out } +// validateClaudeOpus55Request 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) { + return nil + } + switch gjson.GetBytes(body, "thinking.type").String() { + case "disabled", "enabled": + return fmt.Errorf("claude-opus-5-5 requires adaptive thinking; omit thinking or use thinking.type=adaptive and output_config.effort") + } + 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") + } + 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 nil +} + // FilterThinkingBlocks removes thinking blocks from request body // Returns filtered body or original body if filtering fails (fail-safe) // This prevents 400 errors from invalid thinking block signatures. @@ -584,7 +604,7 @@ func FilterThinkingBlocks(body []byte, mappedModel string) []byte { if !ShouldPreFilterThinkingBlocks(mappedModel) { return body } - return filterThinkingBlocksInternal(body, false) + return filterThinkingBlocksInternal(body, claude.IsOpus55(mappedModel)) } // FilterThinkingBlocksForRetry strips thinking-related constructs for retry scenarios. @@ -1321,7 +1341,7 @@ func FilterSignatureSensitiveBlocksForRetry(body []byte, mappedModel string) []b // 策略: // - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块 // - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块 -func filterThinkingBlocksInternal(body []byte, _ bool) []byte { +func filterThinkingBlocksInternal(body []byte, alwaysThinking bool) []byte { // Fast path: if body doesn't contain "thinking", skip parsing if !bytes.Contains(body, []byte(`"type":"thinking"`)) && !bytes.Contains(body, []byte(`"type": "thinking"`)) && @@ -1338,7 +1358,7 @@ func filterThinkingBlocksInternal(body []byte, _ bool) []byte { } // Check if thinking is enabled - thinkingEnabled := false + thinkingEnabled := alwaysThinking if thinking, ok := req["thinking"].(map[string]any); ok { if thinkType, ok := thinking["type"].(string); ok && (thinkType == "enabled" || thinkType == "adaptive") { thinkingEnabled = true @@ -1379,6 +1399,12 @@ func filterThinkingBlocksInternal(body []byte, _ bool) []byte { // When thinking is enabled and this is an assistant message, // only keep thinking blocks with valid signatures if thinkingEnabled && role == "assistant" { + if alwaysThinking && blockType == "redacted_thinking" { + if data, ok := blockMap["data"].(string); ok && data != "" { + newContent = append(newContent, block) + continue + } + } signature, _ := blockMap["signature"].(string) if signature != "" && signature != antigravity.DummyThoughtSignature { newContent = append(newContent, block) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 4a4f02cfea..f4b77f061e 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -486,6 +486,10 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr } if isClaudeCodexModel(modelID) { + if claude.IsOpus55(modelID) { + descriptor.ContextWindow = 1_000_000 + descriptor.MaxContextWindow = 1_000_000 + } descriptor.DisplayName = claudeCodexDisplayName(modelID) descriptor.Description = "Claude coding and reasoning model routed through Sub2API." descriptor.SupportsParallelToolCalls = true @@ -510,7 +514,9 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr descriptor.SupportedReasoningLevels = configuredCodexGPTReasoningLevels(modelID) descriptor.DefaultReasoningSummary = "none" descriptor.TruncationPolicy = configuredCodexTruncationPolicy{Mode: "tokens", Limit: configuredCodexToolOutputMaxTokens} - if isOpenAIGPT56Model(modelID) { + // GPT-6 Sol/Luna retain the existing 5.6 Codex window as an offline + // compatibility template; live account metadata remains authoritative. + if isOpenAIGPT56Model(modelID) || openai.IsGPT6SolOrLunaModelSpelling(modelID) { descriptor.MaxContextWindow = configuredCodexGPT56MaxContext } if isOpenAIGPT6AstraModel(modelID) { @@ -559,8 +565,8 @@ func configuredCodexSupportsPriorityServiceTier(modelID string) bool { return true } } - // GPT-6 Astra advertises Fast via service_tier=priority in public model metadata. - return isOpenAIGPT6AstraModel(modelID) + // GPT-6 models advertise Fast via service_tier=priority in public model metadata. + return isOpenAIGPT6Model(modelID) } func configuredCodexSupportsUltrafastServiceTier(modelID string) bool { @@ -623,7 +629,7 @@ func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasonin {Effort: "xhigh", Description: "Extra-high reasoning depth for difficult tasks"}, } normalized := getNormalizedCodexModel(modelID) - if isOpenAIGPT56Model(modelID) || isOpenAIGPT6AstraModel(modelID) { + if isOpenAIGPT56Model(modelID) || isOpenAIGPT6Model(modelID) { levels = append(levels, configuredCodexReasoningLevel{ Effort: "max", Description: "Maximum reasoning depth for complex tasks", @@ -689,12 +695,12 @@ func isOpenAICodexGPTModel(modelID string) bool { func isOpenAICodexReasoningGPTModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return isOpenAIGPT6AstraModel(normalized) || strings.HasPrefix(normalized, "gpt-5") + return isOpenAIGPT6Model(normalized) || strings.HasPrefix(normalized, "gpt-5") } func isOpenAICodexImageInputModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return isOpenAIGPT6AstraModel(normalized) || + return isOpenAIGPT6Model(normalized) || strings.HasPrefix(normalized, "gpt-5") || strings.HasPrefix(normalized, "gpt-4o") || strings.HasPrefix(normalized, "gpt-4.1") || diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 7149dbf74e..3709ac6866 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -3705,6 +3705,7 @@ func TestFetchCodexModelsManifestOAuthSharedAcrossGroupsWithIndependentFiltering require.EqualValues(t, 1, calls.Load(), "同一账号两个分组同时请求时只发一次上游请求") } + // Scenario: 非 OpenAI GPT 模型的 Codex 提示词不声称自己是 GPT。 // Antigravity(Google) 对「Codex 提示词 + GPT-5 身份」直接回 429 RESOURCE_EXHAUSTED。 func TestBuildCodexModelsManifestStripsGPTIdentityForNonGPTModels(t *testing.T) { @@ -3758,5 +3759,23 @@ func TestCodexGPTIdentityPatternsCoverBundledPrompts(t *testing.T) { } require.NotContains(t, tmpl, "GPT-", model) require.True(t, strings.HasPrefix(tmpl, "You are Codex"), "%s: %.60q", model, tmpl) + +func TestGPT6SolLunaCatalogKeepsAuthoritativeCapabilities(t *testing.T) { + for _, id := range []string{"gpt-6-sol", "gpt-6-luna"} { + svc := &OpenAIGatewayService{} + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"` + id + `","supported_reasoning_levels":[{"effort":"ultra"}],"default_reasoning_level":"ultra","multi_agent_reasoning_effort":"xhigh","service_tiers":[{"id":"ultrafast"}],"context_window":300000,"max_context_window":900000,"supports_search_tool":false,"apply_patch_tool_type":null}]}`)} + account := newCodexModelsAPIKeyTestAccount("https://api.openai.com/v1") + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, []string{"ultra"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, "ultra", models[0]["default_reasoning_level"]) + require.Equal(t, "xhigh", models[0]["multi_agent_reasoning_effort"]) + require.Equal(t, float64(300000), models[0]["context_window"]) + require.Equal(t, float64(900000), models[0]["max_context_window"]) + require.Equal(t, []any{map[string]any{"id": "ultrafast"}}, models[0]["service_tiers"]) + require.Equal(t, false, models[0]["supports_search_tool"]) + require.Nil(t, models[0]["apply_patch_tool_type"]) + } } diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 8feac0843f..bd49f8fcaa 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -11,6 +11,8 @@ import ( ) var codexModelMap = map[string]string{ + "gpt-6-sol": "gpt-6-sol", + "gpt-6-luna": "gpt-6-luna", "gpt-6-astra": "gpt-6-astra", "gpt-5.6-sol": "gpt-5.6-sol", "gpt-5.6-terra": "gpt-5.6-terra", @@ -60,6 +62,8 @@ var codexVersionModelPrefixes = []struct { prefix string target string }{ + {prefix: "gpt-6-sol", target: "gpt-6-sol"}, + {prefix: "gpt-6-luna", target: "gpt-6-luna"}, {prefix: "gpt-5.6-sol", target: "gpt-5.6-sol"}, {prefix: "gpt-5.6-terra", target: "gpt-5.6-terra"}, {prefix: "gpt-5.6-luna", target: "gpt-5.6-luna"}, diff --git a/backend/internal/service/openai_compat_prompt_cache_key.go b/backend/internal/service/openai_compat_prompt_cache_key.go index 3d3595d3c2..99ae309f9a 100644 --- a/backend/internal/service/openai_compat_prompt_cache_key.go +++ b/backend/internal/service/openai_compat_prompt_cache_key.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) const compatPromptCacheKeyPrefix = "compat_cc_" @@ -14,9 +15,8 @@ const compatPromptCacheKeyPrefix = "compat_cc_" func shouldAutoInjectPromptCacheKeyForCompat(model string) bool { trimmed := strings.TrimSpace(strings.ToLower(model)) canonical := canonicalizeOpenAIModelAliasSpelling(trimmed) - // GPT-6 is the public alias for Astra. Keep this deliberately scoped to - // Astra so other GPT-6 families do not inherit Messages compatibility state. - if canonical == "gpt-6" || canonical == "gpt-6-astra" { + // GPT-6 Sol/Luna share the supported Responses cache identity contract. + if canonical == "gpt-6" || canonical == "gpt-6-astra" || openai.IsGPT6SolOrLunaModelSpelling(canonical) { return true } // 仅对 Responses 兼容路径支持的 GPT-5 族开启自动注入,避免 normalizeCodexModel diff --git a/backend/internal/service/openai_compat_prompt_cache_key_test.go b/backend/internal/service/openai_compat_prompt_cache_key_test.go index d486857662..776c68f6ad 100644 --- a/backend/internal/service/openai_compat_prompt_cache_key_test.go +++ b/backend/internal/service/openai_compat_prompt_cache_key_test.go @@ -164,3 +164,12 @@ func TestDeriveAnthropicCompatPromptCacheKey_UsesCacheControlAnchors(t *testing. require.True(t, strings.HasPrefix(k1, "anthropic-cache-")) require.False(t, strings.HasPrefix(k1, compatPromptCacheKeyPrefix)) } + +func TestGPT6SolLunaCompatCacheIdentity(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "openai/gpt-6-luna", "gpt-6-sol-max"} { + require.True(t, shouldAutoInjectPromptCacheKeyForCompat(model), model) + } + for _, model := range []string{"gpt-6-sol-preview", "gpt-6-solitude", "gpt-6-luna-preview"} { + require.False(t, shouldAutoInjectPromptCacheKeyForCompat(model), model) + } +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index cd043db85d..db249da487 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -266,6 +266,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( } else { // Normal path: convert Chat Completions → Responses. // ChatCompletionsToResponses always sets Stream=true (upstream always streams). + chatReq.Model = upstreamModel responsesReq, err = apicompat.ChatCompletionsToResponses(&chatReq) if err != nil { return nil, fmt.Errorf("convert chat completions to responses: %w", err) @@ -347,6 +348,10 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( } // 4b. Apply OpenAI fast policy (may filter service_tier or block the request). + responsesBody, _, err = normalizeGPT6ResponsesSampling(responsesBody, upstreamModel) + if err != nil { + return nil, err + } updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, responsesBody) if policyErr != nil { var blocked *OpenAIFastBlockedError 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 bc029bf480..4019530426 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -63,16 +63,19 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") return nil, fmt.Errorf("convert chat completions to responses: %w", err) } - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) - if err != nil { - writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 3. Model mapping(OpenAI 网关统一入口的映射语义) + // Resolve the mapped model before choosing its thinking/tool protocol. billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - anthropicReq.Model = upstreamModel + if err := validateClaudeOpus55Request(body, upstreamModel); err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + responsesReq.Model = upstreamModel + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) + if err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } // 4. Force upstream streaming(客户端原始终决定响应格式; // 上游恒为流式,非流式由缓冲路径组装)。 diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index c92528f2d7..ba5f7e7c7a 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -80,6 +81,11 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( // anchored to the client's stable conversation prefix. grokCacheIdentity = resolveGrokCacheIdentity(c, body, "", upstreamModel) } + if openai.IsGPT6SolOrLunaModelSpelling(upstreamModel) && (len(gjson.GetBytes(body, "tools").Array()) > 0 || len(gjson.GetBytes(body, "functions").Array()) > 0) && gjson.GetBytes(body, "reasoning_effort").String() != "none" { + err := fmt.Errorf("%s requires Responses for tool calls with reasoning; this account only supports Chat Completions. Use reasoning_effort=none or a Responses-capable account", upstreamModel) + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } // 3. Rewrite model in body (no protocol conversion) upstreamBody := body if upstreamModel != originalModel { diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 35f6de0ed1..e26306a900 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -1136,3 +1136,116 @@ func TestBuildChatStreamErrorSSE(t *testing.T) { require.Equal(t, "cyber_policy", gjson.Get(payload, "error.code").String()) require.Equal(t, "blocked by policy", gjson.Get(payload, "error.message").String()) } + +func TestGPT6RawChatRejectsReasoningToolCalls(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"public": model}}} + svc := &OpenAIGatewayService{} + _, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, []byte(`{"model":"public","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup"}}]}`), "") + require.ErrorContains(t, err, "requires Responses") + require.Equal(t, 400, rec.Code) + } +} + +func TestGPT6ReasoningModeAndSamplingCompatibility(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + body := []byte(`{"model":"` + model + `","reasoning":{"mode":"pro","effort":"max"},"temperature":0.7,"top_p":0.9,"top_logprobs":2,"include":["reasoning.encrypted_content","message.output_text.logprobs"],"prompt_cache_options":{"ttl":"30m"}}`) + out, changed, err := normalizeOpenAIResponsesReasoningMode(body, "") + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "pro", gjson.GetBytes(out, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(out, "reasoning.effort").String()) + require.False(t, gjson.GetBytes(out, "temperature").Exists()) + require.False(t, gjson.GetBytes(out, "top_p").Exists()) + require.False(t, gjson.GetBytes(out, "top_logprobs").Exists()) + require.Equal(t, "reasoning.encrypted_content", gjson.GetBytes(out, "include.0").String()) + require.Equal(t, "30m", gjson.GetBytes(out, "prompt_cache_options.ttl").String()) + require.Equal(t, "max", normalizeOpenAIReasoningEffortForModel("max", model)) + d := newConfiguredCodexModelDescriptor(model) + require.NotNil(t, d.DefaultReasoningLevel) + require.Equal(t, "medium", *d.DefaultReasoningLevel) + require.EqualValues(t, 872000, d.MaxContextWindow) + require.Len(t, d.SupportedReasoningLevels, 5) + require.Len(t, d.ServiceTiers, 1) + } +} + +func TestGPT6RawChatNoneToolsAreForwarded(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"` + model + `","reasoning_effort":"none","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"chat_1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1000,"completion_tokens":10,"prompt_tokens_details":{"cached_tokens":200,"cache_write_tokens":300}}}`))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{"api_key": "fixture-key", "base_url": "https://api.openai.com"}} + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "none", gjson.GetBytes(upstream.lastBody, "reasoning_effort").String()) + require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 1) + require.Equal(t, 1000, result.Usage.InputTokens) + require.Equal(t, 300, result.Usage.CacheCreationInputTokens) + } +} + +func TestGPT6UsageHTTPAndWSHaveSameCacheBreakdown(t *testing.T) { + response := []byte(`{"model":"gpt-6-sol","usage":{"input_tokens":1000,"output_tokens":10,"input_tokens_details":{"cached_tokens":200,"cache_write_tokens":300}}}`) + httpUsage, ok := extractOpenAIUsageFromJSONBytes(response) + require.True(t, ok) + var wsUsage OpenAIUsage + parseOpenAIWSResponseUsageFromCompletedEvent(append(append([]byte(`{"type":"response.completed","response":`), response...), '}'), &wsUsage) + require.Equal(t, httpUsage, wsUsage) + require.Equal(t, 500, httpUsage.InputTokens-httpUsage.CacheReadInputTokens-httpUsage.CacheCreationInputTokens) +} + +func TestGPT6ReasoningModeUsesMappedUpstream(t *testing.T) { + body := []byte(`{"model":"public-model","reasoning":{"mode":"pro","effort":"max"},"temperature":0.5}`) + out, _, err := normalizeOpenAIResponsesReasoningMode(body, "gpt-6-sol") + require.NoError(t, err) + require.Equal(t, "public-model", gjson.GetBytes(out, "model").String()) + require.Equal(t, "pro", gjson.GetBytes(out, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(out, "reasoning.effort").String()) + require.False(t, gjson.GetBytes(out, "temperature").Exists()) +} + +func TestGPT6MappedCompatibilityBridgesKeepReasoningAndTools(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + for _, messages := range []bool{false, true} { + body := []byte(`{"model":"public","reasoning_effort":"max","temperature":0.7,"top_p":0.9,"prompt_cache_options":{"ttl":"30m"},"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"messages":[{"role":"user","content":"hello"}]}`) + if messages { + body = []byte(`{"model":"public","max_tokens":1000,"output_config":{"effort":"max"},"temperature":0.7,"top_p":0.9,"tools":[{"name":"lookup","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"hello"}]}`) + } + response := `data: {"type":"response.completed","response":{"id":"resp_1","model":"` + model + `","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1000,"output_tokens":10,"input_tokens_details":{"cached_tokens":200,"cache_write_tokens":300}}}}` + "\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(response))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{"api_key": "fixture-key", "base_url": "https://api.openai.com", "model_mapping": map[string]any{"public": model}}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + var result *OpenAIForwardResult + var err error + if messages { + result, err = svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + } else { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + } + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "/v1/responses", upstream.lastReq.URL.Path) + require.Equal(t, model, gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 1) + require.False(t, gjson.GetBytes(upstream.lastBody, "temperature").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "top_p").Exists()) + if !messages { + require.Equal(t, "30m", gjson.GetBytes(upstream.lastBody, "prompt_cache_options.ttl").String()) + } + require.Equal(t, 300, result.Usage.CacheCreationInputTokens) + } + } +} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 7f4e354f22..51b7f223bd 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -81,7 +81,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco body = sanitizedToolBody } if account.IsOpenAIOAuthLike() { - reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body) + reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body, account.GetMappedModel(gjson.GetBytes(body, "model").String())) if reasoningErr != nil { return nil, fmt.Errorf("normalize OpenAI Responses reasoning.mode: %w", reasoningErr) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 79de86b45b..277645fa9c 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -147,6 +147,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } // 3. Convert Anthropic → Responses after compatibility-only replay guard. + anthropicReq.Model = upstreamModel responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq) if err != nil { return nil, fmt.Errorf("convert anthropic to responses: %w", err) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 608d8d1ff7..66c852ab13 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "net/http" "strings" "unicode/utf8" @@ -1331,13 +1332,52 @@ func normalizeOpenAIOAuthResponsesCompatibilityBody(body []byte) ([]byte, bool, return normalized, changed, nil } -func normalizeOpenAIResponsesReasoningMode(body []byte) ([]byte, bool, error) { +func normalizeGPT6ResponsesSampling(body []byte, model string) ([]byte, bool, error) { + if !openai.IsGPT6SolOrLunaModelSpelling(model) || gjson.GetBytes(body, "reasoning.effort").String() == "none" { + return body, false, nil + } + out := body + changed := false + for _, key := range []string{"temperature", "top_p", "top_logprobs", "logprobs"} { + if !gjson.GetBytes(out, key).Exists() { + continue + } + var err error + out, err = sjson.DeleteBytes(out, key) + if err != nil { + return body, false, fmt.Errorf("remove GPT-6 sampling parameter %s: %w", key, err) + } + changed = true + } + if include := gjson.GetBytes(out, "include"); include.IsArray() { + items := include.Array() + for i := len(items) - 1; i >= 0; i-- { + if items[i].String() != "message.output_text.logprobs" { + continue + } + var err error + out, err = sjson.DeleteBytes(out, fmt.Sprintf("include.%d", i)) + if err != nil { + return body, false, fmt.Errorf("remove GPT-6 logprobs include: %w", err) + } + changed = true + } + } + return out, changed, nil +} + +func normalizeOpenAIResponsesReasoningMode(body []byte, model string) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } - // Astra 的 reasoning.mode 与 reasoning.effort 是独立参数,不做兼容替换;非 Astra 维持旧 strip-mode/pro->max 行为。 - if isOpenAIGPT6AstraModel(gjson.GetBytes(body, "model").String()) { - return body, false, nil + // GPT-6 treats reasoning.mode and reasoning.effort as independent + // official fields. Preserve both verbatim; earlier models retain the + // established mode stripping and pro-to-max compatibility behavior. + if model == "" { + model = gjson.GetBytes(body, "model").String() + } + if isOpenAIGPT6Model(model) { + return normalizeGPT6ResponsesSampling(body, model) } mode := gjson.GetBytes(body, "reasoning.mode") if !mode.Exists() || mode.Type != gjson.String { @@ -1429,7 +1469,7 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac changed = true } if account != nil && account.IsOpenAI() && account.IsOAuth() { - if reasoningBody, reasoningChanged, err := normalizeOpenAIResponsesReasoningMode(normalized); err != nil { + if reasoningBody, reasoningChanged, err := normalizeOpenAIResponsesReasoningMode(normalized, account.GetMappedModel(gjson.GetBytes(normalized, "model").String())); err != nil { return body, false, err } else if reasoningChanged { normalized = reasoningBody @@ -1535,7 +1575,7 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo if err != nil { return body, false, err } - if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized); reasoningErr != nil { + if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized, ""); reasoningErr != nil { return body, false, reasoningErr } else if reasoningChanged { normalized = reasoningBody @@ -2504,6 +2544,9 @@ func normalizeOpenAIReasoningEffort(raw string) string { } func normalizeOpenAIReasoningEffortForModel(raw, model string) string { + if strings.EqualFold(strings.TrimSpace(raw), "none") && openai.IsGPT6SolOrLunaModelSpelling(model) { + return "none" + } if strings.EqualFold(strings.TrimSpace(raw), "max") && supportsOpenAIReasoningEffortMax(model) { return "max" } @@ -2513,7 +2556,7 @@ func normalizeOpenAIReasoningEffortForModel(raw, model string) string { // supportsOpenAIReasoningEffortMax reports model families whose upstream scale // has a distinct max level. Other models keep the legacy max -> xhigh behavior. func supportsOpenAIReasoningEffortMax(model string) bool { - if isOpenAIGPT6AstraModel(model) || isOpenAIGPT56Model(model) { + if isOpenAIGPT6Model(model) || isOpenAIGPT56Model(model) { return true } diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 4295fa9544..dba295ad63 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -20,6 +20,7 @@ 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" @@ -64,16 +65,19 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( clientStream := responsesReq.Stream // 3. Convert Responses → Anthropic - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) - if err != nil { - writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 4. Model mapping(OpenAI 网关统一入口的映射语义) + // Resolve the mapped model before choosing its thinking/tool protocol. billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - anthropicReq.Model = upstreamModel + if err := validateClaudeOpus55Request(body, upstreamModel); err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + responsesReq.Model = upstreamModel + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) + if err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } // 5. Force upstream streaming(客户端原始终决定响应格式; // 上游恒为流式,非流式由缓冲路径组装)。 @@ -245,6 +249,8 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( finalResp.Content[idx].Text += event.Delta.Text case "thinking_delta": finalResp.Content[idx].Thinking += event.Delta.Thinking + case "signature_delta": + finalResp.Content[idx].Signature += event.Delta.Signature case "input_json_delta": finalResp.Content[idx].Input = appendRawJSON(finalResp.Content[idx].Input, event.Delta.PartialJSON) } @@ -266,6 +272,9 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( } } + if claude.IsOpus55(upstreamModel) { + finalResp.Model = upstreamModel + } responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) responsesResp.Model = originalModel @@ -324,6 +333,7 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( state := apicompat.NewAnthropicEventToResponsesState() state.Model = originalModel + state.PreserveThinkingSignatures = claude.IsOpus55(upstreamModel) clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping) var usage ClaudeUsage diff --git a/backend/internal/service/openai_model_alias.go b/backend/internal/service/openai_model_alias.go index f34a28abac..b7c4e32ef0 100644 --- a/backend/internal/service/openai_model_alias.go +++ b/backend/internal/service/openai_model_alias.go @@ -37,6 +37,13 @@ func normalizeKnownOpenAICodexModel(model string) string { } } + if openai.IsGPT6SolOrLunaModelSpelling(normalized) { + if strings.HasPrefix(normalized, "gpt-6-sol") { + return "gpt-6-sol" + } + return "gpt-6-luna" + } + switch { case normalized == "gpt-6" || normalized == "gpt-6-astra": return "gpt-6-astra" @@ -151,3 +158,7 @@ func firstUsageBillingModel(candidates []string) string { } return "" } + +func isOpenAIGPT6Model(model string) bool { + return isOpenAIGPT6AstraModel(model) || openai.IsGPT6SolOrLunaModelSpelling(model) +} diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index a05bee25ad..f2071443ad 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -129,7 +129,7 @@ func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body), "") require.NoError(t, err) require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) @@ -153,7 +153,7 @@ func TestNormalizeOpenAIResponsesReasoningMode_AstraPreservesBody(t *testing.T) } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body), "") require.NoError(t, err) require.False(t, changed) require.JSONEq(t, tt.body, string(normalized)) @@ -173,7 +173,7 @@ func TestNormalizeOpenAIResponsesReasoningMode_NonAstraKeepsLegacyBehavior(t *te } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body), "") require.NoError(t, err) require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 701526252b..0b873a698b 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -19,6 +19,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" @@ -73,6 +74,48 @@ var ( Mode: "chat", SupportsPromptCaching: true, } + openAIGPT6SolFallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 2e-6, + InputCostPerTokenPriority: 4e-6, + OutputCostPerToken: 10e-6, + OutputCostPerTokenPriority: 20e-6, + CacheCreationInputTokenCost: 2.5e-6, + CacheCreationInputTokenCostPriority: 5e-6, + CacheReadInputTokenCost: 0.2e-6, + CacheReadInputTokenCostPriority: 0.4e-6, + LongContextInputTokenThreshold: 272_000, + LongContextInputCostMultiplier: 2, + LongContextOutputCostMultiplier: 1.5, + SupportsServiceTier: true, + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, + } + openAIGPT6LunaFallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 0.1e-6, + InputCostPerTokenPriority: 0.2e-6, + OutputCostPerToken: 0.5e-6, + OutputCostPerTokenPriority: 1e-6, + CacheCreationInputTokenCost: 0.125e-6, + CacheCreationInputTokenCostPriority: 0.25e-6, + CacheReadInputTokenCost: 0.01e-6, + CacheReadInputTokenCostPriority: 0.02e-6, + LongContextInputTokenThreshold: 272_000, + LongContextInputCostMultiplier: 2, + LongContextOutputCostMultiplier: 1.5, + SupportsServiceTier: true, + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, + } + claudeOpus55FallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 4e-6, OutputCostPerToken: 20e-6, + CacheCreationInputTokenCost: 5e-6, CacheCreationInputTokenCostAbove1hr: 8e-6, + CacheReadInputTokenCost: 0.2e-6, + InputCostPerTokenPriority: 8e-6, OutputCostPerTokenPriority: 40e-6, + CacheCreationInputTokenCostPriority: 10e-6, CacheReadInputTokenCostPriority: 0.4e-6, + SupportsServiceTier: true, LiteLLMProvider: "anthropic", Mode: "chat", SupportsPromptCaching: true, + } openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 5e-06, InputCostPerTokenPriority: 1e-05, @@ -136,6 +179,7 @@ var ( // LiteLLMModelPricing LiteLLM价格数据结构 // 只保留我们需要的字段,使用指针来处理可能缺失的值 type LiteLLMModelPricing struct { + CacheCreationInputTokenCostExplicit bool `json:"-"` InputCostPerToken float64 `json:"input_cost_per_token"` InputCostPerTokenPriority float64 `json:"input_cost_per_token_priority"` OutputCostPerToken float64 `json:"output_cost_per_token"` @@ -628,6 +672,7 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel } if entry.CacheCreationInputTokenCost != nil { pricing.CacheCreationInputTokenCost = *entry.CacheCreationInputTokenCost + pricing.CacheCreationInputTokenCostExplicit = true } if entry.CacheCreationInputTokenCostPriority != nil { pricing.CacheCreationInputTokenCostPriority = *entry.CacheCreationInputTokenCostPriority @@ -1328,6 +1373,12 @@ func (s *PricingService) extractBaseName(model string) string { // matchByModelFamily 基于模型系列匹配 func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { + if claude.IsOpus55(model) { + if pricing, ok := s.pricingData["claude-opus-5-5"]; ok { + return pricing + } + return claudeOpus55FallbackPricing + } // modelFamily 定义一个模型系列的匹配和定价查找规则。 type modelFamily struct { name string // 系列名称 @@ -1428,6 +1479,9 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { for _, pattern := range lookups { for key, pricing := range s.pricingData { keyLower := strings.ToLower(key) + if matched.name == "opus-5" && claude.IsOpus55(keyLower) { + continue + } if strings.Contains(keyLower, pattern) { logger.LegacyPrintf("service.pricing", "[Pricing] Fuzzy matched %s -> %s", model, key) return pricing @@ -1456,6 +1510,16 @@ func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing { } } + if openai.IsGPT6SolOrLunaModelSpelling(model) { + if pricing, ok := s.pricingData[normalizeKnownOpenAICodexModel(model)]; ok { + return pricing + } + if strings.HasPrefix(model, "gpt-6-sol") { + return openAIGPT6SolFallbackPricing + } + return openAIGPT6LunaFallbackPricing + } + // 尝试的回退变体 variants := s.generateOpenAIModelVariants(model, openAIModelDatePattern) 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 bab05e4188..2ba65b23c2 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -10,6 +10,154 @@ "output_cost_per_token_above_200k_tokens": 1.2e-05, "supports_prompt_caching": true }, + "gpt-6-sol": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_batches": 1.25e-06, + "cache_creation_input_token_cost_flex": 1.25e-06, + "cache_creation_input_token_cost_priority": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_flex": 1e-07, + "cache_read_input_token_cost_priority": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 4e-06, + "long_context_input_token_threshold": 272000, + "long_context_input_cost_multiplier": 2.0, + "long_context_output_cost_multiplier": 1.5, + "litellm_provider": "openai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 2e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true, + "cache_read_input_token_cost_batches": 1e-07, + "context_window": 1050000 + }, + "gpt-6-luna": { + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_batches": 6.25e-08, + "cache_creation_input_token_cost_flex": 6.25e-08, + "cache_creation_input_token_cost_priority": 2.5e-07, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_flex": 5e-09, + "cache_read_input_token_cost_priority": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_flex": 5e-08, + "input_cost_per_token_priority": 2e-07, + "long_context_input_token_threshold": 272000, + "long_context_input_cost_multiplier": 2.0, + "long_context_output_cost_multiplier": 1.5, + "litellm_provider": "openai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-07, + "output_cost_per_token_batches": 2.5e-07, + "output_cost_per_token_flex": 2.5e-07, + "output_cost_per_token_priority": 1e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true, + "cache_read_input_token_cost_batches": 5e-09, + "context_window": 1050000 + }, + "claude-opus-5-5": { + "cache_creation_input_token_cost": 5e-06, + "cache_creation_input_token_cost_above_1hr": 8e-06, + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 4e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-05, + "provider_specific_entry": { + "fast": 2.0 + }, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_output_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "input_cost_per_token_priority": 8e-06, + "output_cost_per_token_priority": 4e-05, + "cache_creation_input_token_cost_priority": 1e-05, + "cache_read_input_token_cost_priority": 4e-07, + "input_cost_per_token_batches": 2e-06, + "output_cost_per_token_batches": 1e-05 + }, "claude-3-7-sonnet-20250219": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 64bcfbf594..2130e257ac 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -230,6 +230,7 @@ const formatScopeName = (scope: string): string => { 'claude-opus-4-6-thinking': 'COpus46T', 'claude-opus-4-7': 'COpus47', 'claude-opus-4-8': 'COpus48', + 'claude-opus-5-5': 'COpus55', 'claude-opus-5': 'COpus5', 'claude-sonnet-4-6': 'CSon46', 'claude-sonnet-4-5': 'CSon45', diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 03544573b7..d008fddbbd 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -1396,6 +1396,24 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin max: {} } }, + 'gpt-6-sol': { + name: 'GPT-6 Sol', + limit: { + context: 1050000, + output: 128000 + }, + options: { + store: false + }, + variants: { + none: {}, + low: {}, + medium: {}, + high: {}, + xhigh: {}, + max: {} + } + }, 'gpt-5.6-sol': { name: 'GPT-5.6 Sol', limit: { @@ -1430,6 +1448,24 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin max: {} } }, + 'gpt-6-luna': { + name: 'GPT-6 Luna', + limit: { + context: 1050000, + output: 128000 + }, + options: { + store: false + }, + variants: { + none: {}, + low: {}, + medium: {}, + high: {}, + xhigh: {}, + max: {} + } + }, 'gpt-5.6-luna': { name: 'GPT-5.6 Luna', limit: { @@ -1861,6 +1897,21 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin provider[platform].models = geminiModels } else if (platform === 'anthropic') { provider[platform].npm = '@ai-sdk/anthropic' + provider[platform].models = { + 'claude-opus-5-5': { + name: 'Claude Opus 5.5', + limit: { context: 1000000, output: 128000 }, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + options: { thinking: { type: 'adaptive' }, effort: 'medium' }, + variants: { + low: { effort: 'low' }, + medium: { effort: 'medium' }, + high: { effort: 'high' }, + xhigh: { effort: 'xhigh' }, + max: { effort: 'max' } + } + } + } } else if (platform === 'antigravity-claude') { provider[platform].npm = '@ai-sdk/anthropic' provider[platform].name = 'Antigravity (Claude)' diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index 12b6c47888..efc32a0f57 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -668,12 +668,14 @@ describe('UseKeyModal', () => { const parsed = JSON.parse(wrapper.find('pre code').text()) const models = parsed.provider.openai.models - for (const model of ['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) { + for (const model of ['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-6-sol', 'gpt-6-luna']) { expect(models[model]).toBeDefined() expect(models[model].variants).toHaveProperty('max') expect(models[model].variants).toHaveProperty('xhigh') } expect(models['gpt-5.6'].name).toBe('GPT-5.6 (Sol)') + expect(models['gpt-6-sol'].variants).toHaveProperty('none') + expect(models['gpt-6-luna'].limit).toEqual({ context: 1050000, output: 128000 }) expect(models['gpt-6']).toEqual({ name: 'GPT-6 (Astra)', limit: { context: 1050000, output: 128000 }, @@ -688,6 +690,22 @@ describe('UseKeyModal', () => { }) }) + it('exports Opus 5.5 only 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: '' } } } + }) + const tab = wrapper.findAll('button').find(button => button.text().includes('keys.useKeyModal.cliTabs.opencode')) + 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') + }) + it('renders Claude Fable 5 OpenCode config with adaptive thinking', async () => { const wrapper = mount(UseKeyModal, { props: { diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index df9f347d5a..bb73136e0a 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -17,6 +17,8 @@ describe('useModelWhitelist', () => { expect(models).toContain('gpt-5.6') expect(models).toContain('gpt-6') expect(models).toContain('gpt-6-astra') + expect(models).toContain('gpt-6-sol') + expect(models).toContain('gpt-6-luna') }) it('openai 预设映射包含 GPT-6 别名和 Astra', () => { @@ -50,6 +52,8 @@ describe('useModelWhitelist', () => { expect(getModelsByPlatform('antigravity')).toContain('claude-fable-5-1') expect(getModelsByPlatform('claude')).toContain('claude-fable-5') 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-opus-4-8') expect(getModelsByPlatform('antigravity')).toContain('claude-opus-4-8') }) diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 0b312d21bf..e99695652e 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -10,7 +10,7 @@ const openaiModels = [ // GPT-5.6 系列 'gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', // GPT-6 系列 - 'gpt-6', 'gpt-6-astra', + 'gpt-6', 'gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', // GPT-5.5 系列 'gpt-5.5', // GPT-5.4 系列 @@ -34,6 +34,7 @@ export const claudeModels = [ 'claude-opus-4-6', 'claude-opus-4-7', 'claude-opus-4-8', + 'claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-4-6', 'claude-sonnet-5', @@ -290,6 +291,7 @@ const anthropicPresetMappings = [ { label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'claude-opus-4-7', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'claude-opus-4-8', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, + { label: 'Opus 5.5', from: 'claude-opus-5-5', to: 'claude-opus-5-5', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Opus 5', from: 'claude-opus-5', to: 'claude-opus-5', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' }, { label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, @@ -306,8 +308,10 @@ const openaiPresetMappings = [ { label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' }, { label: 'GPT-6', from: 'gpt-6', to: 'gpt-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }, { label: 'GPT-5.6', from: 'gpt-5.6', to: 'gpt-5.6', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, + { label: 'GPT-6 Sol', from: 'gpt-6-sol', to: 'gpt-6-sol', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' }, { label: 'GPT-5.6 Sol', from: 'gpt-5.6-sol', to: 'gpt-5.6-sol', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' }, { label: 'GPT-5.6 Terra', from: 'gpt-5.6-terra', to: 'gpt-5.6-terra', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' }, + { label: 'GPT-6 Luna', from: 'gpt-6-luna', to: 'gpt-6-luna', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' }, { label: 'GPT-5.6 Luna', from: 'gpt-5.6-luna', to: 'gpt-5.6-luna', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' }, { label: 'GPT-6 Astra', from: 'gpt-6-astra', to: 'gpt-6-astra', color: 'bg-fuchsia-100 text-fuchsia-700 hover:bg-fuchsia-200 dark:bg-fuchsia-900/30 dark:text-fuchsia-400' }, { label: 'GPT-5.5', from: 'gpt-5.5', to: 'gpt-5.5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },