fix(api-access): prevent automatic exclusions from blocking model aliases

- Protect selected models' aliases when generating exclusions
- Reconcile exclusions when editing models and saving mappings
- Preserve manual exclusions and provider disabled state
- Add unit and UI regression tests for alias conflicts
This commit is contained in:
lzt404 committed 2026-09-21 00:30:24 +08:00
1 parent 85eae6d10e
commit 0ec54e1f66
4 files changed
+202 -33

No files matched your search

+45 -20
View File
@@ -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<string>,
selectedModels: ModelOption[],
) => {
const discovered = new Map<string, string>();
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<ModelOption>) => {
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();
+14 -9
View File
@@ -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(
<I18nProvider>
+64 -2
View File
@@ -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();
}
+79 -2
View File
@@ -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(
[],