-
+
{{ issue(row) }}
diff --git a/frontend/src/components/admin/codex/__tests__/CodexBorrowActivity.spec.ts b/frontend/src/components/admin/codex/__tests__/CodexBorrowActivity.spec.ts
deleted file mode 100644
index 8ca7f78d2a68..000000000000
--- a/frontend/src/components/admin/codex/__tests__/CodexBorrowActivity.spec.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { describe, expect, it, vi, beforeEach } from 'vitest'
-import { flushPromises, mount } from '@vue/test-utils'
-import { createI18n } from 'vue-i18n'
-import CodexBorrowActivity from '../CodexBorrowActivity.vue'
-import type { CodexBorrowDiagnosticEvent, CodexGatewayBorrowStatus } from '@/api/admin/codexGatewayBorrow'
-
-const { diagnose } = vi.hoisted(() => ({ diagnose: vi.fn() }))
-vi.mock('@/api/admin/codexGatewayBorrow', () => ({ codexGatewayBorrowAPI: { diagnose } }))
-const status: CodexGatewayBorrowStatus = {
- enabled: true, revision: 1, generated_at: new Date().toISOString(), preparing: false,
- config: { enabled: true, source_account_ids: [1], target_account_ids: [2], models: ['gpt-6-astra'] }, sources: [], targets: [], recent_usage: []
-}
-function open(locale = 'zh') {
- return mount(CodexBorrowActivity, { props: { status, accountName: id => `Account ${id}` }, global: {
- plugins: [createI18n({ legacy: false, locale, messages: { zh: {}, en: {} } })],
- stubs: { RouterLink: { template: '
' } }
- } })
-}
-beforeEach(() => { diagnose.mockReset() })
-
-describe('Codex actual-request diagnostics', () => {
- it('shows recovery waiting and cancellation without counting it as business success or invoking a diagnostic', async () => {
- const wrapper = open('en')
- const row = { account_id: 2, model: 'gpt-6-astra', transport: 'http', origin: 'business', applied: false, dispatched: false, reason: 'preparing', started_at: new Date().toISOString(), outcome: 'preparing', count: 0, applied_count: 0, recovery: { phase: 'cooling' as const, started_at: new Date().toISOString(), next_attempt_at: new Date(Date.now()+3600000).toISOString(), rounds: 2, source_requests: 1, probe_requests: 2, last_reason: 'target_state_changed' } }
- await wrapper.setProps({ status: { ...status, recent_usage: [row] } })
- expect(wrapper.get('[data-test="borrow-recovery"]').text()).toContain('Waiting for cooling')
- expect(wrapper.get('[data-test="borrow-recovery"]').text()).toContain('waiting is not success')
- await wrapper.setProps({ status: { ...status, recent_usage: [{ ...row, recovery: { ...row.recovery, phase: 'reborrowing', next_attempt_at: undefined } }] } })
- expect(wrapper.get('[data-test="borrow-recovery"]').text()).toContain('Reacquiring a route')
- await wrapper.setProps({ status: { ...status, recent_usage: [{ ...row, recovery: { ...row.recovery, phase: 'validating', next_attempt_at: undefined } }] } })
- expect(wrapper.get('[data-test="borrow-recovery"]').text()).toContain('Strict qualification in progress')
- await wrapper.setProps({ status: { ...status, recent_usage: [{ ...row, outcome: 'cancelled', recovery: { ...row.recovery, phase: 'cancelled', next_attempt_at: undefined } }] } })
- expect(wrapper.get('[data-test="borrow-recovery"]').text()).toContain('Recovery cancelled')
- expect(diagnose).not.toHaveBeenCalled()
- wrapper.unmount()
- })
- it('keeps blocked business attempts visible ahead of successful diagnostics', () => {
- const wrapper = open('en')
- const diagnostic = { account_id: 2, model: 'gpt-6-astra', transport: 'http', origin: 'diagnostic', applied: true, dispatched: true, reason: 'borrow_applied', started_at: new Date().toISOString(), outcome: 'completed', count: 2, applied_count: 1 }
- void wrapper.setProps({ status: { ...status, recent_usage: [diagnostic, { ...diagnostic, origin: 'business', applied: false, dispatched: false, reason: 'target_state_changed', failure_stage: 'target_validation', outcome: 'blocked', count: 0, applied_count: 0, attempt_count: 24, blocked_count: 24, gateway_request_id: 'local-failed-request' }] } })
- return flushPromises().then(() => {
- const tables = wrapper.findAll('table')
- expect(tables[0].attributes('data-test')).toBe('borrow-usage-business')
- expect(tables[0].text()).toContain('not dispatched')
- expect(tables[0].text()).toContain('Attempts 24')
- expect(tables[0].text()).toContain('Blocked 24')
- expect(tables[0].text()).toContain('Sent 0')
- expect(tables[0].text()).toContain('target_state_changed')
- expect(tables[0].text()).toContain('local-failed-request')
- expect(tables[1].text()).toContain('Completed')
- expect(diagnose).not.toHaveBeenCalled()
- wrapper.unmount()
- })
- })
- it('reads existing evidence without automatically issuing requests', () => {
- const wrapper = open()
- expect(diagnose).not.toHaveBeenCalled()
- expect(wrapper.get('[data-test="borrow-no-usage"]').text()).toContain('还没有实际发送记录')
- expect(wrapper.get('[data-test="diagnose-selection"]').element.tagName).toBe('SELECT')
- wrapper.unmount()
- })
-
- it('requires an explicit action and shows applied separately from completed', async () => {
- diagnose.mockImplementation(async (_request, emit: (event: CodexBorrowDiagnosticEvent) => void) => {
- emit({ type: 'result', requests: 3, limit: 8, result: { mode: 'borrowed', turn: 1, applied: true, completed: false, reported_model: 'gpt-6-astra', answer: '', raw_response: '{"error":"raw upstream failure"}', error: 'raw upstream failure', duration_ms: 10 } })
- emit({ type: 'done', requests: 3, limit: 8 })
- })
- const wrapper = open('en')
- await wrapper.get('[data-test="diagnose-start"]').trigger('click')
- await flushPromises()
- expect(diagnose).toHaveBeenCalledOnce()
- expect(diagnose.mock.calls[0][0]).toEqual({ account_id: 2, model: 'gpt-6-astra', transport: 'http' })
- const result = wrapper.get('[data-test="diagnose-result"]')
- expect(result.text()).toContain('not completed')
- expect(result.text()).toContain('Borrow was applied')
- expect(result.text()).toContain('raw upstream failure')
- expect(wrapper.emitted('refresh')).toHaveLength(1)
- wrapper.unmount()
- })
-
- it('cancels its explicit diagnostic when the page is closed', async () => {
- let signal: AbortSignal | undefined
- diagnose.mockImplementation((_request, _emit, abort: AbortSignal) => { signal = abort; return new Promise((_resolve, reject) => abort.addEventListener('abort', () => reject(new Error('aborted')), { once: true })) })
- const wrapper = open()
- await wrapper.get('[data-test="diagnose-start"]').trigger('click')
- wrapper.unmount()
- await flushPromises()
- expect(signal?.aborted).toBe(true)
- expect(wrapper.emitted('refresh')).toBeUndefined()
- })
-})
diff --git a/frontend/src/components/admin/codex/__tests__/BorrowPelicanPreview.spec.ts b/frontend/src/components/admin/codex/__tests__/PelicanPreview.spec.ts
similarity index 80%
rename from frontend/src/components/admin/codex/__tests__/BorrowPelicanPreview.spec.ts
rename to frontend/src/components/admin/codex/__tests__/PelicanPreview.spec.ts
index bcb4bf3e364b..5333807448f3 100644
--- a/frontend/src/components/admin/codex/__tests__/BorrowPelicanPreview.spec.ts
+++ b/frontend/src/components/admin/codex/__tests__/PelicanPreview.spec.ts
@@ -1,17 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
-import BorrowPelicanPreview from '../BorrowPelicanPreview.vue'
+import PelicanPreview from '../PelicanPreview.vue'
vi.mock('@/api/client', () => ({ buildApiUrl: (path: string) => `/api/v1${path}` }))
-const previewUrl = `/api/v1/codex-gateway-borrow/preview/${'a'.repeat(43)}/index.html`
+const previewUrl = `/api/v1/pelican-tests/preview/${'a'.repeat(43)}/index.html`
afterEach(() => { vi.unstubAllGlobals() })
-describe('Borrow pelican isolated preview', () => {
+describe('Pelican isolated preview', () => {
it('accepts the independent pelican capability route with the same opaque sandbox', () => {
const standaloneUrl = `/api/v1/pelican-tests/preview/${'b'.repeat(43)}/index.html`
- const wrapper = mount(BorrowPelicanPreview, { props: { previewUrl: standaloneUrl, title: 'Standalone output' } })
+ const wrapper = mount(PelicanPreview, { props: { previewUrl: standaloneUrl, title: 'Standalone output' } })
expect(wrapper.get('iframe').attributes('src')).toBe(new URL(standaloneUrl, window.location.origin).href)
expect(wrapper.get('iframe').attributes('sandbox')).toBe('allow-scripts')
wrapper.unmount()
@@ -20,7 +20,7 @@ describe('Borrow pelican isolated preview', () => {
it('loads only a server capability with an opaque sandbox and a stable scaled viewport', async () => {
const disconnect = vi.fn()
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect = disconnect })
- const wrapper = mount(BorrowPelicanPreview, { props: { previewUrl, title: 'Target #2 · Sol · high' } })
+ const wrapper = mount(PelicanPreview, { props: { previewUrl, title: 'Target #2 · Sol · high' } })
const frame = wrapper.get('iframe')
expect(frame.attributes('src')).toBe(new URL(previewUrl, window.location.origin).href)
expect(frame.attributes('sandbox')).toBe('allow-scripts')
@@ -42,10 +42,11 @@ describe('Borrow pelican isolated preview', () => {
})
it.each([
+ `/api/v1/codex-gateway-borrow/preview/${'a'.repeat(43)}/index.html`,
'javascript:alert(1)', 'data:text/html,', 'blob:http://localhost/one',
`https://other.example${previewUrl}`, '/api/v1/admin/accounts', `${previewUrl}?auth_token=secret`, `${previewUrl}#fragment`
])('does not execute a non-preview URL: %s', value => {
- const wrapper = mount(BorrowPelicanPreview, { props: { previewUrl: value, title: 'Output' } })
+ const wrapper = mount(PelicanPreview, { props: { previewUrl: value, title: 'Output' } })
expect(wrapper.find('iframe').exists()).toBe(false)
wrapper.unmount()
})
diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue
index 0c38b4479b63..55dbcce74c94 100644
--- a/frontend/src/components/layout/AppSidebar.vue
+++ b/frontend/src/components/layout/AppSidebar.vue
@@ -503,7 +503,7 @@ const PluginIcon = {
render: () => h(Icon, { name: 'cube' })
}
-const GatewayBorrowIcon = {
+const CodexIcon = {
render: () => h(Icon, { name: 'link', 'aria-hidden': 'true' })
}
@@ -692,7 +692,6 @@ const ShieldIcon = {
)
}
-
const PriceTagIcon = {
render: () =>
h(
@@ -817,8 +816,8 @@ const personalNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems
const adminExtensionNavItems = computed((): NavItem[] => [
{ path: '/admin/system-prompts', label: t('nav.systemPrompts'), icon: DocumentIcon },
- { path: '/admin/codex', label: 'Codex', icon: GatewayBorrowIcon },
- { path: '/admin/pelican-tests', label: t('nav.pelicanTests'), icon: GatewayBorrowIcon },
+ { path: '/admin/codex', label: 'Codex', icon: CodexIcon },
+ { path: '/admin/pelican-tests', label: t('nav.pelicanTests'), icon: CodexIcon },
])
// Custom menu items filtered by visibility
diff --git a/frontend/src/composables/useCodexBorrowUI.ts b/frontend/src/composables/useCodexBorrowUI.ts
deleted file mode 100644
index c4934c72379f..000000000000
--- a/frontend/src/composables/useCodexBorrowUI.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
-import { useI18n } from 'vue-i18n'
-import { accountsAPI } from '@/api/admin/accounts'
-import type { BorrowTargetStatus } from '@/api/admin/codexGatewayBorrow'
-import type { AccountListItem } from '@/types'
-import { resolveOpenAIWSModeFromExtra } from '@/utils/openaiWsMode'
-
-/** Inventory is local to each page; never discover models or test accounts here. */
-export function useCodexBorrowInventory(signal: AbortSignal) {
- const { t, te } = useI18n()
- const accounts = ref
([])
- const accountMap = computed(() => new Map(accounts.value.map(account => [account.id, account])))
- function accountById(id: number) { return accountMap.value.get(id) }
- function accountName(id: number) { return accountById(id)?.name || t('admin.codexGatewayBorrow.unknownAccount') }
- function accountStateLabel(account?: AccountListItem) {
- if (!account) return t('admin.codexGatewayBorrow.unknownAccount')
- const key = `admin.codexGatewayBorrow.states.${account.status}`
- const parts = [te(key) ? t(key) : account.status]
- if (!account.schedulable) parts.push(t('admin.codexGatewayBorrow.paused'))
- if (account.rate_limit_reset_at && Date.parse(account.rate_limit_reset_at) > Date.now()) parts.push(t('admin.codexGatewayBorrow.rateLimited'))
- return parts.join(' · ')
- }
- function proxyLabel(account?: AccountListItem) {
- if (!account) return '—'
- if (account.proxy) return `${account.proxy.name} #${account.proxy.id}`
- if (account.proxy_id) return `#${account.proxy_id}`
- return t('admin.codexGatewayBorrow.direct')
- }
- function wsLabel(account: AccountListItem) {
- const keys = ['openai_oauth_responses_websockets_v2_mode', 'openai_oauth_responses_websockets_v2_enabled', 'responses_websockets_v2_enabled', 'openai_ws_enabled']
- if (!keys.some(key => account.extra?.[key] !== undefined)) return t('admin.codexGatewayBorrow.wsUnspecified')
- return resolveOpenAIWSModeFromExtra(account.extra, { modeKey: keys[0], enabledKey: keys[1], fallbackEnabledKeys: keys.slice(2) })
- }
- function modelMappingLabel(account: AccountListItem) {
- const mapping = account.credentials?.model_mapping
- if (!mapping || typeof mapping !== 'object' || !Object.keys(mapping).length) return t('admin.codexGatewayBorrow.noModelRestriction')
- const entries = Object.entries(mapping).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
- return `${t('admin.codexGatewayBorrow.localMappings')}: ${entries.map(([from, to]) => from === to ? from : `${from} → ${to}`).join(', ')}`
- }
- async function loadAccounts() {
- const found: AccountListItem[] = []
- for (let page = 1; !signal.aborted; page++) {
- const response = await accountsAPI.list(page, 100, { platform: 'openai', types: 'oauth,setup-token' }, { signal })
- found.push(...response.items.filter(account => account.platform === 'openai' && (account.type === 'oauth' || account.type === 'setup-token')))
- if (!response.items.length || page * 100 >= response.total) break
- }
- if (!signal.aborted) accounts.value = found
- }
- return { accounts, loadAccounts, accountById, accountName, accountStateLabel, proxyLabel, wsLabel, modelMappingLabel }
-}
-
-/** A local tick expires the display without requesting or renewing any route. */
-export function useCodexBorrowClock() {
- const now = ref(Date.now())
- const serverClockKnown = ref(false)
- const serverOffsetMs = ref(0)
- let timer: ReturnType | undefined
- function receiveServerTime(generatedAt: string) {
- const time = Date.parse(generatedAt)
- serverClockKnown.value = Number.isFinite(time)
- if (serverClockKnown.value) {
- serverOffsetMs.value = time - Date.now()
- now.value = time
- }
- }
- function remainingSeconds(expiresAt?: string) {
- const expires = expiresAt ? Date.parse(expiresAt) : NaN
- return Number.isFinite(expires) ? Math.max(0, Math.ceil((expires - now.value) / 1000)) : 0
- }
- function cacheUsable(target?: BorrowTargetStatus) {
- return !!target?.cache_valid && (!target.expires_at || Date.parse(target.expires_at) > now.value)
- }
- onMounted(() => { timer = setInterval(() => { now.value = Date.now() + serverOffsetMs.value }, 1000) })
- onBeforeUnmount(() => { if (timer !== undefined) clearInterval(timer) })
- return { now, serverClockKnown, serverOffsetMs, receiveServerTime, cacheUsable, remainingSeconds }
-}
-
-export function borrowTargetState(target: BorrowTargetStatus | undefined, now: number): 'ready' | 'expired' | 'validating' | 'waiting' | 'rejected' | 'retry_pending' {
- if (!target) return 'waiting'
- if (target.cache_valid && (!target.expires_at || Date.parse(target.expires_at) > now)) return 'ready'
- if (target.state === 'expired' || ((target.cache_valid || target.state === 'ready' || target.state === 'valid') && !!target.expires_at && Date.parse(target.expires_at) <= now)) return 'expired'
- if (target.state === 'validating' || target.reason === 'validating') return 'validating'
- if (target.state === 'retry_pending' && target.retry_on_next_request) return 'retry_pending'
- if (['waiting', 'missing', 'disabled'].includes(target.state) || ['not_prepared', 'not_verified', 'not_configured', 'disabled'].includes(target.reason)) return 'waiting'
- return 'rejected'
-}
-
-const knownReasons = new Set([
- 'account_unavailable', 'account_inactive', 'model_not_allowed', 'model_mapping_mismatch', 'identity_changed',
- 'not_prepared', 'not_verified', 'not_configured', 'disabled', 'route_expired',
- 'source_qualified', 'source_probe_failed', 'source_response_not_qualified', 'source_routing_cookie_unavailable',
- 'target_probe_passed', 'target_probe_failed', 'target_route_changed', 'target_state_missing', 'target_state_changed', 'validating',
- 'candidate_ready', 'validated', 'no_matched_cache'
-])
-export function borrowReasonLabel(reason: string, t: (key: string) => string): string {
- return knownReasons.has(reason) ? t(`admin.codexGatewayBorrow.reasons.${reason}`) : reason
-}
diff --git a/frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts b/frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts
deleted file mode 100644
index 72bf431d814f..000000000000
--- a/frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-export default {
- codexGatewayBorrow: {
- title: 'Codex Borrow Settings',
- description: 'Choose the source accounts that provide borrow routes, the target accounts that use them, and the models, then save the configuration.',
- navLabel: 'Codex borrowing',
- navSettings: 'Borrow Settings',
- navStatus: 'Borrow Status',
- navComparison: 'Pelican Comparison',
- configuration: 'Choose accounts and models',
- enabled: 'Enable gateway borrowing',
- on: 'Enabled',
- off: 'Disabled',
- saveHint: 'Saving while enabled starts one background preparation, collecting source routes and validating targets in two rounds when needed.',
- savedConfiguration: 'Saved configuration',
- savedSelection: '{sources} sources · {targets} targets · {models} models',
- draftHint: 'Unsaved changes stay in this browser session. Switching pages does not save them; Borrow Status uses the saved configuration.',
- unsavedChanges: 'Unsaved changes',
- resetDraft: 'Discard unsaved changes',
- models: 'Borrow models',
- sources: 'Accounts providing borrow routes (source)',
- sourcesHint: 'Source accounts provide borrow routes. An account cannot be both a source and a target.',
- targets: 'Accounts using borrow routes (target)',
- targetsHint: 'After validation, target accounts use the source route to access selected models.',
- searchAccounts: 'Search account name or ID',
- noAccounts: 'No matching local OpenAI OAuth-like accounts',
- missingAccounts: 'Selected accounts absent from the current local inventory: {ids}',
- localInventoryHint: 'This list reads local accounts only. Accounts are selected manually and no upstream model catalog is requested. Existing proxies, WS settings, and model mappings are shown for reference; address missing conditions in account settings.',
- proxy: 'Bound exit',
- accountDetails: 'Account details',
- selected: 'Selected',
- selectedAsOtherRole: 'Selected for the other role',
- direct: 'Direct',
- shadowOf: 'Shadow account; parent: {name} #{id}',
- wsUnspecified: 'Not specified on this account',
- noModelRestriction: 'No local model restriction',
- localMappings: 'Local model mappings',
- unknownAccount: 'Unknown account',
- paused: 'Scheduling paused',
- rateLimited: 'Rate limited',
- disjointRequired: 'Source and target account selections must be disjoint.',
- selectionRequired: 'Choose at least one source, one target, and one model before enabling.',
- invalidConfig: 'The server returned an invalid borrow configuration',
- saved: 'Configuration saved',
- currentPreparationState: 'Current preparation status',
- preparationIdle: 'No preparation is currently running',
- viewBorrowStatus: 'View Borrow Status',
- statusReadFailed: 'Unable to read preparation status; the configuration save is unaffected.',
- loadFailed: 'Unable to read borrow configuration',
- statusLoadFailed: 'Unable to read borrow status',
- inventoryLoadFailed: 'Unable to read local account inventory',
- saveFailed: 'Unable to save borrow configuration',
- refreshStatus: 'Refresh status',
- borrowStatus: 'Borrow status',
- onDemandHint: 'Saving enabled settings prepares once. The next business request acquires and validates missing or expired routes. There is no renewal timer; viewing or refreshing never calls a model.',
- onDemandExpired: 'Expired; waiting for a request to acquire a route',
- modelScope: 'Astra follows ranxi v2.10.5. Sol is a downstream model extension and is validated independently. Cookie and two-shot STATE checks do not prove model identity or capability.',
- statusTitle: 'Codex Borrow Status',
- statusDescription: 'Inspect borrow routes for the saved configuration, then prepare or revalidate when needed.',
- preparationState: 'Preparation progress',
- idle: 'No preparation in progress',
- readyPairs: 'Pairs with valid validation records',
- pairCount: '{ready} / {total}',
- currentSource: 'Current source',
- candidateExpired: 'Source route expired',
- validationMeaning: 'An unexpired source route does not qualify every request. A proof applies only to the same account, model and request identity. A STATE or identity change requires validation before business dispatch.',
- prepareRoutes: 'Prepare borrow routes',
- prepareRoutesHint: 'Prepare the saved sources and targets, collecting a source route and validating account / model pairs in two rounds when needed.',
- revalidatePair: 'Revalidate this account / model',
- revalidateHint: 'Each row shows the latest validation for an account and model. Other sessions or changed requests may need fresh validation. Manual validation sends two probes.',
- pollingHint: 'While visible and borrowing is enabled, read status every 5 seconds. Pause in the background and refresh on return. Reads never call a model or renew a lease.',
- technicalDetails: 'Technical details',
- rawState: 'Server state label',
- rawReason: 'Raw reason',
- fullError: 'Full error',
- stateMinted: 'STATE obtained in first request',
- stateUnavailable: 'This endpoint does not expose the STATE value.',
- targetsStatus: 'Target accounts and models',
- noConfiguredTargets: 'No target accounts and models are saved yet. Complete Borrow Settings first.',
- preparationPending: 'Preparing borrow routes',
- lineStates: {
- retry_pending: 'Probe interrupted; revalidation pending',
- stale: 'Status needs refreshing',
- waiting: 'Not prepared',
- validating: 'Preparing',
- ready: 'Latest validation passed',
- expired: 'Expired',
- rejected: 'Validation failed',
- },
- observedAt: 'Status read at',
- prepare: 'Prepare shared candidate',
- preparing: 'Preparing',
- prepareHint: 'Preparation obtains a source candidate and validates the saved targets and models. Force verification checks just the selected pair. Both may send upstream requests and run separately from animation generation below.',
- prepareFailed: 'Preparation failed',
- sharedCandidate: 'Shared candidate source',
- candidateFingerprint: 'Candidate fingerprint',
- noCandidate: 'No borrowed source route has been acquired yet',
- sourceStates: 'Source acquisition records',
- accountModel: 'Account / model',
- validation: 'Route status',
- expiryCooldown: 'Times and cooldown',
- actions: 'Actions',
- cacheReady: 'Matching cache available',
- cacheMissing: 'No usable matching cache',
- remainingSeconds: '{seconds} seconds remaining',
- checkedAt: 'Checked at',
- expiresAt: 'Expires at',
- retryAfter: 'Cooldown until',
- httpStatuses: 'Mint HTTP {mint} / continuation HTTP {continuation}',
- newTicket: 'New credential obtained',
- reportedModel: 'Upstream reported model',
- forceVerify: 'Force verification',
- validating: 'Validating',
- verifyFailed: 'Verification failed',
- noTargets: 'No target validation records yet',
- pelicanTitle: 'Pelican Comparison',
- pelicanHint: 'Generate works using prepared borrow routes. Each selected account and model produces one work for administrators to inspect.',
- fixedPrompt: 'Fixed prompt',
- testModels: 'Participating models and reasoning effort',
- testAccounts: 'Participating accounts',
- effort: 'Reasoning effort',
- willSkip: 'Will skip without generation',
- executionHint: 'At most 3 jobs run concurrently; models for the same account run serially, with a 90-second limit per item. Paused, errored, or rate-limited accounts with valid routes may be explicitly selected. Generation is for observation. Stopping or leaving this page cancels queued and in-flight generation.',
- startTest: 'Generate works ({count} account/model combinations)',
- stop: 'Stop generation',
- stoppedHint: 'The generation connection has stopped and unfinished items are cancelled. Received results remain visible; the final record can be read from server history.',
- testFailed: 'Generation connection failed',
- clockUnavailable: 'The server clock is not available yet. Refresh status before submitting a generation task.',
- resultViews: 'Result views',
- results: 'Current works',
- history: 'History',
- replayed: 'Existing task; no duplicate generation',
- historyHint: 'History is stored on the server for 24 hours. Refreshing and viewing read records without retesting; expired records are removed automatically.',
- refreshHistory: 'Refresh history',
- historyFailed: 'Unable to read history',
- noHistory: 'No test records in the last 24 hours',
- viewResults: 'View artworks',
- previous: 'Previous',
- noResults: 'Select accounts and click Generate. Artworks will appear here as each item finishes.',
- taskId: 'Task ID',
- duration: 'Duration',
- previewPending: 'Waiting for generation output',
- previewMissingHtml: 'No preview could be extracted. Open the original answer.',
- previewNotGenerated: 'No work was generated. See the status and reason.',
- enlarge: 'Enlarge preview',
- rawAnswer: 'Raw answer',
- htmlSource: 'HTML source',
- rawResponse: 'Raw upstream response',
- downloadAnswer: 'Download raw answer',
- reasons: {
- account_unavailable: 'Account or credential source unavailable; check account settings',
- account_inactive: 'Account is inactive; resolve its status on the account page',
- model_not_allowed: 'This model is not allowed by the account',
- model_mapping_mismatch: 'This model maps to another model; validation cannot be reused',
- identity_changed: 'Identity or exit settings changed; validate again',
-
- not_prepared: 'Borrow routes have not been prepared yet',
- not_verified: 'This account and model have not completed route validation',
- not_configured: 'This account and model are absent from the saved configuration',
- disabled: 'Borrowing is currently disabled',
- route_expired: 'The borrow route has expired; the next business request will acquire and validate it',
- source_passed_target_failed: 'The source returned a candidate but target validation failed. Replacement is bounded and demand-driven, with an acquisition cooldown.',
- source_returned_rejected_route: 'The source returned the same rejected route. Its lease was not extended; acquisition can resume after cooldown.',
- source_qualified: 'The source account can provide a borrow route',
- source_probe_failed: 'Source route collection failed; see technical details',
- source_response_not_qualified: 'The source response did not meet the borrowing conditions',
- source_routing_cookie_unavailable: 'The source response did not include borrowable route information',
- target_probe_passed: 'Both route validation requests passed for this account and model',
- target_probe_failed: 'Route validation failed for this account and model; see technical details',
- target_route_changed: 'The route changed during validation; validate it again',
- target_state_missing: 'The first response had no session state to validate continuation',
- target_state_changed: 'Both upstream requests completed, but continuation returned a different STATE. The route did not qualify for borrowing; inspect the verification details.',
- validating: 'Validating the borrow route for this account and model',
- candidate_ready: 'The source route has been collected',
- validated: 'The borrow route passed validation for this account and model',
- no_matched_cache: 'This account and model have no usable borrow route yet',
- },
- states: {
- pending: 'Pending', running: 'Running', complete: 'Complete', failed: 'Failed', incomplete: 'Incomplete', cancelled: 'Cancelled', skipped: 'Skipped',
- active: 'Active', inactive: 'Inactive', disabled: 'Disabled', error: 'Error', validating: 'Validating', valid: 'Valid', ready: 'Ready', expired: 'Expired', cooldown: 'Cooldown', unavailable: 'Unavailable', missing: 'Missing', preparing: 'Preparing', waiting: 'Not prepared', rejected: 'Validation rejected'
- }
- },
- pelicanTests: {
- title: 'Pelican Tests',
- description: 'Generate the same Pelican animation with each selected account using its current model mapping, credentials and connection settings.',
- refreshLocal: 'Refresh local accounts',
- chooseAccounts: 'Choose accounts',
- localCandidatesHint: 'All platforms and account types are available. Models come from saved local catalogs and mappings; you can enter a specific model ID.',
- searchAccounts: 'Search account name or ID',
- platform: 'Platform',
- allPlatforms: 'All platforms',
- selectFiltered: 'Select matching accounts ({count})',
- clearSelection: 'Clear selection',
- noAccounts: 'No matching accounts',
- model: 'Model',
- manualModel: 'Select or enter a model ID',
- addModel: 'Add model',
- removeModel: 'Remove model',
- normalDefault: 'Model default',
- generationBudget: 'Generation budget',
- minutes: 'min',
- budgetBoundary: 'Use a whole number from 1 to 30 minutes.',
- executionHint: 'All batches share {count} execution slots; each account runs serially. Queueing, preparation and generation are timed separately. Stopping or leaving this page cancels queued and active work.',
- temporarilyUnavailable: 'Temporarily unavailable',
- overloaded: 'Overloaded',
- viewPrompt: 'View fixed prompt',
- results: 'Current works',
- history: 'History',
- cardSize: 'Card size',
- sizes: { compact: 'Compact', standard: 'Standard', large: 'Large' },
- previewOnVisible: 'Preview loads when visible',
- details: 'Details',
- downloadHtml: 'Download HTML',
- account: 'Account',
- mappedModel: 'Actual mapped model',
- actualCall: 'Actual call',
- borrowApplied: 'Borrow route applied',
- accountRoute: 'Account route',
- phaseDurations: 'Phase durations',
- phases: { queued: 'Queued', preparing: 'Preparing', generating: 'Generating' },
- },
- codexPelicanComparison: {
- title: 'Pelican Comparison',
- description: 'Generate works using prepared borrowed routes and compare accounts and models side by side.',
- viewStatus: 'View borrow status',
- refreshAvailability: 'Refresh route status',
- viewPrompt: 'View prompt',
- noTargets: 'No target accounts have been saved. Choose accounts in borrow settings, then prepare routes.',
- disabledHint: 'Borrowing is currently disabled. Enable and save it in borrow settings, then prepare routes on the status page.',
- results: 'Current works',
- history: 'History',
- routeReason: 'Route reason',
- }
-}
diff --git a/frontend/src/i18n/locales/en/admin/index.ts b/frontend/src/i18n/locales/en/admin/index.ts
index 4c3ce8634992..c9202402cb95 100644
--- a/frontend/src/i18n/locales/en/admin/index.ts
+++ b/frontend/src/i18n/locales/en/admin/index.ts
@@ -7,7 +7,7 @@ import settings from './settings'
import audit from './audit'
import promptAudit from './promptAudit'
import systemPrompts from './systemPrompts'
-import codexGatewayBorrow from './codexGatewayBorrow'
+import pelicanTests from './pelicanTests'
import accountTasks from './accountTasks'
import plugins from './plugins'
@@ -21,7 +21,7 @@ export default {
...audit,
...promptAudit,
...systemPrompts,
- ...codexGatewayBorrow,
+ ...pelicanTests,
...accountTasks,
...plugins,
}
diff --git a/frontend/src/i18n/locales/en/admin/pelicanTests.ts b/frontend/src/i18n/locales/en/admin/pelicanTests.ts
new file mode 100644
index 000000000000..17fde2a896e0
--- /dev/null
+++ b/frontend/src/i18n/locales/en/admin/pelicanTests.ts
@@ -0,0 +1,53 @@
+export default {
+ pelicanTests: {
+ title: 'Pelican Tests',
+ description: 'Generate the same Pelican animation with each selected account using its current model mapping, credentials and connection settings.',
+ refreshLocal: 'Refresh local accounts',
+ chooseAccounts: 'Choose accounts',
+ localCandidatesHint: 'All platforms and account types are available. Models come from saved local catalogs and mappings; you can enter a specific model ID.',
+ searchAccounts: 'Search account name or ID',
+ platform: 'Platform',
+ allPlatforms: 'All platforms',
+ selectFiltered: 'Select matching accounts ({count})',
+ clearSelection: 'Clear selection',
+ noAccounts: 'No matching accounts',
+ model: 'Model',
+ manualModel: 'Select or enter a model ID',
+ addModel: 'Add model',
+ removeModel: 'Remove model',
+ normalDefault: 'Model default',
+ generationBudget: 'Generation budget',
+ minutes: 'min',
+ budgetBoundary: 'Use a whole number from 1 to 30 minutes.',
+ executionHint: 'All batches share {count} execution slots; each account runs serially. Queueing, preparation and generation are timed separately. Closing the page or disconnecting leaves tasks running; only explicit stop cancels them.',
+ temporarilyUnavailable: 'Temporarily unavailable',
+ overloaded: 'Overloaded',
+ viewPrompt: 'View fixed prompt',
+ results: 'Current works',
+ history: 'History',
+ cardSize: 'Card size',
+ sizes: { compact: 'Compact', standard: 'Standard', large: 'Large' },
+ previewOnVisible: 'Preview loads when visible',
+ details: 'Details',
+ downloadHtml: 'Download HTML',
+ account: 'Account',
+ mappedModel: 'Actual mapped model',
+ actualCall: 'Actual call',
+ borrowApplied: 'Historical record: borrowing was applied',
+ accountRoute: 'Account route',
+ phaseDurations: 'Phase durations',
+ phases: { queued: 'Queued', preparing: 'Preparing', generating: 'Generating' },
+
+ clockUnavailable: 'The server clock is not available yet. Refresh status before submitting a generation task.',
+ downloadAnswer: 'Download raw answer',
+ duration: 'Duration',
+ effort: 'Reasoning effort',
+ enlarge: 'Enlarge preview',
+ htmlSource: 'HTML source',
+ noHistory: 'No test records in the last 24 hours',
+ rawAnswer: 'Raw answer',
+ rawResponse: 'Raw upstream response',
+ refreshHistory: 'Refresh history',
+ states: { pending: 'Pending', running: 'Running', complete: 'Complete', failed: 'Failed', incomplete: 'Incomplete', cancelled: 'Cancelled', skipped: 'Skipped', active: 'Active', inactive: 'Inactive', disabled: 'Disabled', error: 'Error' },
+ }
+}
diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts
index 7ce9efa9ec73..7fba839ed889 100644
--- a/frontend/src/i18n/locales/en/common.ts
+++ b/frontend/src/i18n/locales/en/common.ts
@@ -224,8 +224,6 @@ export default {
promptAudit: 'Prompt Audit',
auditLogs: 'Audit Logs',
systemPrompts: 'System Prompts',
- codexGatewayBorrow: 'Codex Borrowing',
- codexGatewayBorrowStatus: 'Codex Borrow Status',
codexPelicanComparison: 'Pelican Comparison',
pelicanTests: 'Pelican Tests',
},
diff --git a/frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts b/frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts
deleted file mode 100644
index 5cad61dcaa4b..000000000000
--- a/frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-export default {
- codexGatewayBorrow: {
- title: 'Codex 借用设置',
- description: '选择提供借用线路的来源账号、使用线路的目标账号和模型,再保存配置。',
- navLabel: 'Codex 借用',
- navSettings: '借用设置',
- navStatus: '借用状态',
- navComparison: '鹈鹕对比',
- configuration: '选择账号与模型',
- enabled: '启用网关借用',
- on: '已启用',
- off: '已关闭',
- saveHint: '启用状态下保存会启动一次后台准备,需要时采集来源线路并对目标执行两轮验证。',
- savedConfiguration: '已保存配置',
- savedSelection: '来源 {sources} 个 · 目标 {targets} 个 · 模型 {models} 个',
- draftHint: '未保存的修改仅保留在当前浏览器会话中。切换页面不会自动保存;借用状态始终使用已保存配置。',
- unsavedChanges: '有未保存的修改',
- resetDraft: '放弃未保存的修改',
- models: '借用模型',
- sources: '提供借用线路的账号(来源)',
- sourcesHint: '来源账号负责提供借用线路,来源与目标不能是同一个账号。',
- targets: '使用借用线路的账号(目标)',
- targetsHint: '目标账号通过验证后,使用来源账号提供的线路访问所选模型。',
- searchAccounts: '搜索账号名称或 ID',
- noAccounts: '没有匹配的本地 OpenAI OAuth 类账号',
- missingAccounts: '当前本地列表中不存在的已选账号:{ids}',
- localInventoryHint: '列表只读取本地账号数据,不自动全选、不查询上游模型清单。代理、WS 设置及模型映射只供查看;缺少条件时请到账号设置处理。',
- proxy: '绑定出口',
- accountDetails: '账号详情',
- selected: '已选',
- selectedAsOtherRole: '已选作另一角色',
- direct: '直连',
- shadowOf: '影子账号,母账号:{name} #{id}',
- wsUnspecified: '未在账号上指定',
- noModelRestriction: '本地未设模型限制',
- localMappings: '本地模型映射',
- unknownAccount: '未知账号',
- paused: '已暂停调度',
- rateLimited: '限流中',
- disjointRequired: '来源账号与目标账号不能重叠。',
- selectionRequired: '启用时至少选择一个来源、一个目标及一个模型。',
- invalidConfig: '服务端返回的借用配置无效',
- saved: '配置已保存',
- currentPreparationState: '实际准备状态',
- preparationIdle: '当前没有准备任务在运行',
- viewBorrowStatus: '查看借用状态',
- statusReadFailed: '读取准备状态失败;配置保存结果不受影响。',
- loadFailed: '读取借用配置失败',
- statusLoadFailed: '读取借用状态失败',
- inventoryLoadFailed: '读取本地账号列表失败',
- saveFailed: '保存借用配置失败',
- refreshStatus: '刷新状态',
- borrowStatus: '借用状态',
- onDemandHint: '保存启用配置后准备一次;线路缺失或过期时,由下一次业务请求自动获取并验证。没有定时续期。查看或刷新页面不会调用模型。',
- onDemandExpired: '已过期,等待请求自动获取',
- modelScope: 'Astra 按 ranxi v2.10.5 原版验证;Sol 为本站模型扩展,单独验证。验证只检查路由 Cookie 与两轮 STATE,不证明模型身份或能力。',
- statusTitle: 'Codex 借用状态',
- statusDescription: '查看已保存配置的借用线路,按需准备或重新验证。',
- preparationState: '准备进度',
- idle: '当前没有准备任务',
- readyPairs: '有有效验证记录的账号/模型',
- pairCount: '{ready} / {total}',
- currentSource: '当前来源',
- candidateExpired: '来源线路已过期',
- validationMeaning: '来源线路未到期不等于当前请求已获准。验证结果仅适用于相同账号、模型和请求身份;STATE 或身份变化时会重新验证。只有验证通过才发送业务。',
- prepareRoutes: '准备借用线路',
- prepareRoutesHint: '准备已保存的来源与目标;需要时采集来源线路,并对账号/模型执行两轮验证。',
- revalidatePair: '重新验证该账号/模型',
- revalidateHint: '此处显示每个账号与模型最近一次验证,不能保证其他会话或变化后的请求也能复用。手动验证会发送两轮探针。',
- pollingHint: '页面可见且借用启用时,每 5 秒只读刷新。切到后台暂停,返回时立即刷新;读取状态不会发模型请求或续租。',
- technicalDetails: '技术详情',
- rawState: '服务端状态标识',
- rawReason: '原始原因',
- fullError: '完整错误',
- stateMinted: '首轮取得 STATE',
- stateUnavailable: '此接口未提供 STATE 原值。',
- targetsStatus: '目标账号与模型',
- noConfiguredTargets: '尚未保存目标账号与模型,请先完成借用设置。',
- preparationPending: '正在准备借用线路',
- lineStates: {
- retry_pending: '验证中断,待重验',
- stale: '状态待刷新',
- waiting: '尚未准备',
- validating: '准备中',
- ready: '最近一次验证通过',
- expired: '已过期',
- rejected: '验证失败',
- },
- observedAt: '状态读取时间',
- prepare: '准备共享候选',
- preparing: '正在准备',
- prepareHint: '准备会获取来源候选并验证已保存的目标与模型;强制验证只检查指定组合。这两项均可能发起上游请求,与下面的动画生成分别执行。',
- prepareFailed: '准备失败',
- sharedCandidate: '共享候选来源',
- candidateFingerprint: '候选指纹',
- noCandidate: '尚未取得可借用的来源线路',
- sourceStates: '来源采集记录',
- accountModel: '账号 / 模型',
- validation: '线路状态',
- expiryCooldown: '时间与冷却',
- actions: '操作',
- cacheReady: '匹配缓存可用',
- cacheMissing: '没有可用的匹配缓存',
- remainingSeconds: '剩余 {seconds} 秒',
- checkedAt: '检查时间',
- expiresAt: '到期时间',
- retryAfter: '冷却截止',
- httpStatuses: '获取 HTTP {mint} / 续接 HTTP {continuation}',
- newTicket: '取得新凭据',
- reportedModel: '上游报告模型',
- forceVerify: '强制验证',
- validating: '正在验证',
- verifyFailed: '验证失败',
- noTargets: '尚无目标验证记录',
- pelicanTitle: '鹈鹕对比',
- pelicanHint: '使用已经准备好的借用线路生成作品。每个所选账号与模型生成一份,作品效果由管理员观察。',
- fixedPrompt: '固定题目',
- testModels: '参与模型与思考强度',
- testAccounts: '参与账号',
- effort: '思考强度',
- willSkip: '将跳过,不发起生成',
- executionHint: '最多同时执行 3 个任务,同一账号的模型串行,每项最多 90 秒。暂停、错误或限流账号有有效线路时可明确选中;生成仅供观察。停止或离开本页会取消剩余任务和在途生成。',
- startTest: '生成作品({count} 个账号/模型组合)',
- stop: '停止生成',
- stoppedHint: '生成连接已停止,尚未结束的项目已取消;已收到的结果保留,可从服务端历史查看最终记录。',
- testFailed: '生成连接失败',
- clockUnavailable: '尚未取得服务端时间;刷新状态后才能提交生成任务。',
- resultViews: '结果视图',
- results: '本次作品',
- history: '历史记录',
- replayed: '已有任务,未重复生成',
- historyHint: '历史保存在服务端 24 小时。刷新和查看只读取记录,不会重测;到期后自动移除。',
- refreshHistory: '刷新历史',
- historyFailed: '读取历史失败',
- noHistory: '过去 24 小时没有测试记录',
- viewResults: '查看作品',
- previous: '上一页',
- noResults: '选择账号后点击生成,作品会逐项显示在这里。',
- taskId: '任务 ID',
- duration: '耗时',
- previewPending: '等待生成结果',
- previewMissingHtml: '无法提取预览,可展开回答原文查看',
- previewNotGenerated: '未生成作品,请查看状态与原因',
- enlarge: '放大预览',
- rawAnswer: '回答原文',
- htmlSource: 'HTML 源码',
- rawResponse: '上游响应原文',
- downloadAnswer: '下载回答原文',
- reasons: {
- account_unavailable: '账号或凭据来源不可用,请检查账号设置',
- account_inactive: '账号未启用,请先在账号页处理状态',
- model_not_allowed: '账号未允许此模型,请检查模型列表',
- model_mapping_mismatch: '账号把此模型映射到其他模型,不能复用验证',
- identity_changed: '身份或出口配置已改变,请重新验证',
-
- not_prepared: '尚未准备借用线路',
- not_verified: '此账号与模型尚未完成线路验证',
- not_configured: '此账号与模型未包含在已保存配置中',
- disabled: '借用功能当前关闭',
- route_expired: '借用线路已过期,下一次业务请求会自动获取并验证',
- source_passed_target_failed: '来源返回了候选,但目标验证失败;按需限次重新获取,冷却期间不重复采集。',
- source_returned_rejected_route: '来源再次返回同一条失败线路,未延长租期;冷却后再按需尝试。',
- source_qualified: '来源账号可以提供借用线路',
- source_probe_failed: '采集来源线路失败,请查看技术详情',
- source_response_not_qualified: '来源响应未满足借用条件',
- source_routing_cookie_unavailable: '来源响应缺少可借用的线路信息',
- target_probe_passed: '此账号与模型的两轮线路验证已通过',
- target_probe_failed: '此账号与模型的线路验证失败,请查看技术详情',
- target_route_changed: '验证时线路发生变化,请重新验证',
- target_state_missing: '首轮响应缺少会话状态,无法验证续接',
- target_state_changed: '两轮上游均已完成,但续接返回了不同的 STATE,当前线路未通过借用验证。请查看本次验证依据。',
- validating: '正在验证此账号与模型的借用线路',
- candidate_ready: '来源线路已采集',
- validated: '此账号与模型的借用线路已验证通过',
- no_matched_cache: '此账号与模型尚无可用的借用线路',
- },
- states: {
- pending: '等待中', running: '执行中', complete: '完成', failed: '失败', incomplete: '未完整结束', cancelled: '已取消', skipped: '跳过',
- active: '正常', inactive: '未激活', disabled: '已禁用', error: '错误', validating: '验证中', valid: '验证通过', ready: '可用', expired: '已到期', cooldown: '冷却中', unavailable: '不可用', missing: '缺失', preparing: '准备中', waiting: '尚未准备', rejected: '验证未通过'
- }
- },
- pelicanTests: {
- title: '鹈鹕测试',
- description: '用各个已选账号当前生效的模型映射、凭据与连接设置,生成同一道鹈鹕动画题目。',
- refreshLocal: '刷新本地账号',
- chooseAccounts: '选择账号',
- localCandidatesHint: '包含所有平台和账号类型。模型候选来自已保存的本地目录与映射,也可输入具体模型 ID。',
- searchAccounts: '搜索账号名称或 ID',
- platform: '平台',
- allPlatforms: '全部平台',
- selectFiltered: '选择匹配账号({count})',
- clearSelection: '清空选择',
- noAccounts: '没有匹配的账号',
- model: '模型',
- manualModel: '选择或输入模型 ID',
- addModel: '添加模型',
- removeModel: '移除模型',
- normalDefault: '模型默认',
- generationBudget: '生成预算',
- minutes: '分钟',
- budgetBoundary: '请输入 1–30 分钟内的整数。',
- executionHint: '所有批次共享 {count} 个执行位,同一账号串行。排队、准备与生成分别计时;停止或离开页面会取消排队及在途任务。',
- temporarilyUnavailable: '临时不可用',
- overloaded: '过载',
- viewPrompt: '查看固定题目',
- results: '当前作品',
- history: '历史记录',
- cardSize: '卡片尺寸',
- sizes: { compact: '紧凑', standard: '标准', large: '大图' },
- previewOnVisible: '进入可见范围后加载预览',
- details: '详情',
- downloadHtml: '下载 HTML',
- account: '账号',
- mappedModel: '实际映射模型',
- actualCall: '实际调用',
- borrowApplied: '已应用借用线路',
- accountRoute: '账号自身线路',
- phaseDurations: '阶段耗时',
- phases: { queued: '排队', preparing: '准备', generating: '生成' },
- },
- codexPelicanComparison: {
- title: '鹈鹕对比',
- description: '使用已经准备好的借用线路生成作品,并排观察不同账号和模型的表现。',
- viewStatus: '查看借用状态',
- refreshAvailability: '刷新线路状态',
- viewPrompt: '查看题目',
- noTargets: '尚未保存目标账号。请先在借用设置页选择账号,再准备借用线路。',
- disabledHint: '借用功能当前关闭。启用并保存配置后,可以在借用状态页准备线路。',
- results: '本次作品',
- history: '历史记录',
- routeReason: '线路原因',
- }
-}
diff --git a/frontend/src/i18n/locales/zh/admin/index.ts b/frontend/src/i18n/locales/zh/admin/index.ts
index 4c3ce8634992..c9202402cb95 100644
--- a/frontend/src/i18n/locales/zh/admin/index.ts
+++ b/frontend/src/i18n/locales/zh/admin/index.ts
@@ -7,7 +7,7 @@ import settings from './settings'
import audit from './audit'
import promptAudit from './promptAudit'
import systemPrompts from './systemPrompts'
-import codexGatewayBorrow from './codexGatewayBorrow'
+import pelicanTests from './pelicanTests'
import accountTasks from './accountTasks'
import plugins from './plugins'
@@ -21,7 +21,7 @@ export default {
...audit,
...promptAudit,
...systemPrompts,
- ...codexGatewayBorrow,
+ ...pelicanTests,
...accountTasks,
...plugins,
}
diff --git a/frontend/src/i18n/locales/zh/admin/pelicanTests.ts b/frontend/src/i18n/locales/zh/admin/pelicanTests.ts
new file mode 100644
index 000000000000..a32277d2c6ac
--- /dev/null
+++ b/frontend/src/i18n/locales/zh/admin/pelicanTests.ts
@@ -0,0 +1,53 @@
+export default {
+ pelicanTests: {
+ title: '鹈鹕测试',
+ description: '用各个已选账号当前生效的模型映射、凭据与连接设置,生成同一道鹈鹕动画题目。',
+ refreshLocal: '刷新本地账号',
+ chooseAccounts: '选择账号',
+ localCandidatesHint: '包含所有平台和账号类型。模型候选来自已保存的本地目录与映射,也可输入具体模型 ID。',
+ searchAccounts: '搜索账号名称或 ID',
+ platform: '平台',
+ allPlatforms: '全部平台',
+ selectFiltered: '选择匹配账号({count})',
+ clearSelection: '清空选择',
+ noAccounts: '没有匹配的账号',
+ model: '模型',
+ manualModel: '选择或输入模型 ID',
+ addModel: '添加模型',
+ removeModel: '移除模型',
+ normalDefault: '模型默认',
+ generationBudget: '生成预算',
+ minutes: '分钟',
+ budgetBoundary: '请输入 1–30 分钟内的整数。',
+ executionHint: '所有批次共享 {count} 个执行位,同一账号串行。排队、准备与生成分别计时;关闭页面或断线后任务继续,明确停止才取消。',
+ temporarilyUnavailable: '临时不可用',
+ overloaded: '过载',
+ viewPrompt: '查看固定题目',
+ results: '当前作品',
+ history: '历史记录',
+ cardSize: '卡片尺寸',
+ sizes: { compact: '紧凑', standard: '标准', large: '大图' },
+ previewOnVisible: '进入可见范围后加载预览',
+ details: '详情',
+ downloadHtml: '下载 HTML',
+ account: '账号',
+ mappedModel: '实际映射模型',
+ actualCall: '实际调用',
+ borrowApplied: '历史记录:曾应用借用线路',
+ accountRoute: '账号自身线路',
+ phaseDurations: '阶段耗时',
+ phases: { queued: '排队', preparing: '准备', generating: '生成' },
+
+ clockUnavailable: '尚未取得服务端时间;刷新状态后才能提交生成任务。',
+ downloadAnswer: '下载回答原文',
+ duration: '耗时',
+ effort: '思考强度',
+ enlarge: '放大预览',
+ htmlSource: 'HTML 源码',
+ noHistory: '过去 24 小时没有测试记录',
+ rawAnswer: '回答原文',
+ rawResponse: '上游响应原文',
+ refreshHistory: '刷新历史',
+ states: { pending: '等待中', running: '执行中', complete: '完成', failed: '失败', incomplete: '未完整结束', cancelled: '已取消', skipped: '跳过', active: '正常', inactive: '未激活', disabled: '已禁用', error: '错误' },
+ }
+}
diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts
index 38bb792f0a47..74dfbeb678ed 100644
--- a/frontend/src/i18n/locales/zh/common.ts
+++ b/frontend/src/i18n/locales/zh/common.ts
@@ -224,8 +224,6 @@ export default {
promptAudit: '提示词审计',
auditLogs: '操作日志',
systemPrompts: '系统提示词',
- codexGatewayBorrow: 'Codex 借用',
- codexGatewayBorrowStatus: 'Codex 借用状态',
codexPelicanComparison: '鹈鹕对比',
pelicanTests: '鹈鹕测试',
},
diff --git a/frontend/src/router/__tests__/codex-borrow-routes.spec.ts b/frontend/src/router/__tests__/codex-routes.spec.ts
similarity index 84%
rename from frontend/src/router/__tests__/codex-borrow-routes.spec.ts
rename to frontend/src/router/__tests__/codex-routes.spec.ts
index 79ba29152c3c..3a36a7d5d8c1 100644
--- a/frontend/src/router/__tests__/codex-borrow-routes.spec.ts
+++ b/frontend/src/router/__tests__/codex-routes.spec.ts
@@ -55,8 +55,7 @@ vi.mock('@/composables/useRoutePrefetch', () => ({
}))
vi.mock('@/router/title', () => ({ resolveRouteDocumentTitle: () => 'Sub2API' }))
-const borrowRoutes = [
- { path: '/admin/codex', name: 'AdminCodex' },
+const codexRoutes = [
{ path: '/admin/codex/identity', name: 'AdminCodexIdentity' },
{ path: '/admin/codex/advanced', name: 'AdminCodexAdvanced' },
{ path: '/admin/pelican-tests', name: 'AdminPelicanTests' },
@@ -70,7 +69,7 @@ async function navigate(path: string) {
return next
}
-describe('Codex borrow page routes', () => {
+describe('Codex and Pelican page routes', () => {
beforeAll(async () => { await import('@/router') })
beforeEach(() => {
authStore.isAuthenticated = true
@@ -78,19 +77,23 @@ describe('Codex borrow page routes', () => {
authStore.isSimpleMode = false
})
- it.each([['/admin/codex-runtime', '/admin/codex/advanced'], ['/admin/codex-fingerprint', '/admin/codex/identity'], ['/admin/codex-gateway-borrow', '/admin/codex'], ['/admin/codex-gateway-borrow/status', '/admin/codex#status']])('redirects %s to the unified console', (path, destination) => {
+ it.each([['/admin/codex-runtime', '/admin/codex/advanced'], ['/admin/codex-fingerprint', '/admin/codex/identity'], ['/admin/codex', '/admin/codex/identity']])('redirects %s to the unified console', (path, destination) => {
const record = harness.routes.find(route => route.path === path)
expect(record?.redirect).toBe(destination)
expect(record?.meta).toMatchObject({ requiresAuth: true, requiresAdmin: true })
})
+ it('does not register retired borrowing routes', () => {
+ expect(harness.routes.some(route => route.path.startsWith('/admin/codex-gateway-borrow'))).toBe(false)
+ })
+
it('redirects the bookmarked comparison address to the independent Pelican page', () => {
const record = harness.routes.find(route => route.path === '/admin/codex-pelican-comparison')
expect(record?.redirect).toBe('/admin/pelican-tests')
expect(record?.meta).toMatchObject({ requiresAuth: true, requiresAdmin: true })
})
- it.each(borrowRoutes)('keeps $path directly addressable and protected', ({ path, name }) => {
+ it.each(codexRoutes)('keeps $path directly addressable and protected', ({ path, name }) => {
const record = harness.routes.find(route => route.path === path)
expect(record?.name).toBe(name)
@@ -98,7 +101,7 @@ describe('Codex borrow page routes', () => {
expect(record).not.toHaveProperty('redirect')
})
- it.each(borrowRoutes)('allows admins to open $path in simple mode', async ({ path }) => {
+ it.each(codexRoutes)('allows admins to open $path in simple mode', async ({ path }) => {
authStore.isSimpleMode = true
const next = await navigate(path)
@@ -106,12 +109,12 @@ describe('Codex borrow page routes', () => {
expect(next).toHaveBeenCalledWith()
})
- it.each(borrowRoutes)('blocks regular users from $path', async ({ path }) => {
+ it.each(codexRoutes)('blocks regular users from $path', async ({ path }) => {
authStore.isAdmin = false
expect(await navigate(path)).toHaveBeenCalledWith('/dashboard')
})
- it.each(borrowRoutes)('requires sign-in for $path and preserves the return address', async ({ path }) => {
+ it.each(codexRoutes)('requires sign-in for $path and preserves the return address', async ({ path }) => {
authStore.isAuthenticated = false
authStore.isAdmin = false
expect(await navigate(path)).toHaveBeenCalledWith({ path: '/login', query: { redirect: path } })
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index c48b8ee38c07..c6881d0ed337 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -537,12 +537,7 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.plugins.description'
}
},
- {
- path: '/admin/codex',
- name: 'AdminCodex',
- component: () => import('@/views/admin/CodexGatewayBorrowView.vue'),
- meta: { requiresAuth: true, requiresAdmin: true, title: 'Codex', titleKey: 'nav.codex' }
- },
+ { path: '/admin/codex', redirect: '/admin/codex/identity', meta: { requiresAuth: true, requiresAdmin: true } },
{
path: '/admin/codex/identity',
name: 'AdminCodexIdentity',
@@ -557,8 +552,6 @@ const routes: RouteRecordRaw[] = [
},
{ path: '/admin/codex-runtime', redirect: '/admin/codex/advanced', meta: { requiresAuth: true, requiresAdmin: true } },
{ path: '/admin/codex-fingerprint', redirect: '/admin/codex/identity', meta: { requiresAuth: true, requiresAdmin: true } },
- { path: '/admin/codex-gateway-borrow', redirect: '/admin/codex', meta: { requiresAuth: true, requiresAdmin: true } },
- { path: '/admin/codex-gateway-borrow/status', redirect: '/admin/codex#status', meta: { requiresAuth: true, requiresAdmin: true } },
{
path: '/admin/pelican-tests',
name: 'AdminPelicanTests',
@@ -721,7 +714,6 @@ const routes: RouteRecordRaw[] = [
}
},
-
// ==================== Payment Admin Routes ====================
{
path: '/admin/orders/dashboard',
@@ -949,7 +941,6 @@ router.beforeEach(async (to, _from, next) => {
}
}
-
// 公共设置可能尚未加载(App.vue 的 onMounted 异步拉取晚于首次导航,且纯静态部署
// 无 __APP_CONFIG__ 注入)。此时 cachedPublicSettings 为空会把 payment/risk_control
// 误判为“未启用”而错误拦截,故这里先确保设置加载完成。
diff --git a/frontend/src/views/admin/CodexGatewayBorrowStatusView.vue b/frontend/src/views/admin/CodexGatewayBorrowStatusView.vue
deleted file mode 100644
index 3bd400731b42..000000000000
--- a/frontend/src/views/admin/CodexGatewayBorrowStatusView.vue
+++ /dev/null
@@ -1,408 +0,0 @@
-
-
-
-
-
-
{{ statusError }}
-
{{ inventoryError }}
-
{{ t('common.loading') }}
-
-
-
-
-
-
{{ t('admin.codexGatewayBorrow.borrowStatus') }}
-
{{ t('admin.codexGatewayBorrow.observedAt') }}: {{ formatTime(status.generated_at) }}
-
-
-
-
-
-
- {{ t('admin.codexGatewayBorrow.enabled') }}
- - {{ status.enabled ? t('admin.codexGatewayBorrow.on') : t('admin.codexGatewayBorrow.off') }}
-
-
-
- {{ text('最近验证情况', 'Latest validation') }}
- - {{ preparationLabel }}
-
-
-
- {{ t('admin.codexGatewayBorrow.readyPairs') }}
- - {{ t('admin.codexGatewayBorrow.pairCount', { ready: readyCount, total: targets.length }) }}
-
-
-
- {{ t('admin.codexGatewayBorrow.currentSource') }}
-
-
-
{{ accountName(status.candidate.source_account_id) }} #{{ status.candidate.source_account_id }}
- {{ proxyTitle }}: {{ actualProxyLabel(status.candidate.proxy_id, status.candidate.source_account_id, status.candidate.node_identity) }}
- {{ t('admin.codexGatewayBorrow.remainingSeconds', { seconds: candidateRemaining }) }}
- {{ t('admin.codexGatewayBorrow.candidateExpired') }}
-
-
- {{ t('admin.codexGatewayBorrow.noCandidate') }}
-
-
-
-
{{ text('准备进度', 'Preparation progress') }}: {{ status.setup.completed }} / {{ status.setup.total }} · {{ text('失败', 'Failed') }} {{ status.setup.failed }}
-
{{ status.setup.phase === 'source' ? text('正在获取来源路由', 'Acquiring a source route') : `${accountName(status.setup.account_id)} · ${status.setup.model}` }}
-
{{ status.setup.error }}
-
-
-
{{ t('admin.codexGatewayBorrow.onDemandHint') }}
-
{{ acquisitionLabel }} · {{ formatTime(status.acquisition.started_at) }}
-
- {{ t('admin.codexGatewayBorrow.technicalDetails') }}
-
- {{ text('触发原因', 'Trigger') }}: {{ status.acquisition.trigger }}
- {{ text('完成时间', 'Finished') }}: {{ formatTime(status.acquisition.finished_at) }}
- {{ t('admin.codexGatewayBorrow.retryAfter') }}: {{ formatTime(status.acquisition.retry_after) }}
-
- {{ status.acquisition.error }}
-
-
- {{ t('admin.codexGatewayBorrow.validationMeaning') }}
- {{ text('状态尚未更新,以下为上次记录,不能确认当前可用。', 'Status is out of date. The last records below do not establish current availability.') }}
- {{ t('admin.codexGatewayBorrow.modelScope') }}
-
-
-
{{ actionFailure.message }}
-
- {{ t('admin.codexGatewayBorrow.technicalDetails') }}
- {{ actionFailure.detail }}
-
-
-
- {{ t('admin.codexGatewayBorrow.technicalDetails') }} · {{ t('admin.codexGatewayBorrow.currentSource') }}
-
- - {{ t('admin.codexGatewayBorrow.candidateFingerprint') }}
- {{ status.candidate.cookie_fingerprint || '—' }}
- - {{ t('admin.codexGatewayBorrow.expiresAt') }}
- {{ formatTime(status.candidate.expires_at) }}
-
-
-
- {{ t('admin.codexGatewayBorrow.sourceStates') }} ({{ status.sources.length }})
- {{ t('admin.codexGatewayBorrow.prepareRoutesHint') }}
- {{ t('admin.codexGatewayBorrow.pollingHint') }}
-
-
{{ accountName(source.account_id) }} #{{ source.account_id }}
-
{{ borrowReasonLabel(source.expires_at && remainingSeconds(source.expires_at) === 0 && source.state === 'ready' ? 'route_expired' : source.reason, t) }}
-
- - {{ t('admin.codexGatewayBorrow.rawState') }}
- {{ source.state }}
- - {{ t('admin.codexGatewayBorrow.rawReason') }}
- {{ source.reason || '—' }}
- - {{ t('admin.codexGatewayBorrow.checkedAt') }}
- {{ formatTime(source.checked_at) }}
- - {{ t('admin.codexGatewayBorrow.expiresAt') }}
- {{ formatTime(source.expires_at) }}
- - {{ proxyTitle }}
- {{ actualProxyLabel(source.proxy_id, source.account_id, source.node_identity) }}
-
-
{{ source.error }}
-
-
-
-
-
- {{ t('admin.codexGatewayBorrow.targetsStatus') }}
- {{ t('admin.codexGatewayBorrow.revalidateHint') }}
-
- {{ t('admin.codexGatewayBorrow.noConfiguredTargets') }}
-
-
- {{ text('节点选择与冷却', 'Node selection and cooling') }}
- {{ text('按目标账号和模型分别记录。当前选中仅表示本次验证有效,不代表长期稳定或任务质量通过。', 'Recorded separately for each target and model. Selection indicates a current validation, not long-term stability or task-quality acceptance.') }}
-
-
{{ accountName(node.account_id) }} #{{ node.account_id }}
{{ node.model }}
-
{{ node.proxy_id === 0 ? text('原服务器直连', 'Server direct') : `${node.proxy_name} #${node.proxy_id}` }}
{{ node.gateway || '—' }}
-
{{ nodeStateLabel(node) }}
{{ text('冷却截止', 'Cooling until') }}: {{ formatTime(node.retry_after) }} · {{ remainingSeconds(node.retry_after) }}s
{{ borrowReasonLabel(node.reason, t) }}
-
- {{ text('尚未尝试候选节点。', 'Candidate nodes have not been attempted.') }}
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/views/admin/CodexGatewayBorrowView.vue b/frontend/src/views/admin/CodexGatewayBorrowView.vue
deleted file mode 100644
index 4c8f300e2e97..000000000000
--- a/frontend/src/views/admin/CodexGatewayBorrowView.vue
+++ /dev/null
@@ -1,303 +0,0 @@
-
-
-
-
- {{ text('线路借用', 'Route borrowing') }}
- {{ text('借用来源账号取得的路由信息,目标账号仍使用自己的凭据。客户端身份模拟在另一页独立管理。', 'Reuse routing information acquired by a source account; targets keep their own credentials. Client identity simulation is managed separately.') }}
-
-
{{ loadError }}
-
{{ t('common.loading') }}
-
-
-
{{ t('admin.codexGatewayBorrow.savedSelection', { sources: saved.source_account_ids.length, targets: saved.target_account_ids.length, models: saved.models.length }) }}
-
-
-
- {{ text('配置借用 · 三步完成', 'Configure borrowing · three steps') }}
-
-
-
-
{{ t('admin.codexGatewayBorrow.configuration') }}
- {{ t('admin.codexGatewayBorrow.unsavedChanges') }}
-
-
-
-
-
-
- {{ t('admin.codexGatewayBorrow.draftHint') }}
-
- {{ t('admin.codexGatewayBorrow.saveHint') }}
-
-
-
-
-
-
- {{ t('admin.codexGatewayBorrow.localInventoryHint') }}
- {{ configError }}
-
- {{ saveError }}
-
-
{{ t('admin.codexGatewayBorrow.saved') }}
-
{{ t('admin.codexGatewayBorrow.currentPreparationState') }}: {{ t('common.loading') }}
-
{{ t('admin.codexGatewayBorrow.statusReadFailed') }}
-
{{ t('admin.codexGatewayBorrow.currentPreparationState') }}: {{ status.preparing ? t('admin.codexGatewayBorrow.preparing') : t('admin.codexGatewayBorrow.preparationIdle') }}
-
{{ t('admin.codexGatewayBorrow.viewBorrowStatus') }}
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/views/admin/CodexPelicanComparisonView.vue b/frontend/src/views/admin/CodexPelicanComparisonView.vue
index 2a00ef2a4398..e99a74ef0e31 100644
--- a/frontend/src/views/admin/CodexPelicanComparisonView.vue
+++ b/frontend/src/views/admin/CodexPelicanComparisonView.vue
@@ -5,7 +5,7 @@
{{ t('admin.pelicanTests.title') }}
{{ text('同一道题,按任务保存作品,比较完整结果。', 'One fixed prompt, saved tasks and complete works to compare.') }}
- {{ text('作品呈现只供观察。模型名称、借用应用和完成状态分别记录,不据此判断真实模型身份或“满血/降智”。', 'Visual works are observations. Model labels, borrowing and completion are recorded separately; they do not prove model identity or intelligence.') }}
+ {{ text('作品呈现只供观察。模型名称与完成状态分别记录,不据此判断真实模型身份或“满血/降智”。', 'Visual works are observations. Model labels and completion are recorded separately; they do not prove model identity or intelligence.') }}
{{ error }}
{{ text('上次提交结果尚未确认。查询或重试使用同一个任务标识,避免重复生成。', 'The previous submission is unconfirmed. Checking or retrying uses the same task identity to avoid duplicate generation.') }}
@@ -14,12 +14,12 @@
-
{{ previewTitle(item) }}
{{ item.preview_unavailable || stateLabel(item.status) }}
{{ item.error }}
+
{{ previewTitle(item) }}
{{ item.preview_unavailable || stateLabel(item.status) }}
{{ item.error }}
@@ -54,23 +54,23 @@
- {{ t('admin.pelicanTests.mappedModel') }}
- {{ detailResult.upstream_model || '—' }}
- {{ t('admin.pelicanTests.actualCall') }}
- {{ detailResult.actual_endpoint || '—' }} · {{ detailResult.actual_protocol || '—' }} · {{ detailResult.actual_transport || '—' }} · {{ detailResult.borrow_applied === undefined ? '—' : t(detailResult.borrow_applied ? 'admin.pelicanTests.borrowApplied' : 'admin.pelicanTests.accountRoute') }}
- {{ t('admin.pelicanTests.phaseDurations') }}
- {{ t('admin.pelicanTests.phases.queued') }} {{ formatDuration(detailResult.queue_duration_ms) }} · {{ t('admin.pelicanTests.phases.preparing') }} {{ formatDuration(detailResult.preparation_duration_ms) }} · {{ t('admin.pelicanTests.phases.generating') }} {{ formatDuration(detailResult.generation_duration_ms) }}
- - {{ t('admin.codexGatewayBorrow.duration') }}
- {{ formatDuration(detailResult.duration_ms) }} · {{ formatTime(detailResult.started_at) }} · {{ stateLabel(detailResult.status) }}
+ - {{ t('admin.pelicanTests.duration') }}
- {{ formatDuration(detailResult.duration_ms) }} · {{ formatTime(detailResult.started_at) }} · {{ stateLabel(detailResult.status) }}
{{ detailResult.error }}
{{ detailResult.preview_unavailable }}
-
+
-
+
-
{{ t('admin.codexGatewayBorrow.rawAnswer') }}
{{ detailResult.raw_answer }}
-
{{ t('admin.codexGatewayBorrow.htmlSource') }}
{{ detailResult.raw_html || detailResult.html }}
-
{{ t('admin.codexGatewayBorrow.rawResponse') }}
{{ detailResult.raw_response }}
+
{{ t('admin.pelicanTests.rawAnswer') }}
{{ detailResult.raw_answer }}
+
{{ t('admin.pelicanTests.htmlSource') }}
{{ detailResult.raw_html || detailResult.html }}
+
{{ t('admin.pelicanTests.rawResponse') }}
{{ detailResult.raw_response }}
-
-
+
+
@@ -85,7 +85,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Pagination from '@/components/common/Pagination.vue'
import Icon from '@/components/icons/Icon.vue'
-import BorrowPelicanPreview from '@/components/admin/codex/BorrowPelicanPreview.vue'
+import PelicanPreview from '@/components/admin/codex/PelicanPreview.vue'
import PelicanTaskComposer from '@/components/admin/codex/PelicanTaskComposer.vue'
import PelicanResultCard from '@/components/admin/codex/PelicanResultCard.vue'
import { pelicanTestsAPI, PELICAN_PROMPT, createPelicanClientTaskId, type PelicanTaskSnapshot, type PelicanResultSummary, type PelicanTestResult, type PelicanResultPage, type PelicanTestHistory, type PelicanTestRequest } from '@/api/admin/pelicanTests'
@@ -112,7 +112,7 @@ function readPending(): PelicanTestRequest | null { try { return JSON.parse(sess
function storePending(value: PelicanTestRequest | null) { pendingRequest.value = value; if (value) sessionStorage.setItem('pelican.pending-task.v1', JSON.stringify(value)); else sessionStorage.removeItem('pelican.pending-task.v1') }
function formatTime(value?: string) { if (!value || value.startsWith('0001-')) return '—'; return new Date(value).toLocaleString() }
function formatDuration(ms?: number) { return `${((ms || 0) / 1000).toFixed(1)} s` }
-function stateLabel(value: string) { const key = `admin.codexGatewayBorrow.states.${value}`; return te(key) ? t(key) : value }
+function stateLabel(value: string) { const key = `admin.pelicanTests.states.${value}`; return te(key) ? t(key) : value }
function previewTitle(result: PelicanTestResult) { return `${result.account_name} #${result.account_id} · ${result.model_id} · ${result.effort || t('admin.pelicanTests.normalDefault')}` }
function report(value: unknown) { if (!lifecycle.signal.aborted) error.value = extractApiErrorMessage(value, text('操作未完成,请查看原因后重试。', 'The operation did not complete; review the error and retry.')) }
async function loadHistory() {
@@ -181,7 +181,7 @@ async function observe(id: string) {
}
function newTest() { draft.value = undefined; composerOpen.value = true; error.value = '' }
async function startTask(parameters: Omit) {
- if (!clockKnown.value) { error.value = t('admin.codexGatewayBorrow.clockUnavailable'); return }
+ if (!clockKnown.value) { error.value = t('admin.pelicanTests.clockUnavailable'); return }
await submitRequest({ ...parameters, client_task_id: createPelicanClientTaskId(serverOffset.value) })
}
async function submitRequest(request: PelicanTestRequest) {
diff --git a/frontend/src/views/admin/__tests__/CodexGatewayBorrowStatusView.spec.ts b/frontend/src/views/admin/__tests__/CodexGatewayBorrowStatusView.spec.ts
deleted file mode 100644
index 7d8c7ea19822..000000000000
--- a/frontend/src/views/admin/__tests__/CodexGatewayBorrowStatusView.spec.ts
+++ /dev/null
@@ -1,401 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { flushPromises, mount } from '@vue/test-utils'
-import CodexGatewayBorrowStatusView from '../CodexGatewayBorrowStatusView.vue'
-import type { AccountListItem } from '@/types'
-import type { BorrowVerification, CodexGatewayBorrowStatus } from '@/api/admin/codexGatewayBorrow'
-
-const mocks = vi.hoisted(() => ({
- getConfig: vi.fn(), saveConfig: vi.fn(), getStatus: vi.fn(), prepare: vi.fn(), verify: vi.fn(),
- listTests: vi.fn(), getTest: vi.fn(), streamTests: vi.fn(), listAccounts: vi.fn(), getAvailableModels: vi.fn(), testAccount: vi.fn()
-}))
-vi.mock('@/api/admin/codexGatewayBorrow', async importOriginal => ({
- ...(await importOriginal()), codexGatewayBorrowAPI: mocks
-}))
-vi.mock('@/api/admin/accounts', () => {
- const accountsAPI = { list: mocks.listAccounts, getAvailableModels: mocks.getAvailableModels, testAccount: mocks.testAccount }
- return { accountsAPI, default: accountsAPI }
-})
-vi.mock('vue-i18n', async importOriginal => ({
- ...(await importOriginal()),
- useI18n: () => ({ locale: { value: 'zh' }, t: (key: string, values?: object) => values ? `${key} ${JSON.stringify(values)}` : key, te: () => true })
-}))
-
-const startTime = new Date('2026-10-08T06:00:00.000Z').getTime()
-const wrappers: ReturnType[] = []
-const accounts = [
- { id: 1, name: 'Source', platform: 'openai', type: 'oauth', status: 'active', schedulable: true, proxy_id: 7, proxy: { id: 7, name: 'Bound exit' }, extra: {} },
- { id: 2, name: 'Target', platform: 'openai', type: 'setup-token', status: 'error', schedulable: false, proxy_id: null, parent_account_id: 1, extra: {}, error_message: 'Complete original account error' },
- { id: 3, name: 'Second target', platform: 'openai', type: 'oauth', status: 'active', schedulable: true, proxy_id: null, extra: {} },
- { id: 4, name: 'API Key', platform: 'openai', type: 'apikey', status: 'active', schedulable: true }
-] as unknown as AccountListItem[]
-
-function makeStatus(overrides: Partial = {}): CodexGatewayBorrowStatus {
- const expires = new Date(Date.now() + 60_000).toISOString()
- return {
- enabled: true, preparing: false, revision: 1, generated_at: new Date().toISOString(),
- config: { enabled: true, source_account_ids: [1], target_account_ids: [2, 3], models: ['gpt-6-astra', 'gpt-6.1-sol'] },
- candidate: { source_account_id: 1, expires_at: expires, remaining_seconds: 60, cookie_fingerprint: 'full-cookie-fingerprint' },
- sources: [{ account_id: 1, state: 'ready', reason: 'source_qualified', checked_at: new Date().toISOString(), expires_at: expires, remaining_seconds: 60 }],
- targets: [2, 3].flatMap(account_id => ['gpt-6-astra', 'gpt-6.1-sol'].map(model => ({
- account_id, model, state: account_id === 2 ? 'ready' : 'waiting', reason: account_id === 2 ? 'target_probe_passed' : 'not_verified',
- cache_valid: account_id === 2, checked_at: new Date().toISOString(), expires_at: account_id === 2 ? expires : undefined,
- remaining_seconds: account_id === 2 ? 60 : 0, mint_status: account_id === 2 ? 200 : 0, continue_status: account_id === 2 ? 200 : 0,
- minted: account_id === 2, new_ticket: false, reported_model: account_id === 2 ? model : undefined
- }))), ...overrides
- }
-}
-function deferred() {
- let resolve!: (value: T) => void
- let reject!: (reason?: unknown) => void
- const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise })
- return { promise, resolve, reject }
-}
-async function mountView() {
- const wrapper = mount(CodexGatewayBorrowStatusView, { global: { stubs: {
- CodexLayout: { template: '
' }, CodexGatewayBorrowStatusView: true, CodexBorrowActivity: true, AppLayout: { template: '
' }, CodexBorrowNav: true
- } } })
- wrappers.push(wrapper)
- await flushPromises()
- return wrapper
-}
-function expectNoUnrelatedCalls() {
- for (const name of ['getConfig', 'saveConfig', 'listTests', 'getTest', 'streamTests', 'getAvailableModels', 'testAccount'] as const) expect(mocks[name]).not.toHaveBeenCalled()
-}
-
-beforeEach(() => {
- vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
- vi.setSystemTime(startTime)
- for (const mock of Object.values(mocks)) mock.mockReset()
- mocks.listAccounts.mockResolvedValue({ items: accounts, total: accounts.length })
- mocks.getStatus.mockImplementation(async () => makeStatus())
- mocks.prepare.mockImplementation(async () => makeStatus())
- mocks.verify.mockResolvedValue({ account_id: 2, model: 'gpt-6.1-sol', success: true, reason: 'target_probe_passed' })
-})
-afterEach(() => {
- wrappers.splice(0).forEach(wrapper => wrapper.unmount())
- vi.restoreAllMocks()
- vi.useRealTimers()
-})
-
-describe('CodexGatewayBorrowStatusView request and expiry boundaries', () => {
- it('shows request-owned recovery above the last validation summary without issuing new probes', async () => {
- const state = makeStatus()
- state.recent_usage = [{ account_id: 2, model: 'gpt-6-astra', transport: 'http', origin: 'business', applied: false, dispatched: false, reason: 'preparing', started_at: new Date().toISOString(), outcome: 'preparing', count: 0, applied_count: 0, recovery: { phase: 'cooling', started_at: new Date().toISOString(), rounds: 1, source_requests: 0, probe_requests: 0 } }]
- mocks.getStatus.mockResolvedValue(state)
- const wrapper = await mountView()
- expect(wrapper.get('[data-test="borrow-preparation-status"]').text()).toContain('原请求仍在等待')
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- })
- it('identifies explicit server direct without presenting the saved proxy as the actual exit', async () => {
- const state = makeStatus()
- state.config.rotate_nodes = true
- state.config.node_proxy_ids = [0]
- state.targets[0].node_identity = 'direct-node'
- state.nodes = [{ account_id: 2, model: 'gpt-6-astra', proxy_id: 0, proxy_name: 'Server direct', node_identity: 'direct-node', state: 'selected', expires_at: state.targets[0].expires_at }]
- mocks.getStatus.mockResolvedValue(state)
- const wrapper = await mountView()
- expect(wrapper.get('[data-test="borrow-status-2-gpt-6-astra"] [data-test="borrow-actual-node"]').text()).toContain('原服务器直连')
- expect(wrapper.get('[data-test="borrow-node-status"]').text()).not.toContain('#0')
- expectNoUnrelatedCalls()
- })
- it('shows reacquisition over a retained rejection without claiming validation or business success', async () => {
- const state = makeStatus()
- state.config.rotate_nodes = state.config.wait_for_qualified_route = state.config.immediate_reborrow_on_failure = true
- state.targets[0] = { ...state.targets[0], state: 'rejected', cache_valid: false, reason: 'target_state_changed', new_ticket: true }
- state.recent_usage = [{ account_id: 2, model: 'gpt-6-astra', transport: 'http', origin: 'business', applied: false, dispatched: false, reason: 'preparing', started_at: new Date().toISOString(), outcome: 'preparing', count: 0, applied_count: 0, recovery: { phase: 'reborrowing', started_at: new Date().toISOString(), rounds: 3, source_requests: 3, probe_requests: 4, last_reason: 'target_state_changed' } }]
- mocks.getStatus.mockResolvedValue(state)
- const wrapper = await mountView()
- expect(wrapper.get('[data-test="borrow-preparation-status"]').text()).toContain('正在重新取票')
- expect(wrapper.get('[data-test="borrow-status-2-gpt-6-astra"] [data-test="borrow-line-state"]').text()).toBe('重新取票')
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- })
- it('shows the selected node and model-specific cooling instead of the saved account proxy', async () => {
- const state = makeStatus()
- state.config.rotate_nodes = true
- state.config.node_proxy_ids = [43, 34]
- state.targets[0].proxy_id = 43
- state.targets[0].gateway = 'chat.gateway.unified-198.api.openai.com'
- state.nodes = [
- { account_id: 2, model: 'gpt-6-astra', proxy_id: 43, proxy_name: 'Selected candidate', node_identity: 'selected', gateway: state.targets[0].gateway, state: 'selected', expires_at: state.targets[0].expires_at },
- { account_id: 2, model: 'gpt-6.1-sol', proxy_id: 34, proxy_name: 'Cooling candidate', node_identity: 'cooling', state: 'cooling', retry_after: new Date(Date.now() + 3_600_000).toISOString(), reason: 'target_state_changed' }
- ]
- mocks.getStatus.mockResolvedValue(state)
- const wrapper = await mountView()
- expect(wrapper.get('[data-test="borrow-status-2-gpt-6-astra"] [data-test="borrow-actual-node"]').text()).toContain('Selected candidate #43')
- expect(wrapper.get('[data-test="borrow-node-status"]').text()).toContain('冷却中')
- expect(wrapper.get('[data-test="borrow-node-status"]').text()).toContain('3600s')
- expect(wrapper.get('[data-test="borrow-node-status"]').text()).toContain('不代表长期稳定')
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- })
- it('replaces an idle green result with interrupted validation before the route expires', async () => {
- const initial = makeStatus()
- const interrupted = makeStatus()
- interrupted.targets[1] = { ...interrupted.targets[1], state: 'retry_pending', cache_valid: false,
- reason: 'target_probe_failed', retry_on_next_request: true, failed_shot: 'continue', failure_kind: 'transport', error: 'unexpected EOF' }
- mocks.getStatus.mockResolvedValueOnce(initial).mockImplementation(async () => ({ ...interrupted, generated_at: new Date().toISOString() }))
- const wrapper = await mountView()
- expect(wrapper.get('[data-test="borrow-status-2-gpt-6.1-sol"] [data-test="borrow-line-state"]').text()).toContain('lineStates.ready')
- await vi.advanceTimersByTimeAsync(5000)
- const row = wrapper.get('[data-test="borrow-status-2-gpt-6.1-sol"]')
- expect(row.get('[data-test="borrow-line-state"]').text()).toContain('lineStates.retry_pending')
- expect(row.get('[data-test="borrow-retry-policy"]').text()).toContain('完整验证')
- expect(row.get('[data-test="borrow-line-remaining"]').text()).toContain('"seconds":55')
- expect(wrapper.get('[data-test="borrow-ready-count"]').text()).toContain('"ready":1')
- expect(wrapper.get('[data-test="borrow-validation-scope"]').text()).toContain('validationMeaning')
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- })
-
- it('removes current-readiness claims after a failed or stalled status read', async () => {
- const pending = deferred()
- mocks.getStatus.mockResolvedValueOnce(makeStatus()).mockRejectedValueOnce(new Error('status unavailable')).mockImplementationOnce(() => pending.promise)
- const wrapper = await mountView()
- await vi.advanceTimersByTimeAsync(5000)
- expect(wrapper.get('[data-test="borrow-status-stale"]').text()).toContain('不能确认当前可用')
- expect(wrapper.get('[data-test="borrow-ready-count"]').text()).toContain('"ready":0')
- await vi.advanceTimersByTimeAsync(15_000)
- expect(wrapper.get('[data-test="borrow-status-2-gpt-6.1-sol"] [data-test="borrow-line-state"]').text()).toContain('lineStates.stale')
- pending.resolve(makeStatus())
- await flushPromises()
- expect(wrapper.find('[data-test="borrow-status-stale"]').exists()).toBe(false)
- expect(wrapper.get('[data-test="borrow-ready-count"]').text()).toContain('"ready":2')
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- })
-
- it('pauses reads while hidden and refreshes immediately on return without probing', async () => {
- const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
- const wrapper = await mountView()
- visibility.mockReturnValue('hidden')
- document.dispatchEvent(new Event('visibilitychange'))
- await vi.advanceTimersByTimeAsync(20_000)
- expect(mocks.getStatus).toHaveBeenCalledOnce()
- visibility.mockReturnValue('visible')
- document.dispatchEvent(new Event('visibilitychange'))
- await flushPromises()
- expect(mocks.getStatus).toHaveBeenCalledTimes(2)
- wrapper.unmount()
- document.dispatchEvent(new Event('visibilitychange'))
- await vi.advanceTimersByTimeAsync(20_000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(2)
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- })
-
- it('mounts with saved status and local inventory only, and retains collapsed technical details in full', async () => {
- const fullError = `HTTP 403\n${'Complete synthetic upstream detail '.repeat(1800)}`
- const value = makeStatus()
- value.targets[3] = { ...value.targets[3], state: 'rejected', reason: 'unknown_reason_from_server', error: fullError, mint_status: 200, continue_status: 403, minted: true, new_ticket: true, reported_model: 'reported-model' }
- value.sources[0].error = 'Complete synthetic source error'
- mocks.getStatus.mockResolvedValue(value)
- const wrapper = await mountView()
- expect(mocks.getStatus).toHaveBeenCalledOnce()
- expect(mocks.listAccounts).toHaveBeenCalledWith(1, 100, { platform: 'openai', types: 'oauth,setup-token' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
- expect(wrapper.findAll('[data-test^="borrow-status-"]').filter(row => row.attributes('data-test') !== 'borrow-status-error')).toHaveLength(4)
- expect(wrapper.find('input').exists()).toBe(false)
- expect(wrapper.text()).toContain('Bound exit #7')
- expect(wrapper.get('[data-test="borrow-ready-count"]').text()).toContain('"ready":2,"total":4')
- const failed = wrapper.get('[data-test="borrow-status-3-gpt-6.1-sol"]')
- expect(failed.get('[data-test="borrow-line-reason"]').text()).toBe('unknown_reason_from_server')
- expect(failed.get('[data-test="borrow-target-error"]').text()).toBe(fullError.trim())
- expect(failed.get('[data-test="borrow-target-details"]').text()).toContain('"mint":200,"continuation":403')
- expect(wrapper.get('[data-test="borrow-candidate-details"]').text()).toContain('full-cookie-fingerprint')
- expect(wrapper.get('[data-test="borrow-source-details"]').text()).toContain('Complete synthetic source error')
- expect(wrapper.findAll('details').every(details => !(details.element as HTMLDetailsElement).open)).toBe(true)
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- expectNoUnrelatedCalls()
- })
-
- it('expires routes locally while continuing read-only observation without preparing', async () => {
- const value = makeStatus()
- value.generated_at = new Date(startTime + 120_000).toISOString()
- value.candidate!.expires_at = new Date(startTime + 123_000).toISOString()
- value.targets.filter(target => target.cache_valid).forEach(target => { target.expires_at = value.candidate!.expires_at })
- mocks.getStatus.mockResolvedValue(value)
- const wrapper = await mountView()
- expect(wrapper.get('[data-test="borrow-ready-count"]').text()).toContain('"ready":2')
- expect(wrapper.get('[data-test="borrow-candidate-remaining"]').text()).toContain('"seconds":3')
- await vi.advanceTimersByTimeAsync(3000)
- expect(wrapper.get('[data-test="borrow-ready-count"]').text()).toContain('"ready":0')
- expect(wrapper.get('[data-test="borrow-status-2-gpt-6-astra"] [data-test="borrow-line-state"]').text()).toContain('lineStates.expired')
- expect(wrapper.get('[data-test="borrow-status-2-gpt-6-astra"] [data-test="borrow-line-reason"]').text()).toContain('reasons.route_expired')
- expect(wrapper.get('[data-test="borrow-candidate"]').text()).toContain('candidateExpired')
- await vi.advanceTimersByTimeAsync(20_000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(5)
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- expectNoUnrelatedCalls()
- })
-
- it('polls every 5 seconds without overlapping slow reads and keeps observing after preparation', async () => {
- const slowRead = deferred()
- mocks.getStatus.mockResolvedValueOnce(makeStatus({ preparing: true })).mockImplementationOnce(() => slowRead.promise).mockImplementation(async () => makeStatus())
- const wrapper = await mountView()
- await vi.advanceTimersByTimeAsync(4999)
- expect(mocks.getStatus).toHaveBeenCalledOnce()
- await vi.advanceTimersByTimeAsync(1)
- expect(mocks.getStatus).toHaveBeenCalledTimes(2)
- expect(wrapper.get('[data-test="borrow-refresh"]').element.disabled).toBe(true)
- await vi.advanceTimersByTimeAsync(15_000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(2)
- slowRead.resolve(makeStatus({ preparing: true }))
- await flushPromises()
- await vi.advanceTimersByTimeAsync(5000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(3)
- expect(wrapper.get('[data-test="borrow-preparation-status"]').text()).toContain('部分最近验证通过')
- await vi.advanceTimersByTimeAsync(20_000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(7)
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- expectNoUnrelatedCalls()
- })
-
- it('prepares and verifies only explicit controls, polling while each local operation is pending', async () => {
- const preparation = deferred()
- const verification = deferred()
- mocks.prepare.mockImplementationOnce(() => preparation.promise)
- mocks.verify.mockImplementationOnce(() => verification.promise)
- const wrapper = await mountView()
- await wrapper.get('[data-test="borrow-prepare"]').trigger('click')
- expect(mocks.prepare).toHaveBeenCalledOnce()
- expect(mocks.prepare).toHaveBeenCalledWith(expect.any(AbortSignal))
- await vi.advanceTimersByTimeAsync(5000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(2)
- expect(wrapper.get('[data-test="borrow-verify-2-gpt-6.1-sol"]').element.disabled).toBe(true)
- preparation.resolve(makeStatus())
- await flushPromises()
- expect(mocks.getStatus).toHaveBeenCalledTimes(3)
- await wrapper.get('[data-test="borrow-verify-3-gpt-6.1-sol"]').trigger('click')
- expect(mocks.verify).toHaveBeenCalledWith(3, 'gpt-6.1-sol', expect.any(AbortSignal))
- expect(wrapper.get('[data-test="borrow-status-3-gpt-6.1-sol"] [data-test="borrow-line-state"]').text()).toContain('lineStates.validating')
- await vi.advanceTimersByTimeAsync(5000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(4)
- const fullError = `HTTP 429\n${'Untruncated synthetic verification failure '.repeat(1800)}`
- verification.resolve({ account_id: 3, model: 'gpt-6.1-sol', success: false, reason: 'target_probe_failed', error: fullError, mint_status: 429, continue_status: 0, minted: false, new_ticket: false, checked_at: new Date().toISOString() })
- await flushPromises()
- expect(wrapper.get('[data-test="borrow-action-error"]').text()).toBe(fullError.trim())
- expect(wrapper.get('[data-test="borrow-action-failure"] details').attributes('open')).toBeUndefined()
- expect(mocks.getStatus).toHaveBeenCalledTimes(5)
- await vi.advanceTimersByTimeAsync(20_000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(9)
- expect(mocks.verify).toHaveBeenCalledOnce()
- expectNoUnrelatedCalls()
- })
-
- it('observes external validation and keeps polling after its terminal status', async () => {
- const active = makeStatus()
- active.targets[3] = { ...active.targets[3], state: 'validating', reason: 'validating' }
- const terminal = makeStatus()
- terminal.targets[3] = { ...terminal.targets[3], state: 'ready', reason: 'target_probe_passed', cache_valid: true, expires_at: terminal.candidate!.expires_at }
- mocks.getStatus.mockResolvedValueOnce(active).mockResolvedValueOnce(terminal)
- const wrapper = await mountView()
- expect(wrapper.get('[data-test="borrow-preparation-status"]').text()).toBe('admin.codexGatewayBorrow.validating')
- expect(wrapper.get('[data-test="borrow-status-3-gpt-6.1-sol"] [data-test="borrow-line-state"]').text()).toContain('lineStates.validating')
- expect(wrapper.get('[data-test="borrow-prepare"]').element.disabled).toBe(true)
- expect(wrapper.get('[data-test="borrow-verify-2-gpt-6.1-sol"]').element.disabled).toBe(true)
- await vi.advanceTimersByTimeAsync(4999)
- expect(mocks.getStatus).toHaveBeenCalledOnce()
- await vi.advanceTimersByTimeAsync(1)
- expect(mocks.getStatus).toHaveBeenCalledTimes(2)
- expect(wrapper.get('[data-test="borrow-preparation-status"]').text()).toContain('部分最近验证通过')
- expect(wrapper.get('[data-test="borrow-status-3-gpt-6.1-sol"] [data-test="borrow-line-state"]').text()).toContain('lineStates.ready')
- expect(wrapper.get('[data-test="borrow-prepare"]').element.disabled).toBe(false)
- await vi.advanceTimersByTimeAsync(10_000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(4)
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- expectNoUnrelatedCalls()
- })
-
- it('aborts a pending status read and all polling when leaving the page', async () => {
- const pending = deferred()
- mocks.getStatus.mockResolvedValueOnce(makeStatus({ preparing: true })).mockImplementationOnce(() => pending.promise)
- const wrapper = await mountView()
- await vi.advanceTimersByTimeAsync(5000)
- const signal = mocks.getStatus.mock.calls[1][0] as AbortSignal
- const inventorySignal = mocks.listAccounts.mock.calls[0][3].signal as AbortSignal
- wrapper.unmount()
- expect(signal.aborted).toBe(true)
- expect(inventorySignal.aborted).toBe(true)
- pending.resolve(makeStatus({ preparing: true }))
- await flushPromises()
- await vi.advanceTimersByTimeAsync(20_000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(2)
- expect(vi.getTimerCount()).toBe(0)
- expectNoUnrelatedCalls()
- })
-
- it.each(['verify', 'prepare-older-revision', 'prepare-older-time'] as const)('reads fresh terminal status after an older pending poll: %s', async operation => {
- const oldPoll = deferred()
- const freshRead = deferred()
- const preparation = deferred()
- const verification = deferred()
- const oldStatus = makeStatus()
- mocks.getStatus.mockResolvedValueOnce(makeStatus()).mockImplementationOnce(() => oldPoll.promise).mockImplementationOnce(() => freshRead.promise)
- mocks.prepare.mockImplementationOnce(() => preparation.promise)
- mocks.verify.mockImplementationOnce(() => verification.promise)
- const wrapper = await mountView()
- await wrapper.get(operation === 'verify' ? '[data-test="borrow-verify-2-gpt-6.1-sol"]' : '[data-test="borrow-prepare"]').trigger('click')
- await vi.advanceTimersByTimeAsync(5000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(2)
- await vi.advanceTimersByTimeAsync(1000)
- const terminal = makeStatus({ revision: operation === 'prepare-older-revision' ? 2 : 1 })
- terminal.targets[1] = { ...terminal.targets[1], cache_valid: false, state: 'rejected', reason: 'target_route_changed', error: 'Final synthetic verification error' }
- if (operation === 'verify') verification.resolve({ account_id: 2, model: 'gpt-6.1-sol', success: false, reason: 'target_route_changed', error: terminal.targets[1].error, mint_status: 200, continue_status: 200, minted: true, new_ticket: false, checked_at: new Date().toISOString() })
- else preparation.resolve(terminal)
- await flushPromises()
- expect(mocks.getStatus).toHaveBeenCalledTimes(2)
- expect(wrapper.get('[data-test="borrow-prepare"]').element.disabled).toBe(true)
- oldPoll.resolve(oldStatus)
- await flushPromises()
- expect(mocks.getStatus).toHaveBeenCalledTimes(3)
- if (operation !== 'verify') {
- // The older poll cannot overwrite the newer prepare POST result.
- expect(wrapper.get('[data-test="borrow-ready-count"]').text()).toContain('"ready":1')
- expect(wrapper.get('[data-test="borrow-status-2-gpt-6.1-sol"] [data-test="borrow-line-state"]').text()).not.toContain('lineStates.ready')
- expect(wrapper.get('[data-test="borrow-status-2-gpt-6.1-sol"] [data-test="borrow-target-error"]').text()).toBe('Final synthetic verification error')
- }
- freshRead.resolve(terminal)
- await flushPromises()
- expect(wrapper.get('[data-test="borrow-status-2-gpt-6.1-sol"] [data-test="borrow-line-state"]').text()).toContain('lineStates.rejected')
- expect(wrapper.get('[data-test="borrow-ready-count"]').text()).toContain('"ready":1')
- await vi.advanceTimersByTimeAsync(10_000)
- expect(mocks.getStatus).toHaveBeenCalledTimes(5)
- expectNoUnrelatedCalls()
- })
-
- it('uses only the saved combinations and never shows cached pairs as usable while disabled', async () => {
- const value = makeStatus({ enabled: false })
- value.config = { ...value.config, enabled: false, target_account_ids: [2, 3], models: ['gpt-6.1-sol'] }
- value.targets = [value.targets[1]]
- mocks.getStatus.mockResolvedValue(value)
- const wrapper = await mountView()
- expect(wrapper.get('[data-test="borrow-ready-count"]').text()).toContain('"ready":0,"total":2')
- expect(wrapper.find('[data-test="borrow-status-2-gpt-6-astra"]').exists()).toBe(false)
- expect(wrapper.get('[data-test="borrow-status-3-gpt-6.1-sol"] [data-test="borrow-line-state"]').text()).toContain('lineStates.waiting')
- expect(wrapper.get('[data-test="borrow-status-2-gpt-6.1-sol"] [data-test="borrow-line-reason"]').text()).toContain('reasons.disabled')
- expect(wrapper.get('[data-test="borrow-prepare"]').element.disabled).toBe(true)
- expect(wrapper.get('[data-test="borrow-verify-2-gpt-6.1-sol"]').element.disabled).toBe(true)
- expectNoUnrelatedCalls()
- })
- it('reports acquisition failure and cooldown without starting any model request', async () => {
- mocks.getStatus.mockResolvedValue(makeStatus({ acquisition: {
- phase: 'failed', trigger: 'expired_route', started_at: new Date().toISOString(),
- finished_at: new Date().toISOString(), retry_after: new Date(Date.now() + 15_000).toISOString(),
- reason: 'source_prepare_failed', error: 'Complete acquisition failure'
- } }))
- const wrapper = await mountView()
- expect(wrapper.get('[data-test="borrow-acquisition"]').text()).toContain('获取失败')
- expect(wrapper.get('[data-test="borrow-acquisition"]').text()).toContain('Complete acquisition failure')
- expect(wrapper.get('[data-test="borrow-acquisition"]').text()).toContain('expired_route')
- await wrapper.get('[data-test="borrow-refresh"]').trigger('click')
- await flushPromises()
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- })
-
-})
diff --git a/frontend/src/views/admin/__tests__/CodexGatewayBorrowView.spec.ts b/frontend/src/views/admin/__tests__/CodexGatewayBorrowView.spec.ts
deleted file mode 100644
index fcbaf0fd0f48..000000000000
--- a/frontend/src/views/admin/__tests__/CodexGatewayBorrowView.spec.ts
+++ /dev/null
@@ -1,215 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { flushPromises, mount } from '@vue/test-utils'
-import CodexGatewayBorrowView from '../CodexGatewayBorrowView.vue'
-import type { AccountListItem } from '@/types'
-import type { CodexGatewayBorrowConfig, CodexGatewayBorrowStatus } from '@/api/admin/codexGatewayBorrow'
-
-const mocks = vi.hoisted(() => ({
- getConfig: vi.fn(), saveConfig: vi.fn(), getStatus: vi.fn(), prepare: vi.fn(), verify: vi.fn(), listTests: vi.fn(), getTest: vi.fn(), streamTests: vi.fn(),
- listAccounts: vi.fn(), listProxies: vi.fn(), getAvailableModels: vi.fn(), testAccount: vi.fn(), showSuccess: vi.fn()
-}))
-vi.mock('@/api/admin/codexGatewayBorrow', async importOriginal => ({
- ...(await importOriginal()),
- codexGatewayBorrowAPI: mocks
-}))
-vi.mock('@/api/admin/accounts', () => {
- const accountsAPI = { list: mocks.listAccounts, getAvailableModels: mocks.getAvailableModels, testAccount: mocks.testAccount }
- return { accountsAPI, default: accountsAPI }
-})
-vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showSuccess: mocks.showSuccess }) }))
-vi.mock('@/api/admin/proxies', () => ({ list: mocks.listProxies, default: { list: mocks.listProxies } }))
-vi.mock('vue-i18n', async importOriginal => ({
- ...(await importOriginal()),
- useI18n: () => ({ locale: { value: 'zh' }, t: (key: string, params?: Record) => params ? `${key}:${JSON.stringify(params)}` : key, te: () => true })
-}))
-
-const wrappers: ReturnType[] = []
-const DRAFT_KEY = 'codex-gateway-borrow-draft'
-const now = new Date().toISOString()
-const disabledConfig: CodexGatewayBorrowConfig = { enabled: false, source_account_ids: [], target_account_ids: [], models: ['gpt-6-astra', 'gpt-6.1-sol'] }
-const enabledConfig: CodexGatewayBorrowConfig = { ...disabledConfig, enabled: true, source_account_ids: [1], target_account_ids: [2] }
-const accountError = 'Original account error\n' + 'Full account error details. '.repeat(80)
-const localAccounts = [
- { id: 1, name: 'Source', platform: 'openai', type: 'oauth', status: 'active', schedulable: true, proxy_id: 7, proxy: { id: 7, name: 'Bound proxy' }, extra: { openai_oauth_responses_websockets_v2_mode: 'ctx_pool' } },
- { id: 2, name: 'Paused target', platform: 'openai', type: 'setup-token', status: 'error', schedulable: false, proxy_id: null, parent_account_id: 1, extra: {}, error_message: accountError, credentials: { model_mapping: { 'gpt-6-astra': 'gpt-6-astra' }, refresh_token: 'fixture-not-a-real-token' } },
- { id: 3, name: 'Second source', platform: 'openai', type: 'oauth', status: 'active', schedulable: true, proxy_id: null, extra: {} },
- { id: 4, name: 'API key', platform: 'openai', type: 'apikey', status: 'active' }
-] as unknown as AccountListItem[]
-
-function makeStatus(config = enabledConfig, preparing = false): CodexGatewayBorrowStatus {
- return { enabled: config.enabled, revision: 1, generated_at: now, preparing, config: structuredClone(config), sources: [], targets: [] }
-}
-async function mountView(config = enabledConfig) {
- mocks.getConfig.mockResolvedValue(structuredClone(config))
- const wrapper = mount(CodexGatewayBorrowView, { global: { stubs: {
- CodexLayout: { template: '
' }, CodexGatewayBorrowStatusView: true, CodexBorrowActivity: true, AppLayout: { template: '
' },
- CodexBorrowNav: true,
- RouterLink: { props: ['to'], template: '' }
- } } })
- wrappers.push(wrapper)
- await flushPromises()
- return wrapper
-}
-function expectNoModelRequests() {
- expect(mocks.prepare).not.toHaveBeenCalled()
- expect(mocks.verify).not.toHaveBeenCalled()
- expect(mocks.streamTests).not.toHaveBeenCalled()
- expect(mocks.getAvailableModels).not.toHaveBeenCalled()
- expect(mocks.testAccount).not.toHaveBeenCalled()
- expect(mocks.listTests).not.toHaveBeenCalled()
- expect(mocks.getTest).not.toHaveBeenCalled()
-}
-
-beforeEach(() => {
- for (const mock of Object.values(mocks)) mock.mockReset()
- sessionStorage.clear()
- mocks.listAccounts.mockResolvedValue({ items: localAccounts, total: 4, page: 1, page_size: 100, pages: 1 })
- mocks.getStatus.mockResolvedValue(makeStatus())
- mocks.saveConfig.mockImplementation(async config => structuredClone(config))
- mocks.listProxies.mockResolvedValue({ items: [{ id: 43, name: 'Candidate A' }, { id: 34, name: 'Candidate B' }], total: 2 })
-})
-afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); sessionStorage.clear() })
-
-describe('Codex borrow settings', () => {
- it('saves zero-cooldown reborrow explicitly without changing the legacy cooldown value', async () => {
- const config = { ...enabledConfig, rotate_nodes: true, wait_for_qualified_route: true, node_proxy_ids: [43, 34], max_node_attempts: 5, node_cooldown_seconds: 3600 }
- const wrapper = await mountView(config)
- await wrapper.get('[data-test="borrow-immediate-reborrow"]').setValue(true)
- expect(wrapper.get('[data-test="borrow-node-cooldown"]').attributes('disabled')).toBeDefined()
- await wrapper.get('[data-test="borrow-save"]').trigger('click')
- await flushPromises()
- expect(mocks.saveConfig).toHaveBeenCalledWith({ ...config, immediate_reborrow_on_failure: true }, expect.any(AbortSignal))
- expectNoModelRequests()
- await wrapper.get('[data-test="borrow-wait-qualified"]').setValue(false)
- expect(wrapper.text()).toContain('立即重借需要启用等待恢复和节点选择')
- expect(wrapper.get('[data-test="borrow-save"]').attributes('disabled')).toBeDefined()
- })
- it('preserves ordered node selection and independent bounds through a normal save', async () => {
- const config = { ...enabledConfig, rotate_nodes: true, wait_for_qualified_route: true, node_proxy_ids: [43, 34], max_node_attempts: 5, node_cooldown_seconds: 3600 }
- const wrapper = await mountView(config)
- expect(wrapper.find('[data-test="borrow-unsaved"]').exists()).toBe(false)
- expectNoModelRequests()
- await wrapper.get('[data-test="borrow-node-attempts"]').setValue(4)
- await wrapper.get('[data-test="borrow-save"]').trigger('click')
- await flushPromises()
- expect(mocks.saveConfig).toHaveBeenCalledWith({ ...config, max_node_attempts: 4 }, expect.any(AbortSignal))
- expect(wrapper.text()).toContain('#43 → #34')
- expectNoModelRequests()
- })
- it('loads local settings and inventory only, with complete account details outside selection labels', async () => {
- const wrapper = await mountView(disabledConfig)
- expect(mocks.getConfig).toHaveBeenCalledOnce()
- expect(mocks.getStatus).toHaveBeenCalledOnce()
- expect(mocks.listAccounts).toHaveBeenCalledWith(1, 100, { platform: 'openai', types: 'oauth,setup-token' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
- expect(wrapper.find('[data-test="borrow-source_account_ids-4"]').exists()).toBe(false)
- expect(wrapper.findAll('input[type="checkbox"]').filter(input => input.attributes('data-test')?.match(/borrow-(source|target)_account_ids/)).every(input => !input.element.checked)).toBe(true)
- const account = wrapper.get('[data-test="borrow-account-target_account_ids-2"]')
- const details = account.get('details')
- expect(account.get('label').text()).toContain('Paused target')
- expect(account.get('label').text()).toContain('#2')
- expect(account.get('label').text()).toContain('admin.codexGatewayBorrow.paused')
- expect(account.get('label').text()).toContain('admin.codexGatewayBorrow.proxy')
- expect(details.element.closest('label')).toBeNull()
- expect(details.element.open).toBe(false)
- expect(details.get('pre').text()).toBe(accountError.trim())
- expect(details.text()).toContain('admin.codexGatewayBorrow.shadowOf')
- expect(details.text()).toContain('WS:')
- expect(details.text()).toContain('admin.codexGatewayBorrow.localMappings')
- await details.get('summary').trigger('click')
- expect(wrapper.get('[data-test="borrow-target_account_ids-2"]').element.checked).toBe(false)
- expect(wrapper.find('[data-test="borrow-prepare"]').exists()).toBe(false)
- expect(wrapper.find('[data-test="borrow-test-start"]').exists()).toBe(false)
- expectNoModelRequests()
- })
-
- it('keeps roles disjoint, preserves source selection order and saves once without duplicate preparation', async () => {
- mocks.listAccounts.mockResolvedValue({ items: localAccounts.map(account => account.id === 2 ? { ...account, status: 'active', credentials: {} } : account), total: 4 })
- const wrapper = await mountView(disabledConfig)
- await wrapper.get('[data-test="borrow-enabled"]').trigger('click')
- expect(wrapper.get('[data-test="borrow-save"]').element.disabled).toBe(true)
- await wrapper.get('[data-test="borrow-source_account_ids-3"]').setValue(true)
- await wrapper.get('[data-test="borrow-source_account_ids-1"]').setValue(true)
- expect(wrapper.get('[data-test="borrow-target_account_ids-1"]').element.disabled).toBe(true)
- expect(wrapper.get('[data-test="borrow-target_account_ids-3"]').element.disabled).toBe(true)
- await wrapper.get('[data-test="borrow-target_account_ids-2"]').setValue(true)
- expect(wrapper.get('[data-test="borrow-source_account_ids-2"]').element.disabled).toBe(true)
- mocks.getStatus.mockResolvedValue(makeStatus(enabledConfig, true))
- await wrapper.get('[data-test="borrow-save"]').trigger('click')
- await flushPromises()
- expect(mocks.saveConfig).toHaveBeenCalledOnce()
- expect(mocks.saveConfig.mock.calls[0][0]).toEqual({ enabled: true, source_account_ids: [3, 1], target_account_ids: [2], models: ['gpt-6-astra', 'gpt-6.1-sol'] })
- expect(mocks.getStatus).toHaveBeenCalledTimes(2)
- expect(wrapper.get('[data-test="borrow-save-result"]').text()).toContain('admin.codexGatewayBorrow.saved')
- expect(wrapper.get('[data-test="borrow-save-result"]').text()).toContain('admin.codexGatewayBorrow.preparing')
- expect(wrapper.get('[data-test="borrow-status-link"]').attributes('href')).toBe('/admin/codex#status')
- expect(sessionStorage.getItem(DRAFT_KEY)).toBeNull()
- expectNoModelRequests()
- })
-
- it('retains config-only unsaved drafts across remount and leaves saved status unchanged on refresh', async () => {
- const wrapper = await mountView()
- await wrapper.get('[data-test="borrow-source_account_ids-3"]').setValue(true)
- await wrapper.get('[data-test="borrow-config-model-gpt-6-astra"]').setValue(false)
- const stored = JSON.parse(sessionStorage.getItem(DRAFT_KEY)!)
- expect(stored).toEqual({ version: 1, base: enabledConfig, draft: { ...enabledConfig, source_account_ids: [1, 3], models: ['gpt-6.1-sol'] } })
- expect(sessionStorage.getItem(DRAFT_KEY)).not.toContain('fixture-not-a-real-token')
- await wrapper.get('[data-test="borrow-refresh"]').trigger('click')
- await flushPromises()
- expect(wrapper.get('[data-test="borrow-saved-configuration"]').text()).toContain('"sources":1,"targets":1,"models":2')
- expect(wrapper.get('[data-test="borrow-source_account_ids-3"]').element.checked).toBe(true)
- wrapper.unmount()
- const reopened = await mountView()
- expect(mocks.getConfig).toHaveBeenCalledTimes(2)
- expect(reopened.get('[data-test="borrow-source_account_ids-3"]').element.checked).toBe(true)
- expect(reopened.get('[data-test="borrow-config-model-gpt-6-astra"]').element.checked).toBe(false)
- expect(reopened.find('[data-test="borrow-unsaved"]').exists()).toBe(true)
- expect(reopened.get('[data-test="borrow-saved-configuration"]').text()).toContain('"sources":1,"targets":1,"models":2')
- expect(mocks.saveConfig).not.toHaveBeenCalled()
- expectNoModelRequests()
- await reopened.get('[data-test="borrow-reset"]').trigger('click')
- expect(reopened.get('[data-test="borrow-source_account_ids-3"]').element.checked).toBe(false)
- expect(reopened.get('[data-test="borrow-config-model-gpt-6-astra"]').element.checked).toBe(true)
- expect(sessionStorage.getItem(DRAFT_KEY)).toBeNull()
- })
-
- it('discards a draft when the saved backend baseline has changed', async () => {
- sessionStorage.setItem(DRAFT_KEY, JSON.stringify({ version: 1, base: enabledConfig, draft: { ...enabledConfig, source_account_ids: [1, 3] } }))
- const changed = { ...enabledConfig, models: ['gpt-6.1-sol'] }
- const wrapper = await mountView(changed)
- expect(wrapper.get('[data-test="borrow-source_account_ids-3"]').element.checked).toBe(false)
- expect(wrapper.get('[data-test="borrow-config-model-gpt-6-astra"]').element.checked).toBe(false)
- expect(wrapper.find('[data-test="borrow-unsaved"]').exists()).toBe(false)
- expect(sessionStorage.getItem(DRAFT_KEY)).toBeNull()
- expect(mocks.saveConfig).not.toHaveBeenCalled()
- expectNoModelRequests()
- })
-
- it('reports a successful save separately from a failed status read', async () => {
- const wrapper = await mountView()
- await wrapper.get('[data-test="borrow-source_account_ids-3"]').setValue(true)
- mocks.getStatus.mockRejectedValue(new Error('Status read unavailable'))
- await wrapper.get('[data-test="borrow-save"]').trigger('click')
- await flushPromises()
- expect(mocks.saveConfig).toHaveBeenCalledOnce()
- expect(mocks.showSuccess).toHaveBeenCalledWith('admin.codexGatewayBorrow.saved')
- expect(wrapper.get('[data-test="borrow-save-result"]').text()).toContain('admin.codexGatewayBorrow.saved')
- expect(wrapper.get('[data-test="borrow-save-result"]').text()).toContain('admin.codexGatewayBorrow.statusReadFailed')
- expect(wrapper.find('[data-test="borrow-preparation-state"]').exists()).toBe(false)
- expect(wrapper.find('[data-test="borrow-unsaved"]').exists()).toBe(false)
- expect(sessionStorage.getItem(DRAFT_KEY)).toBeNull()
- expectNoModelRequests()
- })
-
- it('cancels local reads when leaving the settings page without saving', async () => {
- const wrapper = await mountView()
- await wrapper.get('[data-test="borrow-source_account_ids-3"]').setValue(true)
- const configSignal = mocks.getConfig.mock.calls[0][0] as AbortSignal
- const inventorySignal = mocks.listAccounts.mock.calls[0][3].signal as AbortSignal
- wrapper.unmount()
- expect(configSignal.aborted).toBe(true)
- expect(inventorySignal.aborted).toBe(true)
- expect(sessionStorage.getItem(DRAFT_KEY)).not.toBeNull()
- expect(mocks.saveConfig).not.toHaveBeenCalled()
- expectNoModelRequests()
- })
-})
diff --git a/frontend/src/views/admin/__tests__/CodexPelicanComparisonView.spec.ts b/frontend/src/views/admin/__tests__/CodexPelicanComparisonView.spec.ts
index 1a45846f4a1f..4869c547d001 100644
--- a/frontend/src/views/admin/__tests__/CodexPelicanComparisonView.spec.ts
+++ b/frontend/src/views/admin/__tests__/CodexPelicanComparisonView.spec.ts
@@ -29,7 +29,7 @@ async function make() {
AppLayout: { template: '
' },
BaseDialog: { props: ['show'], template: '
' },
PelicanTaskComposer: { props: ['initial', 'submitting'], emits: ['submit'], template: '' },
- BorrowPelicanPreview: true
+ PelicanPreview: true
} } }); wrappers.push(wrapper); await flushPromises(); return wrapper
}
describe('Pelican background workbench', () => {