Merge upstream/main into fix/api-double-billing

This commit is contained in:
benjamin
2026-07-13 11:10:33 +08:00
120 changed files with 6391 additions and 402 deletions
@@ -386,6 +386,7 @@
:label="t('admin.accounts.usageWindow.grokRequests')"
:utilization="grokRequestQuotaBar.utilization"
:resets-at="grokRequestQuotaBar.resetsAt"
:remaining-capacity="true"
color="indigo"
/>
<UsageProgressBar
@@ -393,6 +394,7 @@
:label="t('admin.accounts.usageWindow.grokTokens')"
:utilization="grokTokenQuotaBar.utilization"
:resets-at="grokTokenQuotaBar.resetsAt"
:remaining-capacity="true"
color="emerald"
/>
<div v-if="grokRetryAfterLabel" class="text-[10px] text-amber-600 dark:text-amber-400">
@@ -1036,9 +1038,9 @@ interface GrokQuotaBarInfo {
const makeGrokQuotaBar = (quota?: { limit?: number | null; remaining?: number | null; reset_at?: string | null } | null): GrokQuotaBarInfo | null => {
if (!quota || quota.limit == null || quota.remaining == null || quota.limit <= 0) return null
const used = Math.max(0, quota.limit - quota.remaining)
const remaining = Math.min(quota.limit, Math.max(0, quota.remaining))
return {
utilization: (used / quota.limit) * 100,
utilization: (remaining / quota.limit) * 100,
resetsAt: quota.reset_at || null
}
}
@@ -352,7 +352,7 @@
</div>
</div>
<!-- Account Type Selection (Grok - OAuth only) -->
<!-- Account Type Selection (Grok) -->
<div v-if="form.platform === 'grok'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2" data-tour="account-form-type">
@@ -381,10 +381,34 @@
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.grokOauth') }}</span>
</div>
</button>
<button
type="button"
data-testid="grok-account-type-api-key"
@click="accountCategory = 'apikey'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'apikey'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="key" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.responsesApi') }}</span>
</div>
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.oauth.grok.oauthOnlyHint') }}
</p>
</div>
<!-- Account Type Selection (Gemini) -->
@@ -1087,10 +1111,12 @@
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: form.platform === 'grok'
? 'https://api.x.ai/v1'
: 'https://api.anthropic.com'
"
/>
<p class="input-hint">{{ baseUrlHint }}</p>
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
@@ -1104,10 +1130,12 @@
? 'sk-proj-...'
: form.platform === 'gemini'
? 'AIza...'
: 'sk-ant-...'
: form.platform === 'grok'
? 'xai-...'
: 'sk-ant-...'
"
/>
<p class="input-hint">{{ apiKeyHint }}</p>
<p v-if="apiKeyHint" class="input-hint">{{ apiKeyHint }}</p>
</div>
<!-- Gemini API Key tier selection -->
@@ -3515,14 +3543,14 @@ const oauthStepTitle = computed(() => {
const baseUrlHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
if (form.platform === 'grok') return t('admin.accounts.grok.baseUrlHint')
if (form.platform === 'grok') return ''
return t('admin.accounts.baseUrlHint')
})
const apiKeyHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
if (form.platform === 'grok') return t('admin.accounts.grok.apiKeyHint')
if (form.platform === 'grok') return ''
return t('admin.accounts.apiKeyHint')
})
@@ -4926,7 +4954,9 @@ const handleSubmit = async () => {
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: form.platform === 'grok'
? 'https://api.x.ai/v1'
: 'https://api.anthropic.com'
// Build credentials with optional model mapping
const credentials: Record<string, unknown> = {
@@ -41,10 +41,12 @@
? 'https://generativelanguage.googleapis.com'
: account.platform === 'antigravity'
? 'https://cloudcode-pa.googleapis.com'
: 'https://api.anthropic.com'
: account.platform === 'grok'
? 'https://api.x.ai/v1'
: 'https://api.anthropic.com'
"
/>
<p class="input-hint">{{ baseUrlHint }}</p>
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
@@ -63,7 +65,9 @@
? 'AIza...'
: account.platform === 'antigravity'
? 'sk-...'
: 'sk-ant-...'
: account.platform === 'grok'
? 'xai-...'
: 'sk-ant-...'
"
/>
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
@@ -2626,6 +2630,7 @@ const baseUrlHint = computed(() => {
if (!props.account) return t('admin.accounts.baseUrlHint')
if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
if (props.account.platform === 'grok') return ''
return t('admin.accounts.baseUrlHint')
})
@@ -3076,6 +3081,7 @@ const tempUnschedPresets = computed(() => [
const defaultBaseUrl = computed(() => {
if (props.account?.platform === 'openai') return 'https://api.openai.com'
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
if (props.account?.platform === 'grok') return 'https://api.x.ai/v1'
return 'https://api.anthropic.com'
})
@@ -3370,7 +3376,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: newAccount.platform === 'grok'
? 'https://api.x.ai/v1'
: 'https://api.anthropic.com'
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
// Load model mappings and detect mode
@@ -3446,7 +3454,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: newAccount.platform === 'grok'
? 'https://api.x.ai/v1'
: 'https://api.anthropic.com'
editBaseUrl.value = platformDefaultUrl
// Load model mappings for OpenAI/Grok OAuth accounts
@@ -69,6 +69,7 @@ const props = defineProps<{
color: 'indigo' | 'emerald' | 'purple' | 'amber'
windowStats?: WindowStats | null
showNowWhenIdle?: boolean
remainingCapacity?: boolean
}>()
const { t } = useI18n()
@@ -109,6 +110,14 @@ const labelClass = computed(() => {
// Progress bar color based on utilization
const barClass = computed(() => {
if (props.remainingCapacity) {
if (props.utilization <= 20) {
return 'bg-red-500'
} else if (props.utilization <= 50) {
return 'bg-amber-500'
}
return 'bg-green-500'
}
if (props.utilization >= 100) {
return 'bg-red-500'
} else if (props.utilization >= 80) {
@@ -120,6 +129,14 @@ const barClass = computed(() => {
// Text color based on utilization
const textClass = computed(() => {
if (props.remainingCapacity) {
if (props.utilization <= 20) {
return 'text-red-600 dark:text-red-400'
} else if (props.utilization <= 50) {
return 'text-amber-600 dark:text-amber-400'
}
return 'text-gray-600 dark:text-gray-400'
}
if (props.utilization >= 100) {
return 'text-red-600 dark:text-red-400'
} else if (props.utilization >= 80) {
@@ -131,12 +148,16 @@ const textClass = computed(() => {
// Bar width (capped at 100%)
const barWidth = computed(() => {
return `${Math.min(props.utilization, 100)}%`
return `${Math.min(Math.max(props.utilization, 0), 100)}%`
})
// Display percentage (cap at 999% for readability)
const displayPercent = computed(() => {
const percent = Math.round(props.utilization)
const percent = Math.round(
props.remainingCapacity
? Math.min(Math.max(props.utilization, 0), 100)
: props.utilization
)
return percent > 999 ? '>999%' : `${percent}%`
})
@@ -13,6 +13,14 @@ vi.mock('vue-i18n', async () => {
}
})
vi.mock('@/utils/format', async () => {
const actual = await vi.importActual<typeof import('@/utils/format')>('@/utils/format')
return {
...actual,
formatCountdown: () => '1h'
}
})
function makeAccount(overrides: Partial<Account>): Account {
return {
id: 1,
@@ -43,6 +51,31 @@ function makeAccount(overrides: Partial<Account>): Account {
}
describe('AccountStatusIndicator', () => {
it('Grok 账号额度限流时显示自动恢复时间而非临时不可调度', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
id: 5,
name: 'grok-free-1',
platform: 'grok',
rate_limited_at: '2026-07-11T12:00:00Z',
rate_limit_reset_at: '2099-07-11T13:00:00Z',
temp_unschedulable_until: '2099-07-11T12:30:00Z',
temp_unschedulable_reason: 'legacy grok rate limited'
})
},
global: {
stubs: {
Icon: true
}
}
})
expect(wrapper.find('.badge-warning').text()).toBe('admin.accounts.status.rateLimited')
expect(wrapper.text()).toContain('admin.accounts.status.rateLimitedAutoResume')
expect(wrapper.text()).not.toContain('admin.accounts.status.tempUnschedulable')
})
it('模型限流 + overages 启用 + 无 AICredits key → 显示 ⚡ (credits_active)', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
@@ -566,7 +566,7 @@ describe('AccountUsageCell', () => {
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
})
it('Grok OAuth 会展示本地 user billed 用量并保留超限百分比', async () => {
it('Grok OAuth 会展示本地 user billed 用量并把耗尽配额显示为 0% 剩余', async () => {
getUsage.mockResolvedValue({
grok_local_usage: {
requests: 4,
@@ -611,13 +611,55 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('1.2K')
expect(wrapper.text()).toContain('A $0.12')
expect(wrapper.text()).toContain('U $0.34')
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|120|2026-07-09T16:00:00Z')
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|0|2026-07-09T16:00:00Z')
const badges = wrapper.findAll('span[title]')
expect(badges.some(node => node.attributes('title') === 'usage.accountBilled')).toBe(true)
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
})
it('Grok OAuth 配额条按剩余容量显示 100% 满格和 25% 低量', async () => {
getUsage.mockResolvedValue({
grok_request_quota: {
limit: 100,
remaining: 100,
reset_at: '2026-07-09T16:00:00Z'
},
grok_token_quota: {
limit: 1000,
remaining: 250,
reset_at: '2026-07-09T16:00:00Z'
},
grok_quota_snapshot_state: 'observed'
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 4073,
platform: 'grok',
type: 'oauth',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'color', 'remainingCapacity'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ remainingCapacity }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|100|true')
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25|true')
})
it('Key 账号在 today stats loading 时显示骨架屏', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
resolve(process.cwd(), 'src/components/account/CreateAccountModal.vue'),
'utf8'
)
describe('CreateAccountModal Grok account types', () => {
it('offers API-key setup alongside OAuth with the official xAI default', () => {
expect(source).toContain('data-testid="grok-account-type-api-key"')
expect(source).toContain("@click=\"accountCategory = 'apikey'\"")
expect(source).toContain("newPlatform === 'grok'")
expect(source).toContain("? 'https://api.x.ai/v1'")
expect(source).toContain("form.platform === 'grok'")
expect(source).toContain("? 'xai-...'")
})
})
@@ -267,6 +267,18 @@ function buildGrokOAuthAccount() {
} as any
}
function buildGrokAPIKeyAccount() {
return {
...buildAccount(),
id: 6,
name: 'Grok API Key',
platform: 'grok',
credentials: {},
credentials_status: { has_api_key: true },
concurrency: 2
} as any
}
function buildOpenAISetupTokenAccount() {
return {
...buildAccount(),
@@ -433,6 +445,24 @@ describe('EditAccountModal', () => {
})
})
it('uses the official xAI base URL when a Grok API-key account omits base_url', async () => {
const account = buildGrokAPIKeyAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect((wrapper.get('input[placeholder="https://api.x.ai/v1"]').element as HTMLInputElement).value)
.toBe('https://api.x.ai/v1')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.base_url).toBe('https://api.x.ai/v1')
})
it('only submits model mapping credentials when saving an OpenAI spark shadow account', async () => {
authIsSimpleMode.value = false
const account = buildOpenAISparkShadowAccount()
@@ -96,4 +96,54 @@ describe('UsageProgressBar', () => {
expect(wrapper.text()).toContain('usage.resetNow')
expect(wrapper.text()).not.toContain('usage.resetPending')
})
it('剩余容量模式在 100% 时显示满格绿色', () => {
const wrapper = mount(UsageProgressBar, {
props: {
label: 'Req',
utilization: 100,
remainingCapacity: true,
color: 'indigo'
}
})
expect(wrapper.text()).toContain('100%')
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%')
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-green-500')
})
it('剩余容量模式在低量和耗尽时缩短并变红', async () => {
const wrapper = mount(UsageProgressBar, {
props: {
label: 'Req',
utilization: 15,
remainingCapacity: true,
color: 'indigo'
}
})
expect(wrapper.text()).toContain('15%')
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 15%')
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
await wrapper.setProps({ utilization: 0 })
expect(wrapper.text()).toContain('0%')
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 0%')
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
})
it('默认利用率模式仍把超限显示为满格红色', () => {
const wrapper = mount(UsageProgressBar, {
props: {
label: '5h',
utilization: 120,
color: 'indigo'
}
})
expect(wrapper.text()).toContain('120%')
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%')
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
})
})
+64 -1
View File
@@ -181,6 +181,8 @@ const defaultClientTab = computed(() => {
switch (props.platform) {
case 'openai':
return 'codex'
case 'grok':
return 'grok'
case 'gemini':
return 'gemini'
case 'antigravity':
@@ -288,6 +290,11 @@ const clientTabs = computed((): TabConfig[] => {
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
case 'grok':
return [
{ id: 'grok', label: t('keys.useKeyModal.cliTabs.grokCli'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
default:
return [
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
@@ -313,7 +320,7 @@ const showShellTabs = computed(() => activeClientTab.value !== 'opencode')
const currentTabs = computed(() => {
if (!showShellTabs.value) return []
if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws') {
if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws' || activeClientTab.value === 'grok') {
return openaiTabs
}
return shellTabs
@@ -330,6 +337,8 @@ const platformDescription = computed(() => {
return t('keys.useKeyModal.gemini.description')
case 'antigravity':
return t('keys.useKeyModal.antigravity.description')
case 'grok':
return t('keys.useKeyModal.grok.description')
default:
return t('keys.useKeyModal.description')
}
@@ -350,6 +359,10 @@ const platformNote = computed(() => {
return activeClientTab.value === 'claude'
? t('keys.useKeyModal.antigravity.claudeNote')
: t('keys.useKeyModal.antigravity.geminiNote')
case 'grok':
return activeTab.value === 'windows'
? t('keys.useKeyModal.grok.noteWindows')
: t('keys.useKeyModal.grok.note')
default:
return t('keys.useKeyModal.note')
}
@@ -407,6 +420,8 @@ const currentFiles = computed((): FileConfig[] => {
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
generateOpenCodeConfig('antigravity-gemini', antigravityGeminiBase, apiKey, 'opencode.json (Gemini)')
]
case 'grok':
return [generateOpenCodeConfig('grok', apiBase, apiKey)]
default:
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
}
@@ -428,6 +443,8 @@ const currentFiles = computed((): FileConfig[] => {
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
}
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
case 'grok':
return generateGrokFiles(apiBase, apiKey)
default:
return generateAnthropicFiles(baseUrl, apiKey)
}
@@ -568,6 +585,30 @@ goals = true`
]
}
function generateGrokFiles(baseUrl: string, apiKey: string): FileConfig[] {
const isWindows = activeTab.value === 'windows'
const configDir = isWindows ? '%userprofile%\\.grok' : '~/.grok'
const configContent = `[models]
default = "sub2api-grok"
web_search = "sub2api-grok"
[model."sub2api-grok"]
model = "grok-4.5"
base_url = "${baseUrl}"
name = "Grok 4.5 via Sub2API"
description = "Grok 4.5 through a Sub2API Grok group"
api_key = "${apiKey}"
api_backend = "responses"
context_window = 1000000
supports_backend_search = true`
return [{
path: `${configDir}/config.toml`,
content: configContent,
hint: t('keys.useKeyModal.grok.configTomlHint')
}]
}
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
const isWindows = activeTab.value === 'windows'
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
@@ -1072,6 +1113,24 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
}
}
}
const grokModels = {
'grok-4.5': {
name: 'Grok 4.5',
limit: { context: 1000000, output: 128000 }
},
'grok-4.3': {
name: 'Grok 4.3',
limit: { context: 1000000, output: 128000 }
},
'grok-build-0.1': {
name: 'Grok Build 0.1',
limit: { context: 256000, output: 128000 }
},
'grok-composer-2.5-fast': {
name: 'Grok Composer 2.5 Fast',
limit: { context: 500000, output: 128000 }
}
}
if (platform === 'gemini') {
provider[platform].npm = '@ai-sdk/google'
@@ -1088,6 +1147,10 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
provider[platform].models = antigravityGeminiModels
} else if (platform === 'openai') {
provider[platform].models = openaiModels
} else if (platform === 'grok') {
provider[platform].npm = '@ai-sdk/openai'
provider[platform].name = 'Grok via Sub2API'
provider[platform].models = grokModels
}
const agent =
@@ -17,6 +17,67 @@ vi.mock('@/composables/useClipboard', () => ({
import UseKeyModal from '../UseKeyModal.vue'
describe('UseKeyModal', () => {
it('renders Grok Build and OpenCode setup for Grok groups', async () => {
const wrapper = mount(UseKeyModal, {
props: {
show: true,
apiKey: 'sk-grok-test',
baseUrl: 'https://example.com/v1',
platform: 'grok'
},
global: {
stubs: {
BaseDialog: {
template: '<div><slot /><slot name="footer" /></div>'
},
Icon: {
template: '<span />'
}
}
}
})
const grokTab = wrapper.findAll('button').find((button) =>
button.text().includes('keys.useKeyModal.cliTabs.grokCli')
)
expect(grokTab).toBeDefined()
const grokConfig = wrapper.findAll('pre code')
.map((code) => code.text())
.find((content) => content.includes('[model."sub2api-grok"]'))
expect(grokConfig).toBeDefined()
expect(grokConfig).toContain('model = "grok-4.5"')
expect(grokConfig).toContain('base_url = "https://example.com/v1"')
expect(grokConfig).toContain('api_key = "sk-grok-test"')
expect(grokConfig).toContain('api_backend = "responses"')
const windowsTab = wrapper.findAll('button').find(
(button) => button.text().trim() === 'Windows'
)
expect(windowsTab).toBeDefined()
await windowsTab!.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('%userprofile%\\.grok/config.toml')
const opencodeTab = wrapper.findAll('button').find((button) =>
button.text().includes('keys.useKeyModal.cliTabs.opencode')
)
expect(opencodeTab).toBeDefined()
await opencodeTab!.trigger('click')
await nextTick()
const parsed = JSON.parse(wrapper.find('pre code').text())
expect(parsed.provider.grok.npm).toBe('@ai-sdk/openai')
expect(parsed.provider.grok.options).toEqual({
baseURL: 'https://example.com/v1',
apiKey: 'sk-grok-test'
})
expect(parsed.provider.grok.models['grok-4.5']).toBeDefined()
expect(parsed.provider.grok.models['grok-build-0.1']).toBeDefined()
expect(parsed.provider.grok.models['grok-composer-2.5-fast']).toBeDefined()
expect(parsed.provider.grok.models['gpt-5.6']).toBeUndefined()
})
it('renders GPT-5.5 and goals feature in OpenAI Codex config', () => {
const wrapper = mount(UseKeyModal, {
props: {
@@ -53,3 +53,20 @@ describe('useGrokOAuth.exchangeAuthCode', () => {
)
})
})
describe('useGrokOAuth.buildCredentials', () => {
it('persists the Grok CLI subscription proxy for OAuth inference', () => {
const oauth = useGrokOAuth()
const credentials = oauth.buildCredentials({
access_token: 'access-token',
token_type: 'Bearer',
expires_at: 1_900_000_000,
client_id: 'client-id',
scope: 'openid grok-cli:access',
email: 'grok@example.com'
})
expect(credentials.base_url).toBe('https://cli-chat-proxy.grok.com/v1')
})
})
+2 -1
View File
@@ -122,7 +122,8 @@ export function useGrokOAuth() {
scope: tokenInfo.scope,
email: tokenInfo.email,
subscription_tier: tokenInfo.subscription_tier,
entitlement_status: tokenInfo.entitlement_status
entitlement_status: tokenInfo.entitlement_status,
base_url: 'https://cli-chat-proxy.grok.com/v1'
}
if (tokenInfo.refresh_token) credentials.refresh_token = tokenInfo.refresh_token
if (tokenInfo.id_token) credentials.id_token = tokenInfo.id_token
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import en from '../locales/en'
import zh from '../locales/zh'
describe('OpenAI Fast/Flex policy locale keys', () => {
it('exposes user scope copy at the runtime zh path', () => {
expect(zh.admin.settings.openaiFastPolicy).toMatchObject({
userIds: '指定用户',
userIdsHint: '输入任意邮箱关键词进行模糊搜索。留空表示对全部 Sub2API 用户生效;选中用户的 API Key 请求优先匹配用户规则。',
userSearchPlaceholder: '输入用户邮箱搜索',
userSearchEmpty: '未找到匹配用户',
userDeleted: '(已删除)',
userIdFallback: '用户 #{id}',
removeUser: '移除用户'
})
})
it('exposes user scope copy at the runtime en path', () => {
expect(en.admin.settings.openaiFastPolicy).toMatchObject({
userIds: 'Specific users',
userIdsHint: 'Type any part of a user email to search. Leave empty to apply to all Sub2API users. Selected users match requests from their API keys and take precedence over global rules.',
userSearchPlaceholder: 'Search by user email',
userSearchEmpty: 'No matching users found',
userDeleted: '(deleted)',
userIdFallback: 'User #{id}',
removeUser: 'Remove user'
})
})
})
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import en from '@/i18n/locales/en'
import zh from '@/i18n/locales/zh'
function flattenKeys(obj: Record<string, any>, prefix = ''): string[] {
const keys: string[] = []
@@ -35,4 +36,18 @@ describe('groups locale key completeness', () => {
const enKeys = flattenKeys(en)
expect(enKeys).toContain('admin.groups.failedToSave')
})
const webSearchPricingKeys = [
'admin.groups.webSearchPricing.title',
'admin.groups.webSearchPricing.pricePerCall',
'admin.groups.webSearchPricing.pricePerCallHint',
'admin.groups.webSearchPricing.finalPricePreview',
]
for (const key of webSearchPricingKeys) {
it(`en and zh locales both have ${key}`, () => {
expect(flattenKeys(en)).toContain(key)
expect(flattenKeys(zh)).toContain(key)
})
}
})
@@ -939,6 +939,13 @@ export default {
finalPricePreview: 'Final per-second price preview',
notConfigured: 'Not configured'
},
webSearchPricing: {
title: 'Codex Web Search Pricing',
pricePerCall: 'Price per search call (USD)',
pricePerCallHint:
'Leave empty to use the default $0.01 per call (official pricing: $10 per 1,000 calls); 0 means free. The group rate multiplier is applied on top.',
finalPricePreview: 'Per-call price after current multiplier: {price}'
},
peakRate: {
enable: 'Enable peak rate multiplier',
peakStart: 'Peak start',
@@ -979,11 +979,6 @@ export default {
scopeOAuth: 'OAuth only',
scopeAPIKey: 'API Key only',
scopeBedrock: 'Bedrock only',
userIds: 'Specific user IDs',
userIdsHint: 'Leave empty to apply to all Sub2API users. Specified users match requests from their API keys and take precedence over global rules.',
userIdPlaceholder: 'e.g., 1001',
addUserId: 'Add user ID',
removeUserId: 'Remove user ID',
errorMessage: 'Error message',
errorMessagePlaceholder: 'Custom error message when blocked',
errorMessageHint: 'Leave empty for default message',
@@ -1024,6 +1019,13 @@ export default {
scopeOAuth: 'OAuth only',
scopeAPIKey: 'API Key only',
scopeBedrock: 'Bedrock only',
userIds: 'Specific users',
userIdsHint: 'Type any part of a user email to search. Leave empty to apply to all Sub2API users. Selected users match requests from their API keys and take precedence over global rules.',
userSearchPlaceholder: 'Search by user email',
userSearchEmpty: 'No matching users found',
userDeleted: '(deleted)',
userIdFallback: 'User #{id}',
removeUser: 'Remove user',
errorMessage: 'Error message',
errorMessagePlaceholder: 'Custom error message when blocked',
errorMessageHint: 'Leave empty for the default message.',
@@ -146,6 +146,7 @@ export default {
geminiCli: 'Gemini CLI',
codexCli: 'Codex CLI',
codexCliWs: 'Codex CLI (WebSocket)',
grokCli: 'Grok CLI',
opencode: 'OpenCode',
},
antigravity: {
@@ -160,6 +161,12 @@ export default {
modelComment: 'If you have Gemini 3 access, you can use: gemini-3-pro-preview',
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
},
grok: {
description: 'Configure Grok Build or OpenCode to send Responses API traffic through your Sub2API Grok group.',
configTomlHint: 'Back up an existing config.toml before merging this model entry. Run grok inspect after saving to verify the effective configuration.',
note: 'Save the file as ~/.grok/config.toml, then run grok inspect and select sub2api-grok from /model.',
noteWindows: 'Save the file as %USERPROFILE%\\.grok\\config.toml, then run grok inspect and select sub2api-grok from /model.',
},
opencode: {
title: 'OpenCode Example',
subtitle: 'opencode.json',
@@ -930,6 +930,13 @@ export default {
finalPricePreview: '最终每秒价格预览',
notConfigured: '未配置'
},
webSearchPricing: {
title: 'Codex 网页搜索计费',
pricePerCall: '搜索单次价格(USD/次)',
pricePerCallHint:
'留空使用默认价 $0.01/次(官方定价 $10/1000 次);填 0 表示免费。实际扣费会叠加分组费率倍数。',
finalPricePreview: '应用当前倍率后的单次价格:{price}'
},
peakRate: {
enable: '启用高峰倍率',
peakStart: '高峰开始',
@@ -974,11 +974,6 @@ export default {
scopeOAuth: '仅 OAuth 账号',
scopeAPIKey: '仅 API Key 账号',
scopeBedrock: '仅 Bedrock 账号',
userIds: '指定用户 ID',
userIdsHint: '留空表示对全部 Sub2API 用户生效。指定后仅匹配这些用户的 API Key 请求,且优先于全局规则。',
userIdPlaceholder: '例如: 1001',
addUserId: '添加用户 ID',
removeUserId: '移除用户 ID',
errorMessage: '错误消息',
errorMessagePlaceholder: '拦截时返回的自定义错误消息',
errorMessageHint: '留空则使用默认错误消息',
@@ -1019,6 +1014,13 @@ export default {
scopeOAuth: '仅 OAuth 账号',
scopeAPIKey: '仅 API Key 账号',
scopeBedrock: '仅 Bedrock 账号',
userIds: '指定用户',
userIdsHint: '输入任意邮箱关键词进行模糊搜索。留空表示对全部 Sub2API 用户生效;选中用户的 API Key 请求优先匹配用户规则。',
userSearchPlaceholder: '输入用户邮箱搜索',
userSearchEmpty: '未找到匹配用户',
userDeleted: '(已删除)',
userIdFallback: '用户 #{id}',
removeUser: '移除用户',
errorMessage: '错误消息',
errorMessagePlaceholder: '拦截时返回的自定义错误消息',
errorMessageHint: '留空则使用默认错误消息。',
@@ -147,6 +147,7 @@ export default {
geminiCli: 'Gemini CLI',
codexCli: 'Codex CLI',
codexCliWs: 'Codex CLI (WebSocket)',
grokCli: 'Grok CLI',
opencode: 'OpenCode'
},
antigravity: {
@@ -164,6 +165,12 @@ export default {
modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview',
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
},
grok: {
description: '配置 Grok Build 或 OpenCode,让 Responses API 请求通过当前 Sub2API Grok 分组发送。',
configTomlHint: '如已有 config.toml,请先备份再合并此模型配置。保存后运行 grok inspect 验证生效配置。',
note: '保存为 ~/.grok/config.toml,然后运行 grok inspect,并在 /model 中选择 sub2api-grok。',
noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 sub2api-grok。'
},
opencode: {
title: 'OpenCode 配置示例',
subtitle: 'opencode.json',
+4
View File
@@ -531,6 +531,8 @@ export interface Group {
video_price_480p: number | null
video_price_720p: number | null
video_price_1080p: number | null
// Codex 网页搜索单次价格(USD/次);null 表示使用默认价 0.01
web_search_price_per_call: number | null
// 高峰时段倍率配置
peak_rate_enabled: boolean
peak_start: string
@@ -664,6 +666,7 @@ export interface CreateGroupRequest {
video_price_480p?: number | null
video_price_720p?: number | null
video_price_1080p?: number | null
web_search_price_per_call?: number | null
peak_rate_enabled?: boolean
peak_start?: string
peak_end?: string
@@ -711,6 +714,7 @@ export interface UpdateGroupRequest {
video_price_480p?: number | null
video_price_720p?: number | null
video_price_1080p?: number | null
web_search_price_per_call?: number | null
peak_rate_enabled?: boolean
peak_start?: string
peak_end?: string
+104
View File
@@ -1315,6 +1315,41 @@
</div>
</div>
<!-- Codex 网页搜索按次计费(仅 openai 平台) -->
<div
v-if="createForm.platform === 'openai'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.webSearchPricing.title") }}
</h4>
<div>
<label class="input-label">{{
t("admin.groups.webSearchPricing.pricePerCall")
}}</label>
<input
v-model.number="createForm.web_search_price_per_call"
type="number"
step="0.001"
min="0"
placeholder="0.01"
class="input"
/>
<p class="input-hint">
{{ t("admin.groups.webSearchPricing.pricePerCallHint") }}
</p>
<div
class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
{{
t("admin.groups.webSearchPricing.finalPricePreview", {
price: createWebSearchFinalPricePreview,
})
}}
</div>
</div>
</div>
<!-- OpenAI Messages 调度配置(仅 openai 平台) -->
<div
v-if="createForm.platform === 'openai'"
@@ -2790,6 +2825,41 @@
</div>
</div>
<!-- Codex 网页搜索按次计费(仅 openai 平台) -->
<div
v-if="editForm.platform === 'openai'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.webSearchPricing.title") }}
</h4>
<div>
<label class="input-label">{{
t("admin.groups.webSearchPricing.pricePerCall")
}}</label>
<input
v-model.number="editForm.web_search_price_per_call"
type="number"
step="0.001"
min="0"
placeholder="0.01"
class="input"
/>
<p class="input-hint">
{{ t("admin.groups.webSearchPricing.pricePerCallHint") }}
</p>
<div
class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
{{
t("admin.groups.webSearchPricing.finalPricePreview", {
price: editWebSearchFinalPricePreview,
})
}}
</div>
</div>
</div>
<!-- OpenAI Messages 调度配置(仅 openai 平台) -->
<div
v-if="editForm.platform === 'openai'"
@@ -3888,6 +3958,8 @@ const createForm = reactive({
video_price_480p: null as number | null,
video_price_720p: null as number | null,
video_price_1080p: null as number | null,
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
web_search_price_per_call: null as number | null,
// 高峰时段倍率配置
peak_rate_enabled: false,
peak_start: "",
@@ -4233,6 +4305,8 @@ const editForm = reactive({
video_price_480p: null as number | null,
video_price_720p: null as number | null,
video_price_1080p: null as number | null,
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
web_search_price_per_call: null as number | null,
// 高峰时段倍率配置
peak_rate_enabled: false,
peak_start: "",
@@ -4390,6 +4464,27 @@ const editVideoFinalPricePreview = computed(() =>
buildVideoFinalPricePreview(editForm),
);
// Codex 网页搜索单次默认价(与后端 defaultWebSearchPricePerCall 一致,官方 $10/1000 次)
const DEFAULT_WEB_SEARCH_PRICE_PER_CALL = 0.01;
const buildWebSearchFinalPricePreview = (form: {
web_search_price_per_call: number | string | null;
rate_multiplier: number | string | null;
}) => {
const basePrice =
parsePreviewPrice(form.web_search_price_per_call) ??
DEFAULT_WEB_SEARCH_PRICE_PER_CALL;
const multiplier = normalizePreviewNumber(form.rate_multiplier, 1);
return formatImagePricePreview(basePrice * multiplier);
};
const createWebSearchFinalPricePreview = computed(() =>
buildWebSearchFinalPricePreview(createForm),
);
const editWebSearchFinalPricePreview = computed(() =>
buildWebSearchFinalPricePreview(editForm),
);
const resetDisabledBatchImagePricing = (
form: Pick<
ImagePricingFormState,
@@ -4615,6 +4710,7 @@ const closeCreateModal = () => {
createForm.video_price_480p = null;
createForm.video_price_720p = null;
createForm.video_price_1080p = null;
createForm.web_search_price_per_call = null;
createForm.peak_rate_enabled = false;
createForm.peak_start = "";
createForm.peak_end = "";
@@ -4726,6 +4822,9 @@ const handleCreateGroup = async () => {
requestData.video_price_480p = emptyToNull(requestData.video_price_480p);
requestData.video_price_720p = emptyToNull(requestData.video_price_720p);
requestData.video_price_1080p = emptyToNull(requestData.video_price_1080p);
requestData.web_search_price_per_call = emptyToNull(
requestData.web_search_price_per_call,
);
requestData.peak_rate_enabled = createForm.peak_rate_enabled;
requestData.peak_start = createForm.peak_start;
requestData.peak_end = createForm.peak_end;
@@ -4779,6 +4878,7 @@ const handleEdit = async (group: AdminGroup) => {
editForm.video_price_480p = group.video_price_480p;
editForm.video_price_720p = group.video_price_720p;
editForm.video_price_1080p = group.video_price_1080p;
editForm.web_search_price_per_call = group.web_search_price_per_call ?? null;
editForm.peak_rate_enabled = group.peak_rate_enabled ?? false;
editForm.peak_start = group.peak_start ?? "";
editForm.peak_end = group.peak_end ?? "";
@@ -4836,6 +4936,7 @@ const closeEditModal = () => {
editForm.video_price_480p = null;
editForm.video_price_720p = null;
editForm.video_price_1080p = null;
editForm.web_search_price_per_call = null;
resetMessagesDispatchFormState(editForm);
resetModelsListState(editModelsListState);
};
@@ -4914,6 +5015,9 @@ const handleUpdateGroup = async () => {
payload.video_price_480p = emptyPriceToClear(payload.video_price_480p);
payload.video_price_720p = emptyPriceToClear(payload.video_price_720p);
payload.video_price_1080p = emptyPriceToClear(payload.video_price_1080p);
payload.web_search_price_per_call = emptyPriceToClear(
payload.web_search_price_per_call,
);
payload.peak_rate_enabled = editForm.peak_rate_enabled;
payload.peak_start = editForm.peak_start;
payload.peak_end = editForm.peak_end;
+5 -66
View File
@@ -1199,60 +1199,10 @@
<p class="mb-2 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.openaiFastPolicy.userIdsHint") }}
</p>
<div
v-for="(_, userIDIndex) in rule.user_ids || []"
:key="userIDIndex"
class="mb-1.5 flex items-center gap-2"
>
<input
v-model.number="rule.user_ids![userIDIndex]"
type="number"
min="1"
step="1"
class="input input-sm flex-1"
:placeholder="t('admin.settings.openaiFastPolicy.userIdPlaceholder')"
/>
<button
type="button"
@click="removeOpenAIFastPolicyUserID(rule, userIDIndex)"
class="shrink-0 rounded p-1 text-red-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
:title="t('admin.settings.openaiFastPolicy.removeUserId')"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<button
type="button"
@click="addOpenAIFastPolicyUserID(rule)"
class="mb-2 inline-flex items-center gap-1 text-xs text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t("admin.settings.openaiFastPolicy.addUserId") }}
</button>
<OpenAIFastPolicyUserSelector
:model-value="rule.user_ids || []"
@update:model-value="rule.user_ids = $event"
/>
</div>
<!-- Error Message (only when action=block) -->
@@ -7431,6 +7381,7 @@ import ProxySelector from "@/components/common/ProxySelector.vue";
import ImageUpload from "@/components/common/ImageUpload.vue";
import BackupSettings from "@/views/admin/BackupView.vue";
import EmailTemplateEditor from "@/views/admin/settings/EmailTemplateEditor.vue";
import OpenAIFastPolicyUserSelector from "@/views/admin/settings/OpenAIFastPolicyUserSelector.vue";
import { useClipboard } from "@/composables/useClipboard";
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
@@ -10226,18 +10177,6 @@ function removeOpenAIFastPolicyRule(index: number) {
openaiFastPolicyForm.rules.splice(index, 1);
}
function addOpenAIFastPolicyUserID(rule: OpenAIFastPolicyRule) {
if (!rule.user_ids) rule.user_ids = [];
rule.user_ids.push(0);
}
function removeOpenAIFastPolicyUserID(
rule: OpenAIFastPolicyRule,
idx: number,
) {
rule.user_ids?.splice(idx, 1);
}
function addOpenAIFastPolicyModelPattern(rule: OpenAIFastPolicyRule) {
if (!rule.model_whitelist) rule.model_whitelist = [];
rule.model_whitelist.push("");
@@ -0,0 +1,229 @@
<template>
<div ref="containerRef" class="relative">
<div v-if="selectedUserIds.length > 0" class="mb-2 flex flex-wrap gap-2">
<span
v-for="userId in selectedUserIds"
:key="userId"
class="inline-flex max-w-full items-center gap-1.5 rounded-md bg-gray-100 px-2.5 py-1.5 text-xs text-gray-700 dark:bg-dark-600 dark:text-gray-200"
>
<span class="max-w-64 truncate font-medium" :title="selectedUserLabel(userId)">
{{ selectedUserLabel(userId) }}
</span>
<span class="shrink-0 text-gray-400">#{{ userId }}</span>
<span
v-if="selectedUsers[userId]?.deleted"
class="shrink-0 text-gray-400"
>
{{ t("admin.settings.openaiFastPolicy.userDeleted") }}
</span>
<button
type="button"
class="shrink-0 rounded text-gray-400 hover:text-red-600 dark:hover:text-red-400"
:aria-label="t('admin.settings.openaiFastPolicy.removeUser')"
:title="t('admin.settings.openaiFastPolicy.removeUser')"
@click="removeUser(userId)"
>
<Icon name="x" size="xs" :stroke-width="2" />
</button>
</span>
</div>
<div class="relative">
<Icon
name="search"
size="sm"
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
v-model="searchQuery"
type="text"
autocomplete="off"
class="input input-sm w-full pl-9"
:placeholder="t('admin.settings.openaiFastPolicy.userSearchPlaceholder')"
@input="debounceSearch"
@focus="showDropdown = true"
/>
</div>
<div
v-if="showDropdown && searchQuery.trim()"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-700"
>
<div v-if="searchLoading" class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ t("common.loading") }}
</div>
<div
v-else-if="availableResults.length === 0"
class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
>
{{ t("admin.settings.openaiFastPolicy.userSearchEmpty") }}
</div>
<template v-else>
<button
v-for="user in availableResults"
:key="user.id"
type="button"
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-600"
@click="selectUser(user)"
>
<span class="min-w-0 truncate font-medium text-gray-900 dark:text-white">
{{ user.email }}
<span v-if="user.deleted" class="ml-1 text-xs font-normal text-gray-400">
{{ t("admin.settings.openaiFastPolicy.userDeleted") }}
</span>
</span>
<span class="shrink-0 text-xs text-gray-400">#{{ user.id }}</span>
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { adminAPI } from "@/api/admin";
import type { SimpleUser } from "@/api/admin/usage";
import Icon from "@/components/icons/Icon.vue";
const props = defineProps<{
modelValue: number[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: number[]];
}>();
const { t } = useI18n();
const containerRef = ref<HTMLElement | null>(null);
const searchQuery = ref("");
const searchResults = ref<SimpleUser[]>([]);
const searchLoading = ref(false);
const showDropdown = ref(false);
const selectedUsers = ref<Record<number, SimpleUser>>({});
let searchTimer: ReturnType<typeof setTimeout> | null = null;
let searchSequence = 0;
const selectedUserIds = computed(() =>
Array.from(new Set(props.modelValue.filter((id) => Number.isInteger(id) && id > 0))),
);
const availableResults = computed(() => {
const selected = new Set(selectedUserIds.value);
return searchResults.value
.filter((user) => !selected.has(user.id))
.sort((a, b) => Number(a.deleted) - Number(b.deleted));
});
function selectedUserLabel(userId: number): string {
return selectedUsers.value[userId]?.email ||
t("admin.settings.openaiFastPolicy.userIdFallback", { id: userId });
}
function clearPendingSearch(): void {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
searchSequence += 1;
}
function debounceSearch(): void {
clearPendingSearch();
const query = searchQuery.value.trim();
showDropdown.value = true;
if (!query) {
searchResults.value = [];
searchLoading.value = false;
return;
}
const sequence = searchSequence;
searchTimer = setTimeout(async () => {
searchLoading.value = true;
try {
const results = await adminAPI.usage.searchUsers(query);
if (sequence === searchSequence) {
searchResults.value = results;
}
} catch {
if (sequence === searchSequence) {
searchResults.value = [];
}
} finally {
if (sequence === searchSequence) {
searchLoading.value = false;
}
}
}, 300);
}
function selectUser(user: SimpleUser): void {
selectedUsers.value = { ...selectedUsers.value, [user.id]: user };
emit("update:modelValue", [...selectedUserIds.value, user.id]);
clearPendingSearch();
searchQuery.value = "";
searchResults.value = [];
searchLoading.value = false;
showDropdown.value = false;
}
function removeUser(userId: number): void {
emit(
"update:modelValue",
selectedUserIds.value.filter((id) => id !== userId),
);
}
async function hydrateSelectedUsers(userIds: number[]): Promise<void> {
const missing = userIds.filter((id) => !selectedUsers.value[id]);
if (missing.length === 0) return;
const users = await Promise.all(
missing.map(async (id) => {
try {
const user = await adminAPI.users.getById(id, true);
return {
id: user.id,
email: user.email,
deleted: Boolean(user.deleted_at),
} satisfies SimpleUser;
} catch {
return null;
}
}),
);
const next = { ...selectedUsers.value };
for (const user of users) {
if (user && props.modelValue.includes(user.id)) {
next[user.id] = user;
}
}
selectedUsers.value = next;
}
function handleDocumentClick(event: MouseEvent): void {
const target = event.target as Node | null;
if (target && !containerRef.value?.contains(target)) {
showDropdown.value = false;
}
}
watch(
selectedUserIds,
(userIds) => {
void hydrateSelectedUsers(userIds);
},
{ immediate: true },
);
onMounted(() => {
document.addEventListener("click", handleDocumentClick);
});
onUnmounted(() => {
clearPendingSearch();
document.removeEventListener("click", handleDocumentClick);
});
</script>
@@ -0,0 +1,112 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OpenAIFastPolicyUserSelector from '../OpenAIFastPolicyUserSelector.vue'
const messages: Record<string, string> = {
'admin.settings.openaiFastPolicy.userDeleted': '(deleted)',
'admin.settings.openaiFastPolicy.userIdFallback': 'User #{id}',
'admin.settings.openaiFastPolicy.removeUser': 'Remove user',
'admin.settings.openaiFastPolicy.userSearchPlaceholder': 'Search users',
'admin.settings.openaiFastPolicy.userSearchEmpty': 'No users found',
'common.loading': 'Loading',
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => {
const message = messages[key] ?? key
return params
? Object.entries(params).reduce(
(value, [name, replacement]) => value.replace(`{${name}}`, String(replacement)),
message,
)
: message
},
}),
}))
const mockSearchUsers = vi.fn()
const mockGetUserById = vi.fn()
vi.mock('@/api/admin', () => ({
adminAPI: {
usage: {
searchUsers: (...args: unknown[]) => mockSearchUsers(...args),
},
users: {
getById: (...args: unknown[]) => mockGetUserById(...args),
},
},
}))
describe('OpenAIFastPolicyUserSelector', () => {
beforeEach(() => {
vi.useFakeTimers()
mockSearchUsers.mockReset()
mockGetUserById.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('hydrates existing IDs to email labels without changing the saved IDs', async () => {
mockGetUserById.mockResolvedValue({
id: 7,
email: 'existing@example.com',
deleted_at: null,
})
const wrapper = mount(OpenAIFastPolicyUserSelector, {
props: { modelValue: [7] },
global: { stubs: { Icon: true } },
})
await flushPromises()
expect(mockGetUserById).toHaveBeenCalledWith(7, true)
expect(wrapper.text()).toContain('existing@example.com')
expect(wrapper.text()).toContain('#7')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('searches after one character and adds the selected user ID', async () => {
mockSearchUsers.mockResolvedValue([
{ id: 9, email: 'alice@example.com', deleted: false },
])
const wrapper = mount(OpenAIFastPolicyUserSelector, {
props: { modelValue: [] },
global: { stubs: { Icon: true } },
})
const input = wrapper.get('input')
await input.trigger('focus')
await input.setValue('a')
await input.trigger('input')
vi.advanceTimersByTime(300)
await flushPromises()
expect(mockSearchUsers).toHaveBeenCalledWith('a')
const result = wrapper.findAll('button').find((button) =>
button.text().includes('alice@example.com'),
)
expect(result).toBeDefined()
await result!.trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[[9]]])
})
it('keeps an unresolved saved ID visible and removable', async () => {
mockGetUserById.mockRejectedValue(new Error('not found'))
const wrapper = mount(OpenAIFastPolicyUserSelector, {
props: { modelValue: [42] },
global: { stubs: { Icon: true } },
})
await flushPromises()
expect(wrapper.text()).toContain('User #42')
await wrapper.get('button[aria-label="Remove user"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[[]]])
})
})