mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-01 15:02:58 +08:00
Merge pull request #5721 from lyy0709/codex/bulk-openai-settings
fix(openai): complete bulk account settings
This commit is contained in:
@@ -258,18 +258,20 @@ func TestAdminServiceBulkUpdateAccountsRejectsMalformedOpenAILongContextBillingV
|
||||
require.Zero(t, repo.bulkUpdateCalls)
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccountsAllowsProviderOwnedValueForNonOpenAIAccounts(t *testing.T) {
|
||||
func TestAdminServiceBulkUpdateAccountsRejectsOpenAILongContextKeyForNonOpenAIAccounts(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"}},
|
||||
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 1, repo.bulkUpdateCalls)
|
||||
require.Nil(t, result)
|
||||
var appErr *infraerrors.ApplicationError
|
||||
require.ErrorAs(t, err, &appErr)
|
||||
require.Equal(t, "OPENAI_BULK_TARGET_INVALID", appErr.Reason)
|
||||
require.Zero(t, repo.bulkUpdateCalls)
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccountsRejectsMalformedValueForMixedTargetsIncludingOpenAI(t *testing.T) {
|
||||
|
||||
@@ -915,26 +915,36 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
openAISettings, err := normalizeBulkOpenAISettings(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck
|
||||
_, hasLongContextBillingUpdate := input.Extra[openAILongContextBillingEnabledKey]
|
||||
|
||||
// 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。
|
||||
var cachedTargets []*Account
|
||||
if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || hasLongContextBillingUpdate || input.ProbeEnabled != nil || input.RateMultiplier != nil {
|
||||
if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || openAISettings.any() || input.ProbeEnabled != nil || input.RateMultiplier != nil {
|
||||
loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cachedTargets = loaded
|
||||
}
|
||||
if input.ProbeEnabled != nil {
|
||||
targetsByID := make(map[int64]*Account, len(cachedTargets))
|
||||
for _, account := range cachedTargets {
|
||||
if account != nil {
|
||||
targetsByID[account.ID] = account
|
||||
}
|
||||
targetsByID := make(map[int64]*Account, len(cachedTargets))
|
||||
for _, account := range cachedTargets {
|
||||
if account != nil {
|
||||
targetsByID[account.ID] = account
|
||||
}
|
||||
}
|
||||
if openAISettings.any() {
|
||||
inheritedCount, err := validateBulkOpenAISettingsTargets(input, openAISettings, targetsByID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.LongContextInheritedCount = inheritedCount
|
||||
}
|
||||
if input.ProbeEnabled != nil {
|
||||
for _, accountID := range input.AccountIDs {
|
||||
account, ok := targetsByID[accountID]
|
||||
if !ok {
|
||||
@@ -945,18 +955,6 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
|
||||
}
|
||||
}
|
||||
}
|
||||
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 已解析完成)。
|
||||
if len(input.Credentials) > 0 {
|
||||
|
||||
@@ -477,11 +477,12 @@ type UserGroupRPMStatus struct {
|
||||
|
||||
// BulkUpdateAccountsResult is the aggregated response for bulk updates.
|
||||
type BulkUpdateAccountsResult struct {
|
||||
Success int `json:"success"`
|
||||
Failed int `json:"failed"`
|
||||
SuccessIDs []int64 `json:"success_ids"`
|
||||
FailedIDs []int64 `json:"failed_ids"`
|
||||
Results []BulkUpdateAccountResult `json:"results"`
|
||||
Success int `json:"success"`
|
||||
Failed int `json:"failed"`
|
||||
SuccessIDs []int64 `json:"success_ids"`
|
||||
FailedIDs []int64 `json:"failed_ids"`
|
||||
Results []BulkUpdateAccountResult `json:"results"`
|
||||
LongContextInheritedCount int `json:"long_context_inherited_count,omitempty"`
|
||||
}
|
||||
|
||||
type CreateProxyInput struct {
|
||||
|
||||
@@ -18,6 +18,8 @@ type accountRepoStubForBulkUpdate struct {
|
||||
accountRepoStub
|
||||
bulkUpdateErr error
|
||||
bulkUpdateIDs []int64
|
||||
bulkUpdateCalls int
|
||||
lastBulkUpdate AccountBulkUpdate
|
||||
bindGroupErrByID map[int64]error
|
||||
bindGroupsCalls []int64
|
||||
bindGroupsByAccount map[int64][]int64
|
||||
@@ -50,14 +52,23 @@ type accountRepoStubForBulkUpdate struct {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *accountRepoStubForBulkUpdate) BulkUpdate(_ context.Context, ids []int64, _ AccountBulkUpdate) (int64, error) {
|
||||
func (s *accountRepoStubForBulkUpdate) BulkUpdate(_ context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) {
|
||||
s.bulkUpdateCalls++
|
||||
s.bulkUpdateIDs = append([]int64{}, ids...)
|
||||
s.lastBulkUpdate = updates
|
||||
if s.bulkUpdateErr != nil {
|
||||
return 0, s.bulkUpdateErr
|
||||
}
|
||||
return int64(len(ids)), nil
|
||||
}
|
||||
|
||||
func requireApplicationErrorReason(t *testing.T, err error, reason string) {
|
||||
t.Helper()
|
||||
var appErr *infraerrors.ApplicationError
|
||||
require.ErrorAs(t, err, &appErr)
|
||||
require.Equal(t, reason, appErr.Reason)
|
||||
}
|
||||
|
||||
func (s *accountRepoStubForBulkUpdate) Create(_ context.Context, account *Account) error {
|
||||
s.createAccount = account
|
||||
if s.createID > 0 {
|
||||
@@ -307,3 +318,280 @@ func TestAdminServiceBulkUpdateAccounts_ResolvesIDsFromFilters(t *testing.T) {
|
||||
require.Equal(t, 0, result.Failed)
|
||||
require.Equal(t, []int64{7, 11}, result.SuccessIDs)
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccounts_NormalizesOpenAISettings(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
}}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1, 2},
|
||||
Credentials: map[string]any{
|
||||
openAIEndpointCapabilitiesCredentialKey: []any{"chat_completions", "embeddings"},
|
||||
},
|
||||
Extra: map[string]any{
|
||||
openAILongContextBillingEnabledKey: true,
|
||||
"openai_responses_mode": "auto",
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, result.Success)
|
||||
require.Zero(t, result.LongContextInheritedCount)
|
||||
require.Equal(t, 1, repo.bulkUpdateCalls)
|
||||
require.Contains(t, repo.lastBulkUpdate.Credentials, openAIEndpointCapabilitiesCredentialKey)
|
||||
require.Nil(t, repo.lastBulkUpdate.Credentials[openAIEndpointCapabilitiesCredentialKey])
|
||||
require.Equal(t, true, repo.lastBulkUpdate.Extra[openAILongContextBillingEnabledKey])
|
||||
require.Contains(t, repo.lastBulkUpdate.Extra, "openai_responses_mode")
|
||||
require.Nil(t, repo.lastBulkUpdate.Extra["openai_responses_mode"])
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccounts_AcceptsLongContextAccountTypes(t *testing.T) {
|
||||
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey} {
|
||||
t.Run(accountType, func(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{
|
||||
ID: 1, Platform: PlatformOpenAI, Type: accountType,
|
||||
}}}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
Extra: map[string]any{openAILongContextBillingEnabledKey: false},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, result.Success)
|
||||
require.Equal(t, 1, repo.bulkUpdateCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccounts_EmbeddingsOnlyResetsResponsesMode(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
}}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
_, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
Credentials: map[string]any{
|
||||
openAIEndpointCapabilitiesCredentialKey: []string{"embeddings"},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"embeddings"}, repo.lastBulkUpdate.Credentials[openAIEndpointCapabilitiesCredentialKey])
|
||||
require.Contains(t, repo.lastBulkUpdate.Extra, "openai_responses_mode")
|
||||
require.Nil(t, repo.lastBulkUpdate.Extra["openai_responses_mode"])
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccounts_RejectsInvalidOpenAISettingValuesBeforeWrite(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
credentials map[string]any
|
||||
extra map[string]any
|
||||
reason string
|
||||
}{
|
||||
{name: "long context type", extra: map[string]any{openAILongContextBillingEnabledKey: "true"}, reason: "OPENAI_LONG_CONTEXT_BILLING_INVALID"},
|
||||
{name: "empty capabilities", credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: []any{}}, reason: "OPENAI_ENDPOINT_CAPABILITIES_INVALID"},
|
||||
{name: "unknown capability", credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: []any{"responses"}}, reason: "OPENAI_ENDPOINT_CAPABILITIES_INVALID"},
|
||||
{name: "capabilities type", credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: "chat_completions"}, reason: "OPENAI_ENDPOINT_CAPABILITIES_INVALID"},
|
||||
{name: "responses mode", extra: map[string]any{"openai_responses_mode": "sometimes"}, reason: "OPENAI_RESPONSES_MODE_INVALID"},
|
||||
{name: "responses type", extra: map[string]any{"openai_responses_mode": true}, reason: "OPENAI_RESPONSES_MODE_INVALID"},
|
||||
{
|
||||
name: "embeddings conflict",
|
||||
credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: []any{"embeddings"}},
|
||||
extra: map[string]any{"openai_responses_mode": "force_responses"},
|
||||
reason: "OPENAI_RESPONSES_MODE_INVALID",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
Credentials: tt.credentials,
|
||||
Extra: tt.extra,
|
||||
})
|
||||
require.Nil(t, result)
|
||||
requireApplicationErrorReason(t, err, tt.reason)
|
||||
require.Zero(t, repo.bulkUpdateCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccounts_RejectsInvalidOpenAITargetsBeforeWrite(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
accounts []*Account
|
||||
input *BulkUpdateAccountsInput
|
||||
}{
|
||||
{
|
||||
name: "missing account",
|
||||
accounts: []*Account{{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}},
|
||||
input: &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1, 2},
|
||||
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed platform long context",
|
||||
accounts: []*Account{{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth}},
|
||||
input: &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "oauth endpoint capabilities",
|
||||
accounts: []*Account{{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}},
|
||||
input: &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
Credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: nil},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsupported OpenAI long context account type",
|
||||
accounts: []*Account{{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeServiceAccount}},
|
||||
input: &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: tt.accounts}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), tt.input)
|
||||
require.Nil(t, result)
|
||||
requireApplicationErrorReason(t, err, "OPENAI_BULK_TARGET_INVALID")
|
||||
require.Zero(t, repo.bulkUpdateCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccounts_ForcedResponsesRequiresChatCapability(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{
|
||||
ID: 1,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
openAIEndpointCapabilitiesCredentialKey: []any{"embeddings"},
|
||||
},
|
||||
}}}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
Extra: map[string]any{"openai_responses_mode": "force_chat_completions"},
|
||||
})
|
||||
|
||||
require.Nil(t, result)
|
||||
requireApplicationErrorReason(t, err, "OPENAI_BULK_TARGET_INVALID")
|
||||
require.Zero(t, repo.bulkUpdateCalls)
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccounts_ForcedResponsesAcceptsChatCapabilityUpdate(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{
|
||||
ID: 1,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
openAIEndpointCapabilitiesCredentialKey: []any{"embeddings"},
|
||||
},
|
||||
}}}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
_, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
Credentials: map[string]any{
|
||||
openAIEndpointCapabilitiesCredentialKey: []any{"chat_completions"},
|
||||
},
|
||||
Extra: map[string]any{"openai_responses_mode": "force_responses"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, repo.bulkUpdateCalls)
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccounts_ReportsLongContextShadowInheritance(t *testing.T) {
|
||||
parentID := int64(1)
|
||||
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{
|
||||
{ID: parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth},
|
||||
{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID},
|
||||
}}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{parentID, 2},
|
||||
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, result.LongContextInheritedCount)
|
||||
require.Equal(t, 1, repo.bulkUpdateCalls)
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccounts_RequiresParentForShadowOnlyLongContextUpdate(t *testing.T) {
|
||||
parentID := int64(10)
|
||||
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID},
|
||||
{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID},
|
||||
}}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1, 2},
|
||||
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
|
||||
})
|
||||
|
||||
require.Nil(t, result)
|
||||
requireApplicationErrorReason(t, err, "OPENAI_LONG_CONTEXT_PARENT_REQUIRED")
|
||||
require.Zero(t, repo.bulkUpdateCalls)
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccounts_ShadowLongContextAllowsOtherUpdates(t *testing.T) {
|
||||
parentID := int64(10)
|
||||
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{
|
||||
ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID,
|
||||
}}}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
status := StatusDisabled
|
||||
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
Status: status,
|
||||
Extra: map[string]any{openAILongContextBillingEnabledKey: false},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, result.LongContextInheritedCount)
|
||||
require.Equal(t, 1, repo.bulkUpdateCalls)
|
||||
require.NotNil(t, repo.lastBulkUpdate.Status)
|
||||
require.Equal(t, status, *repo.lastBulkUpdate.Status)
|
||||
}
|
||||
|
||||
func TestAdminServiceBulkUpdateAccounts_ValidatesFilterResolvedOpenAITargets(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{
|
||||
listData: []Account{{ID: 7}},
|
||||
listResult: &pagination.PaginationResult{Total: 1},
|
||||
getByIDsAccounts: []*Account{{ID: 7, Platform: PlatformAnthropic, Type: AccountTypeOAuth}},
|
||||
}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
Filters: &BulkUpdateAccountFilters{Platform: PlatformOpenAI},
|
||||
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
|
||||
})
|
||||
|
||||
require.Nil(t, result)
|
||||
requireApplicationErrorReason(t, err, "OPENAI_BULK_TARGET_INVALID")
|
||||
require.Equal(t, []int64{7}, repo.getByIDsIDs)
|
||||
require.Zero(t, repo.bulkUpdateCalls)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
||||
)
|
||||
|
||||
type bulkOpenAISettings struct {
|
||||
longContextBilling bool
|
||||
endpointCapabilities bool
|
||||
responsesMode bool
|
||||
capabilitiesIncludeChat bool
|
||||
forcedResponsesMode bool
|
||||
}
|
||||
|
||||
func (s bulkOpenAISettings) any() bool {
|
||||
return s.longContextBilling || s.endpointCapabilities || s.responsesMode
|
||||
}
|
||||
|
||||
func normalizeBulkOpenAISettings(input *BulkUpdateAccountsInput) (bulkOpenAISettings, error) {
|
||||
var settings bulkOpenAISettings
|
||||
if input == nil {
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
if _, exists := input.Extra[openAILongContextBillingEnabledKey]; exists {
|
||||
settings.longContextBilling = true
|
||||
if err := ValidateOpenAILongContextBillingExtra(PlatformOpenAI, input.Extra); err != nil {
|
||||
return settings, err
|
||||
}
|
||||
}
|
||||
|
||||
if raw, exists := input.Credentials[openAIEndpointCapabilitiesCredentialKey]; exists {
|
||||
settings.endpointCapabilities = true
|
||||
capabilities, includeChat, err := normalizeBulkOpenAIEndpointCapabilities(raw)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
settings.capabilitiesIncludeChat = includeChat
|
||||
input.Credentials[openAIEndpointCapabilitiesCredentialKey] = capabilities
|
||||
}
|
||||
|
||||
if raw, exists := input.Extra[openai_compat.ExtraKeyResponsesMode]; exists {
|
||||
settings.responsesMode = true
|
||||
mode, forced, err := normalizeBulkOpenAIResponsesMode(raw)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
settings.forcedResponsesMode = forced
|
||||
input.Extra[openai_compat.ExtraKeyResponsesMode] = mode
|
||||
}
|
||||
|
||||
if settings.endpointCapabilities && !settings.capabilitiesIncludeChat {
|
||||
if settings.forcedResponsesMode {
|
||||
return settings, infraerrors.BadRequest(
|
||||
"OPENAI_RESPONSES_MODE_INVALID",
|
||||
"a forced Responses route requires the chat_completions endpoint capability",
|
||||
)
|
||||
}
|
||||
if input.Extra == nil {
|
||||
input.Extra = make(map[string]any, 1)
|
||||
}
|
||||
input.Extra[openai_compat.ExtraKeyResponsesMode] = nil
|
||||
settings.responsesMode = true
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func normalizeBulkOpenAIEndpointCapabilities(raw any) (any, bool, error) {
|
||||
if raw == nil {
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
values := make([]string, 0, 2)
|
||||
switch typed := raw.(type) {
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
value, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, false, invalidBulkOpenAIEndpointCapabilities()
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
case []string:
|
||||
values = append(values, typed...)
|
||||
default:
|
||||
return nil, false, invalidBulkOpenAIEndpointCapabilities()
|
||||
}
|
||||
|
||||
selected := make(map[string]bool, 2)
|
||||
for _, value := range values {
|
||||
switch OpenAIEndpointCapability(value) {
|
||||
case OpenAIEndpointCapabilityChatCompletions, OpenAIEndpointCapabilityEmbeddings:
|
||||
selected[value] = true
|
||||
default:
|
||||
return nil, false, invalidBulkOpenAIEndpointCapabilities()
|
||||
}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return nil, false, invalidBulkOpenAIEndpointCapabilities()
|
||||
}
|
||||
|
||||
includeChat := selected[string(OpenAIEndpointCapabilityChatCompletions)]
|
||||
if includeChat && selected[string(OpenAIEndpointCapabilityEmbeddings)] {
|
||||
return nil, true, nil
|
||||
}
|
||||
if includeChat {
|
||||
return []string{string(OpenAIEndpointCapabilityChatCompletions)}, true, nil
|
||||
}
|
||||
return []string{string(OpenAIEndpointCapabilityEmbeddings)}, false, nil
|
||||
}
|
||||
|
||||
func invalidBulkOpenAIEndpointCapabilities() error {
|
||||
return infraerrors.BadRequest(
|
||||
"OPENAI_ENDPOINT_CAPABILITIES_INVALID",
|
||||
"openai_capabilities must contain chat_completions, embeddings, or both",
|
||||
)
|
||||
}
|
||||
|
||||
func normalizeBulkOpenAIResponsesMode(raw any) (any, bool, error) {
|
||||
if raw == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
mode, ok := raw.(string)
|
||||
if !ok {
|
||||
return nil, false, invalidBulkOpenAIResponsesMode()
|
||||
}
|
||||
switch openai_compat.ResponsesSupportMode(mode) {
|
||||
case openai_compat.ResponsesSupportModeAuto:
|
||||
return nil, false, nil
|
||||
case openai_compat.ResponsesSupportModeForceResponses,
|
||||
openai_compat.ResponsesSupportModeForceChatCompletions:
|
||||
return mode, true, nil
|
||||
default:
|
||||
return nil, false, invalidBulkOpenAIResponsesMode()
|
||||
}
|
||||
}
|
||||
|
||||
func invalidBulkOpenAIResponsesMode() error {
|
||||
return infraerrors.BadRequest(
|
||||
"OPENAI_RESPONSES_MODE_INVALID",
|
||||
"openai_responses_mode must be auto, force_responses, force_chat_completions, or null",
|
||||
)
|
||||
}
|
||||
|
||||
func validateBulkOpenAISettingsTargets(
|
||||
input *BulkUpdateAccountsInput,
|
||||
settings bulkOpenAISettings,
|
||||
targetsByID map[int64]*Account,
|
||||
) (int, error) {
|
||||
if input == nil || !settings.any() {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
inheritedCount := 0
|
||||
for _, accountID := range input.AccountIDs {
|
||||
account, ok := targetsByID[accountID]
|
||||
if !ok || account == nil {
|
||||
return 0, invalidBulkOpenAITarget(accountID, "account does not exist")
|
||||
}
|
||||
|
||||
if settings.longContextBilling {
|
||||
if account.Platform != PlatformOpenAI || !supportsOpenAILongContextBilling(account.Type) {
|
||||
return 0, invalidBulkOpenAITarget(accountID, "long-context billing requires an OpenAI OAuth, setup-token, or API-key account")
|
||||
}
|
||||
if account.IsShadow() {
|
||||
inheritedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if settings.endpointCapabilities || settings.responsesMode {
|
||||
if account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey {
|
||||
return 0, invalidBulkOpenAITarget(accountID, "endpoint capabilities and Responses routing require an OpenAI API-key account")
|
||||
}
|
||||
}
|
||||
|
||||
if settings.forcedResponsesMode && !settings.capabilitiesIncludeChat &&
|
||||
!settings.endpointCapabilities &&
|
||||
!account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions) {
|
||||
return 0, invalidBulkOpenAITarget(accountID, "a forced Responses route requires the chat_completions endpoint capability")
|
||||
}
|
||||
}
|
||||
|
||||
if settings.longContextBilling && inheritedCount == len(input.AccountIDs) && bulkUpdateOnlyChangesLongContext(input) {
|
||||
return 0, infraerrors.BadRequest(
|
||||
"OPENAI_LONG_CONTEXT_PARENT_REQUIRED",
|
||||
"long-context billing is owned by parent accounts; select at least one parent account",
|
||||
)
|
||||
}
|
||||
return inheritedCount, nil
|
||||
}
|
||||
|
||||
func supportsOpenAILongContextBilling(accountType string) bool {
|
||||
switch accountType {
|
||||
case AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func invalidBulkOpenAITarget(accountID int64, message string) error {
|
||||
return infraerrors.BadRequest(
|
||||
"OPENAI_BULK_TARGET_INVALID",
|
||||
fmt.Sprintf("account %d: %s", accountID, message),
|
||||
).WithMetadata(map[string]string{"account_id": strconv.FormatInt(accountID, 10)})
|
||||
}
|
||||
|
||||
func bulkUpdateOnlyChangesLongContext(input *BulkUpdateAccountsInput) bool {
|
||||
if input == nil || input.Name != "" || input.ProxyID != nil || input.Concurrency != nil ||
|
||||
input.Priority != nil || input.RateMultiplier != nil || input.LoadFactor != nil ||
|
||||
input.Status != "" || input.Schedulable != nil || input.GroupIDs != nil ||
|
||||
len(input.Credentials) != 0 || input.ProbeEnabled != nil {
|
||||
return false
|
||||
}
|
||||
if len(input.Extra) != 1 {
|
||||
return false
|
||||
}
|
||||
_, ok := input.Extra[openAILongContextBillingEnabledKey]
|
||||
return ok
|
||||
}
|
||||
@@ -470,6 +470,7 @@ export async function bulkUpdate(
|
||||
failed: number
|
||||
success_ids?: number[]
|
||||
failed_ids?: number[]
|
||||
long_context_inherited_count?: number
|
||||
results: Array<{ account_id: number; success: boolean; error?: string }>
|
||||
}> {
|
||||
const payload = Array.isArray(accountIdsOrPayload)
|
||||
@@ -483,6 +484,7 @@ export async function bulkUpdate(
|
||||
failed: number
|
||||
success_ids?: number[]
|
||||
failed_ids?: number[]
|
||||
long_context_inherited_count?: number
|
||||
results: Array<{ account_id: number; success: boolean; error?: string }>
|
||||
}>('/admin/accounts/bulk-update', payload)
|
||||
return data
|
||||
|
||||
@@ -133,6 +133,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI API long-context billing -->
|
||||
<div
|
||||
v-if="allOpenAIPassthroughCapable"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<label
|
||||
id="bulk-edit-openai-long-context-billing-label"
|
||||
class="input-label mb-0"
|
||||
for="bulk-edit-openai-long-context-billing-enabled"
|
||||
>
|
||||
{{ t('admin.accounts.openai.longContextBilling') }}
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.longContextBillingDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="enableOpenAILongContextBilling"
|
||||
id="bulk-edit-openai-long-context-billing-enabled"
|
||||
type="checkbox"
|
||||
aria-controls="bulk-edit-openai-long-context-billing-body"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="bulk-edit-openai-long-context-billing-body"
|
||||
:class="!enableOpenAILongContextBilling && 'pointer-events-none opacity-50'"
|
||||
role="group"
|
||||
aria-labelledby="bulk-edit-openai-long-context-billing-label"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bulk-edit-openai-long-context-billing-toggle"
|
||||
role="switch"
|
||||
:disabled="!enableOpenAILongContextBilling"
|
||||
:aria-checked="openAILongContextBillingEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
openAILongContextBillingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
@click="openAILongContextBillingEnabled = !openAILongContextBillingEnabled"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
openAILongContextBillingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
data-testid="bulk-edit-openai-long-context-shadow-hint"
|
||||
>
|
||||
{{ t('admin.accounts.bulkEdit.longContextShadowHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Base URL (API Key only) -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -970,6 +1030,101 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI API Key endpoint capabilities -->
|
||||
<div v-if="allOpenAIAPIKey" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<label
|
||||
id="bulk-edit-openai-endpoint-capabilities-label"
|
||||
class="input-label mb-0"
|
||||
for="bulk-edit-openai-endpoint-capabilities-enabled"
|
||||
>
|
||||
{{ t('admin.accounts.openai.endpointCapabilities') }}
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.endpointCapabilitiesDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="enableOpenAIEndpointCapabilities"
|
||||
id="bulk-edit-openai-endpoint-capabilities-enabled"
|
||||
type="checkbox"
|
||||
aria-controls="bulk-edit-openai-endpoint-capabilities-body"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="bulk-edit-openai-endpoint-capabilities-body"
|
||||
:class="!enableOpenAIEndpointCapabilities && 'pointer-events-none opacity-50'"
|
||||
role="group"
|
||||
aria-labelledby="bulk-edit-openai-endpoint-capabilities-label"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="option in openAIEndpointCapabilityOptions"
|
||||
:key="option.value"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm dark:border-dark-600"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:disabled="!enableOpenAIEndpointCapabilities"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
|
||||
:data-testid="`bulk-edit-openai-endpoint-capability-${option.value}`"
|
||||
:checked="openAIEndpointCapabilities.includes(option.value)"
|
||||
@change="toggleOpenAIEndpointCapability(option.value, $event)"
|
||||
/>
|
||||
<span class="text-gray-700 dark:text-gray-200">{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI API Key Responses route -->
|
||||
<div v-if="allOpenAIAPIKey" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<label
|
||||
id="bulk-edit-openai-responses-mode-label"
|
||||
class="input-label mb-0"
|
||||
for="bulk-edit-openai-responses-mode-enabled"
|
||||
>
|
||||
{{ t('admin.accounts.openai.responsesMode') }}
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.responsesModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="enableOpenAIResponsesMode"
|
||||
id="bulk-edit-openai-responses-mode-enabled"
|
||||
type="checkbox"
|
||||
aria-controls="bulk-edit-openai-responses-mode-body"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="bulk-edit-openai-responses-mode-body"
|
||||
:class="!enableOpenAIResponsesMode && 'pointer-events-none opacity-50'"
|
||||
role="group"
|
||||
aria-labelledby="bulk-edit-openai-responses-mode-label"
|
||||
>
|
||||
<Select
|
||||
v-model="openAIResponsesMode"
|
||||
:disabled="!enableOpenAIResponsesMode || !openAIResponsesModeApplicable"
|
||||
data-testid="bulk-edit-openai-responses-mode-select"
|
||||
:options="openAIResponsesModeOptions"
|
||||
aria-labelledby="bulk-edit-openai-responses-mode-label"
|
||||
/>
|
||||
<p
|
||||
v-if="enableOpenAIEndpointCapabilities && !openAITextGenerationCapabilityEnabled"
|
||||
class="mt-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
data-testid="bulk-edit-openai-responses-mode-not-applicable"
|
||||
>
|
||||
{{ t('admin.accounts.openai.responsesModeTextDisabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI API Key WS mode -->
|
||||
<div v-if="allOpenAIAPIKey" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -1321,7 +1476,15 @@ import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform, AccountType, OpenAICompactMode } from '@/types'
|
||||
import type {
|
||||
Proxy as ProxyConfig,
|
||||
AdminGroup,
|
||||
AccountPlatform,
|
||||
AccountType,
|
||||
OpenAICompactMode,
|
||||
OpenAIEndpointCapability,
|
||||
OpenAIResponsesMode
|
||||
} from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
@@ -1495,6 +1658,9 @@ const enableStatus = ref(false)
|
||||
const enableGroups = ref(false)
|
||||
const enableOpenAIPassthrough = ref(false)
|
||||
const enableOpenAIFlattenNamespaces = ref(false)
|
||||
const enableOpenAILongContextBilling = ref(false)
|
||||
const enableOpenAIEndpointCapabilities = ref(false)
|
||||
const enableOpenAIResponsesMode = ref(false)
|
||||
const enableOpenAIWSMode = ref(false)
|
||||
const enableOpenAIAPIKeyWSMode = ref(false)
|
||||
const enableUpstreamBillingAutoProbe = ref(false)
|
||||
@@ -1528,6 +1694,12 @@ const groupIds = ref<number[]>([])
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
// Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
|
||||
const openaiFlattenNamespacesEnabled = ref(false)
|
||||
const openAILongContextBillingEnabled = ref(false)
|
||||
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>([
|
||||
'chat_completions',
|
||||
'embeddings'
|
||||
])
|
||||
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
|
||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const upstreamBillingAutoProbeMode = ref<'enabled' | 'disabled'>('enabled')
|
||||
@@ -1592,6 +1764,65 @@ const openAICompactModeOptions = computed(() => [
|
||||
{ value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
|
||||
{ value: 'force_off', label: t('admin.accounts.openai.compactModeForceOff') }
|
||||
])
|
||||
const openAIResponsesModeOptions = computed(() => [
|
||||
{ value: 'auto', label: t('admin.accounts.openai.responsesModeAuto') },
|
||||
{ value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
|
||||
{
|
||||
value: 'force_chat_completions',
|
||||
label: t('admin.accounts.openai.responsesModeForceChatCompletions')
|
||||
}
|
||||
])
|
||||
const openAITextEndpointCapabilityLabel = computed(() => {
|
||||
if (openAIResponsesMode.value === 'force_responses') {
|
||||
return t('admin.accounts.openai.capabilityResponses')
|
||||
}
|
||||
if (openAIResponsesMode.value === 'force_chat_completions') {
|
||||
return t('admin.accounts.openai.capabilityChatCompletions')
|
||||
}
|
||||
return t('admin.accounts.openai.capabilityTextAuto')
|
||||
})
|
||||
const openAIEndpointCapabilityOptions = computed<
|
||||
Array<{ value: OpenAIEndpointCapability; label: string }>
|
||||
>(() => [
|
||||
{ value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value },
|
||||
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }
|
||||
])
|
||||
const openAITextGenerationCapabilityEnabled = computed(() =>
|
||||
openAIEndpointCapabilities.value.includes('chat_completions')
|
||||
)
|
||||
const openAIResponsesModeApplicable = computed(
|
||||
() => !enableOpenAIEndpointCapabilities.value || openAITextGenerationCapabilityEnabled.value
|
||||
)
|
||||
|
||||
const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => {
|
||||
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings']
|
||||
const selected = allowed.filter((value) => values.includes(value))
|
||||
return selected.length > 0 ? selected : allowed
|
||||
}
|
||||
|
||||
const toggleOpenAIEndpointCapability = (
|
||||
capability: OpenAIEndpointCapability,
|
||||
event?: Event
|
||||
) => {
|
||||
if (openAIEndpointCapabilities.value.includes(capability)) {
|
||||
if (openAIEndpointCapabilities.value.length <= 1) {
|
||||
const input = event?.target as HTMLInputElement | null
|
||||
if (input) input.checked = true
|
||||
return
|
||||
}
|
||||
openAIEndpointCapabilities.value = openAIEndpointCapabilities.value.filter(
|
||||
(value) => value !== capability
|
||||
)
|
||||
if (!openAITextGenerationCapabilityEnabled.value) {
|
||||
openAIResponsesMode.value = 'auto'
|
||||
}
|
||||
return
|
||||
}
|
||||
openAIEndpointCapabilities.value = normalizeOpenAIEndpointCapabilities([
|
||||
...openAIEndpointCapabilities.value,
|
||||
capability
|
||||
])
|
||||
}
|
||||
const openAIWSModeConcurrencyHintKey = computed(() =>
|
||||
resolveOpenAIWSModeConcurrencyHintKey(openaiOAuthResponsesWebSocketV2Mode.value)
|
||||
)
|
||||
@@ -1692,6 +1923,11 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
const updates: Record<string, unknown> = {}
|
||||
const credentials: Record<string, unknown> = {}
|
||||
let credentialsChanged = false
|
||||
const applyOpenAILongContextBilling =
|
||||
enableOpenAILongContextBilling.value && allOpenAIPassthroughCapable.value
|
||||
const applyOpenAIEndpointCapabilities =
|
||||
enableOpenAIEndpointCapabilities.value && allOpenAIAPIKey.value
|
||||
const applyOpenAIResponsesMode = enableOpenAIResponsesMode.value && allOpenAIAPIKey.value
|
||||
const ensureExtra = (): Record<string, unknown> => {
|
||||
if (!updates.extra) {
|
||||
updates.extra = {}
|
||||
@@ -1752,6 +1988,30 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
extra.openai_responses_flatten_namespaces = openaiFlattenNamespacesEnabled.value
|
||||
}
|
||||
|
||||
if (applyOpenAILongContextBilling) {
|
||||
const extra = ensureExtra()
|
||||
extra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value
|
||||
}
|
||||
|
||||
if (applyOpenAIEndpointCapabilities) {
|
||||
credentials.openai_capabilities =
|
||||
openAIEndpointCapabilities.value.length === 2
|
||||
? null
|
||||
: [...openAIEndpointCapabilities.value]
|
||||
credentialsChanged = true
|
||||
}
|
||||
|
||||
if (
|
||||
applyOpenAIResponsesMode ||
|
||||
(applyOpenAIEndpointCapabilities && !openAITextGenerationCapabilityEnabled.value)
|
||||
) {
|
||||
const extra = ensureExtra()
|
||||
extra.openai_responses_mode =
|
||||
!openAIResponsesModeApplicable.value || openAIResponsesMode.value === 'auto'
|
||||
? null
|
||||
: openAIResponsesMode.value
|
||||
}
|
||||
|
||||
if (enableModelRestriction.value && !isOpenAIModelRestrictionDisabled.value) {
|
||||
// 统一使用 model_mapping 字段
|
||||
if (modelRestrictionMode.value === 'whitelist') {
|
||||
@@ -1931,6 +2191,9 @@ const handleSubmit = async () => {
|
||||
enableBaseUrl.value ||
|
||||
enableOpenAIPassthrough.value ||
|
||||
enableOpenAIFlattenNamespaces.value ||
|
||||
(enableOpenAILongContextBilling.value && allOpenAIPassthroughCapable.value) ||
|
||||
(enableOpenAIEndpointCapabilities.value && allOpenAIAPIKey.value) ||
|
||||
(enableOpenAIResponsesMode.value && allOpenAIAPIKey.value) ||
|
||||
enableModelRestriction.value ||
|
||||
enableCustomErrorCodes.value ||
|
||||
enableInterceptWarmup.value ||
|
||||
@@ -2011,11 +2274,22 @@ const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
|
||||
: await adminAPI.accounts.bulkUpdate(props.accountIds, updates)
|
||||
const success = res.success || 0
|
||||
const failed = res.failed || 0
|
||||
const inherited = res.long_context_inherited_count || 0
|
||||
|
||||
if (success > 0 && failed === 0) {
|
||||
appStore.showSuccess(t('admin.accounts.bulkEdit.success', { count: success }))
|
||||
if (inherited > 0) {
|
||||
appStore.showSuccess(t('admin.accounts.bulkEdit.successWithInherited', {
|
||||
count: success,
|
||||
inherited
|
||||
}))
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.accounts.bulkEdit.success', { count: success }))
|
||||
}
|
||||
} else if (success > 0) {
|
||||
appStore.showError(t('admin.accounts.bulkEdit.partialSuccess', { success, failed }))
|
||||
const key = inherited > 0
|
||||
? 'admin.accounts.bulkEdit.partialSuccessWithInherited'
|
||||
: 'admin.accounts.bulkEdit.partialSuccess'
|
||||
appStore.showError(t(key, { success, failed, inherited }))
|
||||
} else {
|
||||
appStore.showError(t('admin.accounts.bulkEdit.failed'))
|
||||
}
|
||||
@@ -2035,6 +2309,8 @@ const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
|
||||
appStore.showError(t('admin.accounts.bulkEdit.rateSyncConflict', {
|
||||
count: error.metadata?.count ?? 1
|
||||
}))
|
||||
} else if (error.reason === 'OPENAI_LONG_CONTEXT_PARENT_REQUIRED') {
|
||||
appStore.showError(t('admin.accounts.bulkEdit.longContextParentRequired'))
|
||||
} else {
|
||||
appStore.showError(error.message || t('admin.accounts.bulkEdit.failed'))
|
||||
console.error('Error bulk updating accounts:', error)
|
||||
@@ -2077,6 +2353,9 @@ watch(
|
||||
enableGroups.value = false
|
||||
enableOpenAIPassthrough.value = false
|
||||
enableOpenAIFlattenNamespaces.value = false
|
||||
enableOpenAILongContextBilling.value = false
|
||||
enableOpenAIEndpointCapabilities.value = false
|
||||
enableOpenAIResponsesMode.value = false
|
||||
enableOpenAIWSMode.value = false
|
||||
enableOpenAIAPIKeyWSMode.value = false
|
||||
enableUpstreamBillingAutoProbe.value = false
|
||||
@@ -2092,6 +2371,9 @@ watch(
|
||||
baseUrl.value = ''
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiFlattenNamespacesEnabled.value = false
|
||||
openAILongContextBillingEnabled.value = false
|
||||
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
|
||||
openAIResponsesMode.value = 'auto'
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
allowedModels.value = []
|
||||
modelMappings.value = []
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import BulkEditAccountModal from '../BulkEditAccountModal.vue'
|
||||
import ModelWhitelistSelector from '../ModelWhitelistSelector.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
|
||||
const { showError } = vi.hoisted(() => ({
|
||||
showError: vi.fn()
|
||||
const { showError, showSuccess, translate } = vi.hoisted(() => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
translate: vi.fn((key: string) => key)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showSuccess: vi.fn(),
|
||||
showSuccess,
|
||||
showInfo: vi.fn()
|
||||
})
|
||||
}))
|
||||
@@ -34,7 +37,7 @@ vi.mock('vue-i18n', async () => {
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
t: translate
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -82,6 +85,8 @@ describe('BulkEditAccountModal', () => {
|
||||
vi.mocked(adminAPI.accounts.bulkUpdate).mockReset()
|
||||
vi.mocked(adminAPI.accounts.checkMixedChannelRisk).mockReset()
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
translate.mockClear()
|
||||
|
||||
vi.mocked(adminAPI.accounts.bulkUpdate).mockResolvedValue({
|
||||
success: 2,
|
||||
@@ -403,6 +408,302 @@ describe('BulkEditAccountModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('OpenAI 支持类型展示长上下文设置,混合平台隐藏全部新增设置', () => {
|
||||
for (const selectedTypes of [['oauth'], ['setup-token'], ['apikey'], ['oauth', 'setup-token', 'apikey']]) {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes
|
||||
})
|
||||
expect(wrapper.find('#bulk-edit-openai-long-context-billing-enabled').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
}
|
||||
|
||||
const mixed = mountModal({
|
||||
selectedPlatforms: ['openai', 'anthropic'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
expect(mixed.find('#bulk-edit-openai-long-context-billing-enabled').exists()).toBe(false)
|
||||
expect(mixed.find('#bulk-edit-openai-endpoint-capabilities-enabled').exists()).toBe(false)
|
||||
expect(mixed.find('#bulk-edit-openai-responses-mode-enabled').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('端点能力与 Responses 路由仅对全部 OpenAI API Key 目标展示', () => {
|
||||
const apiKey = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
expect(apiKey.find('#bulk-edit-openai-endpoint-capabilities-enabled').exists()).toBe(true)
|
||||
expect(apiKey.find('#bulk-edit-openai-responses-mode-enabled').exists()).toBe(true)
|
||||
apiKey.unmount()
|
||||
|
||||
const oauth = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
expect(oauth.find('#bulk-edit-openai-endpoint-capabilities-enabled').exists()).toBe(false)
|
||||
expect(oauth.find('#bulk-edit-openai-responses-mode-enabled').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('长上下文设置独立启用并提交布尔值', async () => {
|
||||
const enabledWrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
|
||||
await enabledWrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
|
||||
await enabledWrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').trigger('click')
|
||||
await enabledWrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenLastCalledWith([1, 2], {
|
||||
extra: { openai_long_context_billing_enabled: true }
|
||||
})
|
||||
enabledWrapper.unmount()
|
||||
|
||||
vi.mocked(adminAPI.accounts.bulkUpdate).mockClear()
|
||||
const disabledWrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['setup-token']
|
||||
})
|
||||
await disabledWrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
|
||||
await disabledWrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
extra: { openai_long_context_billing_enabled: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('端点能力默认值提交 null,表示恢复两个默认端点', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
credentials: { openai_capabilities: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('Responses 路由独立启用,auto 提交 null,强制模式提交明确值', async () => {
|
||||
const autoWrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
await autoWrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
|
||||
await autoWrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenLastCalledWith([1, 2], {
|
||||
extra: { openai_responses_mode: null }
|
||||
})
|
||||
autoWrapper.unmount()
|
||||
|
||||
vi.mocked(adminAPI.accounts.bulkUpdate).mockClear()
|
||||
const forcedWrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
await forcedWrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
|
||||
await forcedWrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').setValue('force_responses')
|
||||
await forcedWrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
extra: { openai_responses_mode: 'force_responses' }
|
||||
})
|
||||
})
|
||||
|
||||
it('仅启用 Embeddings 时恢复 Responses 自动模式并精确提交联动字段', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
|
||||
await wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').setValue('force_chat_completions')
|
||||
await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false)
|
||||
|
||||
expect((wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').element as HTMLSelectElement).value)
|
||||
.toBe('auto')
|
||||
expect(wrapper.find('[data-testid="bulk-edit-openai-responses-mode-not-applicable"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
credentials: { openai_capabilities: ['embeddings'] },
|
||||
extra: { openai_responses_mode: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('关闭端点能力修改后 Responses 路由恢复独立可编辑', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
|
||||
await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false)
|
||||
await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(false)
|
||||
await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
|
||||
|
||||
const select = wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]')
|
||||
expect(select.attributes('disabled')).toBeUndefined()
|
||||
await select.setValue('force_responses')
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
extra: { openai_responses_mode: 'force_responses' }
|
||||
})
|
||||
})
|
||||
|
||||
it('目标变化后不提交已经隐藏的 OpenAI 设置', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
|
||||
await wrapper.setProps({ selectedPlatforms: ['anthropic'], selectedTypes: ['apikey'] })
|
||||
await wrapper.get('#bulk-edit-status-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
status: 'active'
|
||||
})
|
||||
})
|
||||
|
||||
it('至少保留一个端点能力', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
|
||||
await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false)
|
||||
await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-embeddings"]').setValue(false)
|
||||
|
||||
expect((wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-embeddings"]').element as HTMLInputElement).checked)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
it('关闭弹窗后重置新增设置的启用状态和值', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
|
||||
await wrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').trigger('click')
|
||||
await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
|
||||
await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false)
|
||||
await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
|
||||
|
||||
await wrapper.setProps({ show: false })
|
||||
await nextTick()
|
||||
|
||||
expect((wrapper.get('#bulk-edit-openai-long-context-billing-enabled').element as HTMLInputElement).checked).toBe(false)
|
||||
expect(wrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').attributes('aria-checked')).toBe('false')
|
||||
expect((wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').element as HTMLInputElement).checked).toBe(false)
|
||||
expect((wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect((wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').element as HTMLSelectElement).value).toBe('auto')
|
||||
})
|
||||
|
||||
it('筛选全量模式固定展示影子继承说明并按 filters 提交', async () => {
|
||||
const wrapper = mountModal({
|
||||
accountIds: [],
|
||||
selectedPlatforms: [],
|
||||
selectedTypes: [],
|
||||
target: {
|
||||
mode: 'filtered',
|
||||
filters: { platform: 'openai', type: 'oauth', status: 'active' },
|
||||
previewCount: 20,
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-testid="bulk-edit-openai-long-context-shadow-hint"]').text())
|
||||
.toContain('admin.accounts.bulkEdit.longContextShadowHint')
|
||||
await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
|
||||
await wrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').trigger('click')
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith({
|
||||
filters: { platform: 'openai', type: 'oauth', status: 'active' },
|
||||
extra: { openai_long_context_billing_enabled: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('成功响应包含影子继承数量时展示专用提示', async () => {
|
||||
vi.mocked(adminAPI.accounts.bulkUpdate).mockResolvedValueOnce({
|
||||
success: 2,
|
||||
failed: 0,
|
||||
long_context_inherited_count: 1,
|
||||
results: []
|
||||
} as any)
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.bulkEdit.successWithInherited')
|
||||
expect(translate).toHaveBeenCalledWith('admin.accounts.bulkEdit.successWithInherited', {
|
||||
count: 2,
|
||||
inherited: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('部分成功且包含影子继承数量时展示组合提示', async () => {
|
||||
vi.mocked(adminAPI.accounts.bulkUpdate).mockResolvedValueOnce({
|
||||
success: 1,
|
||||
failed: 1,
|
||||
long_context_inherited_count: 1,
|
||||
results: []
|
||||
} as any)
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.bulkEdit.partialSuccessWithInherited')
|
||||
expect(translate).toHaveBeenCalledWith('admin.accounts.bulkEdit.partialSuccessWithInherited', {
|
||||
success: 1,
|
||||
failed: 1,
|
||||
inherited: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('全影子长上下文错误使用专用提示并保持弹窗打开', async () => {
|
||||
vi.mocked(adminAPI.accounts.bulkUpdate).mockRejectedValueOnce({
|
||||
status: 400,
|
||||
reason: 'OPENAI_LONG_CONTEXT_PARENT_REQUIRED',
|
||||
message: 'select parent'
|
||||
})
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.bulkEdit.longContextParentRequired')
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('OpenAI API Key 批量编辑可统一开启上游倍率自动探测', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
|
||||
@@ -460,12 +460,16 @@ export default {
|
||||
submit: 'Update Accounts',
|
||||
updating: 'Updating...',
|
||||
success: 'Updated {count} account(s)',
|
||||
successWithInherited: 'Updated {count} account(s). {inherited} selected shadow account(s) still follow their parent account.',
|
||||
partialSuccess: 'Partially updated: {success} succeeded, {failed} failed',
|
||||
partialSuccessWithInherited: 'Partially updated: {success} succeeded, {failed} failed. {inherited} selected shadow account(s) still follow their parent account.',
|
||||
failed: 'Bulk update failed',
|
||||
noSelection: 'Please select accounts to edit',
|
||||
noFieldsSelected: 'Select at least one field to update',
|
||||
rateSyncWarning: 'Accounts with upstream rate sync enabled cannot be changed in bulk. Disable sync in the account editor first.',
|
||||
rateSyncConflict: 'Cannot change account rates: {count} target account(s) have upstream rate sync enabled.',
|
||||
longContextShadowHint: 'Long-context billing belongs to the parent account. Selected shadow accounts keep following their parent, including when targets come from a filter.',
|
||||
longContextParentRequired: 'All selected accounts are shadows. Select the parent account to change long-context billing.',
|
||||
mixedPlatformWarning: 'Selected accounts span multiple platforms ({platforms}). Model mapping presets shown are combined — ensure mappings are appropriate for each platform.'
|
||||
},
|
||||
bulkDeleteTitle: 'Bulk Delete Accounts',
|
||||
|
||||
@@ -538,12 +538,16 @@ export default {
|
||||
submit: '批量更新',
|
||||
updating: '更新中...',
|
||||
success: '成功更新 {count} 个账号',
|
||||
successWithInherited: '成功更新 {count} 个账号;其中 {inherited} 个影子账号仍跟随母账号。',
|
||||
partialSuccess: '部分更新成功:成功 {success} 个,失败 {failed} 个',
|
||||
partialSuccessWithInherited: '部分更新成功:成功 {success} 个,失败 {failed} 个;其中 {inherited} 个影子账号仍跟随母账号。',
|
||||
failed: '批量更新失败',
|
||||
noSelection: '请选择要编辑的账号',
|
||||
noFieldsSelected: '请至少选择一个要更新的字段',
|
||||
rateSyncWarning: '已开启上游倍率同步的账号不能批量手工修改倍率,请先在账号编辑页关闭同步。',
|
||||
rateSyncConflict: '无法修改账号倍率:{count} 个目标账号已开启上游倍率同步。',
|
||||
longContextShadowHint: '长上下文计费归母账号所有。选中的影子账号仍跟随母账号,筛选全量目标时同样如此。',
|
||||
longContextParentRequired: '选中的账号全部是影子账号,请选择母账号修改长上下文计费。',
|
||||
mixedPlatformWarning: '所选账号跨越多个平台({platforms})。显示的模型映射预设为合并结果——请确保映射对每个平台都适用。'
|
||||
},
|
||||
bulkDeleteTitle: '批量删除账号',
|
||||
|
||||
Reference in New Issue
Block a user