diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 42c858e..223fdb7 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -209,6 +209,7 @@ export type ProviderDraft = { baseUrl: string; priority: string; models: ModelOption[]; + modelSelectionCatalog?: ModelOption[]; prefix?: string; headersText?: string; excludedModelsText?: string; @@ -507,7 +508,7 @@ const mergeModelRecords = (current: unknown, selected: ModelOption[]) => { export const exclusionsForModelSelection = ( currentText: string, discoveredModels: ModelOption[], - selectedModelNames: Iterable, + selectedModels: ModelOption[], ) => { const discovered = new Map(); discoveredModels.forEach((model) => { @@ -515,7 +516,12 @@ export const exclusionsForModelSelection = ( if (name && !discovered.has(name.toLowerCase())) discovered.set(name.toLowerCase(), name); }); const selected = new Set( - Array.from(selectedModelNames, (name) => name.trim().toLowerCase()).filter(Boolean), + selectedModels.map((model) => model.name.trim().toLowerCase()).filter(Boolean), + ); + const selectedClientNames = new Set( + selectedModels + .filter((model) => model.name.trim()) + .map((model) => (model.alias?.trim() || model.name.trim()).toLowerCase()), ); const rules = currentText .split(/[,\n]/) @@ -525,7 +531,7 @@ export const exclusionsForModelSelection = ( if (selected.size > 0) { discovered.forEach((name, key) => { - if (!selected.has(key)) next.push(name); + if (!selected.has(key) && !selectedClientNames.has(key)) next.push(name); }); } @@ -732,7 +738,17 @@ const applyAdvancedFields = ( else delete next.headers; } if (draft.excludedModelsText !== undefined && section !== 'openai-compatibility') { - const excludedModels = draft.excludedModelsText + const excludedModelsText = draft.modelSelectionCatalog && draft.models.some((model) => model.name.trim()) + ? exclusionsForModelSelection( + draft.excludedModelsText, + draft.modelSelectionCatalog, + (Array.isArray(next.models) ? next.models : []).filter(isRecord).map((model) => ({ + name: readString(model, 'name'), + alias: readString(model, 'alias'), + })), + ) + : draft.excludedModelsText; + const excludedModels = excludedModelsText .split(/[,\n]/) .map((value) => value.trim()) .filter((value, index, values) => @@ -1814,7 +1830,11 @@ export function ApiProviderDialog({ setModelError(''); if (activeCategory === 'deepseek') setModelDiscoveryReady(false); } - setDraft((current) => ({ ...current, [field]: value })); + setDraft((current) => ({ + ...current, + [field]: value, + ...(field === 'excludedModelsText' ? { modelSelectionCatalog: undefined } : {}), + })); }; const updateBooleanField = ( @@ -1825,27 +1845,31 @@ export function ApiProviderDialog({ setDraft((current) => ({ ...current, [field]: value })); }; + const updateModels = (update: (models: ModelOption[]) => ModelOption[]) => { + setDraft((current) => { + const models = update(current.models); + return { + ...current, + models, + excludedModelsText: current.modelSelectionCatalog && models.some((model) => model.name.trim()) + ? exclusionsForModelSelection(current.excludedModelsText ?? '', current.modelSelectionCatalog, models) + : current.excludedModelsText, + }; + }); + }; + const updateModel = (index: number, patch: Partial) => { - setDraft((current) => ({ - ...current, - models: current.models.map((model, modelIndex) => - modelIndex === index ? { ...model, ...patch } : model, - ), - })); + updateModels((models) => models.map((model, modelIndex) => + modelIndex === index ? { ...model, ...patch } : model, + )); }; const addModel = () => { - setDraft((current) => ({ - ...current, - models: [...current.models, { name: '', alias: '' }], - })); + updateModels((models) => [...models, { name: '', alias: '' }]); }; const removeModel = (index: number) => { - setDraft((current) => ({ - ...current, - models: current.models.filter((_, modelIndex) => modelIndex !== index), - })); + updateModels((models) => models.filter((_, modelIndex) => modelIndex !== index)); }; const addThinkingLevel = () => { @@ -1949,12 +1973,13 @@ export function ApiProviderDialog({ setDraft((current) => ({ ...current, models: selectedModels, + modelSelectionCatalog: activeSection === 'openai-compatibility' ? undefined : discoveredModels, excludedModelsText: activeSection === 'openai-compatibility' ? current.excludedModelsText : exclusionsForModelSelection( current.excludedModelsText ?? '', discoveredModels, - selectedModelNames, + selectedModels, ), })); closeModelDiscovery(); diff --git a/tests/fixtures/model-selection.tsx b/tests/fixtures/model-selection.tsx index 8a77dec..fd70fe7 100644 --- a/tests/fixtures/model-selection.tsx +++ b/tests/fixtures/model-selection.tsx @@ -6,6 +6,7 @@ import { ApiProviderDialog, type ProviderDraft } from '../../src/pages/ApiAccess import '../../src/styles.css'; const params = new URLSearchParams(location.search); +const scenario = params.get('scenario') ?? 'saved'; localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'en'); document.documentElement.dataset.theme = params.get('theme') ?? 'light'; const fixture = window as typeof window & { @@ -15,12 +16,14 @@ const fixture = window as typeof window & { fixturePending: (() => void)[]; fixtureSaved: ProviderDraft | null; }; -fixture.fixtureCatalog = [ - ...Array.from({ length: 240 }, (_, i) => ({ name: `gpt-${String(i + 1).padStart(3, '0')}` })), - ...Array.from({ length: 120 }, (_, i) => ({ name: `claude-${String(i + 1).padStart(3, '0')}` })), - ...Array.from({ length: 40 }, (_, i) => ({ name: `deepseek-${String(i + 1).padStart(3, '0')}` })), - { name: 'GPT-001' }, -]; +fixture.fixtureCatalog = scenario.startsWith('alias-') + ? [{ name: 'dsv4' }, { name: 'dsv4.1' }, { name: 'other' }] + : [ + ...Array.from({ length: 240 }, (_, i) => ({ name: `gpt-${String(i + 1).padStart(3, '0')}` })), + ...Array.from({ length: 120 }, (_, i) => ({ name: `claude-${String(i + 1).padStart(3, '0')}` })), + ...Array.from({ length: 40 }, (_, i) => ({ name: `deepseek-${String(i + 1).padStart(3, '0')}` })), + { name: 'GPT-001' }, + ]; fixture.fixtureFailFetch = false; fixture.fixtureHoldNext = false; fixture.fixturePending = []; @@ -41,11 +44,13 @@ mockIPC(async (cmd, rawArgs) => { throw new Error(`Unhandled fixture command: ${cmd}`); }); -const scenario = params.get('scenario') ?? 'saved'; const initialDraft: ProviderDraft = { name: '', apiKey: 'fixture-key', remark: '', baseUrl: 'https://models.example.test', priority: '', - models: scenario === 'saved' ? [{ name: ' gpt-001 ', alias: 'Focus' }, { name: 'custom-local', alias: 'Local alias' }] : [], - excludedModelsText: scenario === 'excluded' ? 'gpt-*' : '', + models: scenario === 'alias-saved' ? [{ name: 'dsv4.1', alias: 'dsv4' }] + : scenario === 'saved' ? [{ name: ' gpt-001 ', alias: 'Focus' }, { name: 'custom-local', alias: 'Local alias' }] : [], + excludedModelsText: scenario === 'alias-saved' ? 'manual-model\nlegacy-*\ndsv4' + : scenario === 'alias-new' ? 'manual-model\nlegacy-*' + : scenario === 'excluded' ? 'gpt-*' : '', }; createRoot(document.getElementById('root')!).render( diff --git a/tests/model-selection-ui.cjs b/tests/model-selection-ui.cjs index f0b569b..56c8d3a 100644 --- a/tests/model-selection-ui.cjs +++ b/tests/model-selection-ui.cjs @@ -117,13 +117,75 @@ const { mkdirSync } = require('node:fs'); await counts(400, 0); await left.getByRole('button', { name: 'Add All', exact: true }).click(); await counts(0, 400); + + await open('alias-saved'); + await counts(2, 1); + await dialog.getByRole('button', { name: 'Apply Selection (1)', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + const mapped = await page.evaluate(() => window.fixtureSaved); + assert.deepEqual(mapped.models, [{ name: 'dsv4.1', alias: 'dsv4' }]); + assert.deepEqual(mapped.excludedModelsText.split('\n'), ['manual-model', 'legacy-*', 'other']); + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await ready(); + await counts(2, 1); + await dialog.getByRole('button', { name: 'Apply Selection (1)', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + assert.deepEqual(await page.evaluate(() => window.fixtureSaved), mapped, 'Reapplying must not reintroduce the alias exclusion'); + + await open('alias-new'); + await right.getByRole('button', { name: 'Remove dsv4', exact: true }).click(); + await right.getByRole('button', { name: 'Remove other', exact: true }).click(); + await dialog.getByRole('button', { name: 'Apply Selection (1)', exact: true }).click(); + const exclusions = page.locator('.provider-advanced-settings textarea').last(); + const aliasInput = page.locator('.model-config-entry input').nth(1); + const expectExclusions = async (extra) => assert.deepEqual((await exclusions.inputValue()).split('\n'), ['manual-model', 'legacy-*', ...extra]); + await expectExclusions(['dsv4', 'other']); + await aliasInput.fill(' DSV4 '); + await expectExclusions(['other']); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + assert.deepEqual((await page.evaluate(() => window.fixtureSaved)).excludedModelsText.split('\n'), ['manual-model', 'legacy-*', 'other']); + await aliasInput.fill('other'); + await expectExclusions(['dsv4']); + await aliasInput.fill(''); + await aliasInput.pressSequentially('dsv4.1'); + await expectExclusions(['dsv4', 'other']); + + await page.evaluate(() => { window.fixtureCatalog = [{ name: 'new-upstream' }]; }); + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await ready(); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await aliasInput.fill('dsv4'); + await expectExclusions(['other']); + await aliasInput.fill('dsv4.1'); + await expectExclusions(['dsv4', 'other']); + + await page.locator('.model-config-add').press('Enter'); + const added = page.locator('.model-config-entry').last(); + await added.locator('input').nth(0).fill('custom-upstream'); + await added.locator('input').nth(1).fill('dsv4'); + await expectExclusions(['other']); + await added.getByRole('button').press('Enter'); + await expectExclusions(['dsv4', 'other']); + + await page.locator('.model-config-entry').getByRole('button').press('Enter'); + await expectExclusions(['dsv4', 'other']); + await page.locator('.model-config-add').press('Enter'); + await page.locator('.model-config-entry input').nth(0).fill('dsv4.1'); + await expectExclusions(['dsv4', 'other']); + + const manualRules = 'dsv4\nother\nmanual-model\ndsv*'; + await page.locator('.provider-advanced-settings summary').press('Enter'); + await exclusions.fill(manualRules); + await aliasInput.fill('other'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + assert.equal((await page.evaluate(() => window.fixtureSaved)).excludedModelsText, manualRules, 'Manual exclusions remain authoritative after editing aliases'); assert.deepEqual(errors, []); mkdirSync('misc', { recursive: true }); for (const [locale, theme, width, height] of [['zh-CN', 'light', 1280, 820], ['en', 'dark', 1280, 820], ['ja', 'light', 390, 740]]) { await page.setViewportSize({ width, height }); await page.goto(`http://127.0.0.1:1423/tests/fixtures/model-selection.html?locale=${locale}&theme=${theme}`); - await page.locator('.model-config-heading button').click(); + await page.locator('.model-config-heading button').press('Enter'); await ready(); const popup = page.locator('.model-transfer-dialog'); const rect = await popup.boundingBox(); @@ -134,7 +196,7 @@ const { mkdirSync } = require('node:fs'); } await page.screenshot({ path: `misc/model-selection-${locale}-${theme}.png` }); } - console.log('PASS: 400 models, independent searches, filtered transfers, empty selection recovery, keyboard focus, refresh errors, selection persistence, cancel, stale responses, exclusions, save, and responsive themes'); + console.log('PASS: 400 models, independent searches, filtered transfers, empty selection recovery, keyboard focus, refresh errors, selection persistence, cancel, stale responses, exclusions, alias collisions, alias edits, manual overrides, save, and responsive themes'); } finally { await browser.close(); } diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index ff10e29..a49ad56 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -542,7 +542,7 @@ describe('API 接入配置合并', () => { const result = exclusionsForModelSelection( 'legacy-*\ngpt-image', [{ name: 'gpt-5-codex' }, { name: 'gpt-image' }, { name: 'gpt-5-mini' }], - new Set(['gpt-5-codex']), + [{ name: 'gpt-5-codex' }], ); expect(result.split('\n')).toEqual(['legacy-*', 'gpt-image', 'gpt-5-mini']); @@ -552,12 +552,89 @@ describe('API 接入配置合并', () => { const result = exclusionsForModelSelection( 'legacy-*\ngpt-image\nmanual-model', [{ name: 'gpt-5-codex' }, { name: 'gpt-image' }], - new Set(['gpt-image']), + [{ name: 'gpt-image' }], ); expect(result.split('\n')).toEqual(['legacy-*', 'manual-model', 'gpt-5-codex']); }); + it('未勾选的原模型名与已选模型别名相同时不生成冲突排除规则', () => { + const discovered = [{ name: 'dsv4' }, { name: 'dsv4.1' }, { name: 'other' }]; + const models = [{ name: 'dsv4.1', alias: 'dsv4' }]; + const excludedModelsText = exclusionsForModelSelection('', discovered, models); + + for (const section of ['codex-api-key', 'claude-api-key', 'gemini-api-key'] as const) { + const record = buildProviderRecord(section, { + ...createProviderDraft(section), apiKey: 'key', models, excludedModelsText, + }); + expect(record.models).toEqual(models); + expect(record['excluded-models']).toEqual(['other']); + } + }); + + it('重新应用勾选时清理旧的别名冲突,保留手动规则和通配符', () => { + expect(exclusionsForModelSelection( + 'legacy-*\nmanual-model\nDSV4\ndsv4.1\ndsv*', + [{ name: ' dsv4 ' }, { name: 'dsv4.1' }, { name: 'other' }], + [{ name: ' DSV4.1 ', alias: ' DSV4 ' }], + ).split('\n')).toEqual(['legacy-*', 'manual-model', 'dsv*', 'other']); + }); + + it('已选自定义模型不在发现列表中时也保护其别名', () => { + expect(exclusionsForModelSelection( + 'dsv4\nmanual-model', + [{ name: 'dsv4' }, { name: 'other', alias: 'dsv4' }], + [{ name: 'custom-upstream', alias: 'dsv4' }], + ).split('\n')).toEqual(['manual-model', 'other']); + }); + + it('勾选后编辑或移除别名时同步恢复和移除自动排除规则', () => { + const discovered = [{ name: 'dsv4' }, { name: 'dsv4.1' }, { name: 'other' }]; + const before = exclusionsForModelSelection('manual-model', discovered, [{ name: 'dsv4.1' }]); + expect(before.split('\n')).toEqual(['manual-model', 'dsv4', 'other']); + const mapped = exclusionsForModelSelection(before, discovered, [{ name: 'dsv4.1', alias: 'dsv4' }]); + expect(mapped.split('\n')).toEqual(['manual-model', 'other']); + const renamed = exclusionsForModelSelection(mapped, discovered, [{ name: 'dsv4.1', alias: 'other' }]); + expect(renamed.split('\n')).toEqual(['manual-model', 'dsv4']); + expect(exclusionsForModelSelection(renamed, discovered, [{ name: 'dsv4.1' }])).toBe(before); + }); + + it('保存时按最终映射清理自动冲突,未应用勾选的手动排除保持不变', () => { + const draft = { + ...createProviderDraft('codex-api-key'), apiKey: 'key', + models: [{ name: 'dsv4.1', alias: 'dsv4' }], + excludedModelsText: 'dsv4\nother\ndsv*', + }; + const automatic = buildProviderRecord('codex-api-key', { + ...draft, modelSelectionCatalog: [{ name: 'dsv4' }, { name: 'dsv4.1' }, { name: 'other' }], + }); + expect(automatic['excluded-models']).toEqual(['dsv*', 'other']); + expect(automatic.modelSelectionCatalog).toBeUndefined(); + expect(buildProviderRecord('codex-api-key', draft)['excluded-models']).toEqual(['dsv4', 'other', 'dsv*']); + }); + + it('保存时也保护配置合并保留的同源额外别名,并保留停用状态', () => { + const current = { + 'api-key': 'key', + models: [{ name: 'dsv4.1' }, { name: 'dsv4.1', alias: 'dsv4', custom: true }], + }; + const record = buildProviderRecord('codex-api-key', { + ...createProviderDraft('codex-api-key'), apiKey: 'key', disabled: true, + models: modelsFromRecord(current.models), excludedModelsText: 'dsv4', + modelSelectionCatalog: [{ name: 'dsv4' }, { name: 'dsv4.1' }, { name: 'other' }], + }, current); + expect(record.models).toEqual(current.models); + expect(record['excluded-models']).toEqual(['other', '*']); + }); + + it('反向映射同样保护已选模型对外名称', () => { + expect(exclusionsForModelSelection( + '', + [{ name: 'dsv4' }, { name: 'dsv4.1' }, { name: 'other' }], + [{ name: 'dsv4', alias: 'dsv4.1' }], + )).toBe('other'); + }); + it('普通提供商没有模型映射时按真实开放状态初始化勾选', () => { const selected = modelSelectionForDiscovery( [],