mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/")
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user