From cb7c701f04fbbc79c1e6df5960e6d1242f56eac7 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 19 Sep 2026 20:25:51 +0800 Subject: [PATCH] fix: allow provider entries with shared credentials - Compare full configurations when checking for duplicates - Keep edits, toggles, reordering, deletion, and remarks isolated - Migrate existing remarks and add regression coverage Fixes #276 --- src-tauri/src/app_settings.rs | 123 +++++++++++++++++++++--- src-tauri/src/main.rs | 2 + src/pages/ApiAccessPage.tsx | 116 ++++++++++++++++------ tests/fixtures/provider-duplicates.html | 1 + tests/fixtures/provider-duplicates.tsx | 56 +++++++++++ tests/provider-duplicates-ui.cjs | 74 ++++++++++++++ tests/providerConfig.test.ts | 81 ++++++++++++++++ 7 files changed, 408 insertions(+), 45 deletions(-) create mode 100644 tests/fixtures/provider-duplicates.html create mode 100644 tests/fixtures/provider-duplicates.tsx create mode 100644 tests/provider-duplicates-ui.cjs diff --git a/src-tauri/src/app_settings.rs b/src-tauri/src/app_settings.rs index 4a34b74..81e0820 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -87,6 +87,10 @@ fn api_access_locator_identity( identity.extend_from_slice(&(hash.len() as u64).to_be_bytes()); identity.extend_from_slice(hash.as_bytes()); } + if !locator.config_identity.is_empty() { + identity.extend_from_slice(&(locator.config_identity.len() as u64).to_be_bytes()); + identity.extend_from_slice(locator.config_identity.as_bytes()); + } Some((sha256_bytes(&identity), api_key_hashes)) } @@ -119,6 +123,12 @@ fn resolve_api_access_remark( return Ok(exact_remark); } + if !query.locator.config_identity.is_empty() { + let mut legacy_query = query.clone(); + legacy_query.locator.config_identity.clear(); + return resolve_api_access_remark(config, &legacy_query); + } + Ok(api_key_hashes .iter() .find_map(|hash| { @@ -160,28 +170,30 @@ fn apply_api_access_remark_update( .map(|(record_hash, _)| record_hash.clone()) .collect::>(); let provider_section = update.provider_section; - let legacy_entries = config - .api_access_remarks + let legacy_migrations = update + .all_records .iter() - .filter(|entry| entry.provider_section == provider_section && entry.record_hash.is_empty()) - .cloned() - .collect::>(); - let legacy_migrations = all_records - .iter() - .filter_map(|(record_hash, api_key_hashes)| { - let remark = api_key_hashes.iter().find_map(|api_key_hash| { - legacy_entries - .iter() - .find(|entry| entry.api_key_hash == *api_key_hash) - .map(|entry| entry.remark.clone()) - })?; - Some((record_hash.clone(), api_key_hashes.clone(), remark)) + .filter_map(|locator| { + let (record_hash, api_key_hashes) = + api_access_locator_identity(&provider_section, locator)?; + let remark = resolve_api_access_remark( + config, + &ApiAccessRemarkQuery { + provider_section: provider_section.clone(), + locator: locator.clone(), + }, + ) + .ok()?; + Some((record_hash, api_key_hashes, remark)) }) .collect::>(); config.api_access_remarks.retain(|entry| { entry.provider_section != provider_section || (!entry.record_hash.is_empty() + && all_records + .iter() + .any(|(hash, _)| *hash == entry.record_hash) && !record_hashes_to_replace.contains(&entry.record_hash)) }); let mut inserted_record_hashes = config @@ -236,6 +248,7 @@ mod tests { provider_name: String::new(), base_url: base_url.to_string(), api_keys: api_keys.iter().map(|key| (*key).to_string()).collect(), + config_identity: String::new(), } } @@ -399,6 +412,86 @@ mod tests { .all(|entry| !entry.record_hash.is_empty())); } + #[test] + fn shared_credentials_keep_separate_config_remarks_and_migrate_old_records() { + let mut config = GuiConfigFile::default(); + let legacy = locator("https://api.example/v1", &["shared-key"]); + apply_api_access_remark_update( + &mut config, + update( + Vec::new(), + vec![legacy.clone()], + vec![legacy.clone()], + "original", + ), + ) + .unwrap(); + + let mut first = legacy.clone(); + first.config_identity = r#"{"models":[{"name":"a"}],"priority":10}"#.into(); + let mut second = legacy.clone(); + second.config_identity = r#"{"models":[{"name":"b"}],"priority":1}"#.into(); + assert_eq!( + resolve_api_access_remark(&config, &query(first.clone())).unwrap(), + "original" + ); + + apply_api_access_remark_update( + &mut config, + update( + Vec::new(), + vec![second.clone()], + vec![first.clone(), second.clone()], + "second", + ), + ) + .unwrap(); + assert_eq!( + resolve_api_access_remark(&config, &query(first.clone())).unwrap(), + "original" + ); + assert_eq!( + resolve_api_access_remark(&config, &query(second.clone())).unwrap(), + "second" + ); + assert_eq!(config.api_access_remarks.len(), 2); + + let mut edited = second.clone(); + edited.config_identity = r#"{"models":[{"name":"edited"}],"priority":2}"#.into(); + apply_api_access_remark_update( + &mut config, + update( + vec![second], + vec![edited.clone()], + vec![first.clone(), edited.clone()], + "", + ), + ) + .unwrap(); + assert_eq!( + resolve_api_access_remark(&config, &query(edited.clone())).unwrap(), + "" + ); + assert_eq!( + resolve_api_access_remark(&config, &query(first.clone())).unwrap(), + "original" + ); + + apply_api_access_remark_update( + &mut config, + update(vec![edited], Vec::new(), vec![first.clone()], ""), + ) + .unwrap(); + assert_eq!(config.api_access_remarks.len(), 1); + assert_eq!( + resolve_api_access_remark(&config, &query(first)).unwrap(), + "original" + ); + assert!(!toml::to_string(&config.api_access_remarks[0]) + .unwrap() + .contains("shared-key")); + } + #[test] fn legacy_remark_entries_deserialize_without_a_record_hash() { let entry = toml::from_str::( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6cdc9d0..fe07775 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -876,6 +876,8 @@ struct ApiAccessRemarkLocator { provider_name: String, base_url: String, api_keys: Vec, + #[serde(default)] + config_identity: String, } #[derive(Clone, Debug, Deserialize)] diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 2f27f70..42c858e 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -116,10 +116,11 @@ export type ApiAccessRemarkLocator = { providerName: string; baseUrl: string; apiKeys: string[]; + configIdentity?: string; }; -const providerDragId = ( - row: Pick, +export const providerDragId = ( + row: Pick, ) => { const identity = `${row.section}\u0000${row.name}\u0000${row.apiKey}\u0000${row.baseUrl}`; let hash = 2166136261; @@ -127,7 +128,7 @@ const providerDragId = ( hash ^= identity.charCodeAt(index); hash = Math.imul(hash, 16777619); } - return `${row.section}:${(hash >>> 0).toString(36)}`; + return `${row.section}:${(hash >>> 0).toString(36)}:${row.index}`; }; function SortableProviderRow({ @@ -330,7 +331,7 @@ const rowFromRecord = ( export const providerRemarkIdentity = ( section: ProviderSection, locator: ApiAccessRemarkLocator, -) => JSON.stringify([section, locator.providerName, locator.baseUrl, locator.apiKeys]); +) => JSON.stringify([section, locator.providerName, locator.baseUrl, locator.apiKeys, locator.configIdentity ?? '']); export const apiAccessRemarkLocatorFromRecord = ( section: ProviderSection, @@ -341,14 +342,12 @@ export const apiAccessRemarkLocatorFromRecord = ( providerName: readString(record, 'name'), baseUrl: row.baseUrl, apiKeys: row.apiKeys, + configIdentity: providerRemarkConfigIdentity(record), }; }; -const apiAccessRemarkLocatorFromRow = (row: ProviderRow): ApiAccessRemarkLocator => ({ - providerName: readString(row.record, 'name'), - baseUrl: row.baseUrl, - apiKeys: row.apiKeys, -}); +const apiAccessRemarkLocatorFromRow = (row: ProviderRow): ApiAccessRemarkLocator => + apiAccessRemarkLocatorFromRecord(row.section, row.record); const providerHealthIdentity = (row: ProviderRow) => [ row.section, @@ -396,6 +395,52 @@ export const stripResponseFields = (record: Record) => { return next; }; +const normalizeProviderIdentity = (value: unknown): unknown => { + if (value == null || value === '') return undefined; + if (Array.isArray(value)) return value.length ? value.map(normalizeProviderIdentity) : undefined; + if (isRecord(value)) { + const entries = Object.keys(value).sort().flatMap((key) => { + const normalized = normalizeProviderIdentity(value[key]); + return normalized === undefined ? [] : [[key, normalized]]; + }); + return entries.length ? Object.fromEntries(entries) : undefined; + } + return value; +}; + +const providerConfigIdentity = (record: Record) => { + const config = stripResponseFields(record); + if (config.priority === 0) delete config.priority; + if (config.websockets === false) delete config.websockets; + if (config.disabled === false) delete config.disabled; + return JSON.stringify(normalizeProviderIdentity(config) ?? {}); +}; + +const providerRemarkConfigIdentity = (record: Record) => { + const config = stripResponseFields(record); + for (const key of ['name', 'api-key', 'apiKey', 'api-key-entries', 'base-url', 'baseUrl', 'disabled']) { + delete config[key]; + } + if (Array.isArray(config['excluded-models'])) { + config['excluded-models'] = config['excluded-models'].filter((model) => String(model).trim() !== '*'); + } + return providerConfigIdentity(config); +}; + +export const hasDuplicateProviderRecord = ( + section: ProviderSection, + records: Record[], + candidates: Record[], + targetIndex = -1, +) => records.some((record, index) => index !== targetIndex && candidates.some((candidate) => { + if (definitionFor(section).openAi) return readString(record, 'name') === readString(candidate, 'name'); + if (section === 'gemini-api-key') { + return readString(record, 'api-key', 'apiKey') === readString(candidate, 'api-key', 'apiKey') + && readString(record, 'base-url', 'baseUrl') === readString(candidate, 'base-url', 'baseUrl'); + } + return providerConfigIdentity(record) === providerConfigIdentity(candidate); +})); + const mergeModelRecords = (current: unknown, selected: ModelOption[]) => { const existing = Array.isArray(current) ? current : []; const consumedExistingIndexes = new Set(); @@ -797,7 +842,7 @@ const providerIdentityMatches = ( export type ProviderRecordIdentity = Pick< ProviderRow, 'section' | 'index' | 'name' | 'apiKey' | 'baseUrl' ->; +> & Partial>; const providerPrimaryIdentityMatches = ( row: ProviderRecordIdentity, @@ -810,8 +855,29 @@ export const resolveProviderRecordIndex = ( records: Record[], row: ProviderRecordIdentity, ) => { - const exactIndex = records.findIndex((record) => providerIdentityMatches(row, record)); - if (exactIndex >= 0) return exactIndex; + if (row.record) { + const identity = providerConfigIdentity(row.record); + const matches = records.flatMap((record, index) => ( + providerConfigIdentity(record) === identity ? [index] : [] + )); + if (matches.length === 1) return matches[0]; + if (matches.length > 1) return matches.includes(row.index) ? row.index : -1; + + const withoutBaseUrl = (record: Record) => { + const { 'base-url': _baseUrl, baseUrl: _camelBaseUrl, ...rest } = record; + return providerConfigIdentity(rest); + }; + const defaultUrlMatches = records.flatMap((record, index) => ( + providerPrimaryIdentityMatches(row, record) + && (!readString(record, 'base-url', 'baseUrl') || !row.baseUrl) + && withoutBaseUrl(record) === withoutBaseUrl(row.record!) ? [index] : [] + )); + return defaultUrlMatches.length === 1 ? defaultUrlMatches[0] : -1; + } + + const exactMatches = records.flatMap((record, index) => providerIdentityMatches(row, record) ? [index] : []); + if (exactMatches.length === 1) return exactMatches[0]; + if (exactMatches.length > 1) return -1; const indexedRecord = records[row.index]; if (indexedRecord && providerPrimaryIdentityMatches(row, indexedRecord)) { @@ -1106,16 +1172,6 @@ export function ApiAccessPage() { currentRecord = current[targetIndex]; } - const duplicate = current.some((record, index) => { - if (index === targetIndex) return false; - if (definition.openAi) return readString(record, 'name') === preparedDraft.name.trim(); - return parsedApiKeys.some((apiKey) => ( - readString(record, 'api-key', 'apiKey').trim() === apiKey - && readString(record, 'base-url', 'baseUrl').trim() === baseUrl - )); - }); - if (duplicate) throw new Error(t('apiAccess.error.duplicate')); - const recordsToSave = definition.openAi ? [buildProviderRecord(activeSection, draftToSave, currentRecord)] : parsedApiKeys.map((apiKey) => buildProviderRecord( @@ -1123,6 +1179,9 @@ export function ApiAccessPage() { { ...draftToSave, apiKey }, currentRecord, )); + if (hasDuplicateProviderRecord(activeSection, current, recordsToSave, targetIndex)) { + throw new Error(t('apiAccess.error.duplicate')); + } nextList = editingRow ? [ ...current.slice(0, targetIndex), @@ -1161,15 +1220,12 @@ export function ApiAccessPage() { setBusy(true); setError(''); try { - if (definitionFor(row.section).openAi) { - await managementApi.delete('/openai-compatibility', { query: { name: row.name } }); - } else { - await managementApi.delete(`/${row.section}`, { - query: { 'api-key': row.apiKey, 'base-url': row.baseUrl }, - }); - } const latestConfig = await managementApi.get('/config'); - const remainingRecords = sectionRecordsFromConfig(latestConfig, row.section); + const current = sectionRecordsFromConfig(latestConfig, row.section); + const targetIndex = resolveProviderRecordIndex(current, row); + if (targetIndex < 0) throw new Error(t('apiAccess.error.stale')); + const remainingRecords = current.filter((_, index) => index !== targetIndex).map(stripResponseFields); + await managementApi.put(`/${row.section}`, remainingRecords); await invoke('save_api_access_remark', { update: { providerSection: row.section, diff --git a/tests/fixtures/provider-duplicates.html b/tests/fixtures/provider-duplicates.html new file mode 100644 index 0000000..407d694 --- /dev/null +++ b/tests/fixtures/provider-duplicates.html @@ -0,0 +1 @@ +
diff --git a/tests/fixtures/provider-duplicates.tsx b/tests/fixtures/provider-duplicates.tsx new file mode 100644 index 0000000..9784f8f --- /dev/null +++ b/tests/fixtures/provider-duplicates.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import { I18nProvider } from '../../src/i18n'; +import { ApiAccessPage, providerRemarkIdentity, type ApiAccessRemarkLocator, type ProviderSection } from '../../src/pages/ApiAccessPage'; +import '../../src/styles.css'; + +localStorage.setItem('easy-cli-proxy-api.locale', 'en'); +const fixture = window as typeof window & { + providerFixture: { records: Record[]; writes: { method: string; body: unknown }[] }; +}; +fixture.providerFixture = { + records: [{ + 'api-key': 'shared-test-key', 'base-url': 'https://claude.example.test', priority: 10, + models: [{ name: 'upstream-a', alias: 'claude-a' }], + }], + writes: [], +}; +const remarks = new Map(); +mockIPC((cmd, rawArgs) => { + if (cmd === 'set_app_locale') return null; + if (cmd === 'resolve_api_access_remarks') { + const { queries } = rawArgs as { queries: (ApiAccessRemarkLocator & { providerSection: ProviderSection })[] }; + return queries.map((query) => remarks.get(providerRemarkIdentity(query.providerSection, query)) ?? ''); + } + if (cmd === 'save_api_access_remark') { + const { update } = rawArgs as { update: { + providerSection: ProviderSection; previousRecords: ApiAccessRemarkLocator[]; + records: ApiAccessRemarkLocator[]; remark: string; + } }; + for (const record of update.previousRecords) remarks.delete(providerRemarkIdentity(update.providerSection, record)); + for (const record of update.records) remarks.set(providerRemarkIdentity(update.providerSection, record), update.remark); + return null; + } + if (cmd === 'management_request') { + const { request } = rawArgs as { request: { method: string; path: string; body: Record[] } }; + const section = request.path.slice(1); + if (request.method === 'GET') { + if (section === 'config') return { 'claude-api-key': structuredClone(fixture.providerFixture.records) }; + return { [section]: section === 'claude-api-key' + ? fixture.providerFixture.records.map((record, index) => ({ ...record, 'auth-index': `auth-${index}` })) + : [] }; + } + fixture.providerFixture.writes.push({ method: request.method, body: structuredClone(request.body) }); + if (request.method !== 'PUT' || section !== 'claude-api-key') throw new Error('Unexpected provider mutation'); + fixture.providerFixture.records = structuredClone(request.body); + return { status: 'ok' }; + } + throw new Error(`Unhandled fixture command: ${cmd}`); +}); + +createRoot(document.getElementById('root')!).render( +
+ +
, +); diff --git a/tests/provider-duplicates-ui.cjs b/tests/provider-duplicates-ui.cjs new file mode 100644 index 0000000..1ed591a --- /dev/null +++ b/tests/provider-duplicates-ui.cjs @@ -0,0 +1,74 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const base = 'http://127.0.0.1:1421'; + +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }); + page.setDefaultTimeout(10000); + const errors = []; + page.on('pageerror', (error) => errors.push(String(error))); + page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); }); + await page.route('**/*', (route) => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort()); + await page.goto(`${base}/tests/fixtures/provider-duplicates.html`, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.locator('.provider-category-panel button').filter({ hasText: 'Claude' }).click(); + const rows = page.locator('.real-provider-row'); + const records = () => page.evaluate(() => window.providerFixture.records); + const form = page.locator('.api-provider-dialog'); + const waitForSave = () => form.waitFor({ state: 'detached' }); + + await page.getByRole('button', { name: 'Add', exact: true }).click(); + await form.getByLabel('API Keys (one per line)', { exact: true }).fill('shared-test-key'); + await form.getByLabel('Base URL', { exact: true }).fill('https://claude.example.test'); + await form.getByLabel('Priority', { exact: true }).fill('1'); + await form.getByLabel('Remark', { exact: true }).fill('Second route'); + await form.getByRole('button', { name: 'Add Custom Model', exact: true }).click(); + await form.locator('.model-config-entry input').nth(0).fill('upstream-b'); + await form.locator('.model-config-entry input').nth(1).fill('claude-b'); + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await waitForSave(); + assert.equal((await records()).length, 2, 'A shared key and URL can have a second model/priority route'); + await rows.filter({ hasText: 'Second route' }).waitFor(); + assert.equal(await rows.count(), 2); + + await rows.filter({ hasText: 'Second route' }).getByRole('button', { name: 'Edit', exact: true }).click(); + await form.getByLabel('Priority', { exact: true }).fill('2'); + await form.locator('.model-config-entry input').nth(1).fill('claude-b-edited'); + await page.evaluate(() => window.providerFixture.records.reverse()); + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await waitForSave(); + let saved = await records(); + assert.equal(saved[0].priority, 2); + assert.equal(saved[0].models[0].alias, 'claude-b-edited'); + assert.equal(saved[1].priority, 10); + assert.equal(saved[1].models[0].alias, 'claude-a'); + + await rows.filter({ hasText: 'Second route' }).getByRole('checkbox').uncheck(); + await page.waitForFunction(() => window.providerFixture.records[0]['excluded-models']?.includes('*')); + assert.equal((await records())[1]['excluded-models'], undefined); + await rows.filter({ hasText: 'Second route' }).waitFor(); + + const handle = rows.filter({ hasText: 'Second route' }).locator('.provider-drag-handle'); + await page.waitForFunction(() => [...document.querySelectorAll('.provider-drag-handle')].every((button) => !button.disabled)); + await handle.focus(); + await page.keyboard.press('Space'); + await page.waitForTimeout(100); + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(250); + await page.keyboard.press('Space'); + await page.waitForFunction(() => window.providerFixture.records[1].priority === 2); + assert.equal((await records())[0].priority, 10); + + await rows.filter({ hasText: 'Second route' }).getByRole('button', { name: 'Delete', exact: true }).click(); + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete', exact: true }).click(); + await page.waitForFunction(() => window.providerFixture.records.length === 1); + saved = await records(); + assert.equal(saved[0].models[0].alias, 'claude-a', 'Deleting the second route preserves the first'); + assert.ok((await page.evaluate(() => window.providerFixture.writes)).every((write) => write.method === 'PUT')); + assert.deepEqual(errors, []); + console.log('PASS: #276 create, edit after external reorder, disable, drag, and delete preserve shared-credential sibling routes.'); + } finally { + await browser.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index 2477bfe..ff10e29 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -5,12 +5,14 @@ import { applyProviderPreset, buildProviderRecord, createProviderDraft, + hasDuplicateProviderRecord, DEEPSEEK_BASE_URL, exclusionsForModelSelection, modelSelectionForDiscovery, parseProviderHeaders, parseProviderApiKeys, providerCategoryMatchesRecord, + providerDragId, providerRecordWithDisabledState, providerRemarkIdentity, providerSectionOrder, @@ -21,6 +23,85 @@ import { } from '../src/pages/ApiAccessPage'; import { modelsFromRecord } from '../src/services/modelService'; +describe('shared-credential provider entries (#276)', () => { + const first = { + 'api-key': 'shared-key', + 'base-url': 'https://claude.example.test', + priority: 10, + models: [{ name: 'upstream-a', alias: 'claude-a' }], + }; + const second = { + ...first, priority: 1, models: [{ name: 'upstream-b', alias: 'claude-b' }], + }; + const row = (record: Record, index: number) => ({ + section: 'claude-api-key' as const, index, name: 'Claude', + apiKey: 'shared-key', baseUrl: first['base-url'], record, + }); + + it('allows different model mappings, aliases, priorities and routing settings', () => { + for (const candidate of [ + second, + { ...first, priority: 1 }, + { ...first, models: [{ name: 'upstream-a', alias: 'claude-other' }] }, + { ...first, prefix: 'team-b' }, + { ...first, headers: { 'X-Team': 'b' } }, + { ...first, 'disable-cooling': false }, + { ...first, 'request-retry': 0 }, + { ...first, weight: 0 }, + ]) { + expect(hasDuplicateProviderRecord('claude-api-key', [first], [candidate])).toBe(false); + } + }); + + it('still rejects identical configurations, ignoring runtime IDs and empty defaults', () => { + const persisted = { ...first, 'auth-index': 'runtime-only', headers: {}, prefix: '', cloak: null }; + const candidate = buildProviderRecord('claude-api-key', { + ...createProviderDraft('claude-api-key'), apiKey: 'shared-key', + baseUrl: first['base-url'], priority: '10', models: first.models, + }); + expect(hasDuplicateProviderRecord('claude-api-key', [persisted], [candidate])).toBe(true); + expect(hasDuplicateProviderRecord('claude-api-key', [persisted], [candidate], 0)).toBe(false); + expect(hasDuplicateProviderRecord('claude-api-key', [first], [second, candidate])).toBe(true); + expect(hasDuplicateProviderRecord('openai-compatibility', [{ name: 'same' }], [{ name: 'same', models: first.models }])).toBe(true); + expect(hasDuplicateProviderRecord('gemini-api-key', [first], [second])).toBe(true); + expect(hasDuplicateProviderRecord('codex-api-key', [first], [second])).toBe(false); + }); + + it('locates the second entry for edit/toggle/delete, even after an external reorder', () => { + const selected = row({ ...second, 'auth-index': 'runtime-second' }, 1); + expect(resolveProviderRecordIndex([first, second], selected)).toBe(1); + expect(resolveProviderRecordIndex([second, first], selected)).toBe(0); + expect(resolveProviderRecordIndex([first], selected)).toBe(-1); + expect(resolveProviderRecordIndex([first, { ...second, priority: 5 }], selected)).toBe(-1); + }); + + it('retains default URL compatibility without guessing among sibling records', () => { + const { 'base-url': _base, ...withoutBase } = second; + expect(resolveProviderRecordIndex([first, withoutBase], row(second, 1))).toBe(1); + expect(resolveProviderRecordIndex([first, withoutBase, { ...withoutBase }], row(second, 1))).toBe(-1); + }); + + it('uses distinct drag IDs and reorders only the intended entries', () => { + const firstRow = row(first, 0); + const secondRow = row(second, 1); + expect(providerDragId(firstRow)).not.toBe(providerDragId(secondRow)); + expect(reorderProviderRecords([first, second], [firstRow, secondRow], secondRow, firstRow)).toEqual([second, first]); + }); + + it('keeps remarks separate and stable through enable/disable and property ordering', () => { + const firstLocator = apiAccessRemarkLocatorFromRecord('claude-api-key', first); + const secondLocator = apiAccessRemarkLocatorFromRecord('claude-api-key', second); + expect(providerRemarkIdentity('claude-api-key', firstLocator)) + .not.toBe(providerRemarkIdentity('claude-api-key', secondLocator)); + const disabled = providerRecordWithDisabledState('claude-api-key', first, true); + expect(apiAccessRemarkLocatorFromRecord('claude-api-key', disabled)).toEqual(firstLocator); + expect(apiAccessRemarkLocatorFromRecord('claude-api-key', { + models: first.models, priority: 10, 'base-url': first['base-url'], 'api-key': first['api-key'], + 'auth-index': 'runtime', websockets: false, + })).toEqual(firstLocator); + }); +}); + it('saves non-empty custom model names and removes duplicate or blank entries', () => { const result = buildProviderRecord('openai-compatibility', { name: 'custom-provider',