diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index a7089b0107..b886728159 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1303,6 +1303,10 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) { response.ErrorFrom(c, infraerrors.BadRequest("NOT_OAUTH", "cannot apply oauth credentials to non-OAuth account")) return } + if err := service.ValidateOpenAILongContextBillingExtra(existing.Platform, req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } updatedAccount, err := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{ Type: req.Type, diff --git a/backend/internal/handler/admin/account_handler_long_context_billing_test.go b/backend/internal/handler/admin/account_handler_long_context_billing_test.go index 340d077792..d50513a3e8 100644 --- a/backend/internal/handler/admin/account_handler_long_context_billing_test.go +++ b/backend/internal/handler/admin/account_handler_long_context_billing_test.go @@ -8,6 +8,7 @@ import ( "testing" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -112,6 +113,35 @@ func TestAccountCreateBoundaryDoesNotApplyOpenAIValidationToOtherPlatforms(t *te require.Equal(t, http.StatusOK, recorder.Code) } +func TestApplyOAuthCredentialsRejectsMalformedOpenAILongContextBillingBeforeMutation(t *testing.T) { + gin.SetMode(gin.TestMode) + stub := newStubAdminService() + stub.getAccountResult = &service.Account{ + ID: 1, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + } + handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.POST("/accounts/:id/apply-oauth-credentials", handler.ApplyOAuthCredentials) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/accounts/1/apply-oauth-credentials", bytes.NewBufferString( + `{"type":"oauth","credentials":{"access_token":"new-token"},"extra":{"openai_long_context_billing_enabled":"true"}}`, + )) + request.Header.Set("Content-Type", "application/json") + + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + var responseBody struct { + Reason string `json:"reason"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &responseBody)) + require.Equal(t, "OPENAI_LONG_CONTEXT_BILLING_INVALID", responseBody.Reason) + require.Zero(t, stub.updateAccountCalls) + require.Zero(t, stub.updateAccountExtraCalls) +} + func TestOpenAIOAuthCodexPATBoundaryRejectsMalformedOpenAILongContextBillingValueBeforeTokenValidation(t *testing.T) { gin.SetMode(gin.TestMode) handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil) diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 7a7cbb473e..5e9c4d517e 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -33,6 +33,9 @@ type stubAdminService struct { createSparkShadowErr error updateAccountErr error bulkUpdateAccountErr error + getAccountResult *service.Account + updateAccountCalls int + updateAccountExtraCalls int checkMixedErr error lastMixedCheck struct { accountID int64 @@ -388,6 +391,9 @@ func (s *stubAdminService) ListOpenAISchedulableAccountsForSchedulerScore(_ cont } func (s *stubAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) { + if s.getAccountResult != nil { + return s.getAccountResult, nil + } account := service.Account{ID: id, Name: "account", Status: service.StatusActive} return &account, nil } @@ -413,6 +419,7 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre } func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) { + s.updateAccountCalls++ if s.updateAccountErr != nil { return nil, s.updateAccountErr } @@ -421,6 +428,7 @@ func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *s } func (s *stubAdminService) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error { + s.updateAccountExtraCalls++ return nil } diff --git a/backend/internal/repository/openai_long_context_billing_migration_integration_test.go b/backend/internal/repository/openai_long_context_billing_migration_integration_test.go index f325d5c031..5f50ed0729 100644 --- a/backend/internal/repository/openai_long_context_billing_migration_integration_test.go +++ b/backend/internal/repository/openai_long_context_billing_migration_integration_test.go @@ -60,7 +60,7 @@ SELECT (extra->>'openai_long_context_billing_enabled')::boolean FROM accounts WHERE id = $1 `, ordinaryID).Scan(&ordinaryEnabled)) - require.True(t, ordinaryEnabled) + require.False(t, ordinaryEnabled) var shadowEnabled bool require.NoError(t, tx.QueryRowContext(ctx, ` @@ -126,7 +126,7 @@ INSERT INTO accounts (name, platform, type, extra) VALUES ('migration-175-rolling-writer', 'openai', 'oauth', '{}'::jsonb) RETURNING (extra->>'openai_long_context_billing_enabled')::boolean `).Scan(&ordinaryEnabled)) - require.True(t, ordinaryEnabled) + require.False(t, ordinaryEnabled) _, err = tx.ExecContext(ctx, "TRUNCATE scheduler_outbox") require.NoError(t, err) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index fba772c89d..3e67fa5982 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1195,14 +1195,10 @@ func (a *Account) IsOpenAI() bool { } func (a *Account) IsOpenAILongContextBillingEnabled() bool { - if a == nil || !a.IsOpenAI() { + if a == nil || !a.IsOpenAI() || a.Extra == nil { return false } - raw, exists := a.Extra[openAILongContextBillingEnabledKey] - if !exists { - return true - } - enabled, ok := raw.(bool) + enabled, ok := a.Extra[openAILongContextBillingEnabledKey].(bool) return ok && enabled } diff --git a/backend/internal/service/account_long_context_billing_test.go b/backend/internal/service/account_long_context_billing_test.go index 712ff36de6..709559d932 100644 --- a/backend/internal/service/account_long_context_billing_test.go +++ b/backend/internal/service/account_long_context_billing_test.go @@ -19,8 +19,8 @@ func TestAccountIsOpenAILongContextBillingEnabled(t *testing.T) { }{ {name: "nil account is disabled", account: nil, want: false}, {name: "non OpenAI account is disabled", account: &Account{Platform: PlatformGrok}, want: false}, - {name: "missing extra defaults enabled", account: &Account{Platform: PlatformOpenAI}, want: true}, - {name: "missing key defaults enabled", account: &Account{Platform: PlatformOpenAI, Extra: map[string]any{}}, want: true}, + {name: "missing extra defaults disabled", account: &Account{Platform: PlatformOpenAI}, want: false}, + {name: "missing key defaults disabled", account: &Account{Platform: PlatformOpenAI, Extra: map[string]any{}}, want: false}, {name: "explicit true is enabled", account: &Account{Platform: PlatformOpenAI, Extra: map[string]any{"openai_long_context_billing_enabled": true}}, want: true}, {name: "explicit false is disabled", account: &Account{Platform: PlatformOpenAI, Extra: map[string]any{"openai_long_context_billing_enabled": false}}, want: false}, {name: "malformed value is disabled", account: &Account{Platform: PlatformOpenAI, Extra: map[string]any{"openai_long_context_billing_enabled": "false"}}, want: false}, @@ -34,11 +34,11 @@ func TestAccountIsOpenAILongContextBillingEnabled(t *testing.T) { } func TestNormalizeOpenAILongContextBillingExtra(t *testing.T) { - t.Run("OpenAI missing key persists enabled default", func(t *testing.T) { + t.Run("OpenAI missing key persists disabled default", func(t *testing.T) { extra, err := normalizeOpenAILongContextBillingExtra(PlatformOpenAI, nil) require.NoError(t, err) - require.Equal(t, true, extra["openai_long_context_billing_enabled"]) + require.Equal(t, false, extra["openai_long_context_billing_enabled"]) }) t.Run("OpenAI explicit false is preserved", func(t *testing.T) { @@ -116,7 +116,7 @@ func (r *longContextBillingRepoStub) BulkUpdate(_ context.Context, _ []int64, _ return 1, nil } -func TestAdminServiceCreateAccountDefaultsOpenAILongContextBillingEnabled(t *testing.T) { +func TestAdminServiceCreateAccountDefaultsOpenAILongContextBillingDisabled(t *testing.T) { repo := &longContextBillingRepoStub{} svc := &adminServiceImpl{accountRepo: repo} @@ -130,7 +130,7 @@ func TestAdminServiceCreateAccountDefaultsOpenAILongContextBillingEnabled(t *tes require.NoError(t, err) require.Same(t, account, repo.createdAccount) - require.Equal(t, true, account.Extra[openAILongContextBillingEnabledKey]) + require.Equal(t, false, account.Extra[openAILongContextBillingEnabledKey]) } func TestAdminServiceCreateAccountRejectsMalformedOpenAILongContextBillingValue(t *testing.T) { @@ -162,7 +162,7 @@ func TestAdminServiceUpdateAccountPreservesOpenAILongContextBillingOptOutWhenOmi require.Equal(t, false, account.Extra[openAILongContextBillingEnabledKey]) } -func TestAdminServiceUpdateAccountPreservesCodexImportOptOutWhenIncomingDefaultsTrue(t *testing.T) { +func TestAdminServiceUpdateAccountAllowsExplicitCodexImportOptIn(t *testing.T) { repo := &longContextBillingRepoStub{account: &Account{ ID: 1, Platform: PlatformOpenAI, @@ -184,7 +184,7 @@ func TestAdminServiceUpdateAccountPreservesCodexImportOptOutWhenIncomingDefaults }) require.NoError(t, err) - require.Equal(t, false, account.Extra[openAILongContextBillingEnabledKey]) + require.Equal(t, true, account.Extra[openAILongContextBillingEnabledKey]) } func TestAdminServiceUpdateAccountAllowsExplicitOptInOutsideCodexImport(t *testing.T) { diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 2e6b000ed4..8cb6d8e63b 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -101,7 +101,7 @@ func normalizeOpenAILongContextBillingExtra(platform string, extra map[string]an } _, exists := normalized[openAILongContextBillingEnabledKey] if !exists { - normalized[openAILongContextBillingEnabledKey] = true + normalized[openAILongContextBillingEnabledKey] = false } return normalized, nil } @@ -118,21 +118,6 @@ func normalizeOpenAILongContextBillingUpdateExtra(account *Account, input *Updat if hasCurrent { normalized[openAILongContextBillingEnabledKey] = current } - return normalized, nil - } - - incoming, ok := normalized[openAILongContextBillingEnabledKey].(bool) - if !ok { - return nil, infraerrors.BadRequest( - "OPENAI_LONG_CONTEXT_BILLING_INVALID", - "openai_long_context_billing_enabled must be a boolean", - ) - } - importSource, _ := input.Extra["import_source"].(string) - accessToken, _ := input.Credentials["access_token"].(string) - isCodexSessionImport := importSource == "codex_session" && strings.TrimSpace(accessToken) != "" - if hasCurrent && !current && incoming && isCodexSessionImport { - normalized[openAILongContextBillingEnabledKey] = false } return normalized, nil } diff --git a/backend/internal/service/admin_service_spark_shadow_test.go b/backend/internal/service/admin_service_spark_shadow_test.go index 7e29218f88..0eda0d93c7 100644 --- a/backend/internal/service/admin_service_spark_shadow_test.go +++ b/backend/internal/service/admin_service_spark_shadow_test.go @@ -163,7 +163,7 @@ func TestCreateShadowInheritsParentEffectiveOpenAILongContextBillingValue(t *tes parentExtra map[string]any want bool }{ - {name: "missing parent value defaults enabled", want: true}, + {name: "missing parent value defaults disabled", want: false}, {name: "explicit parent opt-out is inherited", parentExtra: map[string]any{openAILongContextBillingEnabledKey: false}, want: false}, } diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 44dae17db4..7fa69d41ea 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1283,7 +1283,7 @@ func (s *BillingService) CalculateCostWithLongContext(model string, tokens Usage CacheReadCost: inRangeCost.CacheReadCost + outRangeCost.CacheReadCost, TotalCost: inRangeCost.TotalCost + outRangeCost.TotalCost, ActualCost: inRangeCost.ActualCost + outRangeCost.ActualCost, - LongContextBillingApplied: true, + LongContextBillingApplied: outRangeCost.ActualCost > 0, }, nil } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 9f7cc16e8b..885da194e3 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -848,6 +848,17 @@ func TestCalculateCostWithLongContext_AboveThreshold_CacheBelowThreshold(t *test require.True(t, cost.ActualCost > normalCost.ActualCost, "长上下文费用应高于正常费用") } +func TestCalculateCostWithLongContext_MarkerRequiresActualCostIncrease(t *testing.T) { + svc := newTestBillingService() + tokens := UsageTokens{InputTokens: 300000} + + cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 0, 200000, 2.0) + + require.NoError(t, err) + require.Zero(t, cost.ActualCost) + require.False(t, cost.LongContextBillingApplied) +} + func TestCalculateCostWithLongContext_DisabledThreshold(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/crs_sync_long_context_billing_test.go b/backend/internal/service/crs_sync_long_context_billing_test.go index 876eb94e6b..6439f08190 100644 --- a/backend/internal/service/crs_sync_long_context_billing_test.go +++ b/backend/internal/service/crs_sync_long_context_billing_test.go @@ -72,18 +72,24 @@ func TestCRSSyncOpenAILongContextBilling(t *testing.T) { wantAction string wantEnabled bool }{ - {name: "OAuth create defaults missing value enabled", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, wantAction: "created", wantEnabled: true}, + {name: "OAuth create defaults missing value disabled", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, wantAction: "created"}, + {name: "OAuth create preserves source true", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: true}, wantAction: "created", wantEnabled: true}, {name: "OAuth create preserves source false", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: false}, wantAction: "created"}, - {name: "OAuth update defaults missing value enabled", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, existingExtra: map[string]any{"existing": true}, wantAction: "updated", wantEnabled: true}, + {name: "OAuth update defaults missing value disabled", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, existingExtra: map[string]any{"existing": true}, wantAction: "updated"}, + {name: "OAuth update preserves existing true when source omits value", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: true}, wantAction: "updated", wantEnabled: true}, {name: "OAuth update preserves existing false when source omits value", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: false}, wantAction: "updated"}, + {name: "OAuth update preserves source true over existing false", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: true}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: false}, wantAction: "updated", wantEnabled: true}, {name: "OAuth update preserves source false over existing true", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: false}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: true}, wantAction: "updated"}, {name: "OAuth rejects malformed source value", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: "false"}, wantAction: "failed"}, {name: "OAuth rejects malformed existing value", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: "false"}, wantAction: "failed"}, {name: "OAuth update rejects malformed source value", collection: "openaiOAuthAccounts", credentials: map[string]any{"access_token": "oauth-token"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: "false"}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: true}, wantAction: "failed"}, - {name: "API key create defaults missing value enabled", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, wantAction: "created", wantEnabled: true}, + {name: "API key create defaults missing value disabled", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, wantAction: "created"}, + {name: "API key create preserves source true", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: true}, wantAction: "created", wantEnabled: true}, {name: "API key create preserves source false", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: false}, wantAction: "created"}, - {name: "API key update defaults missing value enabled", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, existingExtra: map[string]any{"existing": true}, wantAction: "updated", wantEnabled: true}, + {name: "API key update defaults missing value disabled", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, existingExtra: map[string]any{"existing": true}, wantAction: "updated"}, + {name: "API key update preserves existing true when source omits value", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: true}, wantAction: "updated", wantEnabled: true}, {name: "API key update preserves existing false when source omits value", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: false}, wantAction: "updated"}, + {name: "API key update preserves source true over existing false", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: true}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: false}, wantAction: "updated", wantEnabled: true}, {name: "API key update preserves source false over existing true", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: false}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: true}, wantAction: "updated"}, {name: "API key rejects malformed source value", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: "false"}, wantAction: "failed"}, {name: "API key rejects malformed existing value", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: "false"}, wantAction: "failed"}, diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 039bfd1605..d2eca05d74 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1056,7 +1056,7 @@ func TestOpenAIGatewayServiceRecordUsage_GPT56SeparatesCacheWriteForBillingAndSt require.InDelta(t, usageRepo.lastLog.TotalCost*1.1, usageRepo.lastLog.ActualCost, 1e-12) } -func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledByDefault(t *testing.T) { +func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledByDefault(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} subRepo := &openAIRecordUsageSubRepoStub{} @@ -1080,17 +1080,17 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledByDefault require.NoError(t, err) require.NotNil(t, usageRepo.lastLog) - expectedInput := 300000 * 2.5e-6 * 2.0 - expectedOutput := 2000 * 15e-6 * 1.5 + expectedInput := 300000 * 2.5e-6 + expectedOutput := 2000 * 15e-6 require.InDelta(t, expectedInput, usageRepo.lastLog.InputCost, 1e-10) require.InDelta(t, expectedOutput, usageRepo.lastLog.OutputCost, 1e-10) require.InDelta(t, expectedInput+expectedOutput, usageRepo.lastLog.TotalCost, 1e-10) require.InDelta(t, (expectedInput+expectedOutput)*1.1, usageRepo.lastLog.ActualCost, 1e-10) - require.True(t, usageRepo.lastLog.LongContextBillingApplied) + require.False(t, usageRepo.lastLog.LongContextBillingApplied) require.Equal(t, 1, userRepo.deductCalls) } -func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledPerAccount(t *testing.T) { +func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccount(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} subRepo := &openAIRecordUsageSubRepoStub{} @@ -1111,20 +1111,20 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledPerAccou Account: &Account{ ID: 3015, Platform: PlatformOpenAI, - Extra: map[string]any{"openai_long_context_billing_enabled": false}, + Extra: map[string]any{"openai_long_context_billing_enabled": true}, }, }) require.NoError(t, err) require.NotNil(t, usageRepo.lastLog) - expectedInput := 300000 * 2.5e-6 - expectedOutput := 2000 * 15e-6 + expectedInput := 300000 * 2.5e-6 * 2.0 + expectedOutput := 2000 * 15e-6 * 1.5 require.InDelta(t, expectedInput, usageRepo.lastLog.InputCost, 1e-10) require.InDelta(t, expectedOutput, usageRepo.lastLog.OutputCost, 1e-10) require.InDelta(t, expectedInput+expectedOutput, usageRepo.lastLog.TotalCost, 1e-10) require.InDelta(t, (expectedInput+expectedOutput)*1.1, usageRepo.lastLog.ActualCost, 1e-10) - require.False(t, usageRepo.lastLog.LongContextBillingApplied) + require.True(t, usageRepo.lastLog.LongContextBillingApplied) } func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSetting(t *testing.T) { diff --git a/backend/migrations/175_default_openai_long_context_billing.sql b/backend/migrations/175_default_openai_long_context_billing.sql index 48a4f74096..cccbea4108 100644 --- a/backend/migrations/175_default_openai_long_context_billing.sql +++ b/backend/migrations/175_default_openai_long_context_billing.sql @@ -14,7 +14,7 @@ BEGIN IF NEW.parent_account_id IS NOT NULL AND NEW.quota_dimension = 'spark' THEN SELECT CASE WHEN parent.platform IS DISTINCT FROM 'openai' THEN 'false'::jsonb - WHEN NOT (COALESCE(parent.extra, '{}'::jsonb) ? 'openai_long_context_billing_enabled') THEN 'true'::jsonb + WHEN NOT (COALESCE(parent.extra, '{}'::jsonb) ? 'openai_long_context_billing_enabled') THEN 'false'::jsonb WHEN jsonb_typeof(parent.extra->'openai_long_context_billing_enabled') = 'boolean' THEN parent.extra->'openai_long_context_billing_enabled' ELSE 'false'::jsonb @@ -43,7 +43,7 @@ BEGIN NEW.extra := jsonb_set( NEW.extra, '{openai_long_context_billing_enabled}', - 'true'::jsonb, + 'false'::jsonb, true ); END IF; @@ -121,7 +121,7 @@ UPDATE accounts SET extra = jsonb_set( COALESCE(extra, '{}'::jsonb), '{openai_long_context_billing_enabled}', - 'true'::jsonb, + 'false'::jsonb, true ) WHERE platform = 'openai' @@ -133,7 +133,7 @@ WITH shadow_values AS ( shadow.id, CASE WHEN parent.platform IS DISTINCT FROM 'openai' THEN 'false'::jsonb - WHEN NOT (COALESCE(parent.extra, '{}'::jsonb) ? 'openai_long_context_billing_enabled') THEN 'true'::jsonb + WHEN NOT (COALESCE(parent.extra, '{}'::jsonb) ? 'openai_long_context_billing_enabled') THEN 'false'::jsonb WHEN jsonb_typeof(parent.extra->'openai_long_context_billing_enabled') = 'boolean' THEN parent.extra->'openai_long_context_billing_enabled' ELSE 'false'::jsonb diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 0412ac8213..2639190ec1 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2840,7 +2840,7 @@ data-testid="openai-long-context-billing-toggle" role="switch" :aria-checked="openAILongContextBillingEnabled" - @click="openAILongContextBillingEnabled = !openAILongContextBillingEnabled" + @click="toggleOpenAILongContextBilling" :class="[ 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2', openAILongContextBillingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600' @@ -3708,7 +3708,8 @@ const fillHeaderOverrideTemplate = () => { const interceptWarmupRequests = ref(false) const autoPauseOnExpired = ref(true) const openaiPassthroughEnabled = ref(false) -const openAILongContextBillingEnabled = ref(true) +const openAILongContextBillingEnabled = ref(false) +const openAILongContextBillingTouched = ref(false) const openAICompactMode = ref('auto') const openAIResponsesMode = ref('auto') const openAIEndpointCapabilities = ref(['chat_completions', 'embeddings']) @@ -3721,6 +3722,11 @@ const anthropicPassthroughEnabled = ref(false) const anthropicAPIKeyAuthScheme = ref('x_api_key') const webSearchEmulationMode = ref('default') const webSearchGlobalEnabled = ref(false) + +const toggleOpenAILongContextBilling = () => { + openAILongContextBillingEnabled.value = !openAILongContextBillingEnabled.value + openAILongContextBillingTouched.value = true +} const { globalEnabled: quotaNotifyGlobalEnabled, state: quotaNotifyState, @@ -4563,7 +4569,8 @@ const resetForm = () => { interceptWarmupRequests.value = false autoPauseOnExpired.value = true openaiPassthroughEnabled.value = false - openAILongContextBillingEnabled.value = true + openAILongContextBillingEnabled.value = false + openAILongContextBillingTouched.value = false openAICompactMode.value = 'auto' openAIResponsesMode.value = 'auto' openAIEndpointCapabilities.value = ['chat_completions', 'embeddings'] @@ -4687,7 +4694,9 @@ const buildOpenAICodexImportExtra = (): Record | undefined => { if (!extra) { return undefined } - delete extra.openai_long_context_billing_enabled + if (!openAILongContextBillingTouched.value) { + delete extra.openai_long_context_billing_enabled + } return Object.keys(extra).length > 0 ? extra : undefined } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index ecd1174a5c..cfc2fed151 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1786,9 +1786,9 @@ /> - +
@@ -2815,7 +2815,7 @@ const customBaseUrl = ref('') // OpenAI 自动透传开关(OAuth/API Key) const openaiPassthroughEnabled = ref(false) -const openAILongContextBillingEnabled = ref(true) +const openAILongContextBillingEnabled = ref(false) // OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别 const editPlanType = ref('') const openAICompactMode = ref('auto') @@ -3249,7 +3249,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { // Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key) openaiPassthroughEnabled.value = false - openAILongContextBillingEnabled.value = true + openAILongContextBillingEnabled.value = false editPlanType.value = '' openAICompactMode.value = 'auto' openAIResponsesMode.value = 'auto' @@ -3266,7 +3266,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) { openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true const longContextBillingValue = extra?.openai_long_context_billing_enabled - openAILongContextBillingEnabled.value = longContextBillingValue === undefined || longContextBillingValue === true + openAILongContextBillingEnabled.value = longContextBillingValue === true // plan_type 手动覆盖仅 OAuth 有实际调度语义(IsOpenAIChatGPTSubscription 要求 oauth),故只对 oauth 回填 editPlanType.value = newAccount.type === 'oauth' ? readPlanType(newAccount.credentials as Record | undefined) @@ -4437,7 +4437,11 @@ const handleSubmit = async () => { delete newExtra.openai_passthrough delete newExtra.openai_oauth_passthrough } - newExtra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value + if (isSparkShadow.value) { + delete newExtra.openai_long_context_billing_enabled + } else { + newExtra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value + } if (openAICompactMode.value === 'auto') { delete newExtra.openai_compact_mode } else { diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 5ff2c7d275..62c97d35a4 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -100,7 +100,7 @@ async function selectButtonByText(wrapper: ReturnType, text: await button?.trigger('click') } -async function submitApiKeyAccount(platform: 'openai' | 'anthropic', disableLongContextBilling = false) { +async function submitApiKeyAccount(platform: 'openai' | 'anthropic', enableLongContextBilling = false) { const wrapper = mountModal() await selectButtonByText(wrapper, platform === 'openai' ? 'OpenAI' : 'admin.accounts.claudeConsole') if (platform === 'openai') { @@ -108,16 +108,19 @@ async function submitApiKeyAccount(platform: 'openai' | 'anthropic', disableLong } await wrapper.get('form#create-account-form input[type="text"]').setValue(`${platform} account`) await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') - if (disableLongContextBilling) { + if (enableLongContextBilling) { await wrapper.get('[data-testid="openai-long-context-billing-toggle"]').trigger('click') } await wrapper.get('form#create-account-form').trigger('submit.prevent') await flushPromises() } -async function openCodexImportStep() { +async function openCodexImportStep(toggleClicks = 0) { const wrapper = mountModal() await selectButtonByText(wrapper, 'OpenAI') + for (let click = 0; click < toggleClicks; click += 1) { + await wrapper.get('[data-testid="openai-long-context-billing-toggle"]').trigger('click') + } await wrapper.get('form#create-account-form input[type="text"]').setValue('Codex import') await wrapper.get('form#create-account-form').trigger('submit.prevent') return wrapper @@ -137,18 +140,18 @@ describe('CreateAccountModal OpenAI long-context billing', () => { createOpenAICodexPATMock.mockReset().mockResolvedValue({}) }) - it('sends true explicitly for normal OpenAI account creation by default', async () => { + it('sends false explicitly for normal OpenAI account creation by default', async () => { await submitApiKeyAccount('openai') expect(createAccountMock).toHaveBeenCalledTimes(1) - expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true) + expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) }) - it('sends false explicitly when OpenAI long-context billing is disabled', async () => { + it('sends true explicitly when OpenAI long-context billing is enabled', async () => { await submitApiKeyAccount('openai', true) expect(createAccountMock).toHaveBeenCalledTimes(1) - expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) + expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true) }) it('omits the OpenAI setting for non-OpenAI account creation', async () => { @@ -175,4 +178,36 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(createOpenAICodexPATMock).toHaveBeenCalledTimes(1) expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined() }) + + it('sends explicit true for Codex session import after the toggle is enabled', async () => { + const wrapper = await openCodexImportStep(1) + await wrapper.get('[data-testid="import-codex-session"]').trigger('click') + await flushPromises() + + expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true) + }) + + it('sends explicit false for Codex session import after the toggle is changed back', async () => { + const wrapper = await openCodexImportStep(2) + await wrapper.get('[data-testid="import-codex-session"]').trigger('click') + await flushPromises() + + expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) + }) + + it('sends explicit true for Codex PAT import after the toggle is enabled', async () => { + const wrapper = await openCodexImportStep(1) + await wrapper.get('[data-testid="import-codex-pat"]').trigger('click') + await flushPromises() + + expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true) + }) + + it('sends explicit false for Codex PAT import after the toggle is changed back', async () => { + const wrapper = await openCodexImportStep(2) + await wrapper.get('[data-testid="import-codex-pat"]').trigger('click') + await flushPromises() + + expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) + }) }) diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index d50912e9a9..b3a583d102 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -416,7 +416,7 @@ describe('EditAccountModal', () => { expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false) }) - it('defaults legacy OpenAI accounts to long-context billing enabled', async () => { + it('defaults legacy OpenAI accounts to long-context billing disabled', async () => { const account = buildAccount() updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() @@ -425,12 +425,33 @@ describe('EditAccountModal', () => { const wrapper = mountModal(account) const toggle = wrapper.get('[data-testid="openai-long-context-billing-toggle"]') - expect(toggle.attributes('aria-checked')).toBe('true') + expect(toggle.attributes('aria-checked')).toBe('false') await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) - expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(true) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false) + }) + + it('does not render or submit the long-context billing toggle for Spark shadow accounts', async () => { + const account = buildOpenAISparkShadowAccount() + account.extra = { + openai_long_context_billing_enabled: false + } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + const wrapper = mountModal(account) + + expect(wrapper.find('[data-testid="openai-long-context-billing-toggle"]').exists()).toBe(false) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty( + 'openai_long_context_billing_enabled' + ) }) it('preserves an explicit OpenAI long-context billing opt-out', async () => { diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 69ef653760..f75c4a8060 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -404,7 +404,7 @@ export default { 'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.', longContextBilling: 'API long-context pricing', longContextBillingDesc: - 'Enabled by default. Disable only when this account\'s upstream does not charge OpenAI API long-context rates above the model threshold.', + 'Disabled by default. Enable only when this account\'s upstream charges OpenAI API long-context rates above the model threshold.', responsesWebsocketsV2: 'Responses WebSocket v2', responsesWebsocketsV2Desc: 'Disabled by default. Enable to allow responses_websockets_v2 capability (still gated by global and account-type switches).', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index ce81fc1e35..4887a1c045 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -506,7 +506,7 @@ export default { oauthPassthroughDesc: '开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。', longContextBilling: 'API 长上下文计费', - longContextBillingDesc: '默认开启。仅当该账号的上游不会按模型阈值收取 OpenAI API 长上下文费率时关闭。', + longContextBillingDesc: '默认关闭。仅当该账号的上游会按模型阈值收取 OpenAI API 长上下文费率时开启。', responsesWebsocketsV2: 'Responses WebSocket v2', responsesWebsocketsV2Desc: '默认关闭。开启后可启用 responses_websockets_v2 协议能力(受网关全局开关与账号类型开关约束)。',