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')
})
})