Merge pull request #7509 from lyy0709/codex/gpt6-sol-luna-opus55

feat(models): support GPT-6 Sol, Luna and Claude Opus 5.5
This commit is contained in:
Wesley Liddick
2026-09-23 14:23:38 +08:00
committed by GitHub
46 changed files with 1228 additions and 113 deletions
@@ -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))
})
}
}
@@ -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"
@@ -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
@@ -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
@@ -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,
})
}
}
@@ -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
@@ -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)
@@ -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
+12 -7
View File
@@ -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.
+6
View File
@@ -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",
@@ -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")
}
@@ -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/")
+20
View File
@@ -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
}
@@ -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"))
}
+60 -5
View File
@@ -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
@@ -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))
}
}
@@ -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)
}
@@ -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
@@ -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 != "" {
@@ -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.
@@ -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),
@@ -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
@@ -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())
}
}
}
}
+30 -4
View File
@@ -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)
@@ -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") ||
@@ -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"])
}
}
@@ -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"},
@@ -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
@@ -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)
}
}
@@ -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
@@ -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(客户端原始终决定响应格式;
// 上游恒为流式,非流式由缓冲路径组装)。
@@ -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 {
@@ -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)
}
}
}
@@ -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)
}
@@ -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)
@@ -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
}
@@ -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
@@ -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)
}
@@ -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())
@@ -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)
@@ -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,
@@ -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',
@@ -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)'
@@ -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: '<div><slot /><slot name="footer" /></div>' }, Icon: { template: '<span />' } } }
})
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: {
@@ -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')
})
@@ -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' },