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..d2ce58a038 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -3760,3 +3760,23 @@ func TestCodexGPTIdentityPatternsCoverBundledPrompts(t *testing.T) { 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.Contains(t, models[0], "apply_patch_tool_type") + 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' },