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:
lzt404 committed 2026-09-19 20:42:30 +08:00
1 parent 327b824ef5
commit cb7c701f04
7 files changed
+408 -45

No files matched your search

+86 -30
View File
@@ -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,