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
This commit is contained in:
1 parent
327b824ef5
commit
cb7c701f04
7 files changed
+408
-45
No files matched your search
+108
-15
@@ -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::<HashSet<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<GuiApiAccessRemark>(
|
||||
|
||||
@@ -876,6 +876,8 @@ struct ApiAccessRemarkLocator {
|
||||
provider_name: String,
|
||||
base_url: String,
|
||||
api_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
config_identity: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
||||
+86
-30
@@ -116,10 +116,11 @@ export type ApiAccessRemarkLocator = {
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
apiKeys: string[];
|
||||
configIdentity?: string;
|
||||
};
|
||||
|
||||
const providerDragId = (
|
||||
row: Pick<ProviderRow, 'section' | 'name' | 'apiKey' | 'baseUrl'>,
|
||||
export const providerDragId = (
|
||||
row: Pick<ProviderRow, 'section' | 'index' | 'name' | 'apiKey' | 'baseUrl'>,
|
||||
) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>[],
|
||||
candidates: Record<string, unknown>[],
|
||||
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<number>();
|
||||
@@ -797,7 +842,7 @@ const providerIdentityMatches = (
|
||||
export type ProviderRecordIdentity = Pick<
|
||||
ProviderRow,
|
||||
'section' | 'index' | 'name' | 'apiKey' | 'baseUrl'
|
||||
>;
|
||||
> & Partial<Pick<ProviderRow, 'record'>>;
|
||||
|
||||
const providerPrimaryIdentityMatches = (
|
||||
row: ProviderRecordIdentity,
|
||||
@@ -810,8 +855,29 @@ export const resolveProviderRecordIndex = (
|
||||
records: Record<string, unknown>[],
|
||||
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<string, unknown>) => {
|
||||
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,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
<html><body><div id="root"></div><script type="module" src="/tests/fixtures/provider-duplicates.tsx"></script></body></html>
|
||||
+56
@@ -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<string, unknown>[]; 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<string, string>();
|
||||
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<string, unknown>[] } };
|
||||
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(
|
||||
<I18nProvider><div className="app-shell"><div className="workspace"><main className="content">
|
||||
<ApiAccessPage />
|
||||
</main></div></div></I18nProvider>,
|
||||
);
|
||||
@@ -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; });
|
||||
@@ -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<string, unknown>, 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',
|
||||
|
||||
Reference in new issue
Block a user