diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index d63ce664a7..4075f57791 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -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", diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index c020be94f8..8d96a1d3d3 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -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 } diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index c9d391df4e..79d799d774 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -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"}, diff --git a/backend/internal/pkg/openai/constants_test.go b/backend/internal/pkg/openai/constants_test.go new file mode 100644 index 0000000000..f1f59b70f2 --- /dev/null +++ b/backend/internal/pkg/openai/constants_test.go @@ -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") +} diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 341bdff57d..4359edc2ca 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -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} } } diff --git a/backend/internal/repository/usage_log_repo_breakdown_test.go b/backend/internal/repository/usage_log_repo_breakdown_test.go index da62e8dd56..1fb99b0d64 100644 --- a/backend/internal/repository/usage_log_repo_breakdown_test.go +++ b/backend/internal/repository/usage_log_repo_breakdown_test.go @@ -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()) +} diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index 7302296683..f2e0045ff5 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -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) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 71d74ae6e0..3dfd500b05 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -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 } diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 28c95f7ff8..6def67c7ec 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -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 { diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index 5f4b5bcd2d..61663e636a 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -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) diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 52ec34f27e..b1e99f8da9 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -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 } diff --git a/backend/internal/service/openai_gateway_messages_usage_test.go b/backend/internal/service/openai_gateway_messages_usage_test.go new file mode 100644 index 0000000000..f884e74595 --- /dev/null +++ b/backend/internal/service/openai_gateway_messages_usage_test.go @@ -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) +} diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 313a255341..5841ce6556 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -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"), diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 3fdef698db..b33918652f 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -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) { diff --git a/backend/internal/service/openai_model_alias.go b/backend/internal/service/openai_model_alias.go index 338de9e8a5..4ddf8110c3 100644 --- a/backend/internal/service/openai_model_alias.go +++ b/backend/internal/service/openai_model_alias.go @@ -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 diff --git a/backend/internal/service/openai_model_alias_test.go b/backend/internal/service/openai_model_alias_test.go new file mode 100644 index 0000000000..4cb27206c9 --- /dev/null +++ b/backend/internal/service/openai_model_alias_test.go @@ -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"), + ) +} diff --git a/backend/internal/service/openai_model_mapping_test.go b/backend/internal/service/openai_model_mapping_test.go index 0f65740be7..f2ceb3551c 100644 --- a/backend/internal/service/openai_model_mapping_test.go +++ b/backend/internal/service/openai_model_mapping_test.go @@ -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}, diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index be19438eaa..d41abaac3a 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -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", diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go index cb2bd9cc31..f92117a2bf 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go @@ -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() diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 4528a8886f..d0ae3c7d96 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -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 diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 19098df917..b68d034638 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -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) { diff --git a/backend/migrations/173_allow_cyber_blocked_usage_request_type.sql b/backend/migrations/173_allow_cyber_blocked_usage_request_type.sql new file mode 100644 index 0000000000..57b1672fa8 --- /dev/null +++ b/backend/migrations/173_allow_cyber_blocked_usage_request_type.sql @@ -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; diff --git a/backend/migrations/auth_identity_payment_migrations_regression_test.go b/backend/migrations/auth_identity_payment_migrations_regression_test.go index 14dab99160..68538cdd07 100644 --- a/backend/migrations/auth_identity_payment_migrations_regression_test.go +++ b/backend/migrations/auth_identity_payment_migrations_regression_test.go @@ -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") +} diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 439988a53d..a762b2145f 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -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, diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 5900644814..d3bca5188a 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -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': { diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index 8ad9731baa..f8395dc75f 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -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: '
' + }, + Icon: { + template: '' + } + } + } + }) + + 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: { diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index a9de605c28..cd186213d6 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -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 模型', () => { diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index cce3df6a52..bbd98711fd 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -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' },