diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue
index 25f8b1d63373..27963ec58445 100644
--- a/frontend/src/components/account/ModelWhitelistSelector.vue
+++ b/frontend/src/components/account/ModelWhitelistSelector.vue
@@ -169,17 +169,27 @@
+
+
diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts
index f01be91a566b..fc9f6ed02299 100644
--- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts
+++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts
@@ -31,6 +31,9 @@ vi.mock('vue-i18n', async () => {
if (key === 'admin.accounts.modelMappingConflict') {
return `Model mapping conflict: ${params?.from} → ${params?.to}`
}
+ if (params && key.startsWith('admin.accounts.syncUpstreamPicker.')) {
+ return `${key} ${JSON.stringify(params)}`
+ }
return params?.count ? `${key} ${params.count}` : key
}
})
@@ -85,13 +88,18 @@ function mountSelector(props: Record = {}) {
},
global: {
stubs: {
- ModelIcon: true
+ ModelIcon: true,
+ // The upstream picker is a BaseDialog: render it in place and without a transition so it can be queried.
+ Teleport: true,
+ Transition: true
}
}
})
}
-function findModelRow(wrapper: ReturnType, modelId: string) {
+type SelectorWrapper = ReturnType
+
+function findModelRow(wrapper: SelectorWrapper, modelId: string) {
const row = wrapper
.findAll('[data-testid="model-option"]')
.find(candidate => candidate.attributes('data-model-id') === modelId)
@@ -103,6 +111,75 @@ function findModelRow(wrapper: ReturnType, modelId: string
return row
}
+function findPicker(wrapper: SelectorWrapper) {
+ return wrapper.find('[data-testid="upstream-model-picker"]')
+}
+
+function pickerRows(wrapper: SelectorWrapper) {
+ return wrapper.findAll('[data-testid="upstream-picker-row"]')
+}
+
+function pickerRowIds(wrapper: SelectorWrapper) {
+ return pickerRows(wrapper).map(row => row.attributes('data-model-id'))
+}
+
+// A row's state is its checkbox's checked property; the row's data-checked has to agree with it.
+function isPickerRowChecked(row: ReturnType[number]) {
+ const checked = (row.get('[data-testid="upstream-picker-checkbox"]').element as HTMLInputElement).checked
+ expect(row.attributes('data-checked')).toBe(String(checked))
+ return checked
+}
+
+function checkedPickerRowIds(wrapper: SelectorWrapper) {
+ return pickerRows(wrapper)
+ .filter(isPickerRowChecked)
+ .map(row => row.attributes('data-model-id'))
+}
+
+function findPickerRow(wrapper: SelectorWrapper, modelId: string) {
+ const row = pickerRows(wrapper).find(candidate => candidate.attributes('data-model-id') === modelId)
+ if (!row) {
+ throw new Error(`Picker row not found: ${modelId}`)
+ }
+ return row
+}
+
+function pickerText(wrapper: SelectorWrapper, testId: string) {
+ return wrapper.get(`[data-testid="${testId}"]`).text()
+}
+
+async function openPicker(wrapper: SelectorWrapper) {
+ await wrapper.get('[data-testid="sync-upstream-models"]').trigger('click')
+ await flushPromises()
+ expect(findPicker(wrapper).exists()).toBe(true)
+}
+
+async function confirmPicker(wrapper: SelectorWrapper) {
+ await wrapper.get('[data-testid="upstream-picker-confirm"]').trigger('click')
+}
+
+async function setPickerFilter(wrapper: SelectorWrapper, filter: string) {
+ const option = wrapper
+ .findAll('[data-testid="upstream-picker-filter-option"]')
+ .find(candidate => candidate.attributes('data-filter') === filter)
+ if (!option) {
+ throw new Error(`Picker filter not found: ${filter}`)
+ }
+ await option.trigger('click')
+}
+
+// Upstream returns one model twice, one padded and a blank; the whitelist holds one of them and one it lacks.
+const pickerWhitelist = ['zz-legacy', 'kept-model']
+function pickerFixture(): SyncUpstreamModelsResult {
+ return {
+ models: [' model-10 ', 'model-2', 'kept-model', '', 'model-2'],
+ metadata: {
+ 'model-2': { id: 'model-2', display_name: 'Model Two', context_window: 200_000, max_output_tokens: 64_000 },
+ 'kept-model': { id: 'kept-model', display_name: 'kept-model', context_window: 1_050_000 }
+ }
+ }
+}
+
describe('ModelWhitelistSelector', () => {
beforeEach(() => {
copyToClipboard.mockClear()
@@ -203,7 +280,7 @@ describe('ModelWhitelistSelector', () => {
wrapper.unmount()
})
- it('calls the saved-account sync once and adds each new exact ID while preserving manual IDs', async () => {
+ it('calls the saved-account sync once and applies the picked exact IDs, manual ones kept when checked, on confirm', async () => {
let resolve!: (result: SyncUpstreamModelsResult) => void
syncUpstreamModels.mockReturnValue(new Promise(result => { resolve = result }))
const result: SyncUpstreamModelsResult = {
@@ -218,13 +295,27 @@ describe('ModelWhitelistSelector', () => {
expect(syncUpstreamModels).toHaveBeenCalledOnce()
expect(syncUpstreamModels).toHaveBeenCalledWith(46)
expect(syncUpstreamModelsPreview).not.toHaveBeenCalled()
+ expect(findPicker(wrapper).exists()).toBe(false)
resolve(result)
await flushPromises()
+ // The sync opens the picker; neither the whitelist nor the synced result reaches the form before confirm.
+ expect(pickerRowIds(wrapper)).toEqual(['manual-model', 'New.Exact-ID', 'user-only-model'])
+ expect(wrapper.emitted('upstream-synced')).toBeUndefined()
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ // The search ignores case on the ID itself: this mixed-case ID has no display name.
+ await wrapper.get('[data-testid="upstream-picker-search"]').setValue('new.exact-id')
+ expect(pickerRowIds(wrapper)).toEqual(['New.Exact-ID'])
+ await wrapper.get('[data-testid="upstream-picker-search"]').setValue('')
+
+ await findPickerRow(wrapper, 'user-only-model').trigger('click')
+ await confirmPicker(wrapper)
+
expect(wrapper.emitted('upstream-synced')).toEqual([[result]])
expect(wrapper.emitted('update:modelValue')).toEqual([[['manual-model', 'user-only-model', 'New.Exact-ID']]])
expect(wrapper.emitted('update:capacityDrafts')).toBeUndefined()
+ expect(findPicker(wrapper).exists()).toBe(false)
wrapper.unmount()
})
@@ -237,12 +328,14 @@ describe('ModelWhitelistSelector', () => {
})
expect(wrapper.find('[data-testid="context-capacity-input"]').exists()).toBe(false)
- await wrapper.get('[data-testid="sync-upstream-models"]').trigger('click')
- await flushPromises()
+ await openPicker(wrapper)
expect(syncUpstreamModels).toHaveBeenCalledOnce()
expect(syncUpstreamModels).toHaveBeenCalledWith(91)
- expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-5.6-sol', 'oauth-model']]])
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ // The whitelist entry the upstream did not return starts unchecked, so confirming as offered drops it.
+ await confirmPicker(wrapper)
+ expect(wrapper.emitted('update:modelValue')).toEqual([[['oauth-model']]])
wrapper.unmount()
})
@@ -350,7 +443,7 @@ describe('ModelWhitelistSelector', () => {
expect(wrapper.emitted('capacity-validity')?.at(-1)).toEqual([true])
})
- it('warns when model IDs sync but capability metadata is incomplete', async () => {
+ it('shows incomplete capability metadata inside the picker instead of a toast', async () => {
syncUpstreamModels.mockResolvedValue({
models: ['x-preview-f-free'],
warnings: [{
@@ -366,12 +459,18 @@ describe('ModelWhitelistSelector', () => {
await syncButton!.trigger('click')
await flushPromises()
+ expect(pickerText(wrapper, 'upstream-picker-metadata-notice')).toBe('admin.accounts.syncUpstreamModelsMetadataIncomplete')
+ expect(showWarning).not.toHaveBeenCalled()
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+
+ await confirmPicker(wrapper)
expect(wrapper.emitted('update:modelValue')).toEqual([[['x-preview-f-free']]])
- expect(showWarning).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsMetadataIncomplete')
- expect(showSuccess).not.toHaveBeenCalled()
+ expect(showSuccess).toHaveBeenCalledWith('admin.accounts.syncUpstreamPicker.updated {"added":1,"removed":0}')
+ expect(showWarning).not.toHaveBeenCalled()
+ wrapper.unmount()
})
- it('shows success and a partial warning when some capabilities were saved', async () => {
+ it('shows partial capability metadata inside the picker and the whitelist change on confirm', async () => {
syncUpstreamModels.mockResolvedValue({
models: ['gpt-6-astra', 'gpt-image-2'],
warnings: [
@@ -381,18 +480,7 @@ describe('ModelWhitelistSelector', () => {
}
]
})
- const wrapper = mount(ModelWhitelistSelector, {
- props: {
- modelValue: [],
- platform: 'openai',
- accountId: 46
- },
- global: {
- stubs: {
- ModelIcon: true
- }
- }
- })
+ const wrapper = mountSelector({ accountId: 46 })
const syncButton = wrapper
.findAll('button')
@@ -401,9 +489,26 @@ describe('ModelWhitelistSelector', () => {
await syncButton!.trigger('click')
await flushPromises()
+ expect(pickerText(wrapper, 'upstream-picker-metadata-notice')).toBe('admin.accounts.syncUpstreamModelsMetadataPartial')
+ await confirmPicker(wrapper)
expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-6-astra', 'gpt-image-2']]])
- expect(showSuccess).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsSuccess 2')
- expect(showWarning).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsMetadataPartial')
+ expect(showSuccess).toHaveBeenCalledWith('admin.accounts.syncUpstreamPicker.updated {"added":2,"removed":0}')
+ expect(showWarning).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+
+ it('reports an upstream list of blank IDs without opening the picker or touching the whitelist', async () => {
+ const result: SyncUpstreamModelsResult = { models: ['', ' '] }
+ syncUpstreamModels.mockResolvedValue(result)
+ const wrapper = mountSelector({ modelValue: ['kept-model'], accountId: 7 })
+ await wrapper.get('[data-testid="sync-upstream-models"]').trigger('click')
+ await flushPromises()
+
+ expect(findPicker(wrapper).exists()).toBe(false)
+ expect(showInfo).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsEmpty')
+ expect(wrapper.emitted('upstream-synced')).toEqual([[result]])
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ wrapper.unmount()
})
it('reports a successful preview so account creation can persist metadata', async () => {
@@ -433,6 +538,8 @@ describe('ModelWhitelistSelector', () => {
await flushPromises()
expect(syncUpstreamModelsPreview).toHaveBeenCalledOnce()
+ expect(wrapper.emitted('upstream-synced')).toBeUndefined()
+ await confirmPicker(wrapper)
expect(wrapper.emitted('upstream-synced')).toEqual([[{
models: ['x-preview-f-free'],
metadata: { 'x-preview-f-free': { id: 'x-preview-f-free', reasoning: true, supported_reasoning_levels: ['low', 'high', 'max'] } }
@@ -452,6 +559,7 @@ describe('ModelWhitelistSelector', () => {
const wrapper = mountSelector({ accountId: 46 })
await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.syncUpstreamModels')!.trigger('click')
await flushPromises()
+ await confirmPicker(wrapper)
expect(wrapper.emitted('upstream-synced')).toEqual([[result]])
wrapper.unmount()
@@ -467,6 +575,7 @@ describe('ModelWhitelistSelector', () => {
const wrapper = mountSelector({ syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://a.example/v1', api_key: 'key-a' } })
await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.syncUpstreamModels')!.trigger('click')
await flushPromises()
+ await confirmPicker(wrapper)
await wrapper.get('div.cursor-pointer').trigger('click')
expect(wrapper.text()).toContain('provider-a-only')
await wrapper.setProps({ syncedModels: undefined, syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://b.example/v1', api_key: 'key-b' } })
@@ -482,6 +591,7 @@ describe('ModelWhitelistSelector', () => {
await wrapper.setProps({ syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://b.example/v1', api_key: 'key-b' } })
resolve({ models: ['stale-provider-a-model'] })
await flushPromises()
+ expect(findPicker(wrapper).exists()).toBe(false)
expect(wrapper.emitted('upstream-synced')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(showSuccess).not.toHaveBeenCalled()
@@ -509,6 +619,7 @@ describe('ModelWhitelistSelector', () => {
await flushPromises()
expect(syncUpstreamModels).toHaveBeenCalledOnce()
expect(syncUpstreamModels).toHaveBeenCalledWith(42)
+ expect(findPicker(wrapper).exists()).toBe(false)
expect(wrapper.emitted('upstream-synced')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(showSuccess).not.toHaveBeenCalled()
@@ -544,12 +655,329 @@ describe('ModelWhitelistSelector', () => {
const result = { models: ['new-model'], capacity_rows: [capacityRow()] }
resolve(result)
await flushPromises()
+ expect(pickerRowIds(wrapper)).toEqual(['new-model'])
+ await confirmPicker(wrapper)
expect(wrapper.emitted('upstream-synced')).toEqual([[result]])
expect(wrapper.emitted('update:modelValue')).toEqual([[['new-model']]])
expect(wrapper.props('capacityDrafts')).toEqual({ 'gpt-5.6-sol': '1M' })
wrapper.unmount()
})
+ it('opens the picker sorted by ID with upstream models checked and whitelist-only entries unchecked', async () => {
+ syncUpstreamModels.mockResolvedValue(pickerFixture())
+ const wrapper = mountSelector({ modelValue: pickerWhitelist, accountId: 7 })
+ await openPicker(wrapper)
+
+ expect(pickerRowIds(wrapper)).toEqual(['kept-model', 'model-2', 'model-10', 'zz-legacy'])
+ expect(checkedPickerRowIds(wrapper)).toEqual(['kept-model', 'model-2', 'model-10'])
+ const tags = (modelId: string) => findPickerRow(wrapper, modelId).findAll('.badge').map(tag => tag.attributes('data-testid'))
+ expect(tags('kept-model')).toEqual([])
+ expect(tags('model-2')).toEqual(['upstream-picker-tag-new'])
+ expect(tags('model-10')).toEqual(['upstream-picker-tag-new'])
+ expect(tags('zz-legacy')).toEqual(['upstream-picker-tag-missing'])
+ expect(findPickerRow(wrapper, 'model-2').get('[data-testid="upstream-picker-details"]').text()).toBe(
+ 'Model Two · admin.accounts.syncUpstreamPicker.contextWindow {"value":"200K"} · admin.accounts.syncUpstreamPicker.maxOutput {"value":"64K"}'
+ )
+ // A display name equal to the ID is not repeated.
+ expect(findPickerRow(wrapper, 'kept-model').get('[data-testid="upstream-picker-details"]').text()).toBe(
+ 'admin.accounts.syncUpstreamPicker.contextWindow {"value":"1.05M"}'
+ )
+ expect(findPickerRow(wrapper, 'zz-legacy').find('[data-testid="upstream-picker-details"]').exists()).toBe(false)
+ // Each checkbox is named by the ID and described by its row's tag and capability line.
+ const description = (modelId: string) => (findPickerRow(wrapper, modelId)
+ .get('[data-testid="upstream-picker-checkbox"]').attributes('aria-describedby') ?? '')
+ .split(' ').filter(Boolean).map(id => wrapper.get(`[id="${id}"]`).text())
+ expect(description('model-2')).toEqual([
+ 'admin.accounts.syncUpstreamPicker.tags.new',
+ 'Model Two · admin.accounts.syncUpstreamPicker.contextWindow {"value":"200K"} · admin.accounts.syncUpstreamPicker.maxOutput {"value":"64K"}'
+ ])
+ expect(description('kept-model')).toEqual(['admin.accounts.syncUpstreamPicker.contextWindow {"value":"1.05M"}'])
+ expect(description('zz-legacy')).toEqual(['admin.accounts.syncUpstreamPicker.tags.missing'])
+ expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":3,"total":4}')
+ expect(pickerText(wrapper, 'upstream-picker-summary')).toBe('admin.accounts.syncUpstreamPicker.summary {"added":2,"removed":1}')
+ expect(wrapper.find('[data-testid="upstream-picker-configured-notice"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="upstream-picker-metadata-notice"]').exists()).toBe(false)
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ expect(wrapper.emitted('upstream-synced')).toBeUndefined()
+ expect(showSuccess).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+
+ it('checks every row with 全选 whatever the search and the status filter show', async () => {
+ syncUpstreamModels.mockResolvedValue(pickerFixture())
+ const wrapper = mountSelector({ modelValue: pickerWhitelist, accountId: 7 })
+ await openPicker(wrapper)
+ await setPickerFilter(wrapper, 'new')
+ await wrapper.get('[data-testid="upstream-picker-search"]').setValue('10')
+ expect(pickerRowIds(wrapper)).toEqual(['model-10'])
+
+ await wrapper.get('[data-testid="upstream-picker-select-all"]').trigger('click')
+
+ expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":4,"total":4}')
+ await wrapper.get('[data-testid="upstream-picker-search"]').setValue('')
+ await setPickerFilter(wrapper, 'all')
+ expect(checkedPickerRowIds(wrapper)).toEqual(['kept-model', 'model-2', 'model-10', 'zz-legacy'])
+ wrapper.unmount()
+ })
+
+ it('inverts every row with 反选, including the rows the search hides', async () => {
+ syncUpstreamModels.mockResolvedValue(pickerFixture())
+ const wrapper = mountSelector({ modelValue: pickerWhitelist, accountId: 7 })
+ await openPicker(wrapper)
+ await wrapper.get('[data-testid="upstream-picker-search"]').setValue('model-2')
+ expect(pickerRowIds(wrapper)).toEqual(['model-2'])
+
+ await wrapper.get('[data-testid="upstream-picker-invert"]').trigger('click')
+
+ expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":1,"total":4}')
+ await wrapper.get('[data-testid="upstream-picker-search"]').setValue('')
+ expect(checkedPickerRowIds(wrapper)).toEqual(['zz-legacy'])
+ await wrapper.get('[data-testid="upstream-picker-invert"]').trigger('click')
+ expect(checkedPickerRowIds(wrapper)).toEqual(['kept-model', 'model-2', 'model-10'])
+ wrapper.unmount()
+ })
+
+ it('renders a long list in a window while the counter, 全选, 反选 and confirm cover every row', async () => {
+ const models = Array.from({ length: 300 }, (_, index) => `bulk-model-${String(index).padStart(3, '0')}`)
+ syncUpstreamModels.mockResolvedValue({ models })
+ const wrapper = mountSelector({ accountId: 7 })
+ await openPicker(wrapper)
+
+ // Only the first rows of the sorted list are in the DOM.
+ const rendered = pickerRowIds(wrapper)
+ expect(rendered.length).toBeGreaterThan(0)
+ expect(rendered.length).toBeLessThan(models.length)
+ expect(rendered).toEqual(models.slice(0, rendered.length))
+ expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":300,"total":300}')
+
+ await wrapper.get('[data-testid="upstream-picker-invert"]').trigger('click')
+ expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":0,"total":300}')
+ expect(checkedPickerRowIds(wrapper)).toEqual([])
+ expect(wrapper.get('[data-testid="upstream-picker-confirm"]').attributes('disabled')).toBeDefined()
+
+ await wrapper.get('[data-testid="upstream-picker-select-all"]').trigger('click')
+ expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":300,"total":300}')
+ await confirmPicker(wrapper)
+ expect(wrapper.emitted('update:modelValue')).toEqual([[models]])
+ wrapper.unmount()
+ })
+
+ it('keeps a windowed list windowed when a toggle takes it to the threshold, until the next new list', async () => {
+ const models = Array.from({ length: 101 }, (_, index) => `edge-model-${String(index).padStart(3, '0')}`)
+ syncUpstreamModels.mockResolvedValue({ models })
+ const wrapper = mountSelector({ accountId: 7 })
+ await openPicker(wrapper)
+ await setPickerFilter(wrapper, 'checked')
+ expect(pickerRowIds(wrapper).length).toBeLessThan(100)
+
+ // 101 -> 100 rows: the rows around the gap keep their windowed layout.
+ await findPickerRow(wrapper, 'edge-model-000').trigger('click')
+ await flushPromises()
+ expect(pickerRowIds(wrapper)).not.toContain('edge-model-000')
+ expect(pickerRowIds(wrapper).length).toBeLessThan(100)
+
+ // A new list of 100 rows renders in full.
+ await setPickerFilter(wrapper, 'unchecked')
+ await setPickerFilter(wrapper, 'checked')
+ expect(pickerRowIds(wrapper)).toEqual(models.slice(1))
+ wrapper.unmount()
+ })
+
+ it('combines the status filter with a case-insensitive search on the ID and the display name', async () => {
+ syncUpstreamModels.mockResolvedValue(pickerFixture())
+ const wrapper = mountSelector({ modelValue: pickerWhitelist, accountId: 7 })
+ await openPicker(wrapper)
+ expect(wrapper.findAll('[data-testid="upstream-picker-filter-option"]').map(option => option.text())).toEqual([
+ 'admin.accounts.syncUpstreamPicker.filters.all 4',
+ 'admin.accounts.syncUpstreamPicker.filters.checked 3',
+ 'admin.accounts.syncUpstreamPicker.filters.unchecked 1',
+ 'admin.accounts.syncUpstreamPicker.filters.new 2',
+ 'admin.accounts.syncUpstreamPicker.filters.missing 1'
+ ])
+
+ await setPickerFilter(wrapper, 'new')
+ expect(pickerRowIds(wrapper)).toEqual(['model-2', 'model-10'])
+ await wrapper.get('[data-testid="upstream-picker-search"]').setValue('TWO')
+ expect(pickerRowIds(wrapper)).toEqual(['model-2'])
+ await setPickerFilter(wrapper, 'missing')
+ expect(pickerRowIds(wrapper)).toEqual([])
+ expect(pickerText(wrapper, 'upstream-picker-empty')).toBe('admin.accounts.noMatchingModels')
+ await wrapper.get('[data-testid="upstream-picker-search"]').setValue('')
+ expect(pickerRowIds(wrapper)).toEqual(['zz-legacy'])
+
+ await setPickerFilter(wrapper, 'checked')
+ expect(pickerRowIds(wrapper)).toEqual(['kept-model', 'model-2', 'model-10'])
+ await findPickerRow(wrapper, 'model-2').trigger('click')
+ expect(pickerRowIds(wrapper)).toEqual(['kept-model', 'model-10'])
+ await setPickerFilter(wrapper, 'unchecked')
+ expect(pickerRowIds(wrapper)).toEqual(['model-2', 'zz-legacy'])
+ expect(wrapper.findAll('[data-testid="upstream-picker-filter-option"]').map(option => option.attributes('aria-checked')))
+ .toEqual(['false', 'false', 'true', 'false', 'false'])
+ await wrapper.get('[data-testid="upstream-picker-filter"]').trigger('keydown', { key: 'ArrowLeft' })
+ expect(pickerRowIds(wrapper)).toEqual(['kept-model', 'model-10'])
+
+ // Copying an ID does not toggle its row.
+ await findPickerRow(wrapper, 'kept-model').get('[data-testid="upstream-picker-copy"]').trigger('click')
+ expect(copyToClipboard).toHaveBeenCalledWith('kept-model')
+ expect(isPickerRowChecked(findPickerRow(wrapper, 'kept-model'))).toBe(true)
+ wrapper.unmount()
+ })
+
+ it('moves the focus to the row taking the place of a toggled row that leaves the filtered list', async () => {
+ syncUpstreamModels.mockResolvedValue(pickerFixture())
+ const wrapper = mount(ModelWhitelistSelector, {
+ props: { modelValue: pickerWhitelist, platform: 'openai', accountId: 7 },
+ attachTo: document.body,
+ global: { stubs: { ModelIcon: true, Teleport: true, Transition: true } }
+ })
+ await openPicker(wrapper)
+ await setPickerFilter(wrapper, 'checked')
+ const checkbox = (modelId: string) => findPickerRow(wrapper, modelId).get('[data-testid="upstream-picker-checkbox"]')
+ const toggleFocused = async (modelId: string) => {
+ const box = checkbox(modelId)
+ ;(box.element as HTMLInputElement).focus()
+ await box.trigger('click')
+ await flushPromises()
+ }
+
+ await toggleFocused('model-2')
+ expect(pickerRowIds(wrapper)).toEqual(['kept-model', 'model-10'])
+ expect(document.activeElement).toBe(checkbox('model-10').element)
+ // The last row hands the focus back to the row before it.
+ await toggleFocused('model-10')
+ expect(document.activeElement).toBe(checkbox('kept-model').element)
+ // With no row left, the search takes it.
+ await toggleFocused('kept-model')
+ expect(pickerRowIds(wrapper)).toEqual([])
+ expect(document.activeElement).toBe(wrapper.get('[data-testid="upstream-picker-search"]').element)
+ wrapper.unmount()
+ })
+
+ it('confirms whitelist entries in their own order, then newly checked models in list order', async () => {
+ const result: SyncUpstreamModelsResult = { models: ['zeta-kept', 'beta-new', 'alpha-kept', 'alpha-new'] }
+ syncUpstreamModels.mockResolvedValue(result)
+ const wrapper = mountSelector({ modelValue: ['zeta-kept', 'alpha-kept', 'gone-model'], accountId: 7 })
+ await openPicker(wrapper)
+ expect(pickerRowIds(wrapper)).toEqual(['alpha-kept', 'alpha-new', 'beta-new', 'gone-model', 'zeta-kept'])
+
+ await findPickerRow(wrapper, 'alpha-kept').trigger('click')
+ const betaCheckbox = findPickerRow(wrapper, 'beta-new').get('[data-testid="upstream-picker-checkbox"]')
+ await betaCheckbox.trigger('click')
+ expect(isPickerRowChecked(findPickerRow(wrapper, 'beta-new'))).toBe(false)
+ await betaCheckbox.trigger('click')
+ expect(pickerText(wrapper, 'upstream-picker-summary')).toBe('admin.accounts.syncUpstreamPicker.summary {"added":2,"removed":2}')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+
+ await confirmPicker(wrapper)
+
+ expect(wrapper.emitted('update:modelValue')).toEqual([[['zeta-kept', 'alpha-new', 'beta-new']]])
+ expect(showSuccess).toHaveBeenCalledWith('admin.accounts.syncUpstreamPicker.updated {"added":2,"removed":2}')
+ expect(showInfo).not.toHaveBeenCalled()
+ expect(wrapper.emitted('upstream-synced')).toEqual([[result]])
+ expect(findPicker(wrapper).exists()).toBe(false)
+ // A confirmed result keeps its models available as candidates.
+ await wrapper.get('[data-testid="model-selector-toggle"]').trigger('click')
+ expect(findModelRow(wrapper, 'beta-new').exists()).toBe(true)
+ wrapper.unmount()
+ })
+
+ it('keeps Confirm disabled with a hint while no model is checked', async () => {
+ syncUpstreamModels.mockResolvedValue(pickerFixture())
+ const wrapper = mountSelector({ modelValue: pickerWhitelist, accountId: 7 })
+ await openPicker(wrapper)
+ const confirmButton = wrapper.get('[data-testid="upstream-picker-confirm"]')
+ expect(confirmButton.attributes('disabled')).toBeUndefined()
+ expect(wrapper.find('[data-testid="upstream-picker-empty-hint"]').exists()).toBe(false)
+
+ await wrapper.get('[data-testid="upstream-picker-select-all"]').trigger('click')
+ await wrapper.get('[data-testid="upstream-picker-invert"]').trigger('click')
+
+ expect(checkedPickerRowIds(wrapper)).toEqual([])
+ expect(confirmButton.attributes('disabled')).toBeDefined()
+ expect(pickerText(wrapper, 'upstream-picker-empty-hint')).toBe('admin.accounts.syncUpstreamPicker.emptySelectionHint')
+ await confirmButton.trigger('click')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ expect(findPicker(wrapper).exists()).toBe(true)
+
+ await findPickerRow(wrapper, 'zz-legacy').trigger('click')
+ expect(confirmButton.attributes('disabled')).toBeUndefined()
+ expect(wrapper.find('[data-testid="upstream-picker-empty-hint"]').exists()).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('cancels from the button, the header close or Escape without touching the whitelist or the candidates', async () => {
+ syncUpstreamModels.mockImplementation(async () => ({ models: ['upstream-only-model', 'gpt-5.6-sol'] }))
+ const wrapper = mountSelector({ modelValue: ['gpt-5.6-sol'], accountId: 7 })
+
+ await openPicker(wrapper)
+ await findPickerRow(wrapper, 'gpt-5.6-sol').trigger('click')
+ await wrapper.get('[data-testid="upstream-picker-search"]').setValue('upstream')
+ await wrapper.get('[data-testid="upstream-picker-cancel"]').trigger('click')
+ expect(findPicker(wrapper).exists()).toBe(false)
+
+ // Every open starts again from the result and the current whitelist.
+ await openPicker(wrapper)
+ expect((wrapper.get('[data-testid="upstream-picker-search"]').element as HTMLInputElement).value).toBe('')
+ expect(checkedPickerRowIds(wrapper)).toEqual(['gpt-5.6-sol', 'upstream-only-model'])
+ await wrapper.get('button[aria-label="Close modal"]').trigger('click')
+ expect(findPicker(wrapper).exists()).toBe(false)
+
+ await openPicker(wrapper)
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
+ await flushPromises()
+ expect(findPicker(wrapper).exists()).toBe(false)
+
+ expect(syncUpstreamModels).toHaveBeenCalledTimes(3)
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ expect(wrapper.emitted('upstream-synced')).toBeUndefined()
+ expect(showSuccess).not.toHaveBeenCalled()
+ expect(showInfo).not.toHaveBeenCalled()
+ await wrapper.get('[data-testid="model-selector-toggle"]').trigger('click')
+ expect(wrapper.find('[data-testid="model-option"][data-model-id="upstream-only-model"]').exists()).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('explains a configured model list and reports an unchanged whitelist on confirm', async () => {
+ syncUpstreamModelsPreview.mockResolvedValue({ models: ['b-model', 'a-model'], model_list_source: 'configured' })
+ const wrapper = mountSelector({
+ modelValue: ['b-model', 'a-model'],
+ syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://a.example/v1', api_key: 'key-a' }
+ })
+ await openPicker(wrapper)
+
+ expect(pickerText(wrapper, 'upstream-picker-configured-notice')).toBe('admin.accounts.syncUpstreamPicker.configuredSource')
+ expect(wrapper.findAll('.badge')).toHaveLength(0)
+ await confirmPicker(wrapper)
+ expect(wrapper.emitted('update:modelValue')).toEqual([[['b-model', 'a-model']]])
+ expect(showInfo).toHaveBeenCalledWith('admin.accounts.syncUpstreamPicker.unchanged')
+ expect(showSuccess).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+
+ it('closes an open picker unapplied when the sync source changes, and never submits a form', async () => {
+ syncUpstreamModelsPreview.mockResolvedValue({ models: ['provider-a-only'] })
+ const wrapper = mountSelector({ syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://a.example/v1', api_key: 'key-a' } })
+ await openPicker(wrapper)
+
+ const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
+ wrapper.get('[data-testid="upstream-picker-search"]').element.dispatchEvent(enter)
+ await flushPromises()
+ expect(enter.defaultPrevented).toBe(true)
+ expect(findPicker(wrapper).exists()).toBe(true)
+ const buttons = [
+ ...findPicker(wrapper).findAll('button'),
+ ...wrapper.get('[data-testid="upstream-picker-footer"]').findAll('button')
+ ]
+ expect(buttons.length).toBeGreaterThan(0)
+ expect(buttons.filter(button => button.attributes('type') !== 'button')).toEqual([])
+
+ await wrapper.setProps({ syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://b.example/v1', api_key: 'key-b' } })
+ expect(findPicker(wrapper).exists()).toBe(false)
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ expect(wrapper.emitted('upstream-synced')).toBeUndefined()
+ wrapper.unmount()
+ })
+
it('shows the upstream sync button for OpenCode Go create-account credentials', () => {
const wrapper = mountSelector({
platform: 'opencode_go',
diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts
index 9ccf16e494ff..8bfb858f564c 100644
--- a/frontend/src/i18n/locales/en/admin/accounts.ts
+++ b/frontend/src/i18n/locales/en/admin/accounts.ts
@@ -1072,6 +1072,33 @@ export default {accounts: {
'Model IDs were synced, but no capability metadata could be updated.',
syncUpstreamModelsMetadataPartial:
'Some model capabilities were updated; remaining models are still incomplete.',
+ syncUpstreamPicker: {
+ title: 'Choose whitelist models',
+ searchPlaceholder: 'Search model ID or name',
+ selectAll: 'Select all',
+ invert: 'Invert',
+ filterLabel: 'Filter by status',
+ filters: {
+ all: 'All',
+ checked: 'Checked',
+ unchecked: 'Unchecked',
+ new: 'New',
+ missing: 'Not returned by upstream'
+ },
+ tags: {
+ new: 'New',
+ missing: 'Not returned by upstream'
+ },
+ selectedCount: '{selected} of {total} selected',
+ contextWindow: 'Context {value}',
+ maxOutput: 'Max output {value}',
+ summary: 'Will add {added} and remove {removed}',
+ emptySelectionHint: 'Check at least one model; an empty whitelist allows all models',
+ configuredSource:
+ 'The upstream cannot list models; these are the models currently configured for this account',
+ updated: 'Whitelist updated: {added} added, {removed} removed',
+ unchanged: 'Whitelist unchanged'
+ },
clearAllModels: 'Clear all models',
customModelName: 'Custom model name',
enterCustomModelName: 'Enter custom model name',
diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts
index 59e49b62909e..5610c70a3764 100644
--- a/frontend/src/i18n/locales/zh/admin/accounts.ts
+++ b/frontend/src/i18n/locales/zh/admin/accounts.ts
@@ -1182,6 +1182,32 @@ export default {accounts: {
syncUpstreamModelsError: '同步上游模型失败:{message}',
syncUpstreamModelsMetadataIncomplete: '模型 ID 已同步,但未能更新任何能力元数据。',
syncUpstreamModelsMetadataPartial: '已更新部分模型的能力元数据;其余模型能力仍不完整。',
+ syncUpstreamPicker: {
+ title: '选择白名单模型',
+ searchPlaceholder: '搜索模型 ID 或名称',
+ selectAll: '全选',
+ invert: '反选',
+ filterLabel: '按状态筛选',
+ filters: {
+ all: '全部',
+ checked: '已勾选',
+ unchecked: '未勾选',
+ new: '新增',
+ missing: '上游未返回'
+ },
+ tags: {
+ new: '新增',
+ missing: '上游未返回'
+ },
+ selectedCount: '已选 {selected} / 共 {total}',
+ contextWindow: '上下文 {value}',
+ maxOutput: '最大输出 {value}',
+ summary: '将新增 {added} 个,移除 {removed} 个',
+ emptySelectionHint: '至少勾选一个模型;白名单为空等于不限制模型',
+ configuredSource: '上游不支持列出模型,以下为账号当前已配置的模型',
+ updated: '白名单已更新:新增 {added} 个,移除 {removed} 个',
+ unchanged: '白名单没有变化'
+ },
clearAllModels: '清除所有模型',
customModelName: '自定义模型名称',
enterCustomModelName: '输入自定义模型名称',
diff --git a/frontend/src/styles/console/pages/accounts.css b/frontend/src/styles/console/pages/accounts.css
index 8cac19876c2b..1a73be87285b 100644
--- a/frontend/src/styles/console/pages/accounts.css
+++ b/frontend/src/styles/console/pages/accounts.css
@@ -30,6 +30,11 @@
* Wave 2 (dialogs, section 9; one attribute each):
* admin/account/AccountStatsModal account-stats admin/account/AccountTestModal account-test
* account/OAuthAuthorizationFlow account-oauth-flow account/CreateAccountModal account-create-steps
+ * The upstream model picker (section 9.13; static hooks, a row's state is its data-checked, a filter's aria-checked):
+ * account/UpstreamModelPickerDialog (new) upstream-picker-list (the scroll area: the rows or the empty message),
+ * upstream-picker-row, upstream-picker-id, upstream-picker-details,
+ * upstream-picker-copy; its toolbar uses the shared search-box and seg
+ * hooks (controls.css 3.2, 7.1)
* Card grid, compact list, the drawer's content, the account forms (#create-account-form, #edit-account-form,
* #bulk-edit-account-form) and the taxonomy manager rows are reached through their existing ids and data-test
* attributes. The system prompt binding inside the account dialogs is P3's (settings-ops-public.css). */
@@ -1381,6 +1386,74 @@ html.flat-theme #edit-account-form div[class~="bg-sky-50/60"] > div:last-child b
color: rgb(var(--ui-on-primary));
}
+/* 9.13 The upstream model picker (UpstreamModelPickerDialog, stacked on the create and edit dialogs by the model
+ whitelist's 「同步上游支持的模型」). Shared parts draw the rest: the search field and the status filter (controls.css
+ 3.2, 7.1: the grey track, the current segment the raised pill), the .btn recipes, the native checkboxes
+ (controls.css 6), the 新增 / 上游未返回 pills (data.css 6) and the two notices (data.css 11 callouts; the metadata
+ warning keeps its status colour). Here the list: a structural box (1px line, radius 8, the surface) that keeps the
+ height it opened with (set inline: the whole list's, at most 50vh), so fewer rows and the empty message sit inside
+ it; past 100 rows it holds only the rows near view between two aria-hidden spacer items. Its rows sit on the line
+ at 7 / 12 with the hover fill; the model ID at 13 / 500 ink, wrapping anywhere (nothing is cut); the capability
+ line 11px muted with tabular figures; the copy button the table's 28px icon button (muted, the subtle fill and ink
+ on hover). No state is drawn: the checkbox carries it. */
+html.flat-theme [data-ui~="upstream-picker-list"] {
+ border-color: rgb(var(--ui-line));
+ border-radius: var(--ui-radius-lg);
+ background-color: rgb(var(--ui-surface));
+}
+
+html.flat-theme [data-ui~="upstream-picker-row"] {
+ gap: 10px;
+ padding: 7px 12px;
+ border-color: rgb(var(--ui-line));
+ transition: background-color var(--ui-duration) var(--ui-ease);
+}
+
+html.flat-theme [data-ui~="upstream-picker-row"]:hover {
+ background-color: rgb(var(--ui-hover));
+}
+
+html.flat-theme [data-ui~="upstream-picker-id"] {
+ font-size: 13px;
+ line-height: 19.5px;
+ font-weight: 500;
+ color: rgb(var(--ui-ink));
+}
+
+html.flat-theme [data-ui~="upstream-picker-details"] {
+ font-size: 11px;
+ line-height: 16.5px;
+ font-variant-numeric: tabular-nums;
+ color: rgb(var(--ui-muted));
+}
+
+html.flat-theme [data-ui~="upstream-picker-copy"] {
+ display: inline-grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border-radius: var(--ui-radius-md);
+ color: rgb(var(--ui-muted));
+ transition: color var(--ui-duration) var(--ui-ease), background-color var(--ui-duration) var(--ui-ease);
+}
+
+html.flat-theme [data-ui~="upstream-picker-copy"]:hover {
+ background-color: rgb(var(--ui-subtle));
+ color: rgb(var(--ui-ink));
+}
+
+html.flat-theme [data-ui~="upstream-picker-copy"]:focus-visible {
+ outline: 2px solid rgb(var(--ui-focus));
+ outline-offset: 2px;
+ box-shadow: none;
+}
+
+html.flat-theme [data-ui~="upstream-picker-copy"] svg {
+ width: 15px;
+ height: 15px;
+}
+
/* ================================================================== 8 · phones */
@media (max-width: 639.98px) {
/* The reference's 390px toolbar: the search takes the whole first row. */