fix(billing): 默认关闭 OpenAI 长上下文计费

This commit is contained in:
benjamin
2026-07-13 23:32:16 +08:00
parent f63d168ae0
commit e9fb5983cd
19 changed files with 182 additions and 73 deletions
@@ -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,
@@ -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)
@@ -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
}
@@ -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)
+2 -6
View File
@@ -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
}
@@ -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) {
+1 -16
View File
@@ -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
}
@@ -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},
}
+1 -1
View File
@@ -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
}
@@ -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()
@@ -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"},
@@ -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) {
@@ -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
@@ -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<OpenAICompactMode>('auto')
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
@@ -3721,6 +3722,11 @@ const anthropicPassthroughEnabled = ref(false)
const anthropicAPIKeyAuthScheme = ref<AnthropicAPIKeyAuthScheme>('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<string, unknown> | 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
}
@@ -1786,9 +1786,9 @@
/>
</div>
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<!-- OpenAI API 长上下文计费开关 -->
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
v-if="account?.platform === 'openai' && !isSparkShadow && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between gap-4">
@@ -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<string>('')
const openAICompactMode = ref<OpenAICompactMode>('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<string, unknown> | 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 {
@@ -100,7 +100,7 @@ async function selectButtonByText(wrapper: ReturnType<typeof mountModal>, 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)
})
})
@@ -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 () => {
@@ -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).',
@@ -506,7 +506,7 @@ export default {
oauthPassthroughDesc:
'开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。',
longContextBilling: 'API 长上下文计费',
longContextBillingDesc: '默认开启。仅当该账号的上游不会按模型阈值收取 OpenAI API 长上下文费率时关闭。',
longContextBillingDesc: '默认关闭。仅当该账号的上游会按模型阈值收取 OpenAI API 长上下文费率时开启。',
responsesWebsocketsV2: 'Responses WebSocket v2',
responsesWebsocketsV2Desc:
'默认关闭。开启后可启用 responses_websockets_v2 协议能力(受网关全局开关与账号类型开关约束)。',