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:
1 parent
85eae6d10e
commit
0ec54e1f66
4 files changed
+202
-33
No files matched your search
+45
-20
@@ -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();
|
||||
|
||||
Vendored
+14
-9
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
[],
|
||||
|
||||
Reference in new issue
Block a user