Files
sub2api/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
T
feeeei 81e6ec5346 feat(gemini): Vertex 账号支持 API Key 认证
- Gemini 平台的 Vertex 账号新增 API Key 认证(credentials.auth_mode=apikey),
  不填 Project ID 走全局端点,填写后走项目级端点,Key 通过 x-goog-api-key 传递;
  Anthropic 平台的 Vertex 账号仍只支持 Service Account JSON
- 转发(Claude 兼容 / 原生 / Chat Completions)与测试连接统一走
  buildVertexGeminiAccountURL / setVertexGeminiAuth
- API Key 账号不参与显式缓存(Vertex 缓存接口不接受 API Key)与批量生图
- 创建弹窗新增认证方式选择;编辑弹窗支持轮换 Key 与修改 Project ID,
  Key 留空表示保留
2026-10-10 23:37:48 +08:00

992 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
BUILTIN_PLATFORM_CATALOG,
resetPlatformCatalog,
setPlatformCatalog,
} from '@/constants/platformCatalog'
const {
createAccountMock,
probeUpstreamBillingMock,
syncUpstreamModelsMock,
showWarningMock,
importCodexSessionMock,
createOpenAICodexPATMock,
authIsSimpleMode,
} = vi.hoisted(() => ({
createAccountMock: vi.fn(),
probeUpstreamBillingMock: vi.fn(),
syncUpstreamModelsMock: vi.fn(),
showWarningMock: vi.fn(),
importCodexSessionMock: vi.fn(),
createOpenAICodexPATMock: vi.fn(),
authIsSimpleMode: { value: true },
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showWarning: showWarningMock,
}),
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
get isSimpleMode() {
return authIsSimpleMode.value
},
}),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
create: createAccountMock,
probeUpstreamBilling: probeUpstreamBillingMock,
syncUpstreamModels: syncUpstreamModelsMock,
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',
props: {
showManualOption: Boolean,
showCodexSessionImportOption: Boolean,
showAgentIdentityOption: Boolean,
showCodexPatOption: Boolean,
initialInputMethod: String,
},
data: () => ({ inputMethod: 'manual' }),
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>
`,
})
const GroupSelectorStub = defineComponent({
name: 'GroupSelector',
props: {
modelValue: {
type: Array,
default: () => [],
},
},
emits: ['update:modelValue'],
template: `
<button
type="button"
data-testid="select-pricing-groups"
@click="$emit('update:modelValue', [1, 2])"
>
groups
</button>
`,
})
const ModelWhitelistSelectorStub = defineComponent({
name: 'ModelWhitelistSelector',
props: {
modelValue: {
type: Array,
default: () => [],
},
platform: String,
syncCredentials: Object,
},
emits: ['update:modelValue', 'upstream-synced'],
template: `<button
type="button"
data-testid="model-whitelist-selector"
@click="$emit('update:modelValue', ['public-glm']); $emit('upstream-synced')"
>models</button>`,
})
function mountModal(groups: any[] = []) {
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: GroupSelectorStub,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
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',
enableLongContextBilling = false,
disableUpstreamBillingProbe = 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 (enableLongContextBilling) {
await wrapper.get('[data-testid="openai-long-context-billing-toggle"]').trigger('click')
}
if (disableUpstreamBillingProbe) {
await wrapper.get('[data-testid="upstream-billing-auto-probe"]').trigger('click')
}
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
return wrapper
}
async function openCodexImportStep(toggleClicks = 0) {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
for (let click = 0; click < toggleClicks; click += 1) {
await wrapper.get('[data-testid="openai-long-context-billing-toggle"]').trigger('click')
}
await wrapper.get('form#create-account-form input[type="text"]').setValue('Codex import')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
return wrapper
}
describe('CreateAccountModal OpenAI long-context billing', () => {
beforeEach(() => {
authIsSimpleMode.value = true
createAccountMock.mockReset().mockResolvedValue({ id: 42, platform: 'openai', type: 'apikey' })
probeUpstreamBillingMock.mockReset().mockResolvedValue({})
syncUpstreamModelsMock.mockReset().mockResolvedValue({ models: [], metadata: {} })
showWarningMock.mockReset()
importCodexSessionMock.mockReset().mockResolvedValue({
created: 1,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
warnings: [],
})
createOpenAICodexPATMock.mockReset().mockResolvedValue({})
})
afterEach(() => vi.useRealTimers())
it('sets month and year expiry presets without submitting the account form', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-01-31T12:34:00'))
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('expiry account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
const input = wrapper.get<HTMLInputElement>('input[type="datetime-local"]')
for (const [label, expected] of [
['payment.oneMonth', '2026-02-28T12:34'],
['payment.oneYear', '2027-01-31T12:34'],
]) {
const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)!
expect(button.attributes('type')).toBe('button')
await button.trigger('click')
expect(input.element.value).toBe(expected)
expect(createAccountMock).not.toHaveBeenCalled()
}
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2027-01-31T12:34:00').getTime() / 1000)
wrapper.unmount()
})
it('allows a manually entered expiry to override a preset before account creation', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('custom expiry account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await selectButtonByText(wrapper, 'payment.oneMonth')
await wrapper.get('input[type="datetime-local"]').setValue('2030-04-15T09:20')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2030-04-15T09:20:00').getTime() / 1000)
wrapper.unmount()
})
it('hides only the redundant account toggle when every selected group enables tier pricing', async () => {
authIsSimpleMode.value = false
const wrapper = mountModal([
{ id: 1, long_context_pricing_enabled: true },
{ id: 2, long_context_pricing_enabled: true },
])
await selectButtonByText(wrapper, 'OpenAI')
await wrapper.get('[data-testid="select-pricing-groups"]').trigger('click')
expect(wrapper.find('[data-testid="openai-long-context-billing-toggle"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="create-openai-ws-mode"]').exists()).toBe(true)
})
it('keeps the account toggle when any selected group disables tier pricing', async () => {
authIsSimpleMode.value = false
const wrapper = mountModal([
{ id: 1, long_context_pricing_enabled: true },
{ id: 2, long_context_pricing_enabled: false },
])
await selectButtonByText(wrapper, 'OpenAI')
await wrapper.get('[data-testid="select-pricing-groups"]').trigger('click')
expect(wrapper.find('[data-testid="openai-long-context-billing-toggle"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="create-openai-ws-mode"]').exists()).toBe(true)
})
it('sends false explicitly for normal OpenAI account creation by default', async () => {
await submitApiKeyAccount('openai')
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('omits the upstream request id header from extra when left empty', async () => {
await submitApiKeyAccount('openai')
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('upstream_request_id_header')
})
it('sends the trimmed upstream request id header in extra when filled', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('openai account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await wrapper.get('[data-testid="upstream-request-id-header"]').setValue(' X-Oneapi-Request-Id ')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.upstream_request_id_header).toBe('X-Oneapi-Request-Id')
})
it('omits images_url_to_b64_json from extra by default', async () => {
await submitApiKeyAccount('openai')
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('images_url_to_b64_json')
})
it('sends images_url_to_b64_json in extra when the toggle is enabled', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('openai account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await wrapper.get('[data-testid="openai-images-url-to-b64-json-toggle"]').trigger('click')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.images_url_to_b64_json).toBe(true)
})
it('persists upstream model metadata after creating an account from preview', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenCode account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledOnce()
expect(syncUpstreamModelsMock).toHaveBeenCalledWith(42)
})
it('includes the current concrete model mapping in preview credentials', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click')
await flushPromises()
expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('syncCredentials')).toMatchObject({
model_mapping: { 'public-glm': 'public-glm' }
})
})
it('runs formal capability sync after creating an account with explicit mappings', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('Mapped account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await selectButtonByText(wrapper, 'admin.accounts.modelMapping')
await selectButtonByText(wrapper, 'admin.accounts.addMapping')
await wrapper.get('input[placeholder="admin.accounts.requestModel"]').setValue('public-glm')
await wrapper.get('input[placeholder="admin.accounts.actualModel"]').setValue('glm-5.3')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock.mock.calls[0]?.[0]?.credentials?.model_mapping).toEqual({
'public-glm': 'glm-5.3'
})
expect(syncUpstreamModelsMock).toHaveBeenCalledWith(42)
})
it('warns when post-create capability metadata remains incomplete', async () => {
syncUpstreamModelsMock.mockResolvedValue({
models: ['x-preview-f-free'],
warnings: [{ code: 'upstream_model_metadata_incomplete', message: 'metadata incomplete' }],
})
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenCode account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(showWarningMock).toHaveBeenCalledWith(
'admin.accounts.syncUpstreamModelsMetadataIncomplete'
)
})
// namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平
it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe(
true
)
await selectButtonByText(wrapper, 'API Key')
expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe(
false
)
})
it('enables upstream billing probes by default for new OpenAI API key accounts', async () => {
await submitApiKeyAccount('openai')
expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(true)
})
it('waits for the initial upstream billing probe before refreshing the account list', async () => {
let resolveProbe: (() => void) | undefined
probeUpstreamBillingMock.mockImplementationOnce(
() => new Promise<void>((resolve) => {
resolveProbe = resolve
})
)
const wrapper = await submitApiKeyAccount('openai')
expect(probeUpstreamBillingMock).toHaveBeenCalledWith(42)
expect(wrapper.emitted('created')).toBeUndefined()
resolveProbe?.()
await flushPromises()
expect(wrapper.emitted('created')).toHaveLength(1)
})
it('sends an explicit disabled state when the create toggle is turned off', async () => {
await submitApiKeyAccount('openai', false, true)
expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(false)
expect(probeUpstreamBillingMock).not.toHaveBeenCalled()
})
it('submits OpenCode Zen default protocol rules with adaptive endpoints', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenCode')
await wrapper.get('form#create-account-form input[type="text"]').setValue('oc')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-opencode-zen')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
account_mode: 'zen',
api_protocol: 'adaptive',
base_url: 'https://opencode.ai/zen/v1',
api_base_urls: {
chat_completions: 'https://opencode.ai/zen/v1',
anthropic: 'https://opencode.ai/zen',
responses: 'https://opencode.ai/zen/v1'
},
protocol_rules: [
{ pattern: 'grok-*', protocol: 'responses' },
{ pattern: 'gpt-*', protocol: 'responses' },
{ pattern: 'muse-spark-*', protocol: 'responses' },
{ pattern: 'claude-*', protocol: 'anthropic' },
{ pattern: 'qwen3.8-max', protocol: 'chat_completions' },
{ pattern: 'qwen*', protocol: 'anthropic' }
]
})
})
it('submits OpenCode GO endpoints after switching account type', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenCode')
await selectButtonByText(wrapper, 'admin.accounts.opencodeGo.accountMode.go')
await wrapper.get('form#create-account-form input[type="text"]').setValue('oc-go')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-opencode-go')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
account_mode: 'go',
api_protocol: 'adaptive',
base_url: 'https://opencode.ai/zen/go/v1',
api_base_urls: {
chat_completions: 'https://opencode.ai/zen/go/v1',
anthropic: 'https://opencode.ai/zen/go',
responses: 'https://opencode.ai/zen/go/v1'
},
protocol_rules: [
{ pattern: 'grok-*', protocol: 'responses' },
{ pattern: 'gpt-*', protocol: 'responses' },
{ pattern: 'muse-spark-*', protocol: 'responses' },
{ pattern: 'minimax-*', protocol: 'anthropic' },
{ pattern: 'qwen*', protocol: 'anthropic' }
]
})
})
describe('providers using the generic form', () => {
const serverOnlyProviders = {
platforms: [
...BUILTIN_PLATFORM_CATALOG.platforms,
{
id: 'acme_router',
display_name: 'Acme Router',
gateway: 'openai',
cn_provider: false,
multi_protocol: {
default_mode: 'standard',
routing: 'by_model',
modes: [
{
mode: 'standard',
base_urls: {
chat_completions: 'https://api.acme-router.example/provider/v1',
anthropic: 'https://api.acme-router.example/provider',
},
protocol_rules: [{ pattern: 'claude-*', protocol: 'anthropic' }],
},
{
mode: 'team',
base_urls: {
chat_completions: 'https://team.acme-router.example/provider/v1',
anthropic: 'https://team.acme-router.example/provider',
},
protocol_rules: [{ pattern: 'sonnet-*', protocol: 'anthropic' }],
},
],
},
},
{
id: 'acme_chat',
display_name: 'Acme Chat',
gateway: 'openai',
cn_provider: false,
multi_protocol: {
default_mode: 'pass',
routing: 'by_inbound',
modes: [{ mode: 'pass', base_urls: { chat_completions: 'https://api.acme-chat.example/v1' } }],
},
},
],
composite_precedence: [...BUILTIN_PLATFORM_CATALOG.composite_precedence, 'acme_router', 'acme_chat'],
}
beforeEach(() => {
setPlatformCatalog(serverOnlyProviders)
})
afterEach(() => {
resetPlatformCatalog()
})
it('creates a by-model provider account from its profile defaults', async () => {
const wrapper = mountModal()
await wrapper.get('[data-testid="platform-button-acme_router"]').trigger('click')
await wrapper.get('form#create-account-form input[type="text"]').setValue('cc')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-cc')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
const payload = createAccountMock.mock.calls[0]?.[0]
expect(payload?.platform).toBe('acme_router')
expect(payload?.type).toBe('apikey')
expect(payload?.credentials).toMatchObject({
api_key: 'sk-cc',
account_mode: 'standard',
api_protocol: 'adaptive',
base_url: 'https://api.acme-router.example/provider/v1',
api_base_urls: {
chat_completions: 'https://api.acme-router.example/provider/v1',
anthropic: 'https://api.acme-router.example/provider',
},
protocol_rules: [{ pattern: 'claude-*', protocol: 'anthropic' }],
})
// 该供应商没有原生 Responses 端点,不下发 responses 基址。
expect(payload?.credentials?.api_base_urls).not.toHaveProperty('responses')
// 没有内置模型列表时不预填白名单,新账号不限制模型。
expect(payload?.credentials).not.toHaveProperty('model_mapping')
})
it('switches endpoints and default rules with the provider mode', async () => {
const wrapper = mountModal()
await wrapper.get('[data-testid="platform-button-acme_router"]').trigger('click')
await wrapper.get('[data-testid="generic-account-mode"]').findAll('button')[1].trigger('click')
await wrapper.get('form#create-account-form input[type="text"]').setValue('cc-team')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-cc')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
account_mode: 'team',
base_url: 'https://team.acme-router.example/provider/v1',
api_base_urls: {
chat_completions: 'https://team.acme-router.example/provider/v1',
anthropic: 'https://team.acme-router.example/provider',
},
protocol_rules: [{ pattern: 'sonnet-*', protocol: 'anthropic' }],
})
})
it('creates a by-inbound provider account without protocol rules', async () => {
const wrapper = mountModal()
await wrapper.get('[data-testid="platform-button-acme_chat"]').trigger('click')
// 单一接入模式时不显示模式选择。
expect(wrapper.find('[data-testid="generic-account-mode"]').exists()).toBe(false)
await wrapper.get('form#create-account-form input[type="text"]').setValue('acme-chat')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-acme-chat')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
const credentials = createAccountMock.mock.calls[0]?.[0]?.credentials
expect(credentials).toMatchObject({
account_mode: 'pass',
api_protocol: 'adaptive',
base_url: 'https://api.acme-chat.example/v1',
api_base_urls: { chat_completions: 'https://api.acme-chat.example/v1' },
})
expect(credentials).not.toHaveProperty('protocol_rules')
})
it('falls back to the Kimi default mode after a server-only provider', async () => {
const wrapper = mountModal()
await wrapper.get('[data-testid="platform-button-acme_router"]').trigger('click')
await selectButtonByText(wrapper, 'Kimi')
await wrapper.get('form#create-account-form input[type="text"]').setValue('kimi')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-kimi')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
account_mode: 'payg',
base_url: 'https://api.moonshot.cn/v1',
})
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).not.toHaveProperty('protocol_rules')
})
})
it('groups the aggregators on their own row below the CN providers', () => {
const wrapper = mountModal()
const labels = (testid: string) =>
wrapper.get(`[data-testid="${testid}"]`).findAll('button').map(button => button.text().trim())
expect(labels('platform-row-cn')).toEqual(['Kimi', 'Zhipu GLM', 'DeepSeek', 'MiniMax'])
expect(labels('platform-row-aggregators')).toEqual(['OpenCode', 'Command Code', 'Cline'])
})
it('creates a Cline account without an account type and with only the Chat Completions endpoint', async () => {
const wrapper = mountModal()
await wrapper.get('[data-testid="platform-button-cline"]').trigger('click')
// 积分与 ClinePass 共用同一个 Key,按模型计费,不需要选择账号类型。
expect(wrapper.find('[data-testid="generic-account-mode"]').exists()).toBe(false)
await wrapper.get('form#create-account-form input[type="text"]').setValue('cline')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-cline')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
const payload = createAccountMock.mock.calls[0]?.[0]
expect(payload?.platform).toBe('cline')
expect(payload?.credentials).toMatchObject({
account_mode: 'payg',
api_protocol: 'adaptive',
base_url: 'https://api.cline.bot/api/v1',
api_base_urls: { chat_completions: 'https://api.cline.bot/api/v1' },
})
expect(payload?.credentials).not.toHaveProperty('protocol_rules')
expect(payload?.credentials?.api_base_urls).not.toHaveProperty('responses')
expect(payload?.credentials?.api_base_urls).not.toHaveProperty('anthropic')
})
it('submits adaptive Kimi protocol endpoints', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Kimi')
await wrapper.get('form#create-account-form input[type="text"]').setValue('Kimi adaptive')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-kimi')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
account_mode: 'payg',
api_protocol: 'adaptive',
base_url: 'https://api.moonshot.cn/v1',
api_base_urls: {
chat_completions: 'https://api.moonshot.cn/v1',
anthropic: 'https://api.moonshot.cn/anthropic',
responses: 'https://api.moonshot.cn/v1'
}
})
})
it('submits adaptive Kimi Coding Plan Responses endpoint', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Kimi')
await selectButtonByText(wrapper, 'admin.accounts.cnProviders.accountMode.coding')
await wrapper.get('form#create-account-form input[type="text"]').setValue('Kimi coding')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-kimi-coding')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
account_mode: 'coding',
api_protocol: 'adaptive',
base_url: 'https://api.kimi.com/coding/v1',
api_base_urls: {
chat_completions: 'https://api.kimi.com/coding/v1',
anthropic: 'https://api.kimi.com/coding',
responses: 'https://api.kimi.com/coding/v1'
}
})
})
it('submits adaptive MiniMax protocol endpoints', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'MiniMax')
await wrapper.get('form#create-account-form input[type="text"]').setValue('MiniMax adaptive')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-minimax')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
account_mode: 'payg',
api_protocol: 'adaptive',
base_url: 'https://api.minimaxi.com/v1',
api_base_urls: {
chat_completions: 'https://api.minimaxi.com/v1',
anthropic: 'https://api.minimaxi.com/anthropic',
responses: 'https://api.minimaxi.com/v1'
}
})
})
it('uses the edited adaptive Chat endpoint when previewing upstream models', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Kimi')
await wrapper
.get('[data-testid="cn-adaptive-base-url-chat_completions"]')
.setValue('https://relay.example.com/v1')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-relay')
expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('syncCredentials')).toMatchObject({
platform: 'kimi',
type: 'apikey',
base_url: 'https://relay.example.com/v1',
api_key: 'sk-relay'
})
})
it('exposes Agent Identity in the OpenAI authorization methods', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenAI account')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
const flow = wrapper.getComponent(OAuthAuthorizationFlowStub)
expect(flow.props('showManualOption')).toBe(true)
expect(flow.props('showCodexSessionImportOption')).toBe(true)
expect(flow.props('showAgentIdentityOption')).toBe(true)
expect(flow.props('showCodexPatOption')).toBe(true)
expect(flow.props('initialInputMethod')).toBe('manual')
})
it.each([
['camelCase', { authMode: 'agentIdentity', agentIdentity: { agentRuntimeId: 'runtime' } }],
['nested identity without auth_mode', { agent_identity: { agent_runtime_id: 'runtime' } }],
])('accepts backend-compatible %s Agent Identity imports', async (_name, content) => {
const wrapper = await openCodexImportStep()
const flow = wrapper.getComponent(OAuthAuthorizationFlowStub)
flow.vm.inputMethod = 'agent_identity'
flow.vm.$emit('import-codex-session', JSON.stringify(content))
await flushPromises()
expect(importCodexSessionMock).toHaveBeenCalledTimes(1)
})
it('sends true explicitly when OpenAI long-context billing is enabled', async () => {
await submitApiKeyAccount('openai', true)
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true)
})
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()
// 上游倍率探测已放宽到全部 API-key 平台:非 OpenAI 平台与 OpenAI 一致,默认开启。
expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(true)
})
it('sends an explicit disabled state when the non-OpenAI create toggle is turned off', async () => {
await submitApiKeyAccount('anthropic', false, true)
expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(false)
})
it('antigravity upstream 创建默认携带上游倍率探测开关', async () => {
// antigravity upstream 走独立创建 helper,
// 也必须与其余 API-key 平台一样默认开启探测并传递开关。
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Antigravity')
await selectButtonByText(wrapper, 'admin.accounts.types.antigravityApikey')
await wrapper.get('form#create-account-form input[type="text"]').setValue('antigravity relay')
const baseInput = wrapper
.findAll('input')
.find((candidate) => candidate.attributes('placeholder') === 'https://cloudcode-pa.googleapis.com')
expect(baseInput).toBeDefined()
await baseInput?.setValue('https://relay.example')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-upstream')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
const payload = createAccountMock.mock.calls[0]?.[0]
expect(payload?.platform).toBe('antigravity')
expect(payload?.type).toBe('apikey')
expect(payload?.upstream_billing_probe_enabled).toBe(true)
// 创建成功后前端立即发起一次首探(与其他 apikey 平台一致)。
expect(probeUpstreamBillingMock).toHaveBeenCalledWith(42)
})
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()
})
it('sends explicit true for Codex session import after the toggle is enabled', async () => {
const wrapper = await openCodexImportStep(1)
await wrapper.get('[data-testid="import-codex-session"]').trigger('click')
await flushPromises()
expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true)
})
it('sends explicit false for Codex session import after the toggle is changed back', async () => {
const wrapper = await openCodexImportStep(2)
await wrapper.get('[data-testid="import-codex-session"]').trigger('click')
await flushPromises()
expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('sends explicit true for Codex PAT import after the toggle is enabled', async () => {
const wrapper = await openCodexImportStep(1)
await wrapper.get('[data-testid="import-codex-pat"]').trigger('click')
await flushPromises()
expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true)
})
it('sends explicit false for Codex PAT import after the toggle is changed back', async () => {
const wrapper = await openCodexImportStep(2)
await wrapper.get('[data-testid="import-codex-pat"]').trigger('click')
await flushPromises()
expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
})
describe('CreateAccountModal gemini explicit cache upstream switch', () => {
beforeEach(() => {
authIsSimpleMode.value = true
createAccountMock.mockReset().mockResolvedValue({ id: 43, platform: 'gemini', type: 'apikey' })
probeUpstreamBillingMock.mockReset().mockResolvedValue({})
syncUpstreamModelsMock.mockReset().mockResolvedValue({ models: [], metadata: {} })
})
it('only offers the switch for a custom base url and sends it on create', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Gemini')
await selectButtonByText(wrapper, 'admin.accounts.gemini.accountType.apiKeyTitle')
expect(wrapper.find('[data-testid="explicit-cache-upstream-section"]').exists()).toBe(false)
const baseUrlInput = wrapper
.findAll('form#create-account-form input[type="text"]')
.find((input) => (input.element as HTMLInputElement).value === 'https://generativelanguage.googleapis.com')
expect(baseUrlInput).toBeDefined()
await baseUrlInput!.setValue('https://upstream.example.com')
expect(wrapper.find('[data-testid="explicit-cache-upstream-section"]').exists()).toBe(true)
await wrapper.get('[data-testid="explicit-cache-upstream-toggle"]').trigger('click')
await wrapper.get('[data-testid="explicit-cache-upstream-max-ttl"]').setValue('1800')
await wrapper.get('form#create-account-form input[type="text"]').setValue('gemini upstream gateway')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-upstream')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
base_url: 'https://upstream.example.com',
explicit_cache_upstream: true,
explicit_cache_upstream_max_ttl_seconds: 1800
})
})
})
describe('CreateAccountModal Vertex API key auth', () => {
beforeEach(() => {
authIsSimpleMode.value = true
createAccountMock.mockReset().mockResolvedValue({ id: 44, platform: 'gemini', type: 'service_account' })
probeUpstreamBillingMock.mockReset().mockResolvedValue({})
syncUpstreamModelsMock.mockReset().mockResolvedValue({ models: [], metadata: {} })
})
async function openGeminiVertexApiKeyForm() {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Gemini')
await selectButtonByText(wrapper, 'admin.accounts.vertexLabel')
await wrapper.get('[data-testid="vertex-auth-mode-apikey"]').setValue(true)
await wrapper.get('form#create-account-form input[type="text"]').setValue('vertex api key account')
await wrapper.get('[data-testid="vertex-api-key-input"]').setValue(' vertex-key ')
return wrapper
}
it('creates a Gemini Vertex account with API key auth and no project for the express endpoint', async () => {
const wrapper = await openGeminiVertexApiKeyForm()
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
const payload = createAccountMock.mock.calls[0]?.[0]
expect(payload?.platform).toBe('gemini')
expect(payload?.type).toBe('service_account')
expect(payload?.credentials).toEqual({
auth_mode: 'apikey',
api_key: 'vertex-key',
location: 'global',
tier_id: 'vertex'
})
})
it('sends the project id when an API key account targets a project endpoint', async () => {
const wrapper = await openGeminiVertexApiKeyForm()
await wrapper.get('[data-testid="vertex-api-key-project-id-input"]').setValue(' my-project ')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
auth_mode: 'apikey',
api_key: 'vertex-key',
project_id: 'my-project',
location: 'global'
})
})
it('keeps Anthropic Vertex accounts on Service Account JSON only', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'admin.accounts.vertexLabel')
expect(wrapper.find('[data-testid="vertex-auth-mode-apikey"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="vertex-api-key-input"]').exists()).toBe(false)
})
})