fix(billing): 默认开启 OpenAI 长上下文计费

This commit is contained in:
benjamin
2026-07-13 17:55:01 +08:00
parent 0d9c140bc2
commit a0ac5e0240
22 changed files with 1517 additions and 66 deletions
@@ -115,6 +115,10 @@ func (h *AccountHandler) ImportCodexSession(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
if req.Concurrency != nil && *req.Concurrency < 0 {
response.BadRequest(c, "concurrency must be >= 0")
return
@@ -630,6 +630,7 @@ func TestImportCodexSessionsAccessTokenOnlySameUserUpdatesExisting(t *testing.T)
"chatgpt_user_id": "user-1",
"access_token": existingToken,
},
Extra: map[string]any{"openai_long_context_billing_enabled": false},
}})
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
@@ -650,6 +651,9 @@ func TestImportCodexSessionsAccessTokenOnlySameUserUpdatesExisting(t *testing.T)
if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 10 {
t.Fatalf("updated accounts = %+v, want account 10", svc.updatedAccounts)
}
if got := svc.updatedAccounts[0].input.Extra["openai_long_context_billing_enabled"]; got != false {
t.Fatalf("openai_long_context_billing_enabled = %v, want false", got)
}
}
func TestImportCodexSessionsUpgradesAccessTokenOnlyAccountWithRefreshToken(t *testing.T) {
@@ -784,6 +784,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := service.ValidateOpenAILongContextBillingExtra(req.Platform, req.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
response.BadRequest(c, "rate_multiplier must be >= 0")
return
@@ -1592,6 +1596,12 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
for _, item := range req.Accounts {
if err := service.ValidateOpenAILongContextBillingExtra(item.Platform, item.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
}
executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
success := 0
@@ -0,0 +1,135 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestAccountAdminBoundariesRejectMalformedOpenAILongContextBillingValue(t *testing.T) {
const malformedExtra = `"extra":{"openai_long_context_billing_enabled":"true"}`
tests := []struct {
name string
method string
path string
body string
mount func(*gin.Engine, *AccountHandler)
setup func(*stubAdminService)
}{
{
name: "create",
method: http.MethodPost,
path: "/accounts",
body: `{"name":"account","platform":"openai","type":"apikey","credentials":{"api_key":"test"},` + malformedExtra + `}`,
mount: func(router *gin.Engine, handler *AccountHandler) { router.POST("/accounts", handler.Create) },
},
{
name: "update",
method: http.MethodPut,
path: "/accounts/1",
body: `{` + malformedExtra + `}`,
mount: func(router *gin.Engine, handler *AccountHandler) { router.PUT("/accounts/:id", handler.Update) },
setup: func(stub *stubAdminService) {
stub.updateAccountErr = infraerrors.BadRequest("OPENAI_LONG_CONTEXT_BILLING_INVALID", "invalid")
},
},
{
name: "bulk update",
method: http.MethodPost,
path: "/accounts/bulk-update",
body: `{"account_ids":[1],` + malformedExtra + `}`,
mount: func(router *gin.Engine, handler *AccountHandler) {
router.POST("/accounts/bulk-update", handler.BulkUpdate)
},
setup: func(stub *stubAdminService) {
stub.bulkUpdateAccountErr = infraerrors.BadRequest("OPENAI_LONG_CONTEXT_BILLING_INVALID", "invalid")
},
},
{
name: "batch create",
method: http.MethodPost,
path: "/accounts/batch",
body: `{"accounts":[{"name":"account","platform":"openai","type":"apikey","credentials":{"api_key":"test"},` + malformedExtra + `}]}`,
mount: func(router *gin.Engine, handler *AccountHandler) { router.POST("/accounts/batch", handler.BatchCreate) },
},
{
name: "Codex session import",
method: http.MethodPost,
path: "/accounts/import-codex-session",
body: `{"content":"token",` + malformedExtra + `}`,
mount: func(router *gin.Engine, handler *AccountHandler) {
router.POST("/accounts/import-codex-session", handler.ImportCodexSession)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := newStubAdminService()
if tt.setup != nil {
tt.setup(stub)
}
handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
tt.mount(router, handler)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
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)
})
}
}
func TestAccountCreateBoundaryDoesNotApplyOpenAIValidationToOtherPlatforms(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewAccountHandler(newStubAdminService(), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.POST("/accounts", handler.Create)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(
`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"api_key":"test"},"extra":{"openai_long_context_billing_enabled":"provider-owned"}}`,
))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
}
func TestOpenAIOAuthCodexPATBoundaryRejectsMalformedOpenAILongContextBillingValueBeforeTokenValidation(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil)
router := gin.New()
router.Use(gin.Recovery())
router.POST("/openai/create-from-codex-pat", handler.CreateAccountFromCodexPAT)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/openai/create-from-codex-pat", bytes.NewBufferString(
`{"access_token":"token","extra":{"openai_long_context_billing_enabled":1}}`,
))
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)
}
@@ -304,6 +304,10 @@ func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
if req.Concurrency != nil && *req.Concurrency < 0 {
response.BadRequest(c, "concurrency must be >= 0")
return
@@ -0,0 +1,159 @@
//go:build integration
package repository
import (
"context"
"testing"
dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
"github.com/stretchr/testify/require"
)
func TestMigration175EnforcesOpenAILongContextBillingWriteInvariant(t *testing.T) {
tx := testTx(t)
ctx := context.Background()
migrationSQL, err := dbmigrations.FS.ReadFile("175_default_openai_long_context_billing.sql")
require.NoError(t, err)
_, err = tx.ExecContext(ctx, `
DROP TRIGGER IF EXISTS accounts_propagate_openai_long_context_billing_extra ON accounts;
DROP TRIGGER IF EXISTS accounts_enforce_openai_long_context_billing_extra ON accounts;
`)
require.NoError(t, err)
var ordinaryID int64
require.NoError(t, tx.QueryRowContext(ctx, `
INSERT INTO accounts (name, platform, type, extra)
VALUES ('migration-175-ordinary', 'openai', 'oauth', '{}'::jsonb)
RETURNING id
`).Scan(&ordinaryID))
var parentID int64
require.NoError(t, tx.QueryRowContext(ctx, `
INSERT INTO accounts (name, platform, type, extra)
VALUES ('migration-175-parent', 'openai', 'oauth', '{"openai_long_context_billing_enabled":false}'::jsonb)
RETURNING id
`).Scan(&parentID))
var shadowID int64
require.NoError(t, tx.QueryRowContext(ctx, `
INSERT INTO accounts (name, platform, type, extra, parent_account_id, quota_dimension)
VALUES ('migration-175-shadow', 'openai', 'oauth', '{}'::jsonb, $1, 'spark')
RETURNING id
`, parentID).Scan(&shadowID))
var malformedLegacyID int64
require.NoError(t, tx.QueryRowContext(ctx, `
INSERT INTO accounts (name, platform, type, extra)
VALUES ('migration-175-malformed-legacy', 'openai', 'oauth', '{"openai_long_context_billing_enabled":"false"}'::jsonb)
RETURNING id
`).Scan(&malformedLegacyID))
_, err = tx.ExecContext(ctx, string(migrationSQL))
require.NoError(t, err)
_, err = tx.ExecContext(ctx, string(migrationSQL))
require.NoError(t, err)
var ordinaryEnabled bool
require.NoError(t, tx.QueryRowContext(ctx, `
SELECT (extra->>'openai_long_context_billing_enabled')::boolean
FROM accounts
WHERE id = $1
`, ordinaryID).Scan(&ordinaryEnabled))
require.True(t, ordinaryEnabled)
var shadowEnabled bool
require.NoError(t, tx.QueryRowContext(ctx, `
SELECT (extra->>'openai_long_context_billing_enabled')::boolean
FROM accounts
WHERE id = $1
`, shadowID).Scan(&shadowEnabled))
require.False(t, shadowEnabled)
var initialShadowOutboxEvents int
require.NoError(t, tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM scheduler_outbox
WHERE event_type = 'account_changed' AND account_id = $1
`, shadowID).Scan(&initialShadowOutboxEvents))
require.Equal(t, 1, initialShadowOutboxEvents)
var malformedLegacyEnabled bool
require.NoError(t, tx.QueryRowContext(ctx, `
SELECT (extra->>'openai_long_context_billing_enabled')::boolean
FROM accounts
WHERE id = $1
`, malformedLegacyID).Scan(&malformedLegacyEnabled))
require.False(t, malformedLegacyEnabled)
_, err = tx.ExecContext(ctx, `
UPDATE accounts
SET extra = extra || '{"migration_175_unrelated_update":true}'::jsonb
WHERE id = $1
`, malformedLegacyID)
require.NoError(t, err)
_, err = tx.ExecContext(ctx, "TRUNCATE scheduler_outbox")
require.NoError(t, err)
_, err = tx.ExecContext(ctx, `
UPDATE accounts
SET extra = '{"legacy_writer_replaced_extra":true}'::jsonb
WHERE id = $1
`, parentID)
require.NoError(t, err)
var parentEnabled bool
require.NoError(t, tx.QueryRowContext(ctx, `
SELECT (extra->>'openai_long_context_billing_enabled')::boolean
FROM accounts
WHERE id = $1
`, parentID).Scan(&parentEnabled))
require.False(t, parentEnabled)
require.NoError(t, tx.QueryRowContext(ctx, `
SELECT (extra->>'openai_long_context_billing_enabled')::boolean
FROM accounts
WHERE id = $1
`, shadowID).Scan(&shadowEnabled))
require.False(t, shadowEnabled)
var preservedOptOutEvents int
require.NoError(t, tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM scheduler_outbox
WHERE event_type = 'account_changed' AND account_id = $1
`, shadowID).Scan(&preservedOptOutEvents))
require.Zero(t, preservedOptOutEvents)
require.NoError(t, tx.QueryRowContext(ctx, `
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)
_, err = tx.ExecContext(ctx, "TRUNCATE scheduler_outbox")
require.NoError(t, err)
_, err = tx.ExecContext(ctx, `
UPDATE accounts
SET extra = jsonb_set(extra, '{openai_long_context_billing_enabled}', 'true'::jsonb, true)
WHERE id = $1
`, parentID)
require.NoError(t, err)
require.NoError(t, tx.QueryRowContext(ctx, `
SELECT (extra->>'openai_long_context_billing_enabled')::boolean
FROM accounts
WHERE id = $1
`, shadowID).Scan(&shadowEnabled))
require.True(t, shadowEnabled)
var shadowOutboxEvents int
require.NoError(t, tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM scheduler_outbox
WHERE event_type = 'account_changed' AND account_id = $1
`, shadowID).Scan(&shadowOutboxEvents))
require.Equal(t, 1, shadowOutboxEvents)
_, err = tx.ExecContext(ctx, `
INSERT INTO accounts (name, platform, type, extra)
VALUES ('migration-175-malformed', 'openai', 'oauth', '{"openai_long_context_billing_enabled":"false"}'::jsonb)
`)
require.ErrorContains(t, err, "openai_long_context_billing_enabled must be a boolean")
}
+8 -2
View File
@@ -83,6 +83,8 @@ type Account struct {
type OpenAIEndpointCapability string
const openAILongContextBillingEnabledKey = "openai_long_context_billing_enabled"
const (
OpenAIEndpointCapabilityChatCompletions OpenAIEndpointCapability = "chat_completions"
OpenAIEndpointCapabilityEmbeddings OpenAIEndpointCapability = "embeddings"
@@ -1193,10 +1195,14 @@ func (a *Account) IsOpenAI() bool {
}
func (a *Account) IsOpenAILongContextBillingEnabled() bool {
if a == nil || a.Extra == nil {
if a == nil || !a.IsOpenAI() {
return false
}
enabled, ok := a.Extra["openai_long_context_billing_enabled"].(bool)
raw, exists := a.Extra[openAILongContextBillingEnabledKey]
if !exists {
return true
}
enabled, ok := raw.(bool)
return ok && enabled
}
@@ -0,0 +1,290 @@
//go:build unit
package service
import (
"context"
"net/http"
"testing"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require"
)
func TestAccountIsOpenAILongContextBillingEnabled(t *testing.T) {
tests := []struct {
name string
account *Account
want bool
}{
{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: "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},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, tt.account.IsOpenAILongContextBillingEnabled())
})
}
}
func TestNormalizeOpenAILongContextBillingExtra(t *testing.T) {
t.Run("OpenAI missing key persists enabled default", func(t *testing.T) {
extra, err := normalizeOpenAILongContextBillingExtra(PlatformOpenAI, nil)
require.NoError(t, err)
require.Equal(t, true, extra["openai_long_context_billing_enabled"])
})
t.Run("OpenAI explicit false is preserved", func(t *testing.T) {
extra, err := normalizeOpenAILongContextBillingExtra(PlatformOpenAI, map[string]any{"openai_long_context_billing_enabled": false})
require.NoError(t, err)
require.Equal(t, false, extra["openai_long_context_billing_enabled"])
})
t.Run("OpenAI malformed value is rejected", func(t *testing.T) {
_, err := normalizeOpenAILongContextBillingExtra(PlatformOpenAI, map[string]any{"openai_long_context_billing_enabled": "false"})
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
})
t.Run("non OpenAI extra is unchanged", func(t *testing.T) {
extra, err := normalizeOpenAILongContextBillingExtra(PlatformGrok, nil)
require.NoError(t, err)
require.Nil(t, extra)
})
t.Run("non OpenAI malformed value is ignored", func(t *testing.T) {
extra := map[string]any{openAILongContextBillingEnabledKey: "provider-owned"}
normalized, err := normalizeOpenAILongContextBillingExtra(PlatformAnthropic, extra)
require.NoError(t, err)
require.Equal(t, extra, normalized)
})
}
type longContextBillingRepoStub struct {
accountRepoStub
account *Account
accounts []*Account
createdAccount *Account
updateExtraCalls int
bulkUpdateCalls int
}
func (r *longContextBillingRepoStub) Create(_ context.Context, account *Account) error {
account.ID = 1
r.account = account
r.createdAccount = account
return nil
}
func (r *longContextBillingRepoStub) GetByID(_ context.Context, _ int64) (*Account, error) {
return r.account, nil
}
func (r *longContextBillingRepoStub) GetByIDs(_ context.Context, _ []int64) ([]*Account, error) {
if r.accounts != nil {
return r.accounts, nil
}
if r.account == nil {
return nil, nil
}
return []*Account{r.account}, nil
}
func (r *longContextBillingRepoStub) Update(_ context.Context, account *Account) error {
r.account = account
return nil
}
func (r *longContextBillingRepoStub) UpdateExtra(_ context.Context, _ int64, _ map[string]any) error {
r.updateExtraCalls++
return nil
}
func (r *longContextBillingRepoStub) BulkUpdate(_ context.Context, _ []int64, _ AccountBulkUpdate) (int64, error) {
r.bulkUpdateCalls++
return 1, nil
}
func TestAdminServiceCreateAccountDefaultsOpenAILongContextBillingEnabled(t *testing.T) {
repo := &longContextBillingRepoStub{}
svc := &adminServiceImpl{accountRepo: repo}
account, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
Name: "openai-account",
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "test"},
SkipDefaultGroupBind: true,
})
require.NoError(t, err)
require.Same(t, account, repo.createdAccount)
require.Equal(t, true, account.Extra[openAILongContextBillingEnabledKey])
}
func TestAdminServiceCreateAccountRejectsMalformedOpenAILongContextBillingValue(t *testing.T) {
repo := &longContextBillingRepoStub{}
svc := &adminServiceImpl{accountRepo: repo}
account, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
Platform: PlatformOpenAI,
Extra: map[string]any{openAILongContextBillingEnabledKey: "false"},
})
require.Nil(t, account)
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
require.Nil(t, repo.createdAccount)
}
func TestAdminServiceUpdateAccountPreservesOpenAILongContextBillingOptOutWhenOmitted(t *testing.T) {
repo := &longContextBillingRepoStub{account: &Account{
ID: 1,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{openAILongContextBillingEnabledKey: false},
}}
svc := &adminServiceImpl{accountRepo: repo}
account, err := svc.UpdateAccount(context.Background(), 1, &UpdateAccountInput{Extra: map[string]any{}})
require.NoError(t, err)
require.Equal(t, false, account.Extra[openAILongContextBillingEnabledKey])
}
func TestAdminServiceUpdateAccountPreservesCodexImportOptOutWhenIncomingDefaultsTrue(t *testing.T) {
repo := &longContextBillingRepoStub{account: &Account{
ID: 1,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{"access_token": "old-token"},
Extra: map[string]any{
openAILongContextBillingEnabledKey: false,
"import_source": "codex_session",
},
}}
svc := &adminServiceImpl{accountRepo: repo}
account, err := svc.UpdateAccount(context.Background(), 1, &UpdateAccountInput{
Credentials: map[string]any{"access_token": "new-token"},
Extra: map[string]any{
openAILongContextBillingEnabledKey: true,
"import_source": "codex_session",
},
})
require.NoError(t, err)
require.Equal(t, false, account.Extra[openAILongContextBillingEnabledKey])
}
func TestAdminServiceUpdateAccountAllowsExplicitOptInOutsideCodexImport(t *testing.T) {
repo := &longContextBillingRepoStub{account: &Account{
ID: 1,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{
openAILongContextBillingEnabledKey: false,
"import_source": "codex_session",
},
}}
svc := &adminServiceImpl{accountRepo: repo}
account, err := svc.UpdateAccount(context.Background(), 1, &UpdateAccountInput{Extra: map[string]any{
openAILongContextBillingEnabledKey: true,
"import_source": "codex_session",
}})
require.NoError(t, err)
require.Equal(t, true, account.Extra[openAILongContextBillingEnabledKey])
}
func TestAdminServiceUpdateAccountRejectsMalformedOpenAILongContextBillingValue(t *testing.T) {
repo := &longContextBillingRepoStub{account: &Account{ID: 1, Platform: PlatformOpenAI}}
svc := &adminServiceImpl{accountRepo: repo}
account, err := svc.UpdateAccount(context.Background(), 1, &UpdateAccountInput{Extra: map[string]any{
openAILongContextBillingEnabledKey: 1,
}})
require.Nil(t, account)
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
}
func TestAdminServiceUpdateAccountExtraRejectsMalformedOpenAILongContextBillingValue(t *testing.T) {
repo := &longContextBillingRepoStub{account: &Account{ID: 1, Platform: PlatformOpenAI}}
svc := &adminServiceImpl{accountRepo: repo}
err := svc.UpdateAccountExtra(context.Background(), 1, map[string]any{
openAILongContextBillingEnabledKey: "true",
})
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
require.Zero(t, repo.updateExtraCalls)
}
func TestAdminServiceUpdateAccountExtraAllowsProviderOwnedValueForNonOpenAIAccount(t *testing.T) {
repo := &longContextBillingRepoStub{account: &Account{ID: 1, Platform: PlatformAnthropic}}
svc := &adminServiceImpl{accountRepo: repo}
err := svc.UpdateAccountExtra(context.Background(), 1, map[string]any{
openAILongContextBillingEnabledKey: "provider-owned",
})
require.NoError(t, err)
require.Equal(t, 1, repo.updateExtraCalls)
}
func TestAdminServiceBulkUpdateAccountsRejectsMalformedOpenAILongContextBillingValue(t *testing.T) {
repo := &longContextBillingRepoStub{account: &Account{ID: 1, Platform: PlatformOpenAI}}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Extra: map[string]any{openAILongContextBillingEnabledKey: []bool{true}},
})
require.Nil(t, result)
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
require.Zero(t, repo.bulkUpdateCalls)
}
func TestAdminServiceBulkUpdateAccountsAllowsProviderOwnedValueForNonOpenAIAccounts(t *testing.T) {
repo := &longContextBillingRepoStub{account: &Account{ID: 1, Platform: PlatformGrok}}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Extra: map[string]any{openAILongContextBillingEnabledKey: []string{"provider-owned"}},
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, repo.bulkUpdateCalls)
}
func TestAdminServiceBulkUpdateAccountsRejectsMalformedValueForMixedTargetsIncludingOpenAI(t *testing.T) {
repo := &longContextBillingRepoStub{accounts: []*Account{
{ID: 1, Platform: PlatformGrok},
{ID: 2, Platform: PlatformOpenAI},
}}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1, 2},
Extra: map[string]any{openAILongContextBillingEnabledKey: "malformed"},
})
require.Nil(t, result)
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
require.Zero(t, repo.bulkUpdateCalls)
}
+103 -4
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"strconv"
"strings"
@@ -68,7 +69,74 @@ func normalizeAccountConcurrency(platform, accountType string, concurrency int)
return concurrency
}
// ValidateOpenAILongContextBillingExtra validates the OpenAI account billing flag when present.
func ValidateOpenAILongContextBillingExtra(platform string, extra map[string]any) error {
if platform != PlatformOpenAI {
return nil
}
raw, exists := extra[openAILongContextBillingEnabledKey]
if !exists {
return nil
}
if _, ok := raw.(bool); !ok {
return infraerrors.BadRequest(
"OPENAI_LONG_CONTEXT_BILLING_INVALID",
"openai_long_context_billing_enabled must be a boolean",
)
}
return nil
}
func normalizeOpenAILongContextBillingExtra(platform string, extra map[string]any) (map[string]any, error) {
if platform != PlatformOpenAI {
return extra, nil
}
if err := ValidateOpenAILongContextBillingExtra(platform, extra); err != nil {
return nil, err
}
normalized := maps.Clone(extra)
if normalized == nil {
normalized = make(map[string]any, 1)
}
_, exists := normalized[openAILongContextBillingEnabledKey]
if !exists {
normalized[openAILongContextBillingEnabledKey] = true
}
return normalized, nil
}
func normalizeOpenAILongContextBillingUpdateExtra(account *Account, input *UpdateAccountInput) (map[string]any, error) {
normalized, err := normalizeOpenAILongContextBillingExtra(account.Platform, input.Extra)
if err != nil || account.Platform != PlatformOpenAI {
return normalized, err
}
_, provided := input.Extra[openAILongContextBillingEnabledKey]
current, hasCurrent := account.Extra[openAILongContextBillingEnabledKey].(bool)
if !provided {
if hasCurrent {
normalized[openAILongContextBillingEnabledKey] = current
}
return normalized, nil
}
incoming := normalized[openAILongContextBillingEnabledKey].(bool)
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
}
func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) {
accountExtra, err := normalizeOpenAILongContextBillingExtra(input.Platform, input.Extra)
if err != nil {
return nil, err
}
// 绑定分组
groupIDs := input.GroupIDs
// 如果没有指定分组,自动绑定对应平台的默认分组
@@ -103,7 +171,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
Platform: input.Platform,
Type: input.Type,
Credentials: input.Credentials,
Extra: input.Extra,
Extra: accountExtra,
ProxyID: input.ProxyID,
Concurrency: normalizeAccountConcurrency(input.Platform, input.Type, input.Concurrency),
Priority: input.Priority,
@@ -183,6 +251,13 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if err != nil {
return nil, err
}
var normalizedExtra map[string]any
if input.Extra != nil {
normalizedExtra, err = normalizeOpenAILongContextBillingUpdateExtra(account, input)
if err != nil {
return nil, err
}
}
// 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用,
// 必须在此守住,否则仅在创建时的保证可被这些路径绕过。
if account.IsCredentialShadow() {
@@ -238,10 +313,10 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
// 保留配额用量字段,防止编辑账号时意外重置
for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} {
if v, ok := account.Extra[key]; ok {
input.Extra[key] = v
normalizedExtra[key] = v
}
}
account.Extra = input.Extra
account.Extra = normalizedExtra
if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() {
delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
// 清除 AICredits 限流 key
@@ -353,6 +428,15 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
// UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键
// (如 model_rate_limits / passive_usage_* 等)。
func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error {
if _, exists := updates[openAILongContextBillingEnabledKey]; exists {
account, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
return err
}
if err := ValidateOpenAILongContextBillingExtra(account.Platform, updates); err != nil {
return err
}
}
if len(updates) == 0 {
return nil
}
@@ -386,16 +470,28 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
}
needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck
_, hasLongContextBillingUpdate := input.Extra[openAILongContextBillingEnabledKey]
// 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。
var cachedTargets []*Account
if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck {
if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || hasLongContextBillingUpdate {
loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
if err != nil {
return nil, err
}
cachedTargets = loaded
}
if hasLongContextBillingUpdate {
for _, account := range cachedTargets {
if account == nil || account.Platform != PlatformOpenAI {
continue
}
if err := ValidateOpenAILongContextBillingExtra(account.Platform, input.Extra); err != nil {
return nil, err
}
break
}
}
// 影子账号绝不持有凭据:批量更新携带凭据时,目标中不得含影子(外审 G5,与单账号
// UpdateAccount 守卫对齐)。覆盖显式 IDs 与 filter 解析出的 IDs(此处 AccountIDs 已解析完成)。
@@ -745,6 +841,9 @@ func (s *adminServiceImpl) CreateShadow(ctx context.Context, parentID int64, opt
Priority: priority,
Concurrency: concurrency,
Schedulable: true,
Extra: map[string]any{
openAILongContextBillingEnabledKey: parent.IsOpenAILongContextBillingEnabled(),
},
}
// 5. 持久化(Create 填充 shadow.ID)。并发竞态:预查(步骤2)放行后另一请求抢先建成,本次会撞
@@ -157,6 +157,39 @@ func TestCreateShadow(t *testing.T) {
require.Error(t, err)
}
func TestCreateShadowInheritsParentEffectiveOpenAILongContextBillingValue(t *testing.T) {
tests := []struct {
name string
parentExtra map[string]any
want bool
}{
{name: "missing parent value defaults enabled", want: true},
{name: "explicit parent opt-out is inherited", parentExtra: map[string]any{openAILongContextBillingEnabledKey: false}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "parent",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{"access_token": "token"},
Extra: tt.parentExtra,
}
require.NoError(t, repo.Create(context.Background(), parent))
shadow, err := svc.CreateShadow(context.Background(), parent.ID, ShadowOptions{Name: "shadow"})
require.NoError(t, err)
require.Equal(t, tt.want, shadow.Extra[openAILongContextBillingEnabledKey])
require.Equal(t, tt.want, shadow.IsOpenAILongContextBillingEnabled())
})
}
}
// TestCreateShadow_BindGroups は BindGroups の後置呼び出しを検証する。
// 影子账号が指定グループに属し、ListSchedulableByGroupID で取得可能であること。
func TestCreateShadow_BindGroups(t *testing.T) {
@@ -0,0 +1,163 @@
//go:build unit
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
type crsLongContextAccountRepo struct {
AccountRepository
accounts map[string]*Account
nextID int64
}
type crsOpenAILongContextSource struct {
collection string
credentials map[string]any
extra map[string]any
}
func newCRSLongContextAccountRepo(existing ...*Account) *crsLongContextAccountRepo {
repo := &crsLongContextAccountRepo{accounts: make(map[string]*Account)}
for _, account := range existing {
if account == nil {
continue
}
crsID, _ := account.Extra["crs_account_id"].(string)
repo.accounts[crsID] = account
if account.ID > repo.nextID {
repo.nextID = account.ID
}
}
return repo
}
func (r *crsLongContextAccountRepo) Create(_ context.Context, account *Account) error {
r.nextID++
account.ID = r.nextID
crsID, _ := account.Extra["crs_account_id"].(string)
r.accounts[crsID] = account
return nil
}
func (r *crsLongContextAccountRepo) Update(_ context.Context, account *Account) error {
crsID, _ := account.Extra["crs_account_id"].(string)
r.accounts[crsID] = account
return nil
}
func (r *crsLongContextAccountRepo) GetByCRSAccountID(_ context.Context, crsID string) (*Account, error) {
return r.accounts[crsID], nil
}
func (r *crsLongContextAccountRepo) ListShadowsByParent(_ context.Context, _ int64) ([]*Account, error) {
return nil, nil
}
func TestCRSSyncOpenAILongContextBilling(t *testing.T) {
tests := []struct {
name string
collection string
credentials map[string]any
sourceExtra map[string]any
existingExtra map[string]any
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 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 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 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 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 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 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"},
{name: "API key update rejects malformed source value", collection: "openaiResponsesAccounts", credentials: map[string]any{"api_key": "sk-test"}, sourceExtra: map[string]any{openAILongContextBillingEnabledKey: "false"}, existingExtra: map[string]any{openAILongContextBillingEnabledKey: true}, wantAction: "failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
const crsID = "crs-openai-1"
var existing *Account
if tt.existingExtra != nil {
existingExtra := mergeMap(tt.existingExtra, map[string]any{"crs_account_id": crsID})
accountType := AccountTypeOAuth
if tt.collection == "openaiResponsesAccounts" {
accountType = AccountTypeAPIKey
}
existing = &Account{ID: 41, Platform: PlatformOpenAI, Type: accountType, Extra: existingExtra}
}
repo := newCRSLongContextAccountRepo(existing)
result := runCRSOpenAILongContextSync(t, repo, crsOpenAILongContextSource{
collection: tt.collection,
credentials: tt.credentials,
extra: tt.sourceExtra,
})
require.Len(t, result.Items, 1)
require.Equal(t, tt.wantAction, result.Items[0].Action)
if tt.wantAction == "failed" {
require.Contains(t, result.Items[0].Error, "openai_long_context_billing_enabled must be a boolean")
return
}
stored, ok := repo.accounts[crsID].Extra[openAILongContextBillingEnabledKey]
require.True(t, ok)
require.Equal(t, tt.wantEnabled, stored)
})
}
}
func runCRSOpenAILongContextSync(t *testing.T, repo AccountRepository, source crsOpenAILongContextSource) *SyncFromCRSResult {
t.Helper()
account := map[string]any{
"kind": "openai",
"id": "crs-openai-1",
"name": "OpenAI CRS",
"isActive": true,
"schedulable": true,
"credentials": source.credentials,
}
if source.extra != nil {
account["extra"] = source.extra
}
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Type", "application/json")
if request.URL.Path == "/web/auth/login" {
_, _ = response.Write([]byte(`{"success":true,"token":"admin-token"}`))
return
}
require.Equal(t, "/admin/sync/export-accounts", request.URL.Path)
require.NoError(t, json.NewEncoder(response).Encode(map[string]any{
"success": true,
"data": map[string]any{source.collection: []any{account}},
}))
}))
t.Cleanup(server.Close)
cfg := &config.Config{}
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
service := NewCRSSyncService(repo, nil, nil, nil, nil, cfg)
result, err := service.SyncFromCRS(context.Background(), SyncFromCRSInput{
BaseURL: server.URL,
Username: "admin",
Password: "password",
})
require.NoError(t, err)
return result
}
+37 -6
View File
@@ -168,6 +168,7 @@ type crsOpenAIResponsesAccount struct {
Status string `json:"status"`
Proxy *crsProxy `json:"proxy"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
}
type crsOpenAIOAuthAccount struct {
@@ -632,6 +633,18 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
result.Items = append(result.Items, item)
continue
}
var existingExtra map[string]any
if existing != nil {
existingExtra = existing.Extra
}
extra, err = mergeCRSOpenAILongContextBillingExtra(existingExtra, extra)
if err != nil {
item.Action = "failed"
item.Error = err.Error()
result.Failed++
result.Items = append(result.Items, item)
continue
}
if existing == nil {
if !shouldCreateAccount(src.ID, selectedSet) {
@@ -670,7 +683,7 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
continue
}
existing.Extra = mergeMap(existing.Extra, extra)
existing.Extra = extra
existing.Name = defaultName(src.Name, src.ID)
existing.Platform = PlatformOpenAI
existing.Type = AccountTypeOAuth
@@ -751,11 +764,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
concurrency := 3
status := mapCRSStatus(src.IsActive, src.Status)
extra := map[string]any{
"crs_account_id": src.ID,
"crs_kind": src.Kind,
"crs_synced_at": now,
extra := make(map[string]any, len(src.Extra)+3)
for key, value := range src.Extra {
extra[key] = value
}
extra["crs_account_id"] = src.ID
extra["crs_kind"] = src.Kind
extra["crs_synced_at"] = now
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
if err != nil {
@@ -765,6 +780,18 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
result.Items = append(result.Items, item)
continue
}
var existingExtra map[string]any
if existing != nil {
existingExtra = existing.Extra
}
extra, err = mergeCRSOpenAILongContextBillingExtra(existingExtra, extra)
if err != nil {
item.Action = "failed"
item.Error = err.Error()
result.Failed++
result.Items = append(result.Items, item)
continue
}
if existing == nil {
if !shouldCreateAccount(src.ID, selectedSet) {
@@ -809,7 +836,7 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
continue
}
existing.Extra = mergeMap(existing.Extra, extra)
existing.Extra = extra
existing.Name = defaultName(src.Name, src.ID)
existing.Platform = PlatformOpenAI
existing.Type = AccountTypeAPIKey
@@ -1098,6 +1125,10 @@ func mergeMap(existing map[string]any, updates map[string]any) map[string]any {
return out
}
func mergeCRSOpenAILongContextBillingExtra(existing, updates map[string]any) (map[string]any, error) {
return normalizeOpenAILongContextBillingExtra(PlatformOpenAI, mergeMap(existing, updates))
}
func (s *CRSSyncService) mapOrCreateProxy(ctx context.Context, enabled bool, cached *[]Proxy, src *crsProxy, defaultName string) (*int64, error) {
if !enabled || src == nil {
return nil, nil
@@ -39,6 +39,17 @@ type openAIRecordUsageBillingRepoStub struct {
lastCtxErr error
}
type openAIRecordUsageAccountRepoStub struct {
AccountRepository
account *Account
calls int
}
func (s *openAIRecordUsageAccountRepoStub) GetByID(_ context.Context, _ int64) (*Account, error) {
s.calls++
return s.account, nil
}
func (s *openAIRecordUsageBillingRepoStub) Apply(ctx context.Context, cmd *UsageBillingCommand) (*UsageBillingApplyResult, error) {
s.calls++
s.lastCmd = cmd
@@ -1045,7 +1056,7 @@ func TestOpenAIGatewayServiceRecordUsage_GPT56SeparatesCacheWriteForBillingAndSt
require.InDelta(t, usageRepo.lastLog.TotalCost*1.1, usageRepo.lastLog.ActualCost, 1e-12)
}
func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledByDefault(t *testing.T) {
func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledByDefault(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
subRepo := &openAIRecordUsageSubRepoStub{}
@@ -1063,44 +1074,7 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledByDefaul
},
APIKey: &APIKey{ID: 1014},
User: &User{ID: 2014},
Account: &Account{ID: 3014},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
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.False(t, usageRepo.lastLog.LongContextBillingApplied)
require.Equal(t, 1, userRepo.deductCalls)
}
func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccount(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
subRepo := &openAIRecordUsageSubRepoStub{}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_gpt54_long_context_enabled",
Usage: OpenAIUsage{
InputTokens: 300000,
OutputTokens: 2000,
},
Model: "gpt-5.4-2026-03-05",
Duration: time.Second,
},
APIKey: &APIKey{ID: 1015},
User: &User{ID: 2015},
Account: &Account{
ID: 3015,
Extra: map[string]any{"openai_long_context_billing_enabled": true},
},
Account: &Account{ID: 3014, Platform: PlatformOpenAI},
})
require.NoError(t, err)
@@ -1113,6 +1087,99 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccoun
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.Equal(t, 1, userRepo.deductCalls)
}
func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledPerAccount(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
subRepo := &openAIRecordUsageSubRepoStub{}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_gpt54_long_context_disabled",
Usage: OpenAIUsage{
InputTokens: 300000,
OutputTokens: 2000,
},
Model: "gpt-5.4-2026-03-05",
Duration: time.Second,
},
APIKey: &APIKey{ID: 1015},
User: &User{ID: 2015},
Account: &Account{
ID: 3015,
Platform: PlatformOpenAI,
Extra: map[string]any{"openai_long_context_billing_enabled": false},
},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
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.False(t, usageRepo.lastLog.LongContextBillingApplied)
}
func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSetting(t *testing.T) {
tests := []struct {
name string
parentEnabled bool
}{
{name: "parent opt out overrides stale enabled shadow", parentEnabled: false},
{name: "parent opt in overrides stale disabled shadow", parentEnabled: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
accountRepo := &openAIRecordUsageAccountRepoStub{account: &Account{
ID: 4016,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{openAILongContextBillingEnabledKey: tt.parentEnabled},
}}
svc := newOpenAIRecordUsageServiceForTest(
usageRepo,
&openAIRecordUsageUserRepoStub{},
&openAIRecordUsageSubRepoStub{},
nil,
)
svc.accountRepo = accountRepo
parentID := int64(4016)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_gpt54_shadow_parent_setting",
Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000},
Model: "gpt-5.4-2026-03-05",
Duration: time.Second,
},
APIKey: &APIKey{ID: 1016},
User: &User{ID: 2016},
Account: &Account{
ID: 3016,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
ParentAccountID: &parentID,
QuotaDimension: QuotaDimensionSpark,
Extra: map[string]any{
openAILongContextBillingEnabledKey: !tt.parentEnabled,
},
},
})
require.NoError(t, err)
require.Equal(t, 1, accountRepo.calls)
require.Equal(t, tt.parentEnabled, usageRepo.lastLog.LongContextBillingApplied)
})
}
}
func TestOpenAIGatewayServiceRecordUsage_ServiceTierPriorityUsesFastPricing(t *testing.T) {
@@ -178,7 +178,14 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if result.ServiceTier != nil {
serviceTier = strings.TrimSpace(*result.ServiceTier)
}
longContextBillingEnabled := account.IsOpenAILongContextBillingEnabled()
billingAccount := account
if account.IsShadow() {
billingAccount, err = resolveCredentialAccount(ctx, s.accountRepo, account)
if err != nil {
return err
}
}
longContextBillingEnabled := billingAccount.IsOpenAILongContextBillingEnabled()
cost, err = s.calculateOpenAIRecordUsageCost(
ctx,
result,
@@ -0,0 +1,162 @@
-- Keep mixed-version writers consistent before backfilling rows that already exist.
CREATE OR REPLACE FUNCTION public.enforce_openai_long_context_billing_extra()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
parent_effective_value JSONB;
BEGIN
IF NEW.platform IS DISTINCT FROM 'openai' THEN
RETURN NEW;
END IF;
NEW.extra := COALESCE(NEW.extra, '{}'::jsonb);
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 jsonb_typeof(parent.extra->'openai_long_context_billing_enabled') = 'boolean'
THEN parent.extra->'openai_long_context_billing_enabled'
ELSE 'false'::jsonb
END
INTO parent_effective_value
FROM accounts AS parent
WHERE parent.id = NEW.parent_account_id;
NEW.extra := jsonb_set(
NEW.extra,
'{openai_long_context_billing_enabled}',
COALESCE(parent_effective_value, 'false'::jsonb),
true
);
ELSIF NOT (NEW.extra ? 'openai_long_context_billing_enabled')
AND TG_OP = 'UPDATE'
AND OLD.platform = 'openai'
AND jsonb_typeof(OLD.extra->'openai_long_context_billing_enabled') = 'boolean' THEN
NEW.extra := jsonb_set(
NEW.extra,
'{openai_long_context_billing_enabled}',
OLD.extra->'openai_long_context_billing_enabled',
true
);
ELSIF NOT (NEW.extra ? 'openai_long_context_billing_enabled') THEN
NEW.extra := jsonb_set(
NEW.extra,
'{openai_long_context_billing_enabled}',
'true'::jsonb,
true
);
END IF;
IF jsonb_typeof(NEW.extra->'openai_long_context_billing_enabled') IS DISTINCT FROM 'boolean' THEN
RAISE EXCEPTION 'openai_long_context_billing_enabled must be a boolean'
USING ERRCODE = '22023';
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION public.propagate_openai_long_context_billing_extra_to_shadows()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
WITH updated_shadows AS (
UPDATE accounts AS shadow
SET extra = jsonb_set(
COALESCE(shadow.extra, '{}'::jsonb),
'{openai_long_context_billing_enabled}',
NEW.extra->'openai_long_context_billing_enabled',
true
)
WHERE shadow.parent_account_id = NEW.id
AND shadow.platform = 'openai'
AND shadow.quota_dimension = 'spark'
AND shadow.extra->'openai_long_context_billing_enabled'
IS DISTINCT FROM NEW.extra->'openai_long_context_billing_enabled'
RETURNING shadow.id
)
INSERT INTO scheduler_outbox (event_type, account_id)
SELECT 'account_changed', id
FROM updated_shadows;
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS accounts_enforce_openai_long_context_billing_extra ON accounts;
CREATE TRIGGER accounts_enforce_openai_long_context_billing_extra
BEFORE INSERT OR UPDATE OF platform, extra, parent_account_id, quota_dimension
ON accounts
FOR EACH ROW
EXECUTE FUNCTION public.enforce_openai_long_context_billing_extra();
DROP TRIGGER IF EXISTS accounts_propagate_openai_long_context_billing_extra ON accounts;
CREATE TRIGGER accounts_propagate_openai_long_context_billing_extra
AFTER UPDATE OF platform, extra
ON accounts
FOR EACH ROW
WHEN (
NEW.platform = 'openai'
AND NEW.parent_account_id IS NULL
AND (
OLD.platform IS DISTINCT FROM NEW.platform
OR OLD.extra->'openai_long_context_billing_enabled'
IS DISTINCT FROM NEW.extra->'openai_long_context_billing_enabled'
)
)
EXECUTE FUNCTION public.propagate_openai_long_context_billing_extra_to_shadows();
UPDATE accounts
SET extra = jsonb_set(
COALESCE(extra, '{}'::jsonb),
'{openai_long_context_billing_enabled}',
'false'::jsonb,
true
)
WHERE platform = 'openai'
AND COALESCE(extra, '{}'::jsonb) ? 'openai_long_context_billing_enabled'
AND jsonb_typeof(extra->'openai_long_context_billing_enabled') IS DISTINCT FROM 'boolean';
UPDATE accounts
SET extra = jsonb_set(
COALESCE(extra, '{}'::jsonb),
'{openai_long_context_billing_enabled}',
'true'::jsonb,
true
)
WHERE platform = 'openai'
AND parent_account_id IS NULL
AND NOT (COALESCE(extra, '{}'::jsonb) ? 'openai_long_context_billing_enabled');
WITH shadow_values AS (
SELECT
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 jsonb_typeof(parent.extra->'openai_long_context_billing_enabled') = 'boolean'
THEN parent.extra->'openai_long_context_billing_enabled'
ELSE 'false'::jsonb
END AS effective_value
FROM accounts AS shadow
JOIN accounts AS parent ON parent.id = shadow.parent_account_id
WHERE shadow.platform = 'openai'
AND shadow.quota_dimension = 'spark'
),
updated_shadows AS (
UPDATE accounts AS shadow
SET extra = jsonb_set(
COALESCE(shadow.extra, '{}'::jsonb),
'{openai_long_context_billing_enabled}',
shadow_values.effective_value,
true
)
FROM shadow_values
WHERE shadow.id = shadow_values.id
AND shadow.extra->'openai_long_context_billing_enabled'
IS DISTINCT FROM shadow_values.effective_value
RETURNING shadow.id
)
INSERT INTO scheduler_outbox (event_type, account_id)
SELECT 'account_changed', id
FROM updated_shadows;
@@ -0,0 +1,36 @@
package migrations
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestMigration175DefaultsOrdinaryOpenAIAndInheritsForSparkShadows(t *testing.T) {
content, err := FS.ReadFile("175_default_openai_long_context_billing.sql")
require.NoError(t, err)
sql := string(content)
require.Contains(t, sql, "parent_account_id IS NULL")
require.Contains(t, sql, "quota_dimension = 'spark'")
require.Contains(t, sql, "parent.extra")
require.Contains(t, sql, "jsonb_typeof")
require.Contains(t, sql, "openai_long_context_billing_enabled")
}
func TestMigration175GuardsMixedVersionAccountWrites(t *testing.T) {
content, err := FS.ReadFile("175_default_openai_long_context_billing.sql")
require.NoError(t, err)
sql := string(content)
require.Contains(t, sql, "RETURNS TRIGGER")
require.Contains(t, sql, "BEFORE INSERT OR UPDATE")
require.Contains(t, sql, "CREATE TRIGGER")
require.Contains(t, sql, "must be a boolean")
require.Contains(t, sql, "INSERT INTO scheduler_outbox")
require.Contains(t, sql, "'account_changed'")
require.Contains(t, sql, "jsonb_typeof(extra->'openai_long_context_billing_enabled') IS DISTINCT FROM 'boolean'")
require.Contains(t, sql, "WITH shadow_values AS")
require.Contains(t, sql, "TG_OP = 'UPDATE'")
require.Contains(t, sql, "OLD.extra->'openai_long_context_billing_enabled'")
}
@@ -3708,7 +3708,7 @@ const fillHeaderOverrideTemplate = () => {
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false)
const openAILongContextBillingEnabled = ref(false)
const openAILongContextBillingEnabled = ref(true)
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
@@ -4563,7 +4563,7 @@ const resetForm = () => {
interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false
openAILongContextBillingEnabled.value = false
openAILongContextBillingEnabled.value = true
openAICompactMode.value = 'auto'
openAIResponsesMode.value = 'auto'
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
@@ -4646,11 +4646,7 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
delete extra.openai_passthrough
delete extra.openai_oauth_passthrough
}
if (openAILongContextBillingEnabled.value) {
extra.openai_long_context_billing_enabled = true
} else {
delete extra.openai_long_context_billing_enabled
}
extra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value
if (accountCategory.value === 'oauth-based' && codexCLIOnlyEnabled.value) {
extra.codex_cli_only = true
@@ -4686,6 +4682,15 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
return Object.keys(extra).length > 0 ? extra : undefined
}
const buildOpenAICodexImportExtra = (): Record<string, unknown> | undefined => {
const extra = buildOpenAIExtra()
if (!extra) {
return undefined
}
delete extra.openai_long_context_billing_enabled
return Object.keys(extra).length > 0 ? extra : undefined
}
const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unknown> | undefined => {
if (form.platform !== 'anthropic' || accountCategory.value !== 'apikey') {
return base
@@ -5371,7 +5376,7 @@ const handleOpenAIImportCodexSession = async (content: string) => {
oauthClient.error.value = ''
try {
const extra = buildOpenAIExtra()
const extra = buildOpenAICodexImportExtra()
const result = await adminAPI.accounts.importCodexSession({
content: trimmed,
name: form.name,
@@ -5449,7 +5454,7 @@ const handleOpenAIImportCodexPAT = async (accessToken: string) => {
oauthClient.error.value = ''
try {
const extra = buildOpenAIExtra()
const extra = buildOpenAICodexImportExtra()
await adminAPI.accounts.createOpenAICodexPAT({
access_token: trimmed,
name: form.name,
@@ -2794,7 +2794,7 @@ const customBaseUrl = ref('')
// OpenAI 自动透传开关(OAuth/API Key)
const openaiPassthroughEnabled = ref(false)
const openAILongContextBillingEnabled = ref(false)
const openAILongContextBillingEnabled = ref(true)
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
@@ -3222,7 +3222,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
openaiPassthroughEnabled.value = false
openAILongContextBillingEnabled.value = false
openAILongContextBillingEnabled.value = true
openAICompactMode.value = 'auto'
openAIResponsesMode.value = 'auto'
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
@@ -3237,7 +3237,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
webSearchEmulationMode.value = 'default'
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
openAILongContextBillingEnabled.value = extra?.openai_long_context_billing_enabled === true
const longContextBillingValue = extra?.openai_long_context_billing_enabled
openAILongContextBillingEnabled.value = longContextBillingValue === undefined || longContextBillingValue === true
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
if (newAccount.type === 'apikey') {
openAIResponsesMode.value = normalizeOpenAIResponsesMode(extra?.openai_responses_mode)
@@ -0,0 +1,178 @@
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
createAccountMock,
importCodexSessionMock,
createOpenAICodexPATMock,
} = vi.hoisted(() => ({
createAccountMock: vi.fn(),
importCodexSessionMock: vi.fn(),
createOpenAICodexPATMock: vi.fn(),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showWarning: vi.fn(),
}),
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({ isSimpleMode: true }),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
create: createAccountMock,
checkMixedChannelRisk: vi.fn().mockResolvedValue({ has_risk: false }),
importCodexSession: importCodexSessionMock,
createOpenAICodexPAT: createOpenAICodexPATMock,
},
settings: {
getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }),
getSettings: vi.fn().mockResolvedValue({}),
},
tlsFingerprintProfiles: {
list: vi.fn().mockResolvedValue([]),
},
},
}))
vi.mock('@/api/admin/accounts', () => ({
getAntigravityDefaultModelMapping: vi.fn().mockResolvedValue([]),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
import CreateAccountModal from '../CreateAccountModal.vue'
const BaseDialogStub = defineComponent({
name: 'BaseDialog',
props: { show: { type: Boolean, default: false } },
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
})
const OAuthAuthorizationFlowStub = defineComponent({
name: 'OAuthAuthorizationFlow',
emits: ['import-codex-session', 'import-codex-pat'],
template: `
<div>
<button data-testid="import-codex-session" @click="$emit('import-codex-session', 'session-json')">session</button>
<button data-testid="import-codex-pat" @click="$emit('import-codex-pat', 'pat-token')">pat</button>
</div>
`,
})
function mountModal() {
return mount(CreateAccountModal, {
props: { show: true, proxies: [], groups: [] },
global: {
stubs: {
BaseDialog: BaseDialogStub,
OAuthAuthorizationFlow: OAuthAuthorizationFlowStub,
ConfirmDialog: true,
Select: true,
Icon: true,
PlatformIcon: true,
ProxySelector: true,
ProxyAdBanner: true,
GroupSelector: true,
ModelWhitelistSelector: true,
QuotaLimitCard: true,
},
},
})
}
async function selectButtonByText(wrapper: ReturnType<typeof mountModal>, text: string) {
const button = wrapper.findAll('button').find((candidate) => candidate.text().includes(text))
expect(button).toBeDefined()
await button?.trigger('click')
}
async function submitApiKeyAccount(platform: 'openai' | 'anthropic', disableLongContextBilling = false) {
const wrapper = mountModal()
await selectButtonByText(wrapper, platform === 'openai' ? 'OpenAI' : 'admin.accounts.claudeConsole')
if (platform === 'openai') {
await selectButtonByText(wrapper, 'API Key')
}
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) {
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() {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
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
}
describe('CreateAccountModal OpenAI long-context billing', () => {
beforeEach(() => {
createAccountMock.mockReset().mockResolvedValue({})
importCodexSessionMock.mockReset().mockResolvedValue({
created: 1,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
warnings: [],
})
createOpenAICodexPATMock.mockReset().mockResolvedValue({})
})
it('sends true 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)
})
it('sends false explicitly when OpenAI long-context billing is disabled', async () => {
await submitApiKeyAccount('openai', true)
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('omits the OpenAI setting for non-OpenAI account creation', async () => {
await submitApiKeyAccount('anthropic')
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined()
})
it('leaves Codex session import billing ownership to the backend', async () => {
const wrapper = await openCodexImportStep()
await wrapper.get('[data-testid="import-codex-session"]').trigger('click')
await flushPromises()
expect(importCodexSessionMock).toHaveBeenCalledTimes(1)
expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined()
})
it('leaves Codex PAT import billing ownership to the backend', async () => {
const wrapper = await openCodexImportStep()
await wrapper.get('[data-testid="import-codex-pat"]').trigger('click')
await flushPromises()
expect(createOpenAICodexPATMock).toHaveBeenCalledTimes(1)
expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined()
})
})
@@ -416,6 +416,63 @@ 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 () => {
const account = buildAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const toggle = wrapper.get('[data-testid="openai-long-context-billing-toggle"]')
expect(toggle.attributes('aria-checked')).toBe('true')
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)
})
it('preserves an explicit OpenAI long-context billing opt-out', async () => {
const account = buildAccount()
account.extra = {
openai_long_context_billing_enabled: false
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const toggle = wrapper.get('[data-testid="openai-long-context-billing-toggle"]')
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(false)
})
it('fails closed for malformed OpenAI long-context billing values', async () => {
const account = buildAccount()
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.get('[data-testid="openai-long-context-billing-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(false)
})
it('loads and submits Grok OAuth model mapping edits', async () => {
const account = buildGrokOAuthAccount()
updateAccountMock.mockReset()
@@ -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:
'Disabled by default. Enable only when this account\'s upstream charges OpenAI API long-context rates above the model threshold.',
'Enabled by default. Disable only when this account\'s upstream does not charge 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 协议能力(受网关全局开关与账号类型开关约束)。',