Merge pull request #3948 from superman2003/fix/gpt56-billing-usage-integrity

fix(openai): correct GPT-5.6 billing and usage gaps
This commit is contained in:
Wesley Liddick
2026-07-10 15:45:18 +08:00
committed by GitHub
28 changed files with 524 additions and 49 deletions
@@ -53,6 +53,26 @@ func TestUsageConversionsPreserveCacheWriteTokens(t *testing.T) {
require.Equal(t, 200, roundTrip.InputTokensDetails.CacheWriteTokens)
}
func TestResponsesUsageNestedCacheWritePresenceOverridesTopLevelAlias(t *testing.T) {
tests := []struct {
name string
nestedJSON string
want int
}{
{name: "explicit zero", nestedJSON: `{"cache_write_tokens":0}`, want: 0},
{name: "nonzero", nestedJSON: `{"cache_write_tokens":7}`, want: 7},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var usage ResponsesUsage
payload := []byte(`{"input_tokens":20,"output_tokens":2,"cache_creation_input_tokens":19,"input_tokens_details":` + tt.nestedJSON + `}`)
require.NoError(t, json.Unmarshal(payload, &usage))
require.Equal(t, tt.want, usage.CacheCreationInputTokens)
})
}
}
func TestChatCompletionsToResponses_SystemMessage(t *testing.T) {
req := &ChatCompletionsRequest{
Model: "gpt-4o",
+25
View File
@@ -332,6 +332,10 @@ type ResponsesUsage struct {
func (u *ResponsesUsage) UnmarshalJSON(data []byte) error {
type responsesUsageAlias ResponsesUsage
type cacheTokenPresence struct {
CacheCreationTokens *int `json:"cache_creation_tokens"`
CacheWriteTokens *int `json:"cache_write_tokens"`
}
var aux struct {
responsesUsageAlias
PromptTokens int `json:"prompt_tokens"`
@@ -345,6 +349,13 @@ func (u *ResponsesUsage) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
var nestedPresence struct {
InputTokensDetails *cacheTokenPresence `json:"input_tokens_details"`
PromptTokensDetails *cacheTokenPresence `json:"prompt_tokens_details"`
}
if err := json.Unmarshal(data, &nestedPresence); err != nil {
return err
}
*u = ResponsesUsage(aux.responsesUsageAlias)
if u.InputTokens == 0 && aux.PromptTokens != 0 {
u.InputTokens = aux.PromptTokens
@@ -368,6 +379,20 @@ func (u *ResponsesUsage) UnmarshalJSON(data []byte) error {
if u.OutputTokensDetails == nil && aux.CompletionTokensDetails != nil {
u.OutputTokensDetails = aux.CompletionTokensDetails
}
var canonicalCacheCreationTokens *int
switch {
case nestedPresence.InputTokensDetails != nil && nestedPresence.InputTokensDetails.CacheWriteTokens != nil:
canonicalCacheCreationTokens = nestedPresence.InputTokensDetails.CacheWriteTokens
case nestedPresence.PromptTokensDetails != nil && nestedPresence.PromptTokensDetails.CacheWriteTokens != nil:
canonicalCacheCreationTokens = nestedPresence.PromptTokensDetails.CacheWriteTokens
case nestedPresence.InputTokensDetails != nil && nestedPresence.InputTokensDetails.CacheCreationTokens != nil:
canonicalCacheCreationTokens = nestedPresence.InputTokensDetails.CacheCreationTokens
case nestedPresence.PromptTokensDetails != nil && nestedPresence.PromptTokensDetails.CacheCreationTokens != nil:
canonicalCacheCreationTokens = nestedPresence.PromptTokensDetails.CacheCreationTokens
}
if canonicalCacheCreationTokens != nil {
u.CacheCreationInputTokens = max(*canonicalCacheCreationTokens, 0)
}
if u.TotalTokens == 0 && (u.InputTokens != 0 || u.OutputTokens != 0) {
u.TotalTokens = u.InputTokens + u.OutputTokens
}
+1
View File
@@ -18,6 +18,7 @@ type Model struct {
// DefaultModels OpenAI models list
var DefaultModels = []Model{
{ID: "gpt-5.6", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 (Sol)"},
{ID: "gpt-5.6-sol", 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"},
@@ -0,0 +1,11 @@
package openai
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestDefaultModelsIncludeBareGPT56Alias(t *testing.T) {
require.Contains(t, DefaultModelIDs(), "gpt-5.6")
}
+12 -4
View File
@@ -199,16 +199,24 @@ func appendRequestTypeOrStreamQueryFilter(query string, args []any, requestType
// buildRequestTypeFilterCondition 在 request_type 过滤时兼容 legacy 字段,避免历史数据漏查。
func buildRequestTypeFilterCondition(startArgIndex int, requestType int16) (string, []any) {
return buildRequestTypeFilterConditionWithAlias(startArgIndex, requestType, "")
}
func buildRequestTypeFilterConditionWithAlias(startArgIndex int, requestType int16, alias string) (string, []any) {
normalized := service.RequestTypeFromInt16(requestType)
requestTypeArg := int16(normalized)
prefix := ""
if alias != "" {
prefix = alias + "."
}
switch normalized {
case service.RequestTypeSync:
return fmt.Sprintf("(request_type = $%d OR (request_type = %d AND stream = FALSE AND openai_ws_mode = FALSE))", startArgIndex, int16(service.RequestTypeUnknown)), []any{requestTypeArg}
return fmt.Sprintf("(%srequest_type = $%d OR (%srequest_type = %d AND %sstream = FALSE AND %sopenai_ws_mode = FALSE))", prefix, startArgIndex, prefix, int16(service.RequestTypeUnknown), prefix, prefix), []any{requestTypeArg}
case service.RequestTypeStream:
return fmt.Sprintf("(request_type = $%d OR (request_type = %d AND stream = TRUE AND openai_ws_mode = FALSE))", startArgIndex, int16(service.RequestTypeUnknown)), []any{requestTypeArg}
return fmt.Sprintf("(%srequest_type = $%d OR (%srequest_type = %d AND %sstream = TRUE AND %sopenai_ws_mode = FALSE))", prefix, startArgIndex, prefix, int16(service.RequestTypeUnknown), prefix, prefix), []any{requestTypeArg}
case service.RequestTypeWSV2:
return fmt.Sprintf("(request_type = $%d OR (request_type = %d AND openai_ws_mode = TRUE))", startArgIndex, int16(service.RequestTypeUnknown)), []any{requestTypeArg}
return fmt.Sprintf("(%srequest_type = $%d OR (%srequest_type = %d AND %sopenai_ws_mode = TRUE))", prefix, startArgIndex, prefix, int16(service.RequestTypeUnknown), prefix), []any{requestTypeArg}
default:
return fmt.Sprintf("request_type = $%d", startArgIndex), []any{requestTypeArg}
return fmt.Sprintf("%srequest_type = $%d", prefix, startArgIndex), []any{requestTypeArg}
}
}
@@ -3,9 +3,14 @@
package repository
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
@@ -48,3 +53,27 @@ func TestResolveModelDimensionExpression(t *testing.T) {
})
}
}
func TestGetUserBreakdownStatsRequestTypeIncludesLegacyFallback(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
end := start.Add(24 * time.Hour)
requestType := int16(service.RequestTypeStream)
legacyFilter := `(ul.request_type = $3 OR (ul.request_type = 0 AND ul.stream = TRUE AND ul.openai_ws_mode = FALSE))`
mock.ExpectQuery(regexp.QuoteMeta(legacyFilter)).
WithArgs(start, end, requestType).
WillReturnRows(sqlmock.NewRows([]string{
"user_id", "email", "requests", "input_tokens", "output_tokens",
"cache_tokens", "total_tokens", "cost", "actual_cost", "account_cost",
}))
rows, err := repo.GetUserBreakdownStats(context.Background(), start, end, usagestats.UserBreakdownDimension{
RequestType: &requestType,
}, 0)
require.NoError(t, err)
require.Empty(t, rows)
require.NoError(t, mock.ExpectationsWereMet())
}
@@ -642,8 +642,9 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim
args = append(args, dim.AccountID)
}
if dim.RequestType != nil {
query += fmt.Sprintf(" AND ul.request_type = $%d", len(args)+1)
args = append(args, *dim.RequestType)
condition, conditionArgs := buildRequestTypeFilterConditionWithAlias(len(args)+1, *dim.RequestType, "ul")
query += " AND " + condition
args = append(args, conditionArgs...)
}
if dim.Stream != nil {
query += fmt.Sprintf(" AND ul.stream = $%d", len(args)+1)
+12 -3
View File
@@ -293,6 +293,9 @@ func (s *BillingService) initFallbackPricing() {
CacheCreationPricePerTokenPriority: 12.5e-6,
CacheReadPricePerToken: 0.5e-6,
CacheReadPricePerTokenPriority: 1e-6,
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
}
s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{
InputPricePerToken: 2.5e-6,
@@ -303,6 +306,9 @@ func (s *BillingService) initFallbackPricing() {
CacheCreationPricePerTokenPriority: 6.25e-6,
CacheReadPricePerToken: 0.25e-6,
CacheReadPricePerTokenPriority: 0.5e-6,
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
}
s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{
InputPricePerToken: 1e-6,
@@ -313,6 +319,9 @@ func (s *BillingService) initFallbackPricing() {
CacheCreationPricePerTokenPriority: 2.5e-6,
CacheReadPricePerToken: 0.1e-6,
CacheReadPricePerTokenPriority: 0.2e-6,
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
}
s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{
@@ -1100,7 +1109,7 @@ func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *
if !isGPT56 && !usesLegacyLongContextPricing {
return pricing
}
needsLongContextPolicy := usesLegacyLongContextPricing &&
needsLongContextPolicy := (isGPT56 || usesLegacyLongContextPricing) &&
(pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0)
needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 ||
(pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0))
@@ -1108,7 +1117,7 @@ func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *
return pricing
}
cloned := *pricing
if isGPT56 {
if isGPT56 && !cloned.CacheCreationPriceExplicit {
if cloned.CacheCreationPricePerToken <= 0 {
cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25
}
@@ -1116,7 +1125,7 @@ func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *
cloned.CacheCreationPricePerTokenPriority = cloned.InputPricePerTokenPriority * 1.25
}
}
if usesLegacyLongContextPricing {
if isGPT56 || usesLegacyLongContextPricing {
if cloned.LongContextInputThreshold <= 0 {
cloned.LongContextInputThreshold = openAIGPT54LongContextInputThreshold
}
@@ -2,14 +2,12 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
@@ -411,16 +409,9 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions(
return nil, fmt.Errorf("read upstream body: %w", err)
}
var ccResp apicompat.ChatCompletionsResponse
var usage OpenAIUsage
if err := json.Unmarshal(respBody, &ccResp); err == nil && ccResp.Usage != nil {
usage = OpenAIUsage{
InputTokens: ccResp.Usage.PromptTokens,
OutputTokens: ccResp.Usage.CompletionTokens,
}
if ccResp.Usage.PromptTokensDetails != nil {
usage.CacheReadInputTokens = ccResp.Usage.PromptTokensDetails.CachedTokens
}
if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(respBody); ok {
usage = parsedUsage
}
if s.responseHeaderFilter != nil {
@@ -155,6 +155,57 @@ func TestForwardAsRawChatCompletions_PreservesMappedGPT56MaxEffort(t *testing.T)
require.Equal(t, "max", *result.ReasoningEffort)
}
func TestForwardAsRawChatCompletions_NonStreamingCapturesCacheWriteUsage(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
usageJSON string
wantWrite int
}{
{
name: "positive cache write",
usageJSON: `{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":4,"cache_write_tokens":6}}`,
wantWrite: 6,
},
{
name: "nested zero overrides legacy alias",
usageJSON: `{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15,"cache_creation_input_tokens":19,"prompt_tokens_details":{"cached_tokens":4,"cache_write_tokens":0}}`,
wantWrite: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := []byte(`{"model":"gpt-5.6","messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"chatcmpl_cache","object":"chat.completion","model":"gpt-5.6","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":` + tt.usageJSON + `}`,
)),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 12, result.Usage.InputTokens)
require.Equal(t, 4, result.Usage.CacheReadInputTokens)
require.Equal(t, tt.wantWrite, result.Usage.CacheCreationInputTokens)
})
}
}
func TestForwardAsRawChatCompletions_PreservesDeepSeekReasoningContentNonStreaming(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -1120,13 +1120,6 @@ func copyOpenAIUsageFromResponsesUsage(usage *apicompat.ResponsesUsage) OpenAIUs
}
if usage.InputTokensDetails != nil {
result.CacheReadInputTokens = usage.InputTokensDetails.CachedTokens
if result.CacheCreationInputTokens == 0 {
if usage.InputTokensDetails.CacheWriteTokens > 0 {
result.CacheCreationInputTokens = usage.InputTokensDetails.CacheWriteTokens
} else {
result.CacheCreationInputTokens = usage.InputTokensDetails.CacheCreationTokens
}
}
}
return result
}
@@ -0,0 +1,28 @@
//go:build unit
package service
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/stretchr/testify/require"
)
func TestCopyOpenAIUsageFromResponsesUsageTrustsCanonicalCacheCreationValue(t *testing.T) {
usage := &apicompat.ResponsesUsage{
InputTokens: 20,
OutputTokens: 2,
CacheCreationInputTokens: 0,
InputTokensDetails: &apicompat.ResponsesInputTokensDetails{
CachedTokens: 3,
CacheWriteTokens: 19,
},
}
got := copyOpenAIUsageFromResponsesUsage(usage)
require.Equal(t, 20, got.InputTokens)
require.Equal(t, 3, got.CacheReadInputTokens)
require.Zero(t, got.CacheCreationInputTokens)
}
@@ -772,9 +772,16 @@ func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) {
}
func openAICacheReadTokensFromUsage(value gjson.Result) int {
return firstPositiveGJSONInt(
for _, nested := range []gjson.Result{
value.Get("input_tokens_details.cached_tokens"),
value.Get("prompt_tokens_details.cached_tokens"),
} {
if nested.Exists() {
return max(int(nested.Int()), 0)
}
}
return firstPositiveGJSONInt(
value.Get("cache_read_input_tokens"),
value.Get("cache_read_tokens"),
value.Get("cached_tokens"),
@@ -782,11 +789,18 @@ func openAICacheReadTokensFromUsage(value gjson.Result) int {
}
func openAICacheCreationTokensFromUsage(value gjson.Result) int {
return firstPositiveGJSONInt(
for _, nested := range []gjson.Result{
value.Get("input_tokens_details.cache_write_tokens"),
value.Get("prompt_tokens_details.cache_write_tokens"),
value.Get("input_tokens_details.cache_creation_tokens"),
value.Get("prompt_tokens_details.cache_creation_tokens"),
} {
if nested.Exists() {
return max(int(nested.Int()), 0)
}
}
return firstPositiveGJSONInt(
value.Get("cache_write_tokens"),
value.Get("cache_creation_input_tokens"),
value.Get("cache_write_input_tokens"),
@@ -2821,6 +2821,14 @@ func TestExtractOpenAIUsageFromJSONBytes_AcceptsResponseAndChatUsageShapes(t *te
usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":20,"output_tokens":2,"cache_creation_input_tokens":19,"input_tokens_details":{"cache_write_tokens":7}}}`))
require.True(t, ok)
require.Equal(t, 7, usage.CacheCreationInputTokens, "官方嵌套字段应优先于兼容顶层别名")
usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":20,"output_tokens":2,"cache_creation_input_tokens":19,"input_tokens_details":{"cache_write_tokens":0}}}`))
require.True(t, ok)
require.Zero(t, usage.CacheCreationInputTokens, "官方嵌套字段显式为零时仍应优先于兼容顶层别名")
usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":20,"output_tokens":2,"cache_read_input_tokens":19,"input_tokens_details":{"cached_tokens":0}}}`))
require.True(t, ok)
require.Zero(t, usage.CacheReadInputTokens, "官方嵌套缓存读取字段显式为零时仍应优先于兼容顶层别名")
}
func TestExtractCodexFinalResponse_SampleReplay(t *testing.T) {
@@ -71,6 +71,14 @@ func normalizeKnownOpenAICodexModel(model string) string {
return "gpt-5.6-terra"
case strings.Contains(normalized, "gpt-5.6-luna"):
return "gpt-5.6-luna"
case normalized == "gpt-5.6":
return "gpt-5.6-sol"
case strings.HasPrefix(normalized, "gpt-5.6-"):
suffix := strings.TrimPrefix(normalized, "gpt-5.6-")
if suffix == "max" || isKnownCodexModelSuffix(suffix) {
return "gpt-5.6-sol"
}
return ""
case strings.Contains(normalized, "gpt-5.5-pro"):
return "gpt-5.5-pro"
case strings.Contains(normalized, "gpt-5.5"):
@@ -102,6 +110,12 @@ func normalizeKnownOpenAICodexModel(model string) string {
// (含大小写/路径/后缀变体)或已归一化的基名,两者均能正确识别。
func isOpenAIGPT56Model(model string) bool {
normalized := canonicalizeOpenAIModelAliasSpelling(model)
if normalized == "gpt-5.6" {
return true
}
if suffix, ok := strings.CutPrefix(normalized, "gpt-5.6-"); ok && (suffix == "max" || isKnownCodexModelSuffix(suffix)) {
return true
}
for _, prefix := range []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"} {
if normalized == prefix || strings.HasPrefix(normalized, prefix+"-") {
return true
@@ -0,0 +1,36 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNormalizeKnownOpenAICodexModel_BareGPT56RoutesToSol(t *testing.T) {
tests := map[string]string{
"gpt-5.6": "gpt-5.6-sol",
"openai/gpt-5.6": "gpt-5.6-sol",
"gpt5.6": "gpt-5.6-sol",
"gpt-5.6-high": "gpt-5.6-sol",
"gpt-5.6-max": "gpt-5.6-sol",
"gpt-5.6-2026-07-09": "gpt-5.6-sol",
"openai/gpt-5.6-max": "gpt-5.6-sol",
}
for input, expected := range tests {
t.Run(input, func(t *testing.T) {
require.Equal(t, expected, normalizeKnownOpenAICodexModel(input))
})
}
}
func TestUsageBillingModelCandidates_BareGPT56IncludesSol(t *testing.T) {
require.Equal(t,
[]string{"gpt-5.6", "gpt-5.6-sol"},
usageBillingModelCandidates("gpt-5.6"),
)
require.Equal(t,
[]string{"openai/gpt-5.6", "gpt-5.6", "gpt-5.6-sol"},
usageBillingModelCandidates("openai/gpt-5.6"),
)
}
@@ -252,6 +252,18 @@ func TestNormalizeOpenAIModelForUpstream(t *testing.T) {
model string
want string
}{
{
name: "oauth routes bare GPT-5.6 alias to Sol",
account: &Account{Type: AccountTypeOAuth},
model: "gpt-5.6",
want: "gpt-5.6-sol",
},
{
name: "oauth routes provider-prefixed GPT-5.6 alias to Sol",
account: &Account{Type: AccountTypeOAuth},
model: "openai/gpt-5.6",
want: "gpt-5.6-sol",
},
{
name: "oauth preserves unknown non codex model",
account: &Account{Type: AccountTypeOAuth},
@@ -282,6 +294,12 @@ func TestNormalizeOpenAIModelForUpstream(t *testing.T) {
model: "codex-auto-review",
want: "codex-auto-review",
},
{
name: "apikey preserves official bare GPT-5.6 alias",
account: &Account{Type: AccountTypeAPIKey},
model: "gpt-5.6",
want: "gpt-5.6",
},
{
name: "apikey preserves custom compatible model",
account: &Account{Type: AccountTypeAPIKey},
@@ -816,6 +816,13 @@ func openAICacheCreationTokensFromUsage(value gjson.Result) int {
"prompt_tokens_details.cache_write_tokens",
"input_tokens_details.cache_creation_tokens",
"prompt_tokens_details.cache_creation_tokens",
} {
result := value.Get(field)
if result.Exists() {
return max(int(result.Int()), 0)
}
}
for _, field := range []string{
"cache_write_tokens",
"cache_creation_input_tokens",
"cache_write_input_tokens",
@@ -335,6 +335,13 @@ func TestParseUsageAndAccumulateAcceptsChatUsageAliases(t *testing.T) {
require.Equal(t, got, state.usage)
}
func TestOpenAICacheCreationTokensFromUsageNestedZeroWins(t *testing.T) {
t.Parallel()
usage := gjson.Parse(`{"input_tokens_details":{"cache_write_tokens":0},"cache_creation_input_tokens":19}`)
require.Zero(t, openAICacheCreationTokensFromUsage(usage))
}
func TestEmitTurnCompleteCoverage(t *testing.T) {
t.Parallel()
@@ -44,6 +44,9 @@ var (
CacheCreationInputTokenCostPriority: 1.25e-05,
CacheReadInputTokenCost: 5e-07,
CacheReadInputTokenCostPriority: 1e-06,
LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier,
SupportsServiceTier: true,
LiteLLMProvider: "openai",
Mode: "chat",
@@ -58,6 +61,9 @@ var (
CacheCreationInputTokenCostPriority: 6.25e-06,
CacheReadInputTokenCost: 2.5e-07,
CacheReadInputTokenCostPriority: 5e-07,
LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier,
SupportsServiceTier: true,
LiteLLMProvider: "openai",
Mode: "chat",
@@ -72,6 +78,9 @@ var (
CacheCreationInputTokenCostPriority: 2.5e-06,
CacheReadInputTokenCost: 1e-07,
CacheReadInputTokenCostPriority: 2e-07,
LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier,
SupportsServiceTier: true,
LiteLLMProvider: "openai",
Mode: "chat",
@@ -140,6 +149,9 @@ type LiteLLMRawEntry struct {
CacheCreationInputTokenCostAbove1hr *float64 `json:"cache_creation_input_token_cost_above_1hr"`
CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost"`
CacheReadInputTokenCostPriority *float64 `json:"cache_read_input_token_cost_priority"`
LongContextInputTokenThreshold *int `json:"long_context_input_token_threshold"`
LongContextInputCostMultiplier *float64 `json:"long_context_input_cost_multiplier"`
LongContextOutputCostMultiplier *float64 `json:"long_context_output_cost_multiplier"`
SupportsServiceTier bool `json:"supports_service_tier"`
LiteLLMProvider string `json:"litellm_provider"`
Mode string `json:"mode"`
@@ -462,6 +474,15 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
if entry.CacheReadInputTokenCostPriority != nil {
pricing.CacheReadInputTokenCostPriority = *entry.CacheReadInputTokenCostPriority
}
if entry.LongContextInputTokenThreshold != nil {
pricing.LongContextInputTokenThreshold = *entry.LongContextInputTokenThreshold
}
if entry.LongContextInputCostMultiplier != nil {
pricing.LongContextInputCostMultiplier = *entry.LongContextInputCostMultiplier
}
if entry.LongContextOutputCostMultiplier != nil {
pricing.LongContextOutputCostMultiplier = *entry.LongContextOutputCostMultiplier
}
if entry.OutputCostPerImage != nil {
pricing.OutputCostPerImage = *entry.OutputCostPerImage
}
@@ -713,6 +734,12 @@ func normalizeModelNameForPricing(model string) string {
model = strings.TrimLeft(model, "/")
if canonical := canonicalizeOpenAIModelAliasSpelling(model); canonical != "" {
if canonical == "gpt-5.6" {
return "gpt-5.6-sol"
}
if suffix, ok := strings.CutPrefix(canonical, "gpt-5.6-"); ok && (suffix == "max" || isKnownCodexModelSuffix(suffix)) {
return "gpt-5.6-sol"
}
return canonical
}
return model
@@ -22,6 +22,9 @@ func TestParsePricingData_ParsesPriorityAndServiceTierFields(t *testing.T) {
"cache_creation_input_token_cost_priority": 0.000005,
"cache_read_input_token_cost": 0.00000025,
"cache_read_input_token_cost_priority": 0.0000005,
"long_context_input_token_threshold": 272000,
"long_context_input_cost_multiplier": 2,
"long_context_output_cost_multiplier": 1.5,
"supports_service_tier": true,
"supports_prompt_caching": true,
"litellm_provider": "openai",
@@ -37,6 +40,9 @@ func TestParsePricingData_ParsesPriorityAndServiceTierFields(t *testing.T) {
require.InDelta(t, 3e-5, pricing.OutputCostPerTokenPriority, 1e-12)
require.InDelta(t, 5e-6, pricing.CacheCreationInputTokenCostPriority, 1e-12)
require.InDelta(t, 5e-7, pricing.CacheReadInputTokenCostPriority, 1e-12)
require.Equal(t, 272000, pricing.LongContextInputTokenThreshold)
require.InDelta(t, 2.0, pricing.LongContextInputCostMultiplier, 1e-12)
require.InDelta(t, 1.5, pricing.LongContextOutputCostMultiplier, 1e-12)
require.True(t, pricing.SupportsServiceTier)
}
@@ -72,7 +78,9 @@ func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing.
require.NoError(t, err)
require.InDelta(t, tt.input*1.25, pricing.CacheCreationPricePerToken, 1e-12)
require.InDelta(t, tt.inputPriority*1.25, pricing.CacheCreationPricePerTokenPriority, 1e-12)
require.Zero(t, pricing.LongContextInputThreshold)
require.Equal(t, 272000, pricing.LongContextInputThreshold)
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
tokens := UsageTokens{InputTokens: 700, OutputTokens: 50, CacheCreationTokens: 200, CacheReadTokens: 100}
standard, err := svc.CalculateCostWithServiceTier(tt.model, tokens, 1, "")
@@ -90,25 +98,87 @@ func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing.
}
}
func TestBillingService_GPT56DoesNotUseLegacyLongContextMultiplier(t *testing.T) {
model := "gpt-5.6-sol"
pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{
model: {
InputCostPerToken: 5e-6,
OutputCostPerToken: 30e-6,
CacheReadInputTokenCost: 0.5e-6,
},
}}
svc := NewBillingService(&config.Config{}, pricingSvc)
tokens := UsageTokens{InputTokens: 100000, CacheCreationTokens: 173000, OutputTokens: 10}
func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testing.T) {
models := []struct {
name string
input, cached float64
cacheWrite, output float64
}{
{name: "gpt-5.6-sol", input: 5e-6, cached: 0.5e-6, cacheWrite: 6.25e-6, output: 30e-6},
{name: "gpt-5.6-terra", input: 2.5e-6, cached: 0.25e-6, cacheWrite: 3.125e-6, output: 15e-6},
{name: "gpt-5.6-luna", input: 1e-6, cached: 0.1e-6, cacheWrite: 1.25e-6, output: 6e-6},
}
tiers := []struct {
name string
priceScale float64
}{
{name: "standard", priceScale: 1},
{name: "priority", priceScale: 2},
{name: "flex", priceScale: 0.5},
}
tokens := UsageTokens{
InputTokens: 100000,
CacheCreationTokens: 100000,
CacheReadTokens: 73000,
OutputTokens: 10,
}
cost, err := svc.CalculateCost(model, tokens, 1)
for _, model := range models {
for _, tier := range tiers {
t.Run(model.name+"/"+tier.name, func(t *testing.T) {
svc := NewBillingService(&config.Config{}, nil)
serviceTier := ""
if tier.name != "standard" {
serviceTier = tier.name
}
cost, err := svc.CalculateCostWithServiceTier(model.name, tokens, 1, serviceTier)
require.NoError(t, err)
require.InDelta(t, float64(tokens.InputTokens)*model.input*tier.priceScale*2, cost.InputCost, 1e-12)
require.InDelta(t, float64(tokens.CacheCreationTokens)*model.cacheWrite*tier.priceScale*2, cost.CacheCreationCost, 1e-12)
require.InDelta(t, float64(tokens.CacheReadTokens)*model.cached*tier.priceScale*2, cost.CacheReadCost, 1e-12)
require.InDelta(t, float64(tokens.OutputTokens)*model.output*tier.priceScale*1.5, cost.OutputCost, 1e-12)
})
}
}
}
func TestBillingService_GPT56LongContextBoundaryIsExclusive(t *testing.T) {
svc := NewBillingService(&config.Config{}, nil)
tokens := UsageTokens{InputTokens: 100000, CacheCreationTokens: 100000, CacheReadTokens: 72000, OutputTokens: 10}
cost, err := svc.CalculateCost("gpt-5.6-sol", tokens, 1)
require.NoError(t, err)
require.InDelta(t, 100000*5e-6, cost.InputCost, 1e-12)
require.InDelta(t, 173000*6.25e-6, cost.CacheCreationCost, 1e-12)
require.InDelta(t, 100000*6.25e-6, cost.CacheCreationCost, 1e-12)
require.InDelta(t, 72000*0.5e-6, cost.CacheReadCost, 1e-12)
require.InDelta(t, 10*30e-6, cost.OutputCost, 1e-12)
}
func TestPricingService_BareGPT56AliasDeterministicallyUsesSol(t *testing.T) {
pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{
"gpt-5.6-sol": {InputCostPerToken: 5e-6},
"gpt-5.6-terra": {InputCostPerToken: 2.5e-6},
"gpt-5.6-luna": {InputCostPerToken: 1e-6},
"gpt-5.4": {InputCostPerToken: 2.5e-6},
}}
for i := 0; i < 100; i++ {
for _, alias := range []string{"gpt-5.6", "openai/gpt-5.6"} {
pricing := pricingSvc.GetModelPricing(alias)
require.NotNil(t, pricing)
require.InDelta(t, 5e-6, pricing.InputCostPerToken, 1e-12, "iteration=%d alias=%s", i, alias)
}
}
billingSvc := NewBillingService(&config.Config{}, pricingSvc)
for _, alias := range []string{"gpt-5.6", "openai/gpt-5.6"} {
pricing, err := billingSvc.GetModelPricing(alias)
require.NoError(t, err)
require.InDelta(t, 5e-6, pricing.InputPricePerToken, 1e-12)
require.InDelta(t, 6.25e-6, pricing.CacheCreationPricePerToken, 1e-12)
}
}
func TestDefaultPricingIncludesOfficialGPT56Rates(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json"))
require.NoError(t, err)
@@ -140,7 +210,9 @@ func TestDefaultPricingIncludesOfficialGPT56Rates(t *testing.T) {
require.InDelta(t, tt.cachedPriority, pricing.CacheReadPricePerTokenPriority, 1e-12)
require.InDelta(t, tt.cacheWritePriority, pricing.CacheCreationPricePerTokenPriority, 1e-12)
require.InDelta(t, tt.outputPriority, pricing.OutputPricePerTokenPriority, 1e-12)
require.Zero(t, pricing.LongContextInputThreshold)
require.Equal(t, 272000, pricing.LongContextInputThreshold)
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
})
}
}
@@ -181,7 +253,9 @@ func assertGPT56FallbackPricing(t *testing.T, pricing *ModelPricing, input, cach
require.InDelta(t, cached, pricing.CacheReadPricePerToken, 1e-12)
require.InDelta(t, cacheWrite, pricing.CacheCreationPricePerToken, 1e-12)
require.InDelta(t, output, pricing.OutputPricePerToken, 1e-12)
require.Zero(t, pricing.LongContextInputThreshold)
require.Equal(t, 272000, pricing.LongContextInputThreshold)
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
}
func TestParsePricingData_KeepsImageOnlyPricing(t *testing.T) {
@@ -0,0 +1,8 @@
-- Cyber-policy blocks are recorded as request_type=4 so they remain visible in
-- usage audits without being confused with legacy request_type=0 rows.
ALTER TABLE usage_logs
DROP CONSTRAINT IF EXISTS usage_logs_request_type_check;
ALTER TABLE usage_logs
ADD CONSTRAINT usage_logs_request_type_check
CHECK (request_type IN (0, 1, 2, 3, 4)) NOT VALID;
@@ -213,3 +213,30 @@ func TestMigration154aAddsSparkShadowIndexesConcurrently(t *testing.T) {
require.Contains(t, sql, "quota_dimension = 'spark'")
require.Contains(t, sql, "deleted_at IS NULL")
}
func TestMigration173AllowsCyberBlockedUsageRequestType(t *testing.T) {
entries, err := FS.ReadDir(".")
require.NoError(t, err)
previousIndex := -1
currentIndex := -1
for i, entry := range entries {
switch entry.Name() {
case "172_video_per_second_billing_metadata.sql":
previousIndex = i
case "173_allow_cyber_blocked_usage_request_type.sql":
currentIndex = i
}
}
require.NotEqual(t, -1, previousIndex)
require.NotEqual(t, -1, currentIndex)
require.Less(t, previousIndex, currentIndex)
content, err := FS.ReadFile("173_allow_cyber_blocked_usage_request_type.sql")
require.NoError(t, err)
sql := string(content)
require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS usage_logs_request_type_check")
require.Contains(t, sql, "ADD CONSTRAINT usage_logs_request_type_check")
require.Contains(t, sql, "CHECK (request_type IN (0, 1, 2, 3, 4)) NOT VALID")
}
@@ -4972,6 +4972,9 @@
"input_cost_per_token_batches": 2.5e-06,
"input_cost_per_token_flex": 2.5e-06,
"input_cost_per_token_priority": 1e-05,
"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": 1050000,
"max_output_tokens": 128000,
@@ -5021,6 +5024,9 @@
"input_cost_per_token_batches": 1.25e-06,
"input_cost_per_token_flex": 1.25e-06,
"input_cost_per_token_priority": 5e-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": 1050000,
"max_output_tokens": 128000,
@@ -5070,6 +5076,9 @@
"input_cost_per_token_batches": 5e-07,
"input_cost_per_token_flex": 5e-07,
"input_cost_per_token_priority": 2e-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": 1050000,
"max_output_tokens": 128000,
+23 -3
View File
@@ -636,6 +636,23 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
xhigh: {}
}
},
'gpt-5.6': {
name: 'GPT-5.6 (Sol)',
limit: {
context: 1050000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {},
xhigh: {},
max: {}
}
},
'gpt-5.6-sol': {
name: 'GPT-5.6 Sol',
limit: {
@@ -649,7 +666,8 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
low: {},
medium: {},
high: {},
xhigh: {}
xhigh: {},
max: {}
}
},
'gpt-5.6-terra': {
@@ -665,7 +683,8 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
low: {},
medium: {},
high: {},
xhigh: {}
xhigh: {},
max: {}
}
},
'gpt-5.6-luna': {
@@ -681,7 +700,8 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
low: {},
medium: {},
high: {},
xhigh: {}
xhigh: {},
max: {}
}
},
'gpt-5.5': {
@@ -123,6 +123,43 @@ describe('UseKeyModal', () => {
expect(codeBlock.text()).not.toContain('"name": "GPT-5.4 Nano"')
})
it('renders GPT-5.6 alias and max variants in OpenCode config', async () => {
const wrapper = mount(UseKeyModal, {
props: {
show: true,
apiKey: 'sk-test',
baseUrl: 'https://example.com/v1',
platform: 'openai'
},
global: {
stubs: {
BaseDialog: {
template: '<div><slot /><slot name="footer" /></div>'
},
Icon: {
template: '<span />'
}
}
}
})
const opencodeTab = wrapper.findAll('button').find((button) =>
button.text().includes('keys.useKeyModal.cliTabs.opencode')
)
expect(opencodeTab).toBeDefined()
await opencodeTab!.trigger('click')
await nextTick()
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']) {
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)')
})
it('renders Claude Fable 5 OpenCode config with adaptive thinking', async () => {
const wrapper = mount(UseKeyModal, {
props: {
@@ -14,6 +14,7 @@ describe('useModelWhitelist', () => {
expect(models).toContain('gpt-5.4-mini')
expect(models).toContain('gpt-5.4-2026-03-05')
expect(models).toContain('codex-auto-review')
expect(models).toContain('gpt-5.6')
})
it('openai 模型列表不再暴露已下线的 ChatGPT 登录 Codex 模型', () => {
@@ -8,7 +8,7 @@ const openaiModels = [
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
// GPT-5.6 系列
'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna',
'gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna',
// GPT-5.5 系列
'gpt-5.5',
// GPT-5.4 系列
@@ -280,6 +280,7 @@ const openaiPresetMappings = [
{ label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'GPT-5.3 Codex Spark', from: 'gpt-5.3-codex-spark', to: 'gpt-5.3-codex-spark', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
{ 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-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-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-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' },