mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
fix(billing): 默认开启 OpenAI 长上下文计费
This commit is contained in:
@@ -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
|
||||
|
||||
+159
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 协议能力(受网关全局开关与账号类型开关约束)。',
|
||||
|
||||
Reference in New Issue
Block a user