-
-
- {{ t('admin.settings.features.channelMonitor.hideThroughput') }}
-
-
- {{ t('admin.settings.features.channelMonitor.hideThroughputHint') }}
-
+
+
+
+
+ {{ t('admin.settings.features.channelMonitor.hideThroughput') }}
+
+
+ {{ t('admin.settings.features.channelMonitor.hideThroughputHint') }}
+
+
+
+
+
+
+
+ {{ t('admin.settings.features.channelMonitor.hideUserRanking') }}
+
+
+ {{ t('admin.settings.features.channelMonitor.hideUserRankingHint') }}
+
+
+
-
@@ -9483,6 +9496,7 @@ type SettingsForm = Omit<
/** Form always binds a concrete boolean (SystemSettings marks this optional). */
channel_monitor_hide_throughput: boolean;
channel_monitor_show_quota: boolean;
+ channel_monitor_hide_user_ranking: boolean;
smtp_password: string;
turnstile_secret_key: string;
tencent_captcha_app_secret_key: string;
@@ -9797,6 +9811,7 @@ const form = reactive({
channel_monitor_default_interval_seconds: 60,
channel_monitor_hide_throughput: false,
channel_monitor_show_quota: false,
+ channel_monitor_hide_user_ranking: false,
// Available Channels feature switch
available_channels_enabled: false,
// Model Plaza feature switches + description
@@ -10803,6 +10818,9 @@ async function loadSettings() {
form.channel_monitor_show_quota = Boolean(
settings.channel_monitor_show_quota
);
+ form.channel_monitor_hide_user_ranking = Boolean(
+ settings.channel_monitor_hide_user_ranking
+ );
form.login_agreement_updated_at =
settings.login_agreement_updated_at || "2026-03-31";
form.login_agreement_documents =
@@ -11457,6 +11475,7 @@ async function saveSettings() {
Number(form.channel_monitor_default_interval_seconds) || 60,
channel_monitor_hide_throughput: Boolean(form.channel_monitor_hide_throughput),
channel_monitor_show_quota: Boolean(form.channel_monitor_show_quota),
+ channel_monitor_hide_user_ranking: Boolean(form.channel_monitor_hide_user_ranking),
// Available Channels feature switch
available_channels_enabled: form.available_channels_enabled,
// Model Plaza feature switches + description
@@ -12084,6 +12103,10 @@ const openaiFastPolicyTierOptions = computed(() => [
value: "priority",
label: t("admin.settings.openaiFastPolicy.tierPriority"),
},
+ {
+ value: "ultrafast",
+ label: t("admin.settings.openaiFastPolicy.tierUltrafast"),
+ },
{ value: "flex", label: t("admin.settings.openaiFastPolicy.tierFlex") },
]);
diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue
index 66569dad94..eba7992b7e 100644
--- a/frontend/src/views/admin/SubscriptionsView.vue
+++ b/frontend/src/views/admin/SubscriptionsView.vue
@@ -190,12 +190,15 @@
}}
-
+
{{ userColumnMode === 'email'
? (row.user?.email || t('admin.redeem.userPrefix', { id: row.user_id }))
- : (row.user?.username || '-')
+ : (row.user?.username || t('admin.redeem.userPrefix', { id: row.user_id }))
}}
-
+
diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue
index b297555c1b..c7c2e6ad71 100644
--- a/frontend/src/views/admin/UsageView.vue
+++ b/frontend/src/views/admin/UsageView.vue
@@ -589,7 +589,7 @@ const exportToExcel = async () => {
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
t('usage.rate'), t('usage.accountMultiplier'), t('usage.original'), t('usage.userBilled'), t('usage.accountBilled'),
t('usage.firstToken'), t('usage.duration'),
- t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
+ t('admin.usage.requestId'), t('admin.usage.upstreamRequestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
]
const ws = XLSX.utils.aoa_to_sheet([headers])
while (true) {
@@ -608,7 +608,7 @@ const exportToExcel = async () => {
log.rate_multiplier?.toPrecision(4) || '1.00', (log.account_rate_multiplier ?? 1).toPrecision(4),
log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000',
((log.account_stats_cost ?? log.total_cost) * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms,
- log.request_id || '', log.user_agent || '', log.ip_address || ''
+ log.request_id || '', log.upstream_request_id || '', log.user_agent || '', log.ip_address || ''
])
if (rows.length) {
XLSX.utils.sheet_add_aoa(ws, rows, { origin: -1 })
@@ -630,10 +630,12 @@ const exportToExcel = async () => {
// Column visibility
const ALWAYS_VISIBLE = ['user', 'created_at']
-const DEFAULT_HIDDEN_COLUMNS = ['reasoning_effort', 'request_id', 'user_agent']
+const DEFAULT_HIDDEN_COLUMNS = ['reasoning_effort', 'request_id', 'upstream_request_id', 'user_agent']
const HIDDEN_COLUMNS_KEY = 'usage-hidden-columns'
const HIDDEN_COLUMNS_VERSION_KEY = 'usage-hidden-columns-version'
-const HIDDEN_COLUMNS_CURRENT_VERSION = 'request-id-hidden-by-default'
+// 隐藏列版本链:每级只把当级新增列加入隐藏集,不重置用户已显式打开的列。
+const HIDDEN_COLUMNS_PREV_VERSION = 'request-id-hidden-by-default'
+const HIDDEN_COLUMNS_CURRENT_VERSION = 'upstream-request-id-hidden-by-default'
const allColumns = computed(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false },
@@ -650,6 +652,7 @@ const allColumns = computed(() => [
{ key: 'latency', label: t('usage.latency'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true },
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false },
+ { key: 'upstream_request_id', label: t('admin.usage.upstreamRequestId'), sortable: false },
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false },
{ key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false }
])
@@ -757,8 +760,12 @@ const loadSavedColumns = () => {
(JSON.parse(saved) as string[]).forEach((key) => {
hiddenColumns.add(key)
})
- if (localStorage.getItem(HIDDEN_COLUMNS_VERSION_KEY) !== HIDDEN_COLUMNS_CURRENT_VERSION) {
- hiddenColumns.add('request_id')
+ const savedVersion = localStorage.getItem(HIDDEN_COLUMNS_VERSION_KEY)
+ if (savedVersion !== HIDDEN_COLUMNS_CURRENT_VERSION) {
+ if (savedVersion !== HIDDEN_COLUMNS_PREV_VERSION) {
+ hiddenColumns.add('request_id')
+ }
+ hiddenColumns.add('upstream_request_id')
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
localStorage.setItem(HIDDEN_COLUMNS_VERSION_KEY, HIDDEN_COLUMNS_CURRENT_VERSION)
}
diff --git a/frontend/src/views/admin/__tests__/AccountsView.lite.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.lite.spec.ts
new file mode 100644
index 0000000000..86d29caad1
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/AccountsView.lite.spec.ts
@@ -0,0 +1,272 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+
+import AccountsView from '../AccountsView.vue'
+import AccountActionMenu from '@/components/admin/account/AccountActionMenu.vue'
+
+const {
+ listAccounts,
+ listWithEtag,
+ getById,
+ getBatchTodayStats,
+ getUpstreamBillingProbeSettings,
+ getAllProxies,
+ getAllGroups,
+ showError
+} = vi.hoisted(() => ({
+ listAccounts: vi.fn(),
+ listWithEtag: vi.fn(),
+ getById: vi.fn(),
+ getBatchTodayStats: vi.fn(),
+ getUpstreamBillingProbeSettings: vi.fn(),
+ getAllProxies: vi.fn(),
+ getAllGroups: vi.fn(),
+ showError: vi.fn()
+}))
+
+vi.mock('@/api/admin', () => ({
+ adminAPI: {
+ accounts: {
+ list: listAccounts,
+ getById,
+ listWithEtag,
+ getBatchTodayStats,
+ getUpstreamBillingProbeSettings,
+ delete: vi.fn(),
+ batchClearError: vi.fn(),
+ batchRefresh: vi.fn(),
+ toggleSchedulable: vi.fn()
+ },
+ proxies: { getAll: getAllProxies },
+ groups: { getAll: getAllGroups }
+ }
+}))
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({ showError, showSuccess: vi.fn(), showInfo: vi.fn() })
+}))
+
+vi.mock('@/stores/auth', () => ({
+ useAuthStore: () => ({ token: 'test-token', isSimpleMode: false })
+}))
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual
('vue-i18n')
+ return { ...actual, useI18n: () => ({ t: (key: string) => key }) }
+})
+
+const DataTableStub = defineComponent({
+ props: { data: { type: Array, default: () => [] } },
+ template: `
+
+ `
+})
+
+const AccountGroupsCellStub = defineComponent({
+ props: { groups: { type: Array, default: () => [] } },
+ template: '{{ groups.map(group => group.name).join(",") }} '
+})
+
+const EditAccountModalStub = defineComponent({
+ props: { show: Boolean, account: { type: Object, default: null } },
+ template: '{{ show ? account?.name : "" }}
'
+})
+
+const AccountTestModalStub = defineComponent({
+ props: { show: Boolean, account: { type: Object, default: null } },
+ template: '{{ show ? account?.name : "" }}
'
+})
+
+const AccountStatsModalStub = defineComponent({
+ props: { show: Boolean, account: { type: Object, default: null } },
+ template: '{{ show ? account?.name : "" }}
'
+})
+
+function mountView(stubActionMenu = true) {
+ return mount(AccountsView, {
+ attachTo: document.body,
+ global: {
+ stubs: {
+ AppLayout: { template: '
' },
+ TablePageLayout: { template: '
' },
+ DataTable: DataTableStub,
+ AccountTableActions: { template: '
' },
+ AccountTableFilters: true,
+ AccountBulkActionsBar: true,
+ Pagination: true,
+ ConfirmDialog: true,
+ AccountActionMenu: stubActionMenu,
+ ImportDataModal: true,
+ ReAuthAccountModal: true,
+ AccountTestModal: AccountTestModalStub,
+ AccountStatsModal: AccountStatsModalStub,
+ ScheduledTestsPanel: true,
+ SyncFromCrsModal: true,
+ TempUnschedStatusModal: true,
+ ErrorPassthroughRulesModal: true,
+ TLSFingerprintProfilesModal: true,
+ CreateAccountModal: true,
+ EditAccountModal: EditAccountModalStub,
+ BulkEditAccountModal: true,
+ PlatformTypeBadge: true,
+ AccountCapacityCell: true,
+ AccountStatusIndicator: true,
+ AccountTodayStatsCell: true,
+ AccountGroupsCell: AccountGroupsCellStub,
+ AccountUsageCell: true,
+ UpstreamBillingRateCell: true,
+ HelpTooltip: true,
+ Icon: true,
+ Teleport: stubActionMenu
+ }
+ }
+ })
+}
+
+const listRow = {
+ id: 42,
+ name: 'compact row',
+ platform: 'openai',
+ type: 'oauth',
+ status: 'active',
+ schedulable: true,
+ concurrency: 2,
+ priority: 1,
+ group_ids: [7],
+ extra: {},
+ credentials: {}
+}
+
+const fullAccount = {
+ ...listRow,
+ groups: [{ id: 7, name: 'codex', platform: 'openai' }],
+ account_groups: [{ account_id: 42, group_id: 7 }],
+ credentials: { api_key: 'redacted' },
+ extra: { detail_only: true }
+}
+
+describe('admin AccountsView lite account list', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ listAccounts.mockReset().mockResolvedValue({ items: [listRow], total: 1, page: 1, page_size: 20, pages: 1 })
+ listWithEtag.mockReset().mockResolvedValue({ notModified: true, etag: 'compact-etag', data: null })
+ getById.mockReset().mockResolvedValue(fullAccount)
+ getBatchTodayStats.mockReset().mockResolvedValue({ stats: {} })
+ getUpstreamBillingProbeSettings.mockReset().mockResolvedValue({ enabled: true })
+ getAllProxies.mockReset().mockResolvedValue([])
+ getAllGroups.mockReset().mockResolvedValue([{ id: 7, name: 'codex', platform: 'openai' }])
+ showError.mockReset()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('keeps lite=1 on the initial list request', async () => {
+ const wrapper = mountView()
+ await flushPromises()
+
+ expect(listAccounts).toHaveBeenCalledWith(
+ 1,
+ 20,
+ expect.objectContaining({ lite: '1' }),
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
+ )
+ wrapper.unmount()
+ })
+
+ it('maps group_ids through the group catalog for the table cell', async () => {
+ const wrapper = mountView()
+ await flushPromises()
+
+ expect(wrapper.get('[data-test="account-groups"]').text()).toBe('codex')
+ wrapper.unmount()
+ })
+
+ it('keeps the action menu open during internal scrolling but closes it on table scrolling', async () => {
+ const wrapper = mountView(false)
+ await flushPromises()
+
+ const trigger = wrapper.findAll('button').find(button => button.text() === 'common.more')!
+ await trigger.trigger('click')
+ const menu = new DOMWrapper(document.body.querySelector('.action-menu-content')!)
+ menu.element.dispatchEvent(new Event('scroll'))
+ await flushPromises()
+ expect(wrapper.findComponent(AccountActionMenu).props('show')).toBe(true)
+
+ menu.get('button').element.dispatchEvent(new Event('scroll'))
+ await flushPromises()
+ expect(wrapper.findComponent(AccountActionMenu).props('show')).toBe(true)
+
+ wrapper.getComponent(DataTableStub).element.dispatchEvent(new Event('scroll'))
+ await flushPromises()
+ expect(wrapper.findComponent(AccountActionMenu).props('show')).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('keeps lite=1 on automatic ETag refreshes', async () => {
+ vi.useFakeTimers()
+ vi.spyOn(document, 'hidden', 'get').mockReturnValue(false)
+ localStorage.setItem('account-auto-refresh', JSON.stringify({ enabled: true, interval_seconds: 5 }))
+ const wrapper = mountView()
+ await flushPromises()
+
+ await vi.advanceTimersByTimeAsync(6000)
+ await flushPromises()
+
+ expect(listWithEtag).toHaveBeenCalledWith(
+ 1,
+ 20,
+ expect.objectContaining({ lite: '1' }),
+ expect.objectContaining({ etag: null })
+ )
+ wrapper.unmount()
+ })
+
+ it('loads the full account by id before opening edit, test, and stats actions', async () => {
+ const wrapper = mountView()
+ await flushPromises()
+
+ const editButton = wrapper.findAll('button').find(button => button.text().includes('common.edit'))
+ expect(editButton).toBeTruthy()
+ await editButton!.trigger('click')
+ await flushPromises()
+ expect(getById).toHaveBeenCalledWith(42)
+ expect(wrapper.get('[data-test="edit-account"]').text()).toBe('compact row')
+
+ const menu = wrapper.findComponent(AccountActionMenu)
+ menu.vm.$emit('test', listRow)
+ await flushPromises()
+ expect(getById).toHaveBeenCalledTimes(2)
+ expect(wrapper.get('[data-test="test-account"]').text()).toBe('compact row')
+
+ menu.vm.$emit('stats', listRow)
+ await flushPromises()
+ expect(getById).toHaveBeenCalledTimes(3)
+ expect(wrapper.get('[data-test="stats-account"]').text()).toBe('compact row')
+ wrapper.unmount()
+ })
+
+ it('shows an error and keeps the modal closed when detail loading fails', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ getById.mockRejectedValueOnce(new Error('detail failed'))
+ const wrapper = mountView()
+ await flushPromises()
+
+ const editButton = wrapper.findAll('button').find(button => button.text().includes('common.edit'))
+ await editButton!.trigger('click')
+ await flushPromises()
+
+ expect(showError).toHaveBeenCalledWith('detail failed')
+ expect(wrapper.get('[data-test="edit-account"]').text()).toBe('')
+ consoleError.mockRestore()
+ wrapper.unmount()
+ })
+})
diff --git a/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts
index e3fe225224..c80ddcaaab 100644
--- a/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts
+++ b/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AccountsView from '../AccountsView.vue'
@@ -6,6 +6,7 @@ import AccountsView from '../AccountsView.vue'
const {
listAccounts,
listWithEtag,
+ batchRefresh,
getBatchTodayStats,
getUpstreamBillingProbeSettings,
getAllProxies,
@@ -14,6 +15,7 @@ const {
} = vi.hoisted(() => ({
listAccounts: vi.fn(),
listWithEtag: vi.fn(),
+ batchRefresh: vi.fn(),
getBatchTodayStats: vi.fn(),
getUpstreamBillingProbeSettings: vi.fn(),
getAllProxies: vi.fn(),
@@ -30,7 +32,7 @@ vi.mock('@/api/admin', () => ({
getUpstreamBillingProbeSettings,
batchDelete: vi.fn(),
batchClearError: vi.fn(),
- batchRefresh: vi.fn(),
+ batchRefresh,
bulkUpdate: vi.fn()
},
proxies: {
@@ -79,7 +81,7 @@ const makeAccounts = (count: number) => Array.from({ length: count }, (_, index)
const AccountBulkActionsBarStub = {
props: ['selectedIds', 'totalResults', 'selectingAll', 'allResultsSelected'],
- emits: ['select-all-results', 'select-page', 'clear'],
+ emits: ['select-all-results', 'select-page', 'clear', 'refresh-token'],
template: `
{{ selectedIds.length }}
@@ -88,6 +90,7 @@ const AccountBulkActionsBarStub = {
select page
select all
clear
+ refresh token
`
}
@@ -104,7 +107,10 @@ const mountView = () => mount(AccountsView, {
TablePageLayout: {
template: '
'
},
- DataTable: { props: ['data'], template: '
' },
+ DataTable: {
+ props: ['data'],
+ template: ''
+ },
Pagination: true,
ConfirmDialog: true,
AccountTableActions: { template: '
' },
@@ -139,6 +145,7 @@ describe('admin AccountsView select all filtered results', () => {
localStorage.clear()
listAccounts.mockReset()
listWithEtag.mockReset()
+ batchRefresh.mockReset()
getBatchTodayStats.mockReset()
getUpstreamBillingProbeSettings.mockReset()
getAllProxies.mockReset()
@@ -156,6 +163,38 @@ describe('admin AccountsView select all filtered results', () => {
getAllGroups.mockResolvedValue([])
})
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it.each([
+ { name: 'keeps only failed accounts selected', result: { total: 3, success: 2, failed: 1, errors: [{ account_id: 2, error: 'no refresh token available' }] }, expectedIds: [2] },
+ { name: 'clears the selection after every account succeeds', result: { total: 3, success: 3, failed: 0 }, expectedIds: [] },
+ { name: 'keeps the original selection when failure details are missing', result: { total: 3, success: 2, failed: 1 }, expectedIds: [1, 2, 3] },
+ ])('$name after a batch token refresh and table reload', async ({ result, expectedIds }) => {
+ listAccounts.mockResolvedValue({ items: makeAccounts(3), total: 3, page: 1, page_size: 20, pages: 1 })
+ batchRefresh.mockResolvedValue(result)
+ vi.spyOn(window, 'confirm').mockReturnValue(true)
+ const wrapper = mountView()
+ await flushPromises()
+ await wrapper.get('[data-test="select-page"]').trigger('click')
+ await wrapper.get('[data-test="refresh-token"]').trigger('click')
+ await flushPromises()
+
+ expect(batchRefresh).toHaveBeenCalledWith([1, 2, 3])
+ expect(listAccounts).toHaveBeenCalledTimes(2)
+ expect(wrapper.getComponent(AccountBulkActionsBarStub).props('selectedIds')).toEqual(expectedIds)
+ expect(wrapper.findAll('[data-test="data-table"] input').map(input => input.element.checked))
+ .toEqual([1, 2, 3].map(id => expectedIds.includes(id)))
+ if (result.failed > 0) {
+ expect(showError).toHaveBeenCalledWith('admin.accounts.bulkActions.partialSuccess')
+ await wrapper.get('[data-test="refresh-token"]').trigger('click')
+ await flushPromises()
+ expect(batchRefresh).toHaveBeenLastCalledWith(expectedIds)
+ }
+ wrapper.unmount()
+ })
+
it('selects all matching IDs in one commit and clears the selection when filters change', async () => {
const allAccounts = makeAccounts(45)
listAccounts.mockImplementation(async (_page: number, pageSize: number) => {
diff --git a/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts
new file mode 100644
index 0000000000..9595d27ed3
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts
@@ -0,0 +1,286 @@
+import { defineComponent, h, type PropType } from "vue";
+import { flushPromises, mount } from "@vue/test-utils";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { AdminGroup, CodexModelsManifestConfig } from "@/types";
+import GroupsView from "@/views/admin/GroupsView.vue";
+
+const {
+ listGroups,
+ getModelsListCandidates,
+ getUsageSummary,
+ getCapacitySummary,
+ getLiveCapability,
+} = vi.hoisted(() => ({
+ listGroups: vi.fn(),
+ getModelsListCandidates: vi.fn(),
+ getUsageSummary: vi.fn(),
+ getCapacitySummary: vi.fn(),
+ getLiveCapability: vi.fn(),
+}));
+
+vi.mock("@/api/admin", () => ({
+ adminAPI: {
+ groups: {
+ list: listGroups,
+ getAll: vi.fn(),
+ getModelsListCandidates,
+ getUsageSummary,
+ getCapacitySummary,
+ getLiveCapability,
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ duplicate: vi.fn(),
+ updateSortOrder: vi.fn(),
+ },
+ accounts: {
+ list: vi.fn(),
+ getById: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("@/stores/app", () => ({
+ useAppStore: () => ({
+ showError: vi.fn(),
+ showSuccess: vi.fn(),
+ }),
+}));
+
+vi.mock("@/stores/onboarding", () => ({
+ useOnboardingStore: () => ({
+ isCurrentStep: vi.fn(() => false),
+ nextStep: vi.fn(),
+ }),
+}));
+
+vi.mock("vue-i18n", async () => {
+ const actual = await vi.importActual("vue-i18n");
+ return {
+ ...actual,
+ useI18n: () => ({ t: (key: string) => key }),
+ };
+});
+
+const sourceGroup = {
+ id: 42,
+ name: "OpenAI",
+ description: null,
+ platform: "openai",
+ rate_multiplier: 1,
+ rpm_limit: 0,
+ is_exclusive: false,
+ status: "active",
+ subscription_type: "standard",
+ daily_limit_usd: null,
+ weekly_limit_usd: null,
+ monthly_limit_usd: null,
+ long_context_pricing_enabled: true,
+ force_openai_fast: false,
+ free_openai_fast: false,
+ model_pricing: [],
+ profit_control_enabled: false,
+ profit_min_margin: 0,
+ profit_safety_buffer: 0,
+ allow_image_generation: false,
+ allow_batch_image_generation: false,
+ image_rate_independent: false,
+ image_rate_multiplier: 1,
+ batch_image_discount_multiplier: 0.5,
+ batch_image_hold_multiplier: 0.6,
+ image_price_1k: null,
+ image_price_2k: null,
+ image_price_4k: null,
+ video_rate_independent: false,
+ video_rate_multiplier: 1,
+ video_price_480p: null,
+ video_price_720p: null,
+ video_price_1080p: null,
+ web_search_price_per_call: null,
+ search_price_per_1k: null,
+ audio_realtime_price_per_min: null,
+ audio_tts_price_per_million_chars: null,
+ audio_stt_price_per_hour: null,
+ peak_rate_enabled: false,
+ peak_start: "",
+ peak_end: "",
+ peak_rate_multiplier: 1,
+ claude_code_only: false,
+ fallback_group_id: null,
+ fallback_group_id_on_invalid_request: null,
+ allow_messages_dispatch: false,
+ allow_live: false,
+ require_oauth_only: false,
+ require_privacy_set: false,
+ created_at: "2026-09-05T00:00:00Z",
+ updated_at: "2026-09-05T00:00:00Z",
+ model_routing: null,
+ model_routing_enabled: false,
+ mcp_xml_inject: true,
+ supported_model_scopes: [],
+ account_count: 1,
+ active_account_count: 1,
+ rate_limited_account_count: 0,
+ models_list_config: undefined,
+ codex_models_manifest_config: {
+ enabled: false,
+ account_ids: [],
+ fallback_to_scheduler: false,
+ },
+ sort_order: 10,
+} satisfies AdminGroup;
+
+const AppLayoutStub = defineComponent({
+ template: " ",
+});
+
+const TablePageLayoutStub = defineComponent({
+ template: '',
+});
+
+const DataTableStub = defineComponent({
+ props: {
+ data: { type: Array, default: () => [] },
+ },
+ template: '',
+});
+
+const BaseDialogStub = defineComponent({
+ props: {
+ show: { type: Boolean, default: false },
+ },
+ template: '
',
+});
+
+const CodexManifestAccountsFieldStub = defineComponent({
+ name: "CodexManifestAccountsField",
+ props: {
+ modelValue: {
+ type: Object as PropType,
+ required: true,
+ },
+ },
+ emits: ["update:modelValue"],
+ setup(props, { emit, expose }) {
+ expose({ validate: () => true, resetValidation: () => undefined });
+ return () =>
+ h("div", { "data-testid": "codex-manifest-field" }, [
+ h(
+ "output",
+ { "data-testid": "codex-manifest-value" },
+ JSON.stringify(props.modelValue),
+ ),
+ h(
+ "button",
+ {
+ type: "button",
+ "data-testid": "codex-manifest-enable",
+ onClick: () =>
+ emit("update:modelValue", {
+ ...props.modelValue,
+ enabled: true,
+ }),
+ },
+ "enable",
+ ),
+ h(
+ "button",
+ {
+ type: "button",
+ "data-testid": "codex-manifest-select-account",
+ onClick: () =>
+ emit("update:modelValue", {
+ ...props.modelValue,
+ account_ids: [17],
+ }),
+ },
+ "select account",
+ ),
+ ]);
+ },
+});
+
+const mountView = () =>
+ mount(GroupsView, {
+ global: {
+ stubs: {
+ AppLayout: AppLayoutStub,
+ TablePageLayout: TablePageLayoutStub,
+ DataTable: DataTableStub,
+ Pagination: true,
+ BaseDialog: BaseDialogStub,
+ ConfirmDialog: true,
+ EmptyState: true,
+ Select: true,
+ PlatformIcon: true,
+ Icon: true,
+ GroupCapacityBadge: true,
+ GroupRateMultipliersModal: true,
+ GroupRPMOverridesModal: true,
+ ReasoningEffortPolicyFields: true,
+ CodexManifestAccountsField: CodexManifestAccountsFieldStub,
+ PricingEntryCard: true,
+ VueDraggable: true,
+ },
+ },
+ });
+
+describe("GroupsView Codex manifest binding", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ listGroups.mockReset();
+ getModelsListCandidates.mockReset();
+ getUsageSummary.mockReset();
+ getCapacitySummary.mockReset();
+ getLiveCapability.mockReset();
+
+ listGroups.mockResolvedValue({
+ items: [sourceGroup],
+ total: 1,
+ page: 1,
+ page_size: 20,
+ pages: 1,
+ });
+ getModelsListCandidates.mockResolvedValue([]);
+ getUsageSummary.mockResolvedValue([]);
+ getCapacitySummary.mockResolvedValue([]);
+ getLiveCapability.mockResolvedValue({ supported: false });
+ });
+
+ it("preserves consecutive child updates on the reactive edit config", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+
+ const editButton = wrapper
+ .findAll("button")
+ .find((button) => button.text().includes("common.edit"));
+ expect(editButton).toBeTruthy();
+ await editButton!.trigger("click");
+ await flushPromises();
+
+ expect(wrapper.get('[data-testid="codex-manifest-value"]').text()).toBe(
+ JSON.stringify(sourceGroup.codex_models_manifest_config),
+ );
+
+ await wrapper.get('[data-testid="codex-manifest-enable"]').trigger("click");
+ await flushPromises();
+ expect(wrapper.get('[data-testid="codex-manifest-value"]').text()).toContain(
+ '"enabled":true',
+ );
+
+ await wrapper
+ .get('[data-testid="codex-manifest-select-account"]')
+ .trigger("click");
+ await flushPromises();
+ expect(wrapper.get('[data-testid="codex-manifest-value"]').text()).toBe(
+ JSON.stringify({
+ enabled: true,
+ account_ids: [17],
+ fallback_to_scheduler: false,
+ }),
+ );
+
+ wrapper.unmount();
+ });
+});
diff --git a/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts
index 8d4f8df74f..b05f4f5dcf 100644
--- a/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts
+++ b/frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts
@@ -7,7 +7,7 @@ import GroupsView from '../GroupsView.vue'
const {
listGroups,
getAllGroups,
- getModelsListCandidates,
+ getModelAllowlistCandidates,
getUsageSummary,
getCapacitySummary,
getLiveCapability,
@@ -16,10 +16,11 @@ const {
showSuccess,
isCurrentStep,
nextStep,
+ authState,
} = vi.hoisted(() => ({
listGroups: vi.fn(),
getAllGroups: vi.fn(),
- getModelsListCandidates: vi.fn(),
+ getModelAllowlistCandidates: vi.fn(),
getUsageSummary: vi.fn(),
getCapacitySummary: vi.fn(),
getLiveCapability: vi.fn(),
@@ -28,6 +29,7 @@ const {
showSuccess: vi.fn(),
isCurrentStep: vi.fn(),
nextStep: vi.fn(),
+ authState: { isSimpleMode: false },
}))
const messages: Record = {
@@ -53,7 +55,7 @@ vi.mock('@/api/admin', () => ({
groups: {
list: listGroups,
getAll: getAllGroups,
- getModelsListCandidates,
+ getModelAllowlistCandidates,
getUsageSummary,
getCapacitySummary,
getLiveCapability,
@@ -75,6 +77,10 @@ vi.mock('@/stores/app', () => ({
}),
}))
+vi.mock('@/stores/auth', () => ({
+ useAuthStore: () => authState,
+}))
+
vi.mock('@/stores/onboarding', () => ({
useOnboardingStore: () => ({
isCurrentStep,
@@ -128,7 +134,7 @@ const createGroup = (overrides: Partial = {}): AdminGroup => ({
account_count: 3,
active_account_count: 2,
rate_limited_account_count: 1,
- models_list_config: undefined,
+ model_allowlist: undefined,
sort_order: 10,
...overrides,
})
@@ -233,14 +239,16 @@ describe('admin GroupsView column settings', () => {
listGroups.mockReset()
getAllGroups.mockReset()
- getModelsListCandidates.mockReset()
+ getModelAllowlistCandidates.mockReset()
getUsageSummary.mockReset()
getCapacitySummary.mockReset()
+ getLiveCapability.mockReset()
listAccounts.mockReset()
showError.mockReset()
showSuccess.mockReset()
isCurrentStep.mockReset()
nextStep.mockReset()
+ authState.isSimpleMode = false
listGroups.mockResolvedValue({
items: [createGroup()],
@@ -250,7 +258,7 @@ describe('admin GroupsView column settings', () => {
pages: 1,
})
getAllGroups.mockResolvedValue([])
- getModelsListCandidates.mockResolvedValue([])
+ getModelAllowlistCandidates.mockResolvedValue([])
getUsageSummary.mockResolvedValue([])
getCapacitySummary.mockResolvedValue([])
getLiveCapability.mockResolvedValue({ supported: false })
@@ -258,6 +266,23 @@ describe('admin GroupsView column settings', () => {
isCurrentStep.mockReturnValue(false)
})
+ it('does not call advanced group APIs or expose the exclusive filter in simple mode', async () => {
+ authState.isSimpleMode = true
+ const wrapper = await mountView()
+
+ expect(getLiveCapability).not.toHaveBeenCalled()
+ expect(getModelAllowlistCandidates).not.toHaveBeenCalled()
+ expect(getUsageSummary).not.toHaveBeenCalled()
+ expect(getCapacitySummary).not.toHaveBeenCalled()
+ expect(listGroups).toHaveBeenCalledWith(
+ expect.any(Number),
+ expect.any(Number),
+ expect.objectContaining({ is_exclusive: undefined }),
+ expect.anything(),
+ )
+ expect(wrapper.find('select').text()).not.toContain('admin.groups.allGroups')
+ })
+
afterEach(() => {
localStorage.clear()
})
diff --git a/frontend/src/views/admin/__tests__/GroupsView.compositePlatforms.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.compositePlatforms.spec.ts
index 93dcd679d4..3db50cefc7 100644
--- a/frontend/src/views/admin/__tests__/GroupsView.compositePlatforms.spec.ts
+++ b/frontend/src/views/admin/__tests__/GroupsView.compositePlatforms.spec.ts
@@ -4,7 +4,7 @@ import { CONCRETE_PLATFORM_OPTIONS } from '@/constants/platforms'
describe('GroupsView Composite route options', () => {
it('offers Kimi, Zhipu GLM, and DeepSeek as route targets', () => {
expect(CONCRETE_PLATFORM_OPTIONS.map((option) => option.value)).toEqual(
- expect.arrayContaining(['kimi', 'zhipu', 'deepseek'])
+ expect.arrayContaining(['kimi', 'zhipu', 'deepseek', 'minimax'])
)
})
})
diff --git a/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts
index 10690d87e3..e10e5b9869 100644
--- a/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts
+++ b/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts
@@ -4,12 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AdminGroup } from '@/types'
import GroupsView from '@/views/admin/GroupsView.vue'
+import { adminAPI } from '@/api/admin'
const {
listGroups,
duplicateGroup,
updateGroup,
- getModelsListCandidates,
+ getModelAllowlistCandidates,
getUsageSummary,
getCapacitySummary,
getLiveCapability,
@@ -19,7 +20,7 @@ const {
listGroups: vi.fn(),
duplicateGroup: vi.fn(),
updateGroup: vi.fn(),
- getModelsListCandidates: vi.fn(),
+ getModelAllowlistCandidates: vi.fn(),
getUsageSummary: vi.fn(),
getCapacitySummary: vi.fn(),
getLiveCapability: vi.fn(),
@@ -27,12 +28,14 @@ const {
showError: vi.fn()
}))
+const authState = vi.hoisted(() => ({ isSimpleMode: false }))
+
vi.mock('@/api/admin', () => ({
adminAPI: {
groups: {
list: listGroups,
duplicate: duplicateGroup,
- getModelsListCandidates,
+ getModelAllowlistCandidates,
getUsageSummary,
getCapacitySummary,
getLiveCapability,
@@ -53,6 +56,10 @@ vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showSuccess, showError })
}))
+vi.mock('@/stores/auth', () => ({
+ useAuthStore: () => authState
+}))
+
vi.mock('@/stores/onboarding', () => ({
useOnboardingStore: () => ({
isCurrentStep: vi.fn(() => false),
@@ -117,7 +124,7 @@ const sourceGroup: AdminGroup = {
account_count: 1,
active_account_count: 1,
rate_limited_account_count: 0,
- models_list_config: undefined,
+ model_allowlist: undefined,
sort_order: 10
}
@@ -170,13 +177,14 @@ function mountView() {
describe('GroupsView duplicate action', () => {
beforeEach(() => {
+ authState.isSimpleMode = false
localStorage.clear()
vi.spyOn(console, 'error').mockImplementation(() => {})
for (const fn of [
listGroups,
duplicateGroup,
updateGroup,
- getModelsListCandidates,
+ getModelAllowlistCandidates,
getUsageSummary,
getCapacitySummary,
getLiveCapability,
@@ -199,7 +207,7 @@ describe('GroupsView duplicate action', () => {
name: 'Primary (Copy)',
status: 'inactive'
})
- getModelsListCandidates.mockResolvedValue([])
+ getModelAllowlistCandidates.mockResolvedValue([])
getUsageSummary.mockResolvedValue([])
getCapacitySummary.mockResolvedValue([])
getLiveCapability.mockResolvedValue({ supported: false })
@@ -223,6 +231,20 @@ describe('GroupsView duplicate action', () => {
wrapper.unmount()
})
+ it('hides advanced group actions in simple mode', async () => {
+ authState.isSimpleMode = true
+ const compositeGroup = { ...sourceGroup, platform: 'composite' }
+ listGroups.mockResolvedValueOnce({ items: [compositeGroup], total: 1, page: 1, page_size: 20, pages: 1 })
+ const wrapper = mountView()
+ await flushPromises()
+
+ expect(wrapper.find('[data-testid="group-duplicate"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="group-composite-routes"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="group-rate-multipliers"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="group-rpm-overrides"]').exists()).toBe(false)
+ wrapper.unmount()
+ })
+
it('ignores repeated clicks while the duplicate request is in flight', async () => {
let resolveDuplicate!: (value: AdminGroup) => void
duplicateGroup.mockImplementationOnce(
@@ -302,4 +324,70 @@ describe('GroupsView duplicate action', () => {
expect(showError).toHaveBeenCalledWith('group name already exists')
wrapper.unmount()
})
+
+ it('updates manifest controls immediately and submits the displayed selection', async () => {
+ vi.useFakeTimers()
+ vi.mocked(adminAPI.accounts.list).mockResolvedValue({
+ items: [{ id: 5, name: 'Manifest account' }]
+ } as never)
+ updateGroup.mockResolvedValue(sourceGroup)
+ const wrapper = mountView()
+ try {
+ await flushPromises()
+ const editButton = wrapper.findAll('button').find((button) => button.text() === 'common.edit')!
+ await editButton.trigger('click')
+ await flushPromises()
+
+ const toggle = wrapper.get('[data-testid="codex-manifest-toggle"]')
+ await toggle.trigger('click')
+ expect(toggle.attributes('aria-checked')).toBe('true')
+ const search = wrapper.get('[data-testid="codex-manifest-search"]')
+ await search.trigger('focus')
+ await vi.advanceTimersByTimeAsync(300)
+ await flushPromises()
+ expect(adminAPI.accounts.list).toHaveBeenCalledWith(
+ 1, 20, { search: '', platform: 'openai', group: '42' }, expect.anything()
+ )
+ await wrapper.get('[data-testid="codex-manifest-dropdown"] button').trigger('click')
+ expect(wrapper.get('[data-testid="codex-manifest-selected-tags"]').text()).toContain('Manifest account')
+
+ await wrapper.get('[aria-label="remove account 5"]').trigger('click')
+ expect(wrapper.find('[data-testid="codex-manifest-selected-tags"]').exists()).toBe(false)
+ await wrapper.get('#edit-group-form').trigger('submit')
+ expect(updateGroup).not.toHaveBeenCalled()
+ expect(wrapper.find('[data-testid="codex-manifest-validation-error"]').exists()).toBe(true)
+
+ await search.trigger('focus')
+ await wrapper.get('[data-testid="codex-manifest-dropdown"] button').trigger('click')
+ expect(wrapper.get('[data-testid="codex-manifest-selected-tags"]').text()).toContain('Manifest account')
+ const fallback = wrapper.get('[data-testid="codex-manifest-fallback-toggle"]')
+ await fallback.trigger('click')
+ expect(fallback.attributes('aria-checked')).toBe('true')
+ await fallback.trigger('click')
+ expect(fallback.attributes('aria-checked')).toBe('false')
+ await fallback.trigger('click')
+
+ await toggle.trigger('click')
+ expect(wrapper.find('[data-testid="codex-manifest-search"]').exists()).toBe(false)
+ await toggle.trigger('click')
+ expect(wrapper.get('[data-testid="codex-manifest-selected-tags"]').text()).toContain('Manifest account')
+ await wrapper.get('#edit-group-form').trigger('submit')
+ await flushPromises()
+ expect(updateGroup).toHaveBeenCalledWith(42, expect.objectContaining({
+ codex_models_manifest_config: {
+ enabled: true, account_ids: [5], fallback_to_scheduler: true
+ }
+ }))
+
+ // Reopening reads the saved group afresh, without retaining the prior draft.
+ await editButton.trigger('click')
+ await flushPromises()
+ expect(wrapper.get('[data-testid="codex-manifest-toggle"]').attributes('aria-checked')).toBe('false')
+ expect(wrapper.find('[data-testid="codex-manifest-search"]').exists()).toBe(false)
+ } finally {
+ wrapper.unmount()
+ vi.useRealTimers()
+ }
+ })
+
})
diff --git a/frontend/src/views/admin/__tests__/SubscriptionsView.userUsageLink.spec.ts b/frontend/src/views/admin/__tests__/SubscriptionsView.userUsageLink.spec.ts
new file mode 100644
index 0000000000..0d89d0d4ca
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/SubscriptionsView.userUsageLink.spec.ts
@@ -0,0 +1,143 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+
+import SubscriptionsView from '../SubscriptionsView.vue'
+
+const { listSubscriptions, getAllGroups } = vi.hoisted(() => ({
+ listSubscriptions: vi.fn(),
+ getAllGroups: vi.fn()
+}))
+
+vi.mock('@/api/admin', () => ({
+ adminAPI: {
+ subscriptions: { list: listSubscriptions },
+ groups: { getAll: getAllGroups }
+ }
+}))
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({
+ showError: vi.fn(),
+ showSuccess: vi.fn()
+ })
+}))
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string, params?: { id?: number }) =>
+ key === 'admin.redeem.userPrefix' ? `User #${params?.id}` : key
+ })
+ }
+})
+
+const DataTableStub = {
+ props: ['data'],
+ template: `
+
+ `
+}
+
+const RouterLinkStub = defineComponent({
+ name: 'RouterLink',
+ props: { to: { type: Object, required: true } },
+ template: ' '
+})
+
+describe('admin subscription user usage link', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ listSubscriptions.mockResolvedValue({
+ items: [{
+ id: 9,
+ user_id: 42,
+ group_id: 3,
+ status: 'active',
+ starts_at: '2026-01-01T00:00:00Z',
+ expires_at: null,
+ daily_usage_usd: 0,
+ weekly_usage_usd: 0,
+ monthly_usage_usd: 0,
+ daily_window_start: null,
+ weekly_window_start: null,
+ monthly_window_start: null,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ user: { email: 'reader@example.com', username: 'Reader' }
+ }],
+ total: 1,
+ pages: 1
+ })
+ getAllGroups.mockResolvedValue([])
+ })
+
+ const mountView = () => mount(SubscriptionsView, {
+ global: {
+ stubs: {
+ AppLayout: { template: '
' },
+ TablePageLayout: { template: '
' },
+ DataTable: DataTableStub,
+ RouterLink: RouterLinkStub,
+ Pagination: true,
+ BaseDialog: true,
+ ConfirmDialog: true,
+ EmptyState: true,
+ Select: true,
+ GroupBadge: true,
+ GroupOptionItem: true,
+ Icon: true,
+ Teleport: true
+ }
+ }
+ })
+
+ it('renders the user email as a link to that user filtered usage records', async () => {
+ const wrapper = mountView()
+
+ await flushPromises()
+
+ const link = wrapper.getComponent(RouterLinkStub)
+ expect(link.text()).toBe('reader@example.com')
+ expect(link.props('to')).toEqual({ path: '/admin/usage', query: { user_id: 42 } })
+ })
+
+ it('uses the user ID label for the usage link when username mode has no username', async () => {
+ localStorage.setItem('subscription-user-column-mode', 'username')
+ listSubscriptions.mockResolvedValue({
+ items: [{
+ id: 9,
+ user_id: 42,
+ group_id: 3,
+ status: 'active',
+ starts_at: '2026-01-01T00:00:00Z',
+ expires_at: null,
+ daily_usage_usd: 0,
+ weekly_usage_usd: 0,
+ monthly_usage_usd: 0,
+ daily_window_start: null,
+ weekly_window_start: null,
+ monthly_window_start: null,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ user: { email: 'reader@example.com' }
+ }],
+ total: 1,
+ pages: 1
+ })
+
+ const wrapper = mountView()
+ await flushPromises()
+
+ const link = wrapper.getComponent(RouterLinkStub)
+ expect(link.text()).toBe('User #42')
+ expect(link.props('to')).toEqual({ path: '/admin/usage', query: { user_id: 42 } })
+ })
+})
diff --git a/frontend/src/views/admin/__tests__/UsageView.spec.ts b/frontend/src/views/admin/__tests__/UsageView.spec.ts
index 16823fc093..a79645109b 100644
--- a/frontend/src/views/admin/__tests__/UsageView.spec.ts
+++ b/frontend/src/views/admin/__tests__/UsageView.spec.ts
@@ -33,6 +33,7 @@ const messages: Record = {
'admin.dashboard.hour': 'Hour',
'admin.usage.failedToLoadUser': 'Failed to load user',
'admin.usage.requestId': 'Request ID',
+ 'admin.usage.upstreamRequestId': 'Upstream ID',
'usage.requestedModel': 'Requested model',
'usage.sentUpstreamModel': 'Sent upstream model',
'usage.upstreamResponseModel': 'Upstream response model',
@@ -519,7 +520,48 @@ describe('admin UsageView request ID column visibility', () => {
)
expect(localStorage.setItem).toHaveBeenCalledWith(
'usage-hidden-columns-version',
- 'request-id-hidden-by-default',
+ 'upstream-request-id-hidden-by-default',
+ )
+ })
+
+ it('keeps upstream ID hidden by default and allows enabling it from column settings', async () => {
+ const wrapper = mount(UsageView, {
+ global: {
+ stubs: {
+ AppLayout: AppLayoutStub,
+ UsageStatsCards: true,
+ UsageFilters: UsageFiltersStub,
+ UsageTable: UsageTableStub,
+ UsageExportProgress: true,
+ UsageCleanupDialog: true,
+ UserBalanceHistoryModal: true,
+ AuditLogModal: true,
+ Pagination: true,
+ Select: true,
+ DateRangePicker: true,
+ Icon: true,
+ TokenUsageTrend: true,
+ ModelDistributionChart: true,
+ GroupDistributionChart: true,
+ EndpointDistributionChart: true,
+ UserTokenRanking: true,
+ },
+ },
+ })
+ await wrapper.vm.$nextTick()
+
+ const usageTable = wrapper.findComponent(UsageTableStub)
+ expect(usageTable.props('columns')).not.toEqual(
+ expect.arrayContaining([expect.objectContaining({ key: 'upstream_request_id' })]),
+ )
+
+ await wrapper.get('button[title="admin.users.columnSettings"]').trigger('click')
+ const upstreamToggle = wrapper.findAll('button').find((button) => button.text() === 'Upstream ID')
+ expect(upstreamToggle).toBeDefined()
+ await upstreamToggle!.trigger('click')
+
+ expect(usageTable.props('columns')).toEqual(
+ expect.arrayContaining([expect.objectContaining({ key: 'upstream_request_id', label: 'Upstream ID' })]),
)
})
})
diff --git a/frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts b/frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts
index bd078c8175..7c02ec0022 100644
--- a/frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts
+++ b/frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts
@@ -10,5 +10,6 @@ describe('Composite channel platform options', () => {
expect(declaration).toContain("'kimi'")
expect(declaration).toContain("'zhipu'")
expect(declaration).toContain("'deepseek'")
+ expect(declaration).toContain("'minimax'")
})
})
diff --git a/frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts b/frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts
new file mode 100644
index 0000000000..b36212a8c0
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts
@@ -0,0 +1,161 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ addCustomModelAllowlistItem,
+ createModelAllowlistState,
+ type ModelAllowlistAddError,
+} from "../groupModelAllowlist";
+
+import {
+ buildModelAllowlistConfig,
+ createModelAllowlistState,
+ hydrateModelAllowlistState,
+ invertModelAllowlistSelection,
+ moveModelAllowlistItem,
+ selectAllModelAllowlistItems,
+ setModelAllowlistCandidates,
+ toggleModelAllowlistItem,
+} from "../groupModelAllowlist";
+
+describe("groupModelAllowlist", () => {
+ it("selects all default candidates for a new disabled config", () => {
+ const state = createModelAllowlistState();
+
+ setModelAllowlistCandidates(state, ["gpt-5.5", "gpt-5.4"]);
+
+ expect(state.enabled).toBe(false);
+ expect(state.items).toEqual([
+ { id: "gpt-5.5", selected: true },
+ { id: "gpt-5.4", selected: true },
+ ]);
+ });
+
+ it("keeps saved selections and marks new candidates as unselected when editing", () => {
+ const state = createModelAllowlistState({
+ enabled: true,
+ models: ["gpt-5.5", "gpt-5.4"],
+ });
+
+ setModelAllowlistCandidates(state, ["gpt-5.4", "legacy-gpt", "gpt-5.5"]);
+
+ expect(state.enabled).toBe(true);
+ expect(state.items).toEqual([
+ { id: "gpt-5.5", selected: true },
+ { id: "gpt-5.4", selected: true },
+ { id: "legacy-gpt", selected: false },
+ ]);
+ });
+
+ it("preserves explicitly unselected saved candidates when candidates refresh", () => {
+ const state = createModelAllowlistState({
+ enabled: true,
+ models: ["gpt-5.5"],
+ });
+
+ setModelAllowlistCandidates(state, ["gpt-5.5", "gpt-5.4"]);
+
+ expect(state.items).toEqual([
+ { id: "gpt-5.5", selected: true },
+ { id: "gpt-5.4", selected: false },
+ ]);
+ });
+
+ it("builds config with selected models in current display order", () => {
+ const state = hydrateModelAllowlistState({
+ enabled: true,
+ models: ["gpt-5.5", "gpt-5.4", "legacy-gpt"],
+ }, ["gpt-5.5", "gpt-5.4", "legacy-gpt"]);
+
+ toggleModelAllowlistItem(state, "legacy-gpt");
+ moveModelAllowlistItem(state, 1, 0);
+
+ expect(buildModelAllowlistConfig(state)).toEqual({
+ enabled: true,
+ models: ["gpt-5.4", "gpt-5.5"],
+ });
+ });
+
+ it("keeps selected models in payload even when disabled so reopening can restore choices", () => {
+ const state = hydrateModelAllowlistState({
+ enabled: false,
+ models: ["gpt-5.5"],
+ }, ["gpt-5.5", "gpt-5.4"]);
+
+ expect(buildModelAllowlistConfig(state)).toEqual({
+ enabled: false,
+ models: ["gpt-5.5"],
+ });
+ });
+
+ it("preserves saved models when candidates have not loaded yet", () => {
+ const state = createModelAllowlistState({
+ enabled: true,
+ models: ["gpt-5.5", "gpt-5.4"],
+ });
+
+ expect(buildModelAllowlistConfig(state)).toEqual({
+ enabled: true,
+ models: ["gpt-5.5", "gpt-5.4"],
+ });
+ });
+
+ it("selects all candidate models from the toolbar action", () => {
+ const state = hydrateModelAllowlistState({
+ enabled: true,
+ models: ["gpt-5.5"],
+ }, ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]);
+
+ selectAllModelAllowlistItems(state);
+
+ expect(state.items).toEqual([
+ { id: "gpt-5.5", selected: true },
+ { id: "gpt-5.4", selected: true },
+ { id: "gpt-5.4-mini", selected: true },
+ ]);
+ });
+
+ it("inverts selected models from the toolbar action", () => {
+ const state = hydrateModelAllowlistState({
+ enabled: true,
+ models: ["gpt-5.5"],
+ }, ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]);
+
+ invertModelAllowlistSelection(state);
+
+ expect(state.items).toEqual([
+ { id: "gpt-5.5", selected: false },
+ { id: "gpt-5.4", selected: true },
+ { id: "gpt-5.4-mini", selected: true },
+ ]);
+ });
+});
+
+describe("addCustomModelAllowlistItem", () => {
+ const state = () => createModelAllowlistState({ enabled: true, models: ["gpt-5.4"] });
+
+ it("appends a trimmed entry as selected", () => {
+ const s = state();
+ expect(addCustomModelAllowlistItem(s, " claude-sonnet-4.5 ")).toBeNull();
+ expect(s.items.at(-1)).toEqual({ id: "claude-sonnet-4.5", selected: true });
+ });
+
+ it("accepts trailing wildcard entries", () => {
+ const s = state();
+ expect(addCustomModelAllowlistItem(s, "gpt-5.5-*")).toBeNull();
+ expect(s.items.at(-1)?.id).toBe("gpt-5.5-*");
+ });
+
+ it("rejects blank input", () => {
+ expect(addCustomModelAllowlistItem(state(), " ")).toBe("empty");
+ });
+
+ it("rejects wildcards that are not trailing", () => {
+ expect(addCustomModelAllowlistItem(state(), "gpt-*-5.4")).toBe("invalid_wildcard");
+ expect(addCustomModelAllowlistItem(state(), "gpt-*-codex-*")).toBe("invalid_wildcard");
+ });
+
+ it("rejects duplicates case-insensitively", () => {
+ const s = state();
+ expect(addCustomModelAllowlistItem(s, "GPT-5.4")).toBe("duplicate");
+ });
+});
diff --git a/frontend/src/views/admin/__tests__/groupsModelsListLayout.spec.ts b/frontend/src/views/admin/__tests__/groupModelAllowlistLayout.spec.ts
similarity index 94%
rename from frontend/src/views/admin/__tests__/groupsModelsListLayout.spec.ts
rename to frontend/src/views/admin/__tests__/groupModelAllowlistLayout.spec.ts
index 20fb02af05..0f9bc8d5c9 100644
--- a/frontend/src/views/admin/__tests__/groupsModelsListLayout.spec.ts
+++ b/frontend/src/views/admin/__tests__/groupModelAllowlistLayout.spec.ts
@@ -10,7 +10,7 @@ const groupsViewSource = readFileSync(
"utf8",
);
-describe("groups models list layout", () => {
+describe("groups model allowlist layout", () => {
it("keeps the toolbar outside of the scrolling list content", () => {
expect(groupsViewSource).toContain("overflow-hidden rounded-lg border");
expect(groupsViewSource).toContain("max-h-64 space-y-2 overflow-y-auto p-2");
diff --git a/frontend/src/views/admin/__tests__/groupsModelsList.spec.ts b/frontend/src/views/admin/__tests__/groupsModelsList.spec.ts
deleted file mode 100644
index ae50c861cd..0000000000
--- a/frontend/src/views/admin/__tests__/groupsModelsList.spec.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import {
- buildModelsListConfig,
- createModelsListState,
- hydrateModelsListState,
- invertModelsListSelection,
- moveModelsListItem,
- selectAllModelsListItems,
- setModelsListCandidates,
- toggleModelsListItem,
-} from "../groupsModelsList";
-
-describe("groupsModelsList", () => {
- it("selects all default candidates for a new disabled config", () => {
- const state = createModelsListState();
-
- setModelsListCandidates(state, ["gpt-5.5", "gpt-5.4"]);
-
- expect(state.enabled).toBe(false);
- expect(state.items).toEqual([
- { id: "gpt-5.5", selected: true },
- { id: "gpt-5.4", selected: true },
- ]);
- });
-
- it("keeps saved selections and marks new candidates as unselected when editing", () => {
- const state = createModelsListState({
- enabled: true,
- models: ["gpt-5.5", "gpt-5.4"],
- });
-
- setModelsListCandidates(state, ["gpt-5.4", "legacy-gpt", "gpt-5.5"]);
-
- expect(state.enabled).toBe(true);
- expect(state.items).toEqual([
- { id: "gpt-5.5", selected: true },
- { id: "gpt-5.4", selected: true },
- { id: "legacy-gpt", selected: false },
- ]);
- });
-
- it("preserves explicitly unselected saved candidates when candidates refresh", () => {
- const state = createModelsListState({
- enabled: true,
- models: ["gpt-5.5"],
- });
-
- setModelsListCandidates(state, ["gpt-5.5", "gpt-5.4"]);
-
- expect(state.items).toEqual([
- { id: "gpt-5.5", selected: true },
- { id: "gpt-5.4", selected: false },
- ]);
- });
-
- it("builds config with selected models in current display order", () => {
- const state = hydrateModelsListState({
- enabled: true,
- models: ["gpt-5.5", "gpt-5.4", "legacy-gpt"],
- }, ["gpt-5.5", "gpt-5.4", "legacy-gpt"]);
-
- toggleModelsListItem(state, "legacy-gpt");
- moveModelsListItem(state, 1, 0);
-
- expect(buildModelsListConfig(state)).toEqual({
- enabled: true,
- models: ["gpt-5.4", "gpt-5.5"],
- });
- });
-
- it("keeps selected models in payload even when disabled so reopening can restore choices", () => {
- const state = hydrateModelsListState({
- enabled: false,
- models: ["gpt-5.5"],
- }, ["gpt-5.5", "gpt-5.4"]);
-
- expect(buildModelsListConfig(state)).toEqual({
- enabled: false,
- models: ["gpt-5.5"],
- });
- });
-
- it("preserves saved models when candidates have not loaded yet", () => {
- const state = createModelsListState({
- enabled: true,
- models: ["gpt-5.5", "gpt-5.4"],
- });
-
- expect(buildModelsListConfig(state)).toEqual({
- enabled: true,
- models: ["gpt-5.5", "gpt-5.4"],
- });
- });
-
- it("selects all candidate models from the toolbar action", () => {
- const state = hydrateModelsListState({
- enabled: true,
- models: ["gpt-5.5"],
- }, ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]);
-
- selectAllModelsListItems(state);
-
- expect(state.items).toEqual([
- { id: "gpt-5.5", selected: true },
- { id: "gpt-5.4", selected: true },
- { id: "gpt-5.4-mini", selected: true },
- ]);
- });
-
- it("inverts selected models from the toolbar action", () => {
- const state = hydrateModelsListState({
- enabled: true,
- models: ["gpt-5.5"],
- }, ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]);
-
- invertModelsListSelection(state);
-
- expect(state.items).toEqual([
- { id: "gpt-5.5", selected: false },
- { id: "gpt-5.4", selected: true },
- { id: "gpt-5.4-mini", selected: true },
- ]);
- });
-});
diff --git a/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts b/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts
index 663d7cefd3..98393000de 100644
--- a/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts
+++ b/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts
@@ -4,13 +4,18 @@ import {
createReasoningEffortMappingPair,
createReasoningEffortMappingRow,
normalizeReasoningEffortForPlatform,
+ normalizeReasoningEffortSourceForPlatform,
normalizeReasoningEffortMatchType,
normalizeReasoningEffortOverLimit,
reasoningEffortMappingsToAPI,
reasoningEffortMappingsToRows,
reasoningEffortOptionsForPlatform,
+ reasoningEffortSourceOptionsForPlatform,
+ reasoningEffortTargetOptionsForPlatform,
+ reasoningEffortMappingDeny,
reasoningEffortOverLimitDeny,
reasoningEffortOverLimitDowngrade,
+ normalizeReasoningEffortTargetForPlatform,
supportsReasoningEffortPolicyPlatform,
validateReasoningEffortMappings,
} from "../groupsReasoningEffort";
@@ -31,15 +36,34 @@ describe("groupsReasoningEffort", () => {
(option) => option.value,
),
).toEqual(expected);
+ expect(
+ reasoningEffortSourceOptionsForPlatform(platform).map(
+ (option) => option.value,
+ ),
+ ).toEqual(["none", ...expected]);
+ expect(
+ reasoningEffortTargetOptionsForPlatform(platform).map(
+ (option) => option.value,
+ ),
+ ).toEqual([...expected, reasoningEffortMappingDeny]);
expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(true);
}
- for (const platform of [
- "anthropic",
- "gemini",
- "antigravity",
- "grok",
- ] as const) {
+ expect(
+ reasoningEffortOptionsForPlatform("anthropic").map(
+ (option) => option.value,
+ ),
+ ).toEqual(["low", "medium", "high", "xhigh", "max"]);
+ expect(
+ reasoningEffortTargetOptionsForPlatform("anthropic").map(
+ (option) => option.value,
+ ),
+ ).toEqual(["low", "medium", "high", "xhigh", "max", reasoningEffortMappingDeny]);
+ expect(supportsReasoningEffortPolicyPlatform("anthropic")).toBe(true);
+
+ for (const platform of ["gemini", "antigravity", "grok"] as const) {
expect(reasoningEffortOptionsForPlatform(platform)).toEqual([]);
+ expect(reasoningEffortSourceOptionsForPlatform(platform)).toEqual([]);
+ expect(reasoningEffortTargetOptionsForPlatform(platform)).toEqual([]);
expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(false);
}
});
@@ -59,6 +83,51 @@ describe("groupsReasoningEffort", () => {
]);
});
+ it("hydrates and validates none only as a mapping source", () => {
+ const rows = reasoningEffortMappingsToRows(
+ [{ from: " NONE ", to: "low" }],
+ "openai",
+ );
+ expect(reasoningEffortMappingsToAPI(rows)).toEqual([
+ { from: "none", to: "low" },
+ ]);
+ expect(validateReasoningEffortMappings(rows, "openai")).toEqual({});
+ expect(normalizeReasoningEffortSourceForPlatform("composite", " NONE ")).toBe("none");
+
+ const invalidTarget = createReasoningEffortMappingRow({
+ from: "low",
+ to: "none",
+ });
+ expect(validateReasoningEffortMappings([invalidTarget], "openai")).toEqual({
+ [invalidTarget.pairs[0].id]: { to: "unsupportedTo" },
+ });
+ expect(normalizeReasoningEffortForPlatform("openai", "none")).toBe("");
+ });
+
+ it("hydrates and validates deny only as a mapping target", () => {
+ const rows = reasoningEffortMappingsToRows(
+ [{ from: " xhigh ", to: " DENY " }],
+ "openai",
+ );
+ expect(reasoningEffortMappingsToAPI(rows)).toEqual([
+ { from: "xhigh", to: reasoningEffortMappingDeny },
+ ]);
+ expect(validateReasoningEffortMappings(rows, "openai")).toEqual({});
+ expect(normalizeReasoningEffortTargetForPlatform("openai", " Deny ")).toBe(
+ reasoningEffortMappingDeny,
+ );
+ expect(normalizeReasoningEffortForPlatform("openai", "deny")).toBe("");
+ expect(normalizeReasoningEffortSourceForPlatform("openai", "deny")).toBe("");
+
+ const invalidSource = createReasoningEffortMappingRow({
+ from: "deny",
+ to: "low",
+ });
+ expect(validateReasoningEffortMappings([invalidSource], "openai")).toEqual({
+ [invalidSource.pairs[0].id]: { from: "unsupportedFrom" },
+ });
+ });
+
it("hydrates model scoped mappings", () => {
const rows = reasoningEffortMappingsToRows(
[
@@ -129,6 +198,12 @@ describe("groupsReasoningEffort", () => {
expect(normalizeReasoningEffortForPlatform("composite", " MAX ")).toBe(
"max",
);
+ expect(normalizeReasoningEffortForPlatform("anthropic", "xhigh")).toBe(
+ "xhigh",
+ );
+ expect(normalizeReasoningEffortForPlatform("anthropic", "minimal")).toBe(
+ "",
+ );
expect(normalizeReasoningEffortForPlatform("grok", "max")).toBe("");
expect(normalizeReasoningEffortForPlatform("openai", "none")).toBe("");
});
diff --git a/frontend/src/views/admin/__tests__/groupsModelsListCandidates.spec.ts b/frontend/src/views/admin/__tests__/modelAllowlistCandidates.spec.ts
similarity index 82%
rename from frontend/src/views/admin/__tests__/groupsModelsListCandidates.spec.ts
rename to frontend/src/views/admin/__tests__/modelAllowlistCandidates.spec.ts
index ec292c6392..cf3ad87344 100644
--- a/frontend/src/views/admin/__tests__/groupsModelsListCandidates.spec.ts
+++ b/frontend/src/views/admin/__tests__/modelAllowlistCandidates.spec.ts
@@ -1,12 +1,12 @@
import { describe, expect, it } from "vitest";
import {
- createModelsListCandidatesTracker,
-} from "../groupsModelsListCandidates";
+ createModelAllowlistCandidatesTracker,
+} from "../modelAllowlistCandidates";
-describe("groupsModelsListCandidates", () => {
+describe("modelAllowlistCandidates", () => {
it("rejects stale candidate responses after a newer platform request starts", () => {
- const tracker = createModelsListCandidatesTracker();
+ const tracker = createModelAllowlistCandidatesTracker();
const first = {
mode: "create" as const,
groupID: 0,
@@ -26,7 +26,7 @@ describe("groupsModelsListCandidates", () => {
});
it("rejects responses for a previous edit group even with the same platform", () => {
- const tracker = createModelsListCandidatesTracker();
+ const tracker = createModelAllowlistCandidatesTracker();
const first = {
mode: "edit" as const,
groupID: 10,
@@ -45,7 +45,7 @@ describe("groupsModelsListCandidates", () => {
});
it("tracks create and edit requests independently", () => {
- const tracker = createModelsListCandidatesTracker();
+ const tracker = createModelAllowlistCandidatesTracker();
const editRequest = {
mode: "edit" as const,
groupID: 10,
diff --git a/frontend/src/views/admin/groupsModelsList.ts b/frontend/src/views/admin/groupModelAllowlist.ts
similarity index 52%
rename from frontend/src/views/admin/groupsModelsList.ts
rename to frontend/src/views/admin/groupModelAllowlist.ts
index 790268fe95..13b5a01919 100644
--- a/frontend/src/views/admin/groupsModelsList.ts
+++ b/frontend/src/views/admin/groupModelAllowlist.ts
@@ -1,38 +1,41 @@
-export interface ModelsListConfig {
+export interface ModelAllowlistConfig {
enabled: boolean
models: string[]
}
-export interface ModelsListItem {
+export interface ModelAllowlistItem {
id: string
selected: boolean
}
-export interface ModelsListState {
+export interface ModelAllowlistState {
enabled: boolean
savedModels: string[]
- items: ModelsListItem[]
+ items: ModelAllowlistItem[]
}
-export const createModelsListState = (
- config?: Partial | null,
-): ModelsListState => ({
+// 自定义条目校验错误码,由视图映射为 i18n 提示。
+export type ModelAllowlistAddError = 'empty' | 'invalid_wildcard' | 'duplicate'
+
+export const createModelAllowlistState = (
+ config?: Partial | null,
+): ModelAllowlistState => ({
enabled: config?.enabled ?? false,
savedModels: normalizeModels(config?.models ?? []),
items: [],
})
-export const hydrateModelsListState = (
- config: Partial | null | undefined,
+export const hydrateModelAllowlistState = (
+ config: Partial | null | undefined,
candidates: string[],
-): ModelsListState => {
- const state = createModelsListState(config)
- setModelsListCandidates(state, candidates)
+): ModelAllowlistState => {
+ const state = createModelAllowlistState(config)
+ setModelAllowlistCandidates(state, candidates)
return state
}
-export const setModelsListCandidates = (
- state: ModelsListState,
+export const setModelAllowlistCandidates = (
+ state: ModelAllowlistState,
candidates: string[],
) => {
const normalizedCandidates = normalizeModels(candidates)
@@ -62,27 +65,30 @@ export const setModelsListCandidates = (
})
}
-export const toggleModelsListItem = (state: ModelsListState, modelID: string) => {
+export const toggleModelAllowlistItem = (
+ state: ModelAllowlistState,
+ modelID: string,
+) => {
const item = state.items.find(item => item.id === modelID)
if (item) {
item.selected = !item.selected
}
}
-export const selectAllModelsListItems = (state: ModelsListState) => {
+export const selectAllModelAllowlistItems = (state: ModelAllowlistState) => {
state.items.forEach(item => {
item.selected = true
})
}
-export const invertModelsListSelection = (state: ModelsListState) => {
+export const invertModelAllowlistSelection = (state: ModelAllowlistState) => {
state.items.forEach(item => {
item.selected = !item.selected
})
}
-export const moveModelsListItem = (
- state: ModelsListState,
+export const moveModelAllowlistItem = (
+ state: ModelAllowlistState,
fromIndex: number,
toIndex: number,
) => {
@@ -99,13 +105,41 @@ export const moveModelsListItem = (
state.items.splice(toIndex, 0, item)
}
-export const buildModelsListConfig = (state: ModelsListState): ModelsListConfig => ({
+// addCustomModelAllowlistItem 把手工输入的条目追加到白名单末尾(选中状态)。
+// 去重;`*` 只允许出现在末尾。返回错误码或 null(成功)。
+export const addCustomModelAllowlistItem = (
+ state: ModelAllowlistState,
+ raw: string,
+): ModelAllowlistAddError | null => {
+ const entry = raw.trim()
+ if (!entry) {
+ return 'empty'
+ }
+ if (entry.slice(0, -1).includes('*')) {
+ return 'invalid_wildcard'
+ }
+ if (
+ state.items.some(item => item.id.toLowerCase() === entry.toLowerCase()) ||
+ state.savedModels.some(model => model.toLowerCase() === entry.toLowerCase())
+ ) {
+ return 'duplicate'
+ }
+ state.items.push({ id: entry, selected: true })
+ return null
+}
+
+export const buildModelAllowlistConfig = (
+ state: ModelAllowlistState,
+): ModelAllowlistConfig => ({
enabled: state.enabled,
models: state.items.length > 0
? state.items.filter(item => item.selected).map(item => item.id)
: [...state.savedModels],
})
+export const selectedModelAllowlistCount = (state: ModelAllowlistState): number =>
+ state.items.filter(item => item.selected).length
+
const normalizeModels = (models: string[]): string[] => {
const seen = new Set()
const out: string[] = []
diff --git a/frontend/src/views/admin/groupsReasoningEffort.ts b/frontend/src/views/admin/groupsReasoningEffort.ts
index de2fbbee4b..f65ee92573 100644
--- a/frontend/src/views/admin/groupsReasoningEffort.ts
+++ b/frontend/src/views/admin/groupsReasoningEffort.ts
@@ -12,6 +12,18 @@ const openAIReasoningEffortValues = [
"xhigh",
"max",
] as const;
+const openAIReasoningEffortSourceValues = [
+ "none",
+ ...openAIReasoningEffortValues,
+] as const;
+
+const anthropicReasoningEffortValues = [
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+] as const;
const reasoningEffortMatchTypes: readonly ReasoningEffortMatchType[] = [
"exact",
@@ -21,18 +33,21 @@ const reasoningEffortMatchTypes: readonly ReasoningEffortMatchType[] = [
export const reasoningEffortOverLimitDowngrade = "downgrade";
export const reasoningEffortOverLimitDeny = "deny";
+export const reasoningEffortMappingDeny = "deny";
const reasoningEffortValuesForPlatform = (
platform: GroupPlatform,
): readonly string[] =>
- supportsReasoningEffortPolicyPlatform(platform)
- ? openAIReasoningEffortValues
- : [];
+ platform === "anthropic"
+ ? anthropicReasoningEffortValues
+ : supportsReasoningEffortPolicyPlatform(platform)
+ ? openAIReasoningEffortValues
+ : [];
export function supportsReasoningEffortPolicyPlatform(
platform: GroupPlatform,
): boolean {
- return platform === "openai" || platform === "composite";
+ return platform === "anthropic" || platform === "openai" || platform === "composite";
}
export function reasoningEffortOptionsForPlatform(platform: GroupPlatform) {
@@ -42,6 +57,26 @@ export function reasoningEffortOptionsForPlatform(platform: GroupPlatform) {
}));
}
+export function reasoningEffortSourceOptionsForPlatform(
+ platform: GroupPlatform,
+) {
+ return (supportsReasoningEffortPolicyPlatform(platform)
+ ? openAIReasoningEffortSourceValues
+ : []
+ ).map((value) => ({ value, label: value }));
+}
+
+export function reasoningEffortTargetOptionsForPlatform(
+ platform: GroupPlatform,
+) {
+ const options = reasoningEffortOptionsForPlatform(platform);
+ if (options.length === 0) return options;
+ return [
+ ...options,
+ { value: reasoningEffortMappingDeny, label: reasoningEffortMappingDeny },
+ ];
+}
+
export function normalizeReasoningEffortForPlatform(
platform: GroupPlatform,
value: string | null | undefined,
@@ -54,6 +89,28 @@ export function normalizeReasoningEffortForPlatform(
: "";
}
+export function normalizeReasoningEffortSourceForPlatform(
+ platform: GroupPlatform,
+ value: string | null | undefined,
+): string {
+ const normalized = value?.trim().toLowerCase() ?? "";
+ return supportsReasoningEffortPolicyPlatform(platform) &&
+ normalized === "none"
+ ? "none"
+ : normalizeReasoningEffortForPlatform(platform, value);
+}
+
+export function normalizeReasoningEffortTargetForPlatform(
+ platform: GroupPlatform,
+ value: string | null | undefined,
+): string {
+ const normalized = value?.trim().toLowerCase() ?? "";
+ return supportsReasoningEffortPolicyPlatform(platform) &&
+ normalized === reasoningEffortMappingDeny
+ ? reasoningEffortMappingDeny
+ : normalizeReasoningEffortForPlatform(platform, value);
+}
+
export function normalizeReasoningEffortMatchType(
value: string | null | undefined,
): ReasoningEffortMatchType | "" {
@@ -154,8 +211,11 @@ export function reasoningEffortMappingsToRows(
const indexByScope = new Map();
(mappings ?? []).forEach((mapping) => {
- const from = normalizeReasoningEffortForPlatform(platform, mapping.from);
- const to = normalizeReasoningEffortForPlatform(platform, mapping.to);
+ const from = normalizeReasoningEffortSourceForPlatform(
+ platform,
+ mapping.from,
+ );
+ const to = normalizeReasoningEffortTargetForPlatform(platform, mapping.to);
if (!from || !to) return;
const matchType = normalizeReasoningEffortMatchType(mapping.match_type);
@@ -225,7 +285,7 @@ export function validateReasoningEffortMappings(
const to = pair.to.trim();
if (!from) {
errors[pair.id] = { ...errors[pair.id], from: "fromRequired" };
- } else if (!normalizeReasoningEffortForPlatform(platform, from)) {
+ } else if (!normalizeReasoningEffortSourceForPlatform(platform, from)) {
errors[pair.id] = { ...errors[pair.id], from: "unsupportedFrom" };
} else {
const key = `${scope}\0${from.toLowerCase()}`;
@@ -233,7 +293,7 @@ export function validateReasoningEffortMappings(
}
if (!to) {
errors[pair.id] = { ...errors[pair.id], to: "toRequired" };
- } else if (!normalizeReasoningEffortForPlatform(platform, to)) {
+ } else if (!normalizeReasoningEffortTargetForPlatform(platform, to)) {
errors[pair.id] = { ...errors[pair.id], to: "unsupportedTo" };
}
});
diff --git a/frontend/src/views/admin/groupsModelsListCandidates.ts b/frontend/src/views/admin/modelAllowlistCandidates.ts
similarity index 53%
rename from frontend/src/views/admin/groupsModelsListCandidates.ts
rename to frontend/src/views/admin/modelAllowlistCandidates.ts
index 2c722af8e3..e86be92c53 100644
--- a/frontend/src/views/admin/groupsModelsListCandidates.ts
+++ b/frontend/src/views/admin/modelAllowlistCandidates.ts
@@ -1,23 +1,23 @@
import type { GroupPlatform } from "@/types";
-export type ModelsListCandidatesMode = "create" | "edit";
+export type ModelAllowlistCandidatesMode = "create" | "edit";
-export interface ModelsListCandidatesRequest {
- mode: ModelsListCandidatesMode;
+export interface ModelAllowlistCandidatesRequest {
+ mode: ModelAllowlistCandidatesMode;
groupID: number;
platform: GroupPlatform;
}
-export interface ModelsListCandidatesTracker {
- next(request: ModelsListCandidatesRequest): number;
- isCurrent(requestID: number, request: ModelsListCandidatesRequest): boolean;
+export interface ModelAllowlistCandidatesTracker {
+ next(request: ModelAllowlistCandidatesRequest): number;
+ isCurrent(requestID: number, request: ModelAllowlistCandidatesRequest): boolean;
}
-export const createModelsListCandidatesTracker = (): ModelsListCandidatesTracker => {
+export const createModelAllowlistCandidatesTracker = (): ModelAllowlistCandidatesTracker => {
let currentRequestID = 0;
- const currentByMode: Partial> = {};
return {
diff --git a/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue b/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue
index 64b9e0dded..e360be3ce3 100644
--- a/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue
+++ b/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue
@@ -41,6 +41,7 @@ const runtimeLoading = ref(false)
const runtimeSaving = ref(false)
const runtimeConfig = reactive({
level: 'info',
+ persist_access_logs: false,
enable_sampling: false,
sampling_initial: 100,
sampling_thereafter: 100,
@@ -231,6 +232,7 @@ const loadRuntimeConfig = async () => {
try {
const cfg = await opsAPI.getRuntimeLogConfig()
runtimeConfig.level = cfg.level
+ runtimeConfig.persist_access_logs = cfg.persist_access_logs
runtimeConfig.enable_sampling = cfg.enable_sampling
runtimeConfig.sampling_initial = cfg.sampling_initial
runtimeConfig.sampling_thereafter = cfg.sampling_thereafter
@@ -249,6 +251,7 @@ const saveRuntimeConfig = async () => {
try {
const saved = await opsAPI.updateRuntimeLogConfig({ ...runtimeConfig })
runtimeConfig.level = saved.level
+ runtimeConfig.persist_access_logs = saved.persist_access_logs
runtimeConfig.enable_sampling = saved.enable_sampling
runtimeConfig.sampling_initial = saved.sampling_initial
runtimeConfig.sampling_thereafter = saved.sampling_thereafter
@@ -272,6 +275,7 @@ const resetRuntimeConfig = async () => {
try {
const saved = await opsAPI.resetRuntimeLogConfig()
runtimeConfig.level = saved.level
+ runtimeConfig.persist_access_logs = saved.persist_access_logs
runtimeConfig.enable_sampling = saved.enable_sampling
runtimeConfig.sampling_initial = saved.sampling_initial
runtimeConfig.sampling_thereafter = saved.sampling_thereafter
@@ -419,6 +423,7 @@ onMounted(async () => {
{{ t('admin.ops.systemLogs.retentionDays') }}
+ {{ t('admin.ops.systemLogs.retentionDaysHint') }}
+
+
{{ t('auth.dontHaveAccount') }}
(false)
const publicSettingsLoaded = ref(false)
// Public settings
+const registrationEnabled = ref(false)
const turnstileEnabled = ref(false)
const turnstileSiteKey = ref('')
const tencentCaptchaEnabled = ref(false)
@@ -381,6 +382,7 @@ onMounted(async () => {
try {
const settings = await getPublicSettings()
+ registrationEnabled.value = settings.registration_enabled === true
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
diff --git a/frontend/src/views/auth/__tests__/LoginView.spec.ts b/frontend/src/views/auth/__tests__/LoginView.spec.ts
new file mode 100644
index 0000000000..954fefa26f
--- /dev/null
+++ b/frontend/src/views/auth/__tests__/LoginView.spec.ts
@@ -0,0 +1,118 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import LoginView from '@/views/auth/LoginView.vue'
+
+const { getPublicSettingsMock, pushMock } = vi.hoisted(() => ({
+ getPublicSettingsMock: vi.fn(),
+ pushMock: vi.fn()
+}))
+
+const publicSettings = {
+ registration_enabled: true,
+ turnstile_enabled: false,
+ turnstile_site_key: '',
+ tencent_captcha_enabled: false,
+ tencent_captcha_app_id: '',
+ aliyun_captcha_enabled: false,
+ aliyun_captcha_scene_id: '',
+ aliyun_captcha_prefix: '',
+ linuxdo_oauth_enabled: false,
+ dingtalk_oauth_enabled: false,
+ wechat_oauth_enabled: false,
+ backend_mode_enabled: false,
+ oidc_oauth_enabled: false,
+ oidc_oauth_provider_name: 'OIDC',
+ github_oauth_enabled: false,
+ google_oauth_enabled: false,
+ password_reset_enabled: false,
+ passkey_enabled: false,
+ login_agreement_enabled: false,
+ login_agreement_documents: []
+}
+
+vi.mock('vue-router', () => ({
+ useRouter: () => ({
+ push: pushMock,
+ currentRoute: { value: { query: {} } }
+ })
+}))
+
+vi.mock('vue-i18n', () => ({
+ createI18n: () => ({
+ global: {
+ t: (key: string) => key
+ }
+ }),
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+}))
+
+vi.mock('@/stores', () => ({
+ useAuthStore: () => ({
+ login: vi.fn(),
+ loginWithPasskey: vi.fn(),
+ login2FA: vi.fn()
+ }),
+ useAppStore: () => ({
+ showError: vi.fn(),
+ showSuccess: vi.fn(),
+ showWarning: vi.fn()
+ })
+}))
+
+vi.mock('@/api/auth', () => ({
+ buildOAuthLoginStartURL: vi.fn(),
+ getPublicSettings: (...args: unknown[]) => getPublicSettingsMock(...args),
+ isTotp2FARequired: vi.fn(() => false),
+ isWeChatWebOAuthEnabled: vi.fn(() => false),
+ startOAuthLogin: vi.fn()
+}))
+
+function mountLogin() {
+ return mount(LoginView, {
+ global: {
+ stubs: {
+ AuthLayout: { template: '
' },
+ DingTalkOAuthSection: true,
+ EmailOAuthButtons: true,
+ Icon: true,
+ LinuxDoOAuthSection: true,
+ LoginAgreementPrompt: true,
+ OidcOAuthSection: true,
+ RouterLink: { template: ' ' },
+ TotpLoginModal: true,
+ TurnstileWidget: true,
+ WechatOAuthSection: true,
+ transition: false
+ }
+ }
+ })
+}
+
+describe('LoginView registration entry', () => {
+ beforeEach(() => {
+ getPublicSettingsMock.mockReset()
+ pushMock.mockReset()
+ getPublicSettingsMock.mockResolvedValue(publicSettings)
+ })
+
+ it('shows the registration entry when registration is enabled', async () => {
+ const wrapper = mountLogin()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('auth.signUp')
+ })
+
+ it('hides the registration entry when registration is disabled', async () => {
+ getPublicSettingsMock.mockResolvedValueOnce({
+ ...publicSettings,
+ registration_enabled: false
+ })
+
+ const wrapper = mountLogin()
+ await flushPromises()
+
+ expect(wrapper.text()).not.toContain('auth.signUp')
+ })
+})
diff --git a/frontend/src/views/user/ChannelStatusV2View.vue b/frontend/src/views/user/ChannelStatusV2View.vue
index d97b14cb99..4fdcaf278a 100644
--- a/frontend/src/views/user/ChannelStatusV2View.vue
+++ b/frontend/src/views/user/ChannelStatusV2View.vue
@@ -211,7 +211,7 @@
:value="formatMs(snapshot.metrics.ttft.p50_ms)"
:detail="latencyKpiSecondary(snapshot.metrics.ttft)"
:title="latencyDetail(snapshot.metrics.ttft)"
- :state="snapshot.health.ttft"
+ :state="ttftCellState(snapshot.health.ttft, snapshot.metrics.ttft)"
/>
authStore.isAdmin)
/** Admins always see RPM/TPM; users honor the hide-throughput system setting. */
const showThroughput = computed(() => isAdmin.value || !isChannelMonitorThroughputHidden())
+/** Admins always see ranking; users honor the hide-user-ranking system setting. */
+const showUserRanking = computed(() => isAdmin.value || !isChannelMonitorUserRankingHidden())
const ranges = computed(() => [
{ value: '90m' as MonitorRange, label: t('channelMonitorV2.ranges.90m') },
@@ -519,11 +522,16 @@ const ranges = computed(() => [
{ value: '7d' as MonitorRange, label: t('channelMonitorV2.ranges.7d') },
{ value: '30d' as MonitorRange, label: t('channelMonitorV2.ranges.30d') },
])
-const tabs = computed(() => [
- { value: 'models' as Tab, label: t('channelMonitorV2.tabs.models') },
- { value: 'errors' as Tab, label: t('channelMonitorV2.tabs.errors') },
- { value: 'users' as Tab, label: t('channelMonitorV2.tabs.users') },
-])
+const tabs = computed(() => {
+ const items: Array<{ value: Tab; label: string }> = [
+ { value: 'models', label: t('channelMonitorV2.tabs.models') },
+ { value: 'errors', label: t('channelMonitorV2.tabs.errors') },
+ ]
+ if (showUserRanking.value) {
+ items.push({ value: 'users', label: t('channelMonitorV2.tabs.users') })
+ }
+ return items
+})
const matrixGroupOptions = computed(() => [
{ value: 'platform' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platform') },
{ value: 'platform_group' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platformGroup') },
@@ -543,9 +551,7 @@ const filter = ref({
groupIds: csv(route.query.group).map(Number).filter(Boolean),
models: csv(route.query.model),
})
-const activeTab = ref(
- (['models', 'errors', 'users'].includes(String(route.query.tab)) ? route.query.tab : 'models') as Tab
-)
+const activeTab = ref(parseTab(route.query.tab, showUserRanking.value))
const matrixGroupBy = ref(parseMatrixGroupBy(route.query.group_by))
const healthMode = ref(parseHealthMode(route.query.health_mode))
const trendView = ref(parseTrendView(route.query.trend_view))
@@ -672,6 +678,10 @@ function parseMatrixGroupBy(value: unknown): MonitorMatrixGroupBy {
? (value as MonitorMatrixGroupBy)
: 'platform_group'
}
+function parseTab(value: unknown, allowUsers: boolean): Tab {
+ const allowed: Tab[] = allowUsers ? ['models', 'errors', 'users'] : ['models', 'errors']
+ return allowed.includes(value as Tab) ? (value as Tab) : 'models'
+}
function parseHealthMode(value: unknown): HealthMode {
const allowed: HealthMode[] = ['overall', 'success', 'ttft', 'cache']
return allowed.includes(value as HealthMode) ? (value as HealthMode) : 'overall'
@@ -773,8 +783,10 @@ async function loadTab(signal?: AbortSignal, id = sequence) {
modelRows.value = (await api.getModels(filter.value, isAdmin.value, signal)).items || []
} else if (activeTab.value === 'errors') {
errorRows.value = (await api.getErrors(filter.value, isAdmin.value, signal)).items || []
- } else {
+ } else if (showUserRanking.value) {
userRows.value = (await api.getUsers(filter.value, isAdmin.value, signal)).items || []
+ } else {
+ userRows.value = []
}
} catch (error) {
const e = error as { name?: string; code?: string }
@@ -835,6 +847,9 @@ function formatPercent(value: number) {
function formatMs(value: number | null) {
return formatMonitorMs(value)
}
+function ttftCellState(state: HealthState | undefined, metric: { p50_ms: number | null; sample_count?: number }) {
+ return ttftDisplayState(state, metric)
+}
function latencyDetail(metric: {
p50_ms: number | null
p90_ms?: number | null
@@ -904,6 +919,11 @@ watch(activeTab, () => {
syncQuery()
void loadTab()
})
+watch(showUserRanking, (allowed) => {
+ if (!allowed && activeTab.value === 'users') {
+ activeTab.value = 'models'
+ }
+})
onMounted(() => void reload(false))
onBeforeUnmount(() => {
controller?.abort()
diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue
index 8006695dda..10ddceb479 100644
--- a/frontend/src/views/user/CustomPageView.vue
+++ b/frontend/src/views/user/CustomPageView.vue
@@ -94,12 +94,21 @@
-
+
{{ t('customPage.openInNewTab') }}
@@ -118,6 +127,7 @@
',
+ ' ',
+ '[Unsafe](javascript:alert%281%29)',
+ '[Support](https://example.com/help)',
+ ].join('\n\n'))
+ const help = wrapper.get('.markdown-body')
+ expect(help.find('script').exists()).toBe(false)
+ expect(help.get('img').attributes('onerror')).toBeUndefined()
+ expect(help.findAll('a').map(link => link.attributes('href'))).toEqual([undefined, 'https://example.com/help'])
+ })
+
+ it('keeps plain-text soft line breaks and the separate help image preview', async () => {
+ const wrapper = await mountHelp('First line\nSecond line', 'https://example.com/help.png')
+ const help = wrapper.get('.markdown-body')
+ expect(help.get('p').text()).toBe('First line\nSecond line')
+ expect(help.find('br').exists()).toBe(false)
+ await wrapper.get('img').trigger('click')
+ expect(wrapper.findAll('img')).toHaveLength(2)
+ expect(wrapper.findAll('img')[1].attributes('src')).toBe('https://example.com/help.png')
+ })
+
+ it('keeps image-only help without an empty Markdown container', async () => {
+ const wrapper = await mountHelp('', 'https://example.com/help.png')
+ expect(wrapper.find('.markdown-body').exists()).toBe(false)
+ expect(wrapper.get('img').attributes('src')).toBe('https://example.com/help.png')
+ })
+})
+
describe('PaymentView subscription plan grid', () => {
it.each([3, 4, 6])('keeps %i plans on the existing mobile/tablet/desktop grid', async (planCount) => {
const wrapper = await mountSubscriptionPlanList(planCount)
diff --git a/frontend/src/views/user/__tests__/UsageView.spec.ts b/frontend/src/views/user/__tests__/UsageView.spec.ts
index a27cf08a68..0099a1095b 100644
--- a/frontend/src/views/user/__tests__/UsageView.spec.ts
+++ b/frontend/src/views/user/__tests__/UsageView.spec.ts
@@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import UsageView from '../UsageView.vue'
+import Select, { type SelectOption } from '@/components/common/Select.vue'
const {
query,
getStats,
getDashboardModels,
getDashboardSnapshotV2,
+ listMyErrorRequests,
list,
getAvailable,
showError,
@@ -19,6 +21,7 @@ const {
getStats: vi.fn(),
getDashboardModels: vi.fn(),
getDashboardSnapshotV2: vi.fn(),
+ listMyErrorRequests: vi.fn(),
list: vi.fn(),
getAvailable: vi.fn(),
showError: vi.fn(),
@@ -47,6 +50,9 @@ const messages: Record = {
'admin.usage.allGroups': 'All groups',
'admin.usage.allModels': 'All models',
'usage.allApiKeys': 'All API Keys',
+ 'usage.errors.allKeys': 'All API Keys',
+ 'usage.tabs.usage': 'Usage records',
+ 'usage.tabs.errors': 'Error records',
'usage.apiKeyFilter': 'API Key',
'usage.model': 'Model',
'usage.type': 'Type',
@@ -73,6 +79,7 @@ vi.mock('@/api', () => ({
getStats,
getDashboardModels,
getDashboardSnapshotV2,
+ listMyErrorRequests,
},
keysAPI: {
list,
@@ -83,7 +90,10 @@ vi.mock('@/api', () => ({
}))
vi.mock('@/stores/app', () => ({
- useAppStore: () => ({ showError, showWarning, showSuccess, showInfo }),
+ useAppStore: () => ({
+ showError, showWarning, showSuccess, showInfo,
+ cachedPublicSettings: { allow_user_view_error_requests: true },
+ }),
}))
vi.mock('vue-i18n', async () => {
@@ -142,6 +152,7 @@ function mountUsageView() {
Icon: true,
UsageStatsCards: chartStub,
UsageTable: chartStub,
+ UserErrorRequestsTable: chartStub,
ModelDistributionChart: chartStub,
GroupDistributionChart: chartStub,
EndpointDistributionChart: chartStub,
@@ -157,6 +168,7 @@ describe('user UsageView', () => {
getStats.mockReset()
getDashboardModels.mockReset()
getDashboardSnapshotV2.mockReset()
+ listMyErrorRequests.mockReset()
list.mockReset()
getAvailable.mockReset()
showError.mockReset()
@@ -191,7 +203,8 @@ describe('user UsageView', () => {
trend: [],
groups: [],
})
- list.mockResolvedValue({ items: [{ id: 1, name: 'demo-key' }] })
+ listMyErrorRequests.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
+ list.mockResolvedValue({ items: [{ id: 1, name: 'demo-key' }], total: 1, page: 1, page_size: 100, pages: 1 })
getAvailable.mockResolvedValue([{ id: 1, name: 'default' }])
})
@@ -207,10 +220,98 @@ describe('user UsageView', () => {
include_model_stats: false,
include_group_stats: true,
}))
+ expect(list).toHaveBeenCalledTimes(1)
expect(list).toHaveBeenCalledWith(1, 100)
expect(getAvailable).toHaveBeenCalled()
})
+ it('includes API keys after the first page in both record filters and queries by the selected key', async () => {
+ const firstPageKeys = Array.from({ length: 100 }, (_, index) => ({
+ id: index + 1,
+ name: `key-${index + 1}`,
+ }))
+ const laterKey = { id: 101, name: 'key-from-second-page' }
+ list
+ .mockResolvedValueOnce({ items: firstPageKeys, total: 101, page: 1, page_size: 100, pages: 2 })
+ .mockResolvedValueOnce({ items: [laterKey], total: 101, page: 2, page_size: 100, pages: 2 })
+
+ const wrapper = mountUsageView()
+ await flushPromises()
+
+ expect(list.mock.calls).toEqual([[1, 100], [2, 100]])
+ const usageKeySelect = wrapper.findAllComponents(Select).find((select) =>
+ select.props('options').some((option: SelectOption) => option.label === 'All API Keys')
+ )!
+ expect(usageKeySelect.props('options')).toHaveLength(102)
+ expect(usageKeySelect.props('options')).toContainEqual({ value: laterKey.id, label: laterKey.name })
+
+ query.mockClear()
+ usageKeySelect.vm.$emit('update:modelValue', laterKey.id)
+ usageKeySelect.vm.$emit('change', laterKey.id)
+ await flushPromises()
+
+ expect(query).toHaveBeenCalledWith(
+ expect.objectContaining({ api_key_id: laterKey.id, page: 1 }),
+ expect.anything()
+ )
+
+ await wrapper.findAll('button').find((button) => button.text() === 'Error records')!.trigger('click')
+ await flushPromises()
+
+ const errorKeySelect = wrapper.findAllComponents(Select).find((select) =>
+ select.props('options').some((option: SelectOption) => option.label === 'All API Keys')
+ )!
+ expect(errorKeySelect.props('options')).toHaveLength(102)
+ expect(errorKeySelect.props('options')).toContainEqual({ value: laterKey.id, label: laterKey.name })
+
+ listMyErrorRequests.mockClear()
+ errorKeySelect.vm.$emit('update:modelValue', laterKey.id)
+ errorKeySelect.vm.$emit('change', laterKey.id)
+ await flushPromises()
+
+ expect(listMyErrorRequests).toHaveBeenCalledWith(
+ expect.objectContaining({ api_key_id: laterKey.id, page: 1 })
+ )
+ expect(list).toHaveBeenCalledTimes(2)
+ wrapper.unmount()
+ })
+
+ it('does not request another API key page when the user has no keys', async () => {
+ list.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 100, pages: 0 })
+
+ const wrapper = mountUsageView()
+ await flushPromises()
+
+ expect(list.mock.calls).toEqual([[1, 100]])
+ const keySelect = wrapper.findAllComponents(Select).find((select) =>
+ select.props('options').some((option: SelectOption) => option.label === 'All API Keys')
+ )!
+ expect(keySelect.props('options')).toEqual([{ value: null, label: 'All API Keys' }])
+ expect(getAvailable).toHaveBeenCalled()
+ wrapper.unmount()
+ })
+
+ it('stops loading API keys when a later page is empty despite an outdated page count', async () => {
+ const firstPageKeys = Array.from({ length: 100 }, (_, index) => ({
+ id: index + 1,
+ name: `key-${index + 1}`,
+ }))
+ list
+ .mockResolvedValueOnce({ items: firstPageKeys, total: 201, page: 1, page_size: 100, pages: 3 })
+ .mockResolvedValueOnce({ items: [], total: 201, page: 2, page_size: 100, pages: 3 })
+
+ const wrapper = mountUsageView()
+ await flushPromises()
+
+ expect(list.mock.calls).toEqual([[1, 100], [2, 100]])
+ const keySelect = wrapper.findAllComponents(Select).find((select) =>
+ select.props('options').some((option: SelectOption) => option.label === 'All API Keys')
+ )!
+ expect(keySelect.props('options')).toHaveLength(101)
+ expect(keySelect.props('options')).toContainEqual({ value: 100, label: 'key-100' })
+ wrapper.unmount()
+ })
+
it('propagates and resets the native compaction filter across page requests', async () => {
const wrapper = mountUsageView()
await flushPromises()
diff --git a/openspec/changes/codex-manifest-pinned-accounts/.openspec.yaml b/openspec/changes/codex-manifest-pinned-accounts/.openspec.yaml
new file mode 100644
index 0000000000..1d9aeef992
--- /dev/null
+++ b/openspec/changes/codex-manifest-pinned-accounts/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-04
diff --git a/openspec/changes/codex-manifest-pinned-accounts/design.md b/openspec/changes/codex-manifest-pinned-accounts/design.md
new file mode 100644
index 0000000000..0c174b399b
--- /dev/null
+++ b/openspec/changes/codex-manifest-pinned-accounts/design.md
@@ -0,0 +1,98 @@
+## Context
+
+动机见 proposal.md。与方案相关的现状:
+
+- Codex Model Manifest 入口是 `OpenAIGatewayHandler.CodexModels`:先尝试用账号模型映射本地生成;否则循环 `SelectAccountForModelWithExclusions` 选账号、`FetchCodexModelsManifest` 拉取、`CompleteAPIKeyCodexModelsManifestForClient` 补全、`MergeGroupConfiguredCodexModels` 做分组过滤与 ETag。
+- `FetchCodexModelsManifest` 对 API Key 账号走 `fetchCachedAPIKeyCodexModelsManifest`(30 秒新鲜、5 分钟乐观、单飞后台刷新),对 OAuth 账号直接请求上游且带 agent identity 任务恢复逻辑,不缓存。
+- 分组配置落地链路:ent schema → SQL 迁移 → `service.Group` → `group_repo` 创建与更新 setter → `api_key_repo` 分组字段投影与 `groupEntityToService` → 认证快照 `api_key_auth_cache.go` 结构体及 impl 两处映射 → 管理端 DTO / mapper → `admin_group.go` 创建与更新 → 分组复制。`models_list_config` 是完整样板。
+- 前端 `GroupsView.vue` 已有 7000 行;模型路由的账号选择用「标签 + 搜索输入 + 下拉」内联实现,`Select.vue` 仅支持单选。`components/admin/group/` 下已有抽出的表单片段组件(如 `ReasoningEffortPolicyFields.vue`)。
+- 后端管理端账号列表接口支持 `platform`、`group`、`search` 过滤。
+
+## Goals / Non-Goals
+
+**Goals:**
+- 固定账号模式的运行时逻辑放在 service 层,handler 只做分支与错误映射。
+- 合并逻辑是纯函数,可独立单测。
+- 缓存策略只有一套实现,OAuth 与 API Key 账号共用;固定账号模式不引入第二层分组级缓存。
+- 新字段在所有分组加载路径上都可见,特别是认证快照与 API Key 投影。
+
+**Non-Goals:**
+- 不改动调度器逻辑与普通请求的账号选择。
+- 不为账号解绑或删除增加对该配置的级联清理,运行时容忍失效 ID。
+- 固定账号关闭时保留本地生成 manifest 的路径;开启时以指定账号上游发现为先,映射在发现后应用。
+- 创建分组对话框不提供该配置。
+
+## Decisions
+
+### D1:配置以单个 JSONB 列存储
+`groups.codex_models_manifest_config`,领域类型 `domain.GroupCodexModelsManifestConfig{Enabled bool; AccountIDs []int64; FallbackToScheduler bool}`,JSON 键为 `enabled`、`account_ids`、`fallback_to_scheduler`。
+- 备选:三个独立列。否决:三个字段语义耦合,JSON 保证原子写入,且与 `models_list_config` 一致。
+- 迁移文件 `234_group_codex_models_manifest_config.sql`:`ADD COLUMN IF NOT EXISTS ... JSONB NOT NULL DEFAULT '{}'::jsonb`。ent schema 使用 `field.JSON(...).Default(domain.GroupCodexModelsManifestConfig{})`。
+
+### D2:校验放在 admin service,创建路径拒绝开启
+- 新文件 `group_codex_models_manifest.go` 提供 `normalizeCodexModelsManifestConfig`(平台非 openai 归零;去重保序;`enabled=false` 时保留列表便于再次启用)与 `validateCodexModelsManifestConfig(ctx, groupID, cfg)`(`enabled=true` 时:非空、≤10、全部属于 `accountRepo.ListByGroup(groupID)` 且平台 openai)。
+- 创建路径:`enabled=true` 直接 400。原因:账号绑定发生在分组创建之后,创建时无法校验成员关系;前端也不展示。
+- 备选:校验放 handler。否决:需要访问账号仓储,且创建与更新两条路径共用,service 是唯一收口。
+
+### D3:运行时入口与回退策略
+- handler 在本地生成分支之前判断 `group.Platform == openai && cfg.Enabled`,调用 `gatewayService.FetchPinnedCodexModelsManifest(ctx, group, clientVersion)`。
+- 返回值:`(*OpenAIModelsResponse, *Account, error)`,`*Account` 为配置顺序中第一个成功账号,用于 `setOpsSelectedAccount`。
+- 「无可用账号」以哨兵错误 `ErrNoPinnedCodexModelsAccounts` 表示;「全部失败」返回最后一个上游错误。handler 根据 `FallbackToScheduler` 决定:true 时跌入现有调度器循环;false 时无可用账号返回 503、全部失败按 `infraerrors.Code(err)` 返回。
+- 可用性判定:`IsActive() && Schedulable && !(AutoPauseOnExpired && 已过期)`。刻意不使用 `IsSchedulable()`,因为它包含限流、过载与临时不可调度窗口。
+- 账号来源:`accountRepo.ListByGroup(group.ID)`(active 成员),按配置顺序筛选并跳过缺失 ID。
+- 备选:复用 `BuildGroupConfiguredCodexModelsManifest` 已加载的账号列表以省一次查询。否决:那两个列表是可调度集合,会漏掉限流中的账号;一次按分组的查询成本可接受。
+
+### D4:并发拉取与纯函数合并
+- 用 `sync.WaitGroup` 对每个可用账号并发执行:`FetchCodexModelsManifest(ctx, acc, clientVersion, "")` → API Key 账号再 `CompleteAPIKeyCodexModelsManifestForClient`。结果写入按配置顺序索引的切片,失败记录到同下标的错误切片;各账号独立完成,不因单账号失败取消其他请求。
+- Codex 合并函数 `mergeCodexModelsManifestBodies(bodies [][]byte) ([]byte, error)`:以第一个 body 的顶层信封为基底,`models` 按 slug 并集,先出现者优先;slug 为空或解析失败的条目按出现顺序保留一次。输出后设置 `ETag = codexModelsManifestBodyETag(body)`,再交给 `MergeGroupConfiguredCodexModels` 做分组过滤与 304 判断。
+- 部分失败:`slog.Warn` 带 group_id 与失败账号 ID 列表。
+- 实现放在新文件 `openai_codex_models_pinned.go`,避免继续膨胀 2300 行的主文件。
+
+### D5:缓存统一并调整时效
+- 将 `fetchCachedAPIKeyCodexModelsManifest` 泛化为 `fetchCachedOpenAIModels(ctx, request, fetch, ifNoneMatch)`,`fetch func(ctx, ifNoneMatch) (*CodexModelsManifest, error)` 由调用方提供:API Key 路径传 `fetchCodexModelsManifestUpstream`;OAuth 路径传一个包含 agent identity 任务恢复逻辑的闭包。`handleCodexModelsManifestAccountAuthError` 在 OAuth 闭包返回错误时照旧调用。
+- 常量:`openAIModelsCacheTTL` 30s → 60s;`openAIModelsCacheStaleTTL` 保持 5 分钟;超过 5 分钟 `get` 删除条目返回 miss,调用方同步等待单飞结果,这与「超期强制等待上游刷新」一致,无需新状态。
+- 缓存键已包含 Authorization 与 Version 头,令牌刷新自然失效旧条目;`client_version` 不同的客户端各占一个条目。
+- 缓存键不含分组信息,多个分组共用同一账号时共享同一条目,新鲜期内零上游请求,乐观期与超期刷新均按键单飞,同一时刻一个账号只发一次上游请求;分组级处理(并集合并、自定义列表过滤、别名合并)都在缓存体的克隆上进行,不写回缓存。
+- `openAIModelsCacheMaxEntries` 64 → 512。OAuth 路径接入后条目数为「账号数 × 客户端版本数」,64 条在大规模部署下会被淘汰导致额外上游请求;单条清单通常几十 KB,最坏情况内存占用为几十 MB 量级。
+- 后台刷新使用 `context.Background()` 加上游 15 秒超时,与现状一致。
+- 备选:为固定账号模式增加分组级合并结果缓存。否决:账号级缓存命中后合并只是内存操作,再加一层会引入两套时效与失效问题。
+
+### D6:前端拆出独立组件
+- 新组件 `components/admin/group/CodexManifestAccountsField.vue`:props 为 `groupId`、`modelValue`(`{enabled, account_ids, fallback_to_scheduler}`)与已选账号名称映射;内部实现开关、标签列表、带防抖的搜索输入与下拉、回退子开关。搜索调用 `adminAPI.accounts.list(1, 20, {search, platform: 'openai', group: String(groupId)})`。
+- `GroupsView.vue` 只在编辑对话框 OpenAI 区块挂载组件,打开编辑时用 `adminAPI.accounts.getById` 解析已存 ID 的名称,失败则显示 `#`。提交时开关打开且列表为空则 toast 报错并阻止。
+- 备选:在 GroupsView 内联复制模型路由的搜索状态。否决:会再引入一套按 key 索引的搜索状态,文件已过大。
+
+## Risks / Trade-offs
+
+- [固定账号模式下每次缓存超期会向 N 个账号并发请求] → 账号数上限 10;账号级缓存 1 分钟新鲜期把稳态请求量压到低于现状。
+- [限流中的账号被强制用于拉取 manifest 可能加重其 429] → 这是需求明确要求的行为;manifest 请求不占并发槽,且被缓存吸收;失败时按部分合并处理。
+- [部分账号失败导致模型列表短暂缺项] → 记录警告日志;下一次超期刷新恢复。用户已确认接受。
+- [OAuth manifest 首次接入缓存,乐观期内令牌已被撤销仍会返回旧内容最多 4 分钟] → manifest 非敏感数据,且撤销后的下一次刷新会失败并按现有 401 处理流程处理账号。
+- [配置引用的账号被解绑或删除后成为脏 ID] → 运行时跳过;编辑对话框显示 `#` 提示管理员清理;全部失效时按回退配置处理。
+- [认证快照漏投影新字段会让开关静默失效] → 仓库已有投影对账集成测试,任务中包含更新该测试。
+
+## Migration Plan
+
+1. 部署包含迁移 234 的后端版本;迁移幂等,默认值 `{}` 反序列化为关闭状态,存量分组行为不变。
+2. 缓存时效变化随部署即时生效,无需数据迁移。
+3. 回滚:回退代码即可,列保留无副作用;如需彻底清理可手工 `DROP COLUMN`。
+
+### D7:普通模型列表共用固定账号来源
+
+- `GatewayHandler.Models` 在 OpenAI 分组开启固定账号时调用 `FetchPinnedOpenAIModelsList`;普通 `/models` 别名共用该入口。
+- 共享原有配置、固定账号成员筛选、并发收集与部分失败规则;标准列表失败且允许回退时用现有 OpenAI 调度器选账,再调用普通目录获取。无账号为 503,全部上游失败保留上游错误语义,不以静态默认列表掩盖失败。
+- API Key 使用标准模型 URL,不添加 Codex `client_version` 或身份请求头;从管理端抽出 API Key 请求构造。OAuth 复用 `FetchCodexModelsManifest` 和规范客户端版本,保留凭据引用、Agent Identity 恢复、401 状态处理。
+- 响应/缓存元数据类型泛化为 `OpenAIModelsResponse`;抽出原始 HTTP 响应获取,Codex 专用转换/元数据补全仅在 manifest 路径执行。两种请求使用同一缓存实现,以响应格式、完整 URL、账号、凭据、代理和请求头区分条目;相同 OAuth 请求共享条目。
+- 标准列表保留上游 `id`、`created`、`owned_by` 等字段。OAuth slug 转成标准条目;无创建时间采用 0。有效空数组是成功结果,缺失或错误的数组结构是上游错误。
+
+### D8:固定账号目录的映射与过滤
+
+- 每个成功账号先基于它的原始目录做公开名称投影,随后按配置顺序取并集。透传或无映射账号保留上游具体 ID;有映射账号仅暴露映射目标存在于该账号目录的具体公开名称。
+- 具体映射键以及分组选择的具体名称可作为别名候选;通配映射可匹配上游/分组提供的具体候选,但通配规则本身不作为模型 ID 返回。复用 `ResolveMappedModel` 的匹配优先级。
+- Codex 别名继承对应上游条目的能力元数据;固定账号开启时不再注入其他分组账号的本地别名。固定账号回退选出的账号也应用相同投影。
+- 分组列表过滤最后执行,遵守配置顺序;过滤后为空返回 200 空数组,不使用静态默认模型。最终响应体计算 ETag 并处理 304。
+- 前端文案说明该配置覆盖普通模型列表和 Codex Model Manifest。持久化配置名称和结构不变。
+
+### D9:扩展验证
+
+新增路由/handler 与 service 测试覆盖普通两条路由、固定账号优先、原始媒体和未知模型、映射与通配别名、部分/全部失败及调度器回退、凭据/请求协议隔离、OAuth 缓存复用、三段缓存时效、分组过滤隔离与空目录、最终 ETag。运行受影响 Go 测试(含 race)、完整 unit 检查及前端类型/组件检查。
diff --git a/openspec/changes/codex-manifest-pinned-accounts/proposal.md b/openspec/changes/codex-manifest-pinned-accounts/proposal.md
new file mode 100644
index 0000000000..e21dda639b
--- /dev/null
+++ b/openspec/changes/codex-manifest-pinned-accounts/proposal.md
@@ -0,0 +1,30 @@
+## Why
+
+OpenAI 分组在没有账号模型映射时,每次 Codex 客户端请求 Model Manifest 都会经由调度器挑选一个账号向上游拉取。调度结果受优先级、负载因子和限流窗口影响,而分组内账号的模型权限并不一致(例如部分账号拥有 Trusted Access for Cyber 的特殊模型),导致同一个 API Key 前后看到的模型列表不确定。此外 OAuth 账号的 manifest 拉取完全没有缓存,每次客户端刷新都直接打到 chatgpt.com。
+
+## What Changes
+
+- 为平台为 OpenAI 的分组新增「使用特定账号获取 Codex Model Manifest」配置:一个默认关闭的开关、一个必须至少选择一个账号的多选账号列表,以及「选定账号全部不可用时回退调度器」的子选项(默认关闭,即返回 503)。
+- 开关打开后,该分组的 API Key 请求 Codex Model Manifest 时只使用选定账号向上游拉取,忽略优先级、负载因子、限流与过载窗口;多个账号并发请求,`models` 按 slug 取并集后返回。部分账号失败时以成功部分合并;全部失败按配置返回 503 或回退到现有调度器路径。
+- 管理端分组编辑对话框的 OpenAI 区块新增该配置的开关与带搜索的多选账号下拉,账号来源限定为当前分组内的 OpenAI 账号。创建对话框不展示该配置(分组创建时尚无账号绑定)。
+- 分组复制时该配置重置为关闭。
+- 普通 `/v1/models` 与 `/models`(无非空 `client_version`)复用同一固定账号配置;API Key 请求标准上游模型列表,OAuth 从现有 Codex manifest 提取 ID,输出 OpenAI 列表。
+- **BREAKING**:固定账号开启时,普通列表和 Codex manifest 都优先进行指定账号发现,随后按来源账号模型映射生成公开目录并应用分组过滤;显式模型映射不再短路固定账号发现。
+- **BREAKING**(行为层面):Codex Model Manifest 缓存统一为 1 分钟新鲜(命中即返回,不请求上游)、1 到 5 分钟乐观返回缓存并后台刷新、超过 5 分钟同步等待上游刷新后再返回。缓存覆盖范围从仅 API Key 账号扩展到 OAuth 账号,固定账号模式的每个账号也走同一缓存。
+
+## Capabilities
+
+### New Capabilities
+- `codex-manifest-pinned-accounts`:OpenAI 分组的固定账号 Manifest 配置(数据模型、管理端校验与 UI)、运行时的固定账号并发拉取与合并、不可用时的回退策略。
+- `codex-manifest-cache`:Codex Model Manifest 的按账号缓存策略:新鲜期、乐观期与强制刷新期的行为,以及对所有账号类型生效。
+
+### Modified Capabilities
+
+
+## Impact
+
+- **数据库**:`groups` 新增 JSONB 列 `codex_models_manifest_config`,新增迁移文件;ent schema 与生成代码更新。
+- **后端**:`Group` 领域模型、分组仓储的创建与更新、API Key 加载分组的字段投影、认证快照缓存、管理端分组 DTO 与校验、分组复制逻辑;`openai_codex_models_handler.go` 新增固定账号分支;`openai_codex_models_service.go` 新增固定账号并发拉取与合并、缓存策略调整并扩展到 OAuth 路径。
+- **管理端 API**:`POST/PUT /admin/groups` 请求与响应新增 `codex_models_manifest_config` 字段;`GET /admin/accounts` 已支持按分组过滤,无需改动。
+- **前端**:`GroupsView.vue` 编辑对话框 OpenAI 区块、`types/index.ts`、中英文 i18n。
+- **上游影响**:固定账号模式下每次缓存失效会向 N 个账号并发请求;缓存新鲜期延长到 1 分钟后,稳态上游请求量低于当前。
diff --git a/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-cache/spec.md b/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-cache/spec.md
new file mode 100644
index 0000000000..d26e516987
--- /dev/null
+++ b/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-cache/spec.md
@@ -0,0 +1,71 @@
+## Purpose
+
+规定 Codex Model Manifest 按账号缓存的时效策略,使所有账号类型的 manifest 拉取在新鲜期内不打上游、乐观期内先返回缓存再后台刷新、超期后同步刷新。
+
+## ADDED Requirements
+
+### Requirement: 所有账号类型的 manifest 拉取都经过缓存
+系统 MUST 对 OAuth 账号与 API Key 账号的 Codex Model Manifest 上游拉取统一使用同一套按账号缓存。缓存键 MUST 区分账号、凭据账号、代理与请求头(含授权头与客户端版本),凭据变化后 MUST 视为新的缓存项。缓存键 MUST NOT 包含分组信息,多个分组共用同一账号时 MUST 共享同一缓存项;固定账号模式下每个选定账号的拉取 MUST 同样经过该缓存。缓存 MUST 至少容纳 512 个条目。
+
+#### Scenario: OAuth 账号命中缓存
+- **WHEN** 同一 OAuth 账号在新鲜期内被再次用于获取 manifest
+- **THEN** 系统 MUST 直接返回缓存内容,MUST NOT 向 chatgpt.com 发起请求
+
+#### Scenario: 多个分组共用同一账号
+- **WHEN** 两个分组在同一时刻通过同一账号请求 manifest 且缓存已超期
+- **THEN** 系统 MUST 只向上游发起一次请求,两个分组 MUST 各自基于该结果做分组级处理
+- **THEN** 一个分组的过滤结果 MUST NOT 影响另一个分组收到的内容
+
+#### Scenario: 凭据刷新后
+- **WHEN** 账号的访问令牌被刷新
+- **THEN** 下一次拉取 MUST 视为缓存未命中并同步请求上游
+
+### Requirement: 新鲜期 1 分钟内强制使用缓存
+缓存项写入后 1 分钟内 MUST 视为新鲜:系统 MUST 直接返回缓存内容且 MUST NOT 触发任何上游请求(包括后台刷新)。
+
+#### Scenario: 新鲜期内并发请求
+- **WHEN** 缓存写入后 30 秒内收到 10 个同账号 manifest 请求
+- **THEN** 上游请求次数 MUST 为 0
+
+### Requirement: 1 到 5 分钟乐观返回并后台刷新
+缓存项写入后超过 1 分钟且不超过 5 分钟时 MUST 视为过期但可用:系统 MUST 立即返回缓存内容,并在后台对同一缓存键做单飞刷新。后台刷新 MUST 携带上游 ETag,上游返回 304 时 MUST 续期现有缓存项。
+
+#### Scenario: 乐观期返回旧值
+- **WHEN** 缓存写入后 3 分钟收到请求
+- **THEN** 系统 MUST 立即返回缓存内容
+- **THEN** 系统 MUST 触发一次后台上游刷新,同一时刻多个请求 MUST 只触发一次
+
+#### Scenario: 后台刷新失败
+- **WHEN** 乐观期后台刷新失败
+- **THEN** 已返回给客户端的响应 MUST 不受影响
+- **THEN** 缓存项 MUST 保持不变直到超期
+
+### Requirement: 超过 5 分钟强制同步刷新
+缓存项写入后超过 5 分钟 MUST 视为失效:系统 MUST 丢弃该缓存项,同步等待上游响应后再返回;上游失败时 MUST 向客户端返回错误而不是旧缓存。
+
+#### Scenario: 超期后同步等待
+- **WHEN** 缓存写入后 6 分钟收到请求
+- **THEN** 系统 MUST 等待上游响应后返回新内容
+- **THEN** 新内容 MUST 写入缓存并重新开始 1 分钟新鲜期
+
+#### Scenario: 超期后上游失败
+- **WHEN** 缓存已超期且上游请求失败
+- **THEN** 系统 MUST 返回上游错误,MUST NOT 返回旧缓存
+
+### Requirement: 条件请求基于缓存内容的 ETag
+客户端携带的 `If-None-Match` MUST 与返回给客户端的最终响应体 ETag 比较,而不是透传给上游。缓存命中且 ETag 匹配时 MUST 返回 304。
+
+#### Scenario: 缓存命中且 ETag 匹配
+- **WHEN** 客户端 `If-None-Match` 等于当前缓存内容的 ETag
+- **THEN** 系统 MUST 返回 304 且不请求上游
+
+### Requirement: 普通模型发现使用相同缓存策略
+普通固定账号模型列表的上游获取 MUST 使用本规范的同一套按账号缓存与单飞实现。完整请求 URL MUST 参与缓存键;API Key 的普通请求与 Codex 协议请求 MUST 分离。OAuth 普通列表使用规范版本获取的 manifest MUST 与相同请求条件的 Codex manifest 共享缓存。分组后处理 MUST NOT 修改缓存。
+
+#### Scenario: OAuth 两种列表共用缓存
+- **WHEN** 相同 OAuth 账号的普通列表与相同版本 Codex manifest 在新鲜期内连续请求
+- **THEN** 系统 MUST 仅发起一次上游请求,并输出各自正确的响应结构
+
+#### Scenario: API Key 两种协议分离
+- **WHEN** 相同 API Key 账号先后被用于普通列表和带 client_version 的 Codex manifest
+- **THEN** 各请求 MUST 获取并缓存自己的上游响应,MUST NOT 复用另一协议已转换的内容
diff --git a/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-pinned-accounts/spec.md b/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-pinned-accounts/spec.md
new file mode 100644
index 0000000000..c4eeb6ff40
--- /dev/null
+++ b/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-pinned-accounts/spec.md
@@ -0,0 +1,138 @@
+## Purpose
+
+让 OpenAI 分组管理员指定一组固定账号来获取普通模型列表与 Codex Model Manifest,使同一分组的 API Key 始终看到确定且合并后的模型列表,而不受调度器选账结果影响。
+
+## ADDED Requirements
+
+### Requirement: OpenAI 分组可配置固定账号获取 Codex Model Manifest
+系统 SHALL 为平台为 `openai` 的分组提供 `codex_models_manifest_config` 配置,包含 `enabled`(默认 false)、`account_ids`(账号 ID 列表)和 `fallback_to_scheduler`(默认 false)。`enabled=false` 时系统 MUST 保持现有的本地目录优先、无本地目录时调度器获取 manifest 的行为;普通列表保持本地映射/默认列表行为。
+
+#### Scenario: 默认关闭
+- **WHEN** 分组未设置或 `enabled=false`
+- **THEN** Codex Model Manifest 请求 MUST 走现有本地生成或调度器选账路径
+- **THEN** `account_ids` 与 `fallback_to_scheduler` MUST 不影响任何运行时行为
+
+#### Scenario: 非 OpenAI 平台分组
+- **WHEN** 分组平台不是 `openai` 且请求携带 `enabled=true` 的配置
+- **THEN** 系统 MUST 将该配置归一化为关闭状态后落库,而不是返回错误
+
+### Requirement: 开启时必须至少选择一个分组内的 OpenAI 账号
+当 `enabled=true` 时,管理端更新接口 MUST 校验 `account_ids` 去重后至少包含一个账号,且每个账号 MUST 是当前分组内状态为 active、平台为 `openai` 的账号;账号数量 MUST NOT 超过 10 个。校验失败 MUST 返回 400,不落库。
+
+#### Scenario: 开启但未选择账号
+- **WHEN** 管理员保存 `enabled=true` 且 `account_ids` 为空
+- **THEN** 系统 MUST 返回 400,错误码为 `INVALID_CODEX_MODELS_MANIFEST_CONFIG`
+
+#### Scenario: 选择了不属于当前分组的账号
+- **WHEN** `account_ids` 包含未绑定到该分组、已停用或平台不是 `openai` 的账号
+- **THEN** 系统 MUST 返回 400,错误信息指出无效账号 ID
+
+#### Scenario: 创建分组时开启
+- **WHEN** 创建分组请求携带 `enabled=true`
+- **THEN** 系统 MUST 返回 400,提示创建后再在编辑中配置
+
+#### Scenario: 重复账号 ID
+- **WHEN** `account_ids` 含重复 ID
+- **THEN** 系统 MUST 去重后保存,保持首次出现的顺序
+
+### Requirement: 固定账号模式只使用选定账号获取 manifest
+当 `enabled=true` 时,该分组 API Key 的 Codex Model Manifest 请求 MUST 只向选定账号发起上游请求,MUST NOT 调用调度器。选定账号的可用性判定 MUST 只考虑账号状态为 active、可调度开关打开、未因过期自动暂停;MUST 忽略优先级、负载因子、限流窗口与过载窗口。已从分组移除或已删除的账号 MUST 被跳过。
+
+#### Scenario: 限流中的选定账号仍被使用
+- **WHEN** 某个选定账号处于限流或过载窗口内
+- **THEN** 系统 MUST 仍然使用该账号请求 manifest
+
+#### Scenario: 停用的选定账号被跳过
+- **WHEN** 某个选定账号状态为 inactive 或可调度开关关闭
+- **THEN** 系统 MUST 跳过该账号,不向其发起请求
+
+#### Scenario: 选定账号已不在分组内
+- **WHEN** 配置中的账号 ID 已从分组解绑或已删除
+- **THEN** 系统 MUST 跳过该 ID,并继续处理其余账号
+
+### Requirement: 多个选定账号并发请求并合并模型列表
+系统 MUST 对所有可用的选定账号并发发起 manifest 请求,并把各账号响应的 `models` 按 `slug` 取并集:同一 slug 以配置顺序中靠前账号的条目为准;顶层其余字段取配置顺序中第一个成功账号的响应。合并结果 MUST 继续应用分组自定义模型列表过滤;别名 MUST 仅来自成功拉取账号的映射投影,并基于最终响应体计算 ETag。
+
+#### Scenario: 并集合并
+- **WHEN** 账号 1 返回模型 A、B,账号 2 返回模型 A、C
+- **THEN** 客户端 MUST 收到模型 A、B、C,且 A 的条目来自账号 1
+
+#### Scenario: 部分账号失败
+- **WHEN** 一个选定账号上游返回错误而其他账号成功
+- **THEN** 系统 MUST 以成功账号的响应合并并返回 200
+- **THEN** 系统 MUST 记录包含失败账号 ID 的警告日志
+
+#### Scenario: 分组自定义模型列表仍然生效
+- **WHEN** 分组启用了自定义 `/v1/models` 列表
+- **THEN** 合并后的 manifest MUST 只保留列表中允许的模型
+
+#### Scenario: ETag 条件请求
+- **WHEN** 客户端 `If-None-Match` 与合并后最终响应体的 ETag 匹配
+- **THEN** 系统 MUST 返回 304 且响应体为空
+
+### Requirement: 选定账号全部不可用或全部失败时按配置回退
+当没有任何可用的选定账号,或所有可用账号的上游请求全部失败时:`fallback_to_scheduler=false` 时系统 MUST 返回上游错误(全部失败时)或 503(无可用账号时);`fallback_to_scheduler=true` 时系统 MUST 回退到现有调度器选账路径。
+
+#### Scenario: 默认不回退
+- **WHEN** `fallback_to_scheduler=false` 且所有选定账号都不可用
+- **THEN** 系统 MUST 返回 503,错误类型为 `upstream_error`
+
+#### Scenario: 全部失败且不回退
+- **WHEN** `fallback_to_scheduler=false` 且所有可用选定账号的上游请求均失败
+- **THEN** 系统 MUST 返回最后一个上游错误对应的状态码与信息
+
+#### Scenario: 开启回退
+- **WHEN** `fallback_to_scheduler=true` 且所有选定账号不可用或全部失败
+- **THEN** 系统 MUST 使用调度器选择账号并按现有流程返回 manifest
+
+### Requirement: 管理端展示与编辑固定账号配置
+分组编辑对话框在平台为 `openai` 时 MUST 展示该配置:一个开关;开关打开后展示带搜索的多选账号下拉与「全部不可用时回退调度器」子开关。账号下拉 MUST 只列出当前分组内平台为 `openai` 的账号,并支持按名称搜索。分组创建对话框 MUST NOT 展示该配置。
+
+#### Scenario: 开启后未选账号即提交
+- **WHEN** 管理员打开开关但未选择任何账号并点击保存
+- **THEN** 前端 MUST 阻止提交并提示至少选择一个账号
+
+#### Scenario: 回显已保存账号
+- **WHEN** 打开一个已配置固定账号的分组编辑对话框
+- **THEN** 已选账号 MUST 以名称标签展示;无法解析名称的账号 MUST 以 `#` 展示
+
+#### Scenario: 分组复制
+- **WHEN** 管理员复制一个开启了固定账号配置的分组
+- **THEN** 新分组的该配置 MUST 为关闭且账号列表为空
+
+### Requirement: 普通模型列表复用固定账号配置
+当 OpenAI 分组开启固定账号时,普通 `/v1/models` 与 `/models` 请求 MUST 使用同一组指定账号向上游发现模型,并输出 `{object:"list",data:[...]}`。API Key 请求 MUST 使用标准模型列表端点,不添加 `client_version`;OAuth MUST 使用已有 Codex manifest 链路,将 slug 转为标准 ID。普通列表 MUST 保留上游媒体模型与未知具体模型。
+
+#### Scenario: 普通客户端发现特殊模型
+- **WHEN** 不带 `client_version` 的请求使用开启固定账号的分组,所选账号返回非内置的模型
+- **THEN** 普通列表 MUST 包含该模型且 MUST NOT 请求未选账号
+
+#### Scenario: API Key 与 OAuth 账号混合
+- **WHEN** 所选账号包含 API Key 与 OAuth
+- **THEN** 系统 MUST 按各自协议获取目录并按 ID 合并,重复 ID MUST 取配置顺序靠前账号的条目
+
+#### Scenario: 普通请求回退
+- **WHEN** 固定账号全不可用或全失败且开启回退
+- **THEN** 系统 MUST 经由调度器选账号获取普通模型目录,而不是返回本地默认列表
+
+### Requirement: 固定账号发现先于本地目录生成
+固定账号开启时,普通列表与 Codex manifest MUST 先获取指定账号目录,再按照来源账号的模型映射生成公开名称,最后应用分组列表过滤。MUST NOT 因为存在显式模型映射而跳过上游发现。透传账号 MUST 忽略残留模型映射;普通模式的具体别名仅在映射目标存在于来源账号目录时可见。MUST NOT 注入未成功拉取账号的别名或把通配模式作为模型 ID 返回。
+
+#### Scenario: 有映射的固定账号
+- **WHEN** 所选账号配置 public-name 到 upstream-name 的映射且上游返回 upstream-name
+- **THEN** 系统 MUST 请求该账号上游并以 public-name 返回该模型,Codex 条目 MUST 继承上游能力元数据
+
+#### Scenario: 固定账号开启但列表为空
+- **WHEN** 运行时配置开启但账号 ID 列表为空
+- **THEN** 系统 MUST 按无可用固定账号的失败/回退规则处理,MUST NOT 静默使用本地目录
+
+#### Scenario: 成功空目录或过滤为空
+- **WHEN** 上游返回有效空数组或分组过滤排除了全部模型
+- **THEN** 普通模型列表 MUST 返回 200 且 data 为空数组,MUST NOT 返回默认模型或触发失败回退
+
+### Requirement: 普通模型列表的条件请求
+普通固定账号列表 MUST 基于映射、合并和过滤后的最终响应体计算 ETag。
+
+#### Scenario: 普通列表 ETag 匹配
+- **WHEN** 客户端 If-None-Match 与最终普通列表响应的 ETag 匹配
+- **THEN** 系统 MUST 返回 304 且无响应体
diff --git a/openspec/changes/codex-manifest-pinned-accounts/tasks.md b/openspec/changes/codex-manifest-pinned-accounts/tasks.md
new file mode 100644
index 0000000000..a8faeb1274
--- /dev/null
+++ b/openspec/changes/codex-manifest-pinned-accounts/tasks.md
@@ -0,0 +1,57 @@
+## 1. 数据模型与持久化
+
+- [x] 1.1 在 `backend/internal/domain` 新增 `GroupCodexModelsManifestConfig{Enabled, AccountIDs, FallbackToScheduler}`,JSON 键为 `enabled`、`account_ids`、`fallback_to_scheduler`
+- [x] 1.2 在 `backend/ent/schema/group.go` 新增 JSONB 字段 `codex_models_manifest_config`(默认空结构体),执行 `go generate ./ent`
+- [x] 1.3 新增迁移 `backend/migrations/234_group_codex_models_manifest_config.sql`(`ADD COLUMN IF NOT EXISTS ... JSONB NOT NULL DEFAULT '{}'::jsonb`)
+- [x] 1.4 `service.Group` 新增 `CodexModelsManifestConfig` 字段并添加类型别名;`group_repo.go` 创建与更新两处 setter 写入该字段
+- [x] 1.5 `api_key_repo.go`:分组字段投影列表与 `groupEntityToService` 加入新字段
+- [x] 1.6 `api_key_auth_cache.go` 快照结构体与 `api_key_auth_cache_impl.go` 两处映射加入新字段
+- [x] 1.7 `admin_group_duplicate.go` 复制分组时将该配置重置为关闭且账号列表为空
+- [x] 1.8 更新 API Key 分组投影对账集成测试,覆盖新字段
+
+## 2. 管理端配置接口与校验
+
+- [x] 2.1 新增 `backend/internal/service/group_codex_models_manifest.go`:`normalizeCodexModelsManifestConfig`(非 openai 平台归零、去重保序)与 `validateCodexModelsManifestConfig`(开启时非空、≤10、全部为分组内 active 的 openai 账号),错误码 `INVALID_CODEX_MODELS_MANIFEST_CONFIG`
+- [x] 2.2 `admin_service.go` 的 `CreateGroupInput`/`UpdateGroupInput` 与 `admin_group.go` 创建、更新路径接入归一化与校验;创建路径 `enabled=true` 返回 400
+- [x] 2.3 `handler/admin/group_handler.go` 的创建与更新请求结构体、`dto/types.go` 响应结构体、`dto/mappers.go` 增加 `codex_models_manifest_config`
+- [x] 2.4 在 `admin_service_group_test.go` 增加测试:开启但空列表被拒、非成员或非 openai 账号被拒、超过 10 个被拒、非 openai 平台被归零、重复 ID 去重保序、创建时开启被拒
+
+## 3. 缓存策略统一
+
+- [x] 3.1 将 `codexModelsManifestCacheTTL` 调整为 60 秒,保持 `codexModelsManifestCacheStaleTTL` 为 5 分钟,将 `codexModelsManifestCacheMaxEntries` 提高到 512,并更新常量注释说明三段时效与容量依据
+- [x] 3.2 将 `fetchCachedAPIKeyCodexModelsManifest`/`refreshCachedAPIKeyCodexModelsManifest` 泛化为接受 `fetch` 闭包的 `fetchCachedCodexModelsManifest`/`refreshCachedCodexModelsManifest`
+- [x] 3.3 `FetchCodexModelsManifest` 的 OAuth 分支改为经缓存调用,闭包内保留 agent identity 任务恢复逻辑,错误时仍调用 `handleCodexModelsManifestAccountAuthError`
+- [x] 3.4 在 `openai_codex_models_service_test.go` 增加或调整测试:OAuth 账号新鲜期内零上游请求、乐观期返回旧值且单飞后台刷新、超期后同步等待并写回、超期上游失败返回错误、令牌变化后缓存未命中、If-None-Match 基于缓存 ETag 返回 304、同一账号被两个分组同时请求时只发一次上游请求且各分组过滤互不影响
+- [x] 3.5 检查现有测试对 30 秒 TTL 或 OAuth 不缓存的隐含假设并修正
+
+## 4. 固定账号模式运行时
+
+- [x] 4.1 新增 `backend/internal/service/openai_codex_models_pinned.go`:`mergeCodexModelsManifestBodies` 纯函数(第一个信封为基底、`models` 按 slug 并集且先出现者优先)
+- [x] 4.2 同文件实现 `FetchPinnedCodexModelsManifest(ctx, group, clientVersion)`:`ListByGroup` 取成员、按配置顺序筛选可用账号(active、Schedulable、未过期;忽略限流与过载)、errgroup 并发拉取与补全、按下标收集结果、部分失败记录 `slog.Warn`、全部不可用返回 `ErrNoPinnedCodexModelsAccounts`、全部失败返回最后一个错误、成功时设置合并体 ETag 并返回首个成功账号
+- [x] 4.3 `openai_codex_models_handler.go`:本地生成分支之后加入固定账号分支,成功时 `setOpsSelectedAccount` 后调用 `MergeGroupConfiguredCodexModels` 并写响应;按 `FallbackToScheduler` 决定回退调度器循环或返回 503 / 上游错误
+- [x] 4.4 为合并函数编写单元测试:并集与顺序、重复 slug 取靠前账号条目、信封字段来源、无 slug 条目处理
+- [x] 4.5 在 `openai_codex_models_handler_test.go` 增加测试:两个账号返回不同模型时客户端收到并集且未调用调度器、限流账号仍被使用、停用账号被跳过、不在分组的 ID 被跳过、部分失败仍 200、全部不可用时默认 503、开启回退时走调度器、自定义模型列表过滤仍生效、ETag 匹配返回 304
+
+## 5. 前端
+
+- [x] 5.1 `frontend/src/types/index.ts` 新增 `CodexModelsManifestConfig` 类型,并加入 `AdminGroup`、创建与更新请求类型
+- [x] 5.2 新增 `frontend/src/components/admin/group/CodexManifestAccountsField.vue`:开关、已选账号标签、带防抖的搜索下拉(`adminAPI.accounts.list` 按 `platform=openai` 与 `group` 过滤)、回退子开关
+- [x] 5.3 `GroupsView.vue` 编辑对话框 OpenAI 区块挂载组件;打开编辑时解析已存账号名称(失败显示 `#`);提交时开关打开且列表为空则提示并阻止;更新请求携带该字段;创建请求固定发送关闭状态
+- [x] 5.4 中英文 i18n(`overview.ts` 的 `admin.groups.codexModelsManifest.*`):标题、开关文案、启用与未启用提示、账号标签、搜索占位、回退开关文案、至少选择一个账号的错误提示
+- [x] 5.5 为新组件编写 Vitest 测试:开关切换显示、选择与移除账号、开启后空列表触发校验
+
+## 6. 验证
+
+- [x] 6.1 `cd backend && go test -tags=unit ./...` 与 `golangci-lint run ./...` 通过
+- [x] 6.2 `cd backend && go test -tags=integration ./internal/repository/...` 通过(含投影对账测试)
+- [x] 6.3 `cd frontend && pnpm test` 与类型检查通过
+- [ ] 6.4 本地启动后手动验证:编辑 OpenAI 分组开启固定账号并选择两个权限不同的账号,Codex 客户端拉取 manifest 得到并集;1 分钟内重复请求不打上游
+
+## 7. 普通模型列表扩展
+
+- [x] 7.1 泛化模型响应/缓存基础,抽出原始上游传输和 API Key 标准请求构造,OAuth 复用现有认证与缓存
+- [x] 7.2 固定账号筛选与并发获取共用,新增普通模型目录聚合及调度器回退
+- [x] 7.3 实现来源账号映射投影、分组最终过滤、空目录与 ETag;Codex 固定账号优先于本地生成
+- [x] 7.4 普通 `/v1/models` 与 `/models` 接入固定账号逻辑,中英文 UI 文案覆盖两个入口
+- [x] 7.5 添加两种上游、映射优先级、失败回退、跨分组及跨协议缓存、三段时效和 ETag 回归测试
+- [x] 7.6 完成受影响测试、race、unit/lint、前端类型与组件验证及 OpenSpec 校验,记录验证范围
diff --git a/openspec/changes/codex-manifest-pinned-accounts/verification.md b/openspec/changes/codex-manifest-pinned-accounts/verification.md
new file mode 100644
index 0000000000..6e26b14c65
--- /dev/null
+++ b/openspec/changes/codex-manifest-pinned-accounts/verification.md
@@ -0,0 +1,23 @@
+# 普通模型列表扩展验证(2026-09-05)
+
+## 已实现
+
+普通 `/v1/models` 与 `/models` 共用分组固定账号配置。API Key 使用标准模型端点;OAuth/Agent Identity 继续使用现有 Codex 上游认证链路。两种目录共享固定账号筛选、并发、部分失败处理和缓存策略;固定账号发现先于本地目录,映射、分组过滤和 ETag 在缓存之后应用。
+
+普通列表保留上游未知模型、媒体模型及模型元数据。有效空目录/过滤为空不会触发静态默认或调度器回退。无可用账号返回 503,上游失败遵守回退配置;客户端错误使用 OpenAI 错误信封。
+
+## 自动验证
+
+- `GOCACHE=/tmp/sub2api-go-cache go test -tags=unit ./... -timeout=5m`:通过,55 个含测试的包成功。
+- 最后补充响应格式缓存隔离和冷缓存 304 错误处理后,执行 `GOCACHE=/tmp/sub2api-go-cache go test -race -tags=unit -p 2 ./internal/service ./internal/handler ./internal/server/routes -run 'Test.*(OpenAIModels|CodexModels|OrdinaryPinned|PinnedModels|ProjectAccountModels)' -count=1 -timeout=3m`:三个包全部通过。
+- 最终代码执行 `GOCACHE=/tmp/sub2api-go-cache GOLANGCI_LINT_CACHE=/tmp/sub2api-models-lint-cache golangci-lint run ./... --timeout=5m`:0 issues。
+- 前端使用已安装依赖执行 `./node_modules/.bin/vitest run src/components/admin/group/__tests__/CodexManifestAccountsField.spec.ts src/views/admin/__tests__/GroupsView.duplicate.spec.ts`:2 个文件、10 个测试通过。
+- `./node_modules/.bin/vue-tsc --noEmit`:通过。
+- 两个修改的 i18n 文件 ESLint:通过。
+- `openspec validate codex-manifest-pinned-accounts --strict`、`git diff --check`:通过。
+
+回归覆盖普通两条路由(含空 client_version)、三个 Codex 入口、API Key/OAuth 混合账号、影子账号凭据解析、优先级/临时限流、停用/过期/非成员跳过、部分失败/全部失败/调度器回退、映射别名和通配规则、跨分组并发缓存隔离、OAuth 跨入口缓存复用、API Key 协议隔离、三段时效/单飞/ETag 续期、最终响应 304、空目录、媒体别名不能绕过 Codex 专用过滤。
+
+## 验证边界
+
+测试使用本地模拟上游和仓储,没有访问真实 OpenAI/ChatGPT 账号或部署服务。原任务 6.4 的真实客户端手动联调仍未勾选;本次新增 7.1—7.6 已完成。