From d76707a257a1825007774bccc5d91066b8b0f2ea Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Thu, 23 Jul 2026 03:03:22 +0800 Subject: [PATCH] Preserve custom provider lineage during ID migrations Safely migrate custom providers whose IDs become reserved by built-in providers without losing API keys or historical session routing. Retain legacy provider IDs for deterministic recovery. Preserve completed migration state across partial imports, and keep existing built-in secrets from being assigned to newly imported custom providers. Use exact raw provider IDs when canonical siblings collide so label and secret recovery stays isolated. Keep ambiguous or disabled matches fail-closed. --- src/config/index.mjs | 464 +++++++++++-- src/popup/sections/ApiModes.jsx | 3 +- .../sections/api-modes-provider-utils.mjs | 137 +++- src/popup/sections/import-data-cleanup.mjs | 76 ++ src/services/apis/provider-registry.mjs | 203 ++++-- src/utils/model-name-convert.mjs | 49 +- .../unit/config/migrate-user-config.test.mjs | 656 +++++++++++++++++- .../popup/api-modes-provider-utils.test.mjs | 90 +++ tests/unit/popup/import-data-cleanup.test.mjs | 147 ++++ .../services/apis/provider-registry.test.mjs | 396 ++++++++++- tests/unit/utils/model-name-convert.test.mjs | 59 ++ 11 files changed, 2126 insertions(+), 154 deletions(-) diff --git a/src/config/index.mjs b/src/config/index.mjs index 48c2d98b2..22b64df31 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -10,6 +10,7 @@ import { } from '../utils/model-name-convert.mjs' import { t } from 'i18next' import { + LEGACY_API_KEY_FIELD_BY_PROVIDER_ID, LEGACY_SECRET_KEY_TO_PROVIDER_ID, OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID as API_MODE_GROUP_TO_PROVIDER_ID, } from './openai-provider-mappings.mjs' @@ -38,6 +39,9 @@ export const ModelMode = { fast: 'Fast', } +// Provider IDs added after custom providers became configurable need one-time secret migration. +const NEWLY_RESERVED_BUILTIN_PROVIDER_IDS = new Set(['xai', 'nvidia-nim', 'mistral']) + export const chatgptWebModelKeys = [ 'chatgptFree35', 'chatgptFree4oMini', @@ -853,6 +857,7 @@ export const defaultConfig = { knownApiModeDefaultIds: [], customOpenAIProviders: [], providerSecrets: {}, + completedBuiltinProviderIdMigrations: [], configSchemaVersion: 2, activeSelectionTools: ['translate', 'translateToEn', 'summary', 'polish', 'code', 'ask'], customSelectionTools: [ @@ -1059,6 +1064,28 @@ function normalizeEndpointUrlForCompare(value) { return normalizeText(value).replace(/\/+$/, '') } +function ensureLeadingSlash(value, fallback = '') { + const normalized = normalizeText(value) + if (!normalized) return fallback + return normalized.startsWith('/') ? normalized : `/${normalized}` +} + +function getProviderChatCompletionsUrlForCompare(provider) { + const directUrl = normalizeEndpointUrlForCompare(provider?.chatCompletionsUrl) + if (directUrl) return directUrl + + let baseUrl = normalizeEndpointUrlForCompare(provider?.baseUrl) + const path = ensureLeadingSlash(provider?.chatCompletionsPath) + if (!baseUrl || !path) return '' + const usesDefaultV1Paths = + path === '/v1/chat/completions' && + ensureLeadingSlash(provider?.completionsPath) === '/v1/completions' + if (!normalizeText(provider?.completionsUrl) && usesDefaultV1Paths) { + baseUrl = baseUrl.replace(/\/v1$/i, '') + } + return normalizeEndpointUrlForCompare(`${baseUrl}/${path.replace(/^\/+/, '')}`) +} + function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } @@ -1091,6 +1118,31 @@ function ensureUniqueProviderId(providerIdSet, preferredId) { return id } +function getLegacyProviderIds(value, currentProviderId = '') { + const normalizedCurrentProviderId = normalizeProviderId(currentProviderId) + return Array.from( + new Set( + (Array.isArray(value) ? value : []) + .map(normalizeProviderId) + .filter((providerId) => providerId && providerId !== normalizedCurrentProviderId), + ), + ) +} + +function addLegacyProviderId(target, providerId) { + const normalizedProviderId = normalizeProviderId(providerId) + if (!target || !normalizedProviderId) return false + const legacyProviderIds = getLegacyProviderIds(target.legacyProviderIds, target.providerId) + if ( + normalizedProviderId === normalizeProviderId(target.providerId) || + legacyProviderIds.includes(normalizedProviderId) + ) { + return false + } + target.legacyProviderIds = [...legacyProviderIds, normalizedProviderId] + return true +} + function normalizeCustomProviderForStorage(provider, index, providerIdSet) { if (!provider || typeof provider !== 'object') return null const originalRawId = normalizeText(provider.id) @@ -1100,22 +1152,39 @@ function normalizeCustomProviderForStorage(provider, index, providerIdSet) { const preferredId = originalId || `custom-provider-${index + 1}` const id = ensureUniqueProviderId(providerIdSet, preferredId) providerIdSet.add(id) + const legacyProviderIds = getLegacyProviderIds( + [...(Array.isArray(provider.legacyProviderIds) ? provider.legacyProviderIds : []), originalId], + id, + ) + const chatCompletionsPath = ensureLeadingSlash( + provider.chatCompletionsPath, + '/v1/chat/completions', + ) + const completionsPath = ensureLeadingSlash(provider.completionsPath, '/v1/completions') + const normalizedLegacyProviderIds = legacyProviderIds.length > 0 ? legacyProviderIds : undefined + const storageShapeChanged = + (normalizeText(provider.chatCompletionsPath) || '/v1/chat/completions') !== + chatCompletionsPath || + (normalizeText(provider.completionsPath) || '/v1/completions') !== completionsPath || + JSON.stringify(provider.legacyProviderIds) !== JSON.stringify(normalizedLegacyProviderIds) return { originalId, originalRawId, sourceProviderOriginalId: sourceProviderId, sourceProviderOriginalRawId, + storageShapeChanged, provider: { id, name: normalizeText(provider.name) || `Custom Provider ${index + 1}`, baseUrl: normalizeText(provider.baseUrl), - chatCompletionsPath: normalizeText(provider.chatCompletionsPath) || '/v1/chat/completions', - completionsPath: normalizeText(provider.completionsPath) || '/v1/completions', + chatCompletionsPath, + completionsPath, chatCompletionsUrl: normalizeText(provider.chatCompletionsUrl), completionsUrl: normalizeText(provider.completionsUrl), enabled: provider.enabled !== false, allowLegacyResponseField: provider.allowLegacyResponseField !== false, ...(sourceProviderId ? { sourceProviderId } : {}), + ...(normalizedLegacyProviderIds ? { legacyProviderIds: normalizedLegacyProviderIds } : {}), }, } } @@ -1158,6 +1227,7 @@ function migrateUserConfig(options) { if (!hasProviderSecretsRecord) { dirty = true } + const providerSecretSnapshot = { ...providerSecrets } for (const [legacyKey, providerId] of Object.entries(LEGACY_SECRET_KEY_TO_PROVIDER_ID)) { const legacyKeyValue = normalizeText(migrated[legacyKey]) const hasProviderSecret = Object.hasOwn(providerSecrets, providerId) @@ -1166,6 +1236,23 @@ function migrateUserConfig(options) { dirty = true } } + const completedBuiltinProviderIdMigrations = new Set( + Array.isArray(migrated.completedBuiltinProviderIdMigrations) + ? migrated.completedBuiltinProviderIdMigrations.map(normalizeProviderId).filter(Boolean) + : [], + ) + const currentBuiltinProviderIdCollisions = new Set( + (Array.isArray(migrated.customOpenAIProviders) ? migrated.customOpenAIProviders : []) + .map((provider) => normalizeProviderId(provider?.id)) + .filter((providerId) => NEWLY_RESERVED_BUILTIN_PROVIDER_IDS.has(providerId)), + ) + const pendingBuiltinProviderIdMigrations = new Set( + [...NEWLY_RESERVED_BUILTIN_PROVIDER_IDS].filter( + (providerId) => + !completedBuiltinProviderIdMigrations.has(providerId) || + currentBuiltinProviderIdCollisions.has(providerId), + ), + ) const builtinProviderIds = new Set( Object.values(API_MODE_GROUP_TO_PROVIDER_ID) @@ -1195,7 +1282,10 @@ function migrateUserConfig(options) { .filter((id) => id), ) const customOpenAIProviders = normalizedProviderResults.map( - ({ originalId, originalRawId, sourceProviderOriginalRawId, provider }) => { + ({ originalId, originalRawId, sourceProviderOriginalRawId, storageShapeChanged, provider }) => { + if (storageShapeChanged) { + dirty = true + } if (normalizeText(originalRawId) !== normalizeText(provider.id)) { dirty = true } @@ -1215,7 +1305,52 @@ function migrateUserConfig(options) { return provider }, ) + for (const provider of customOpenAIProviders) { + for (const legacyProviderId of getLegacyProviderIds(provider.legacyProviderIds)) { + providerIdSet.add(legacyProviderId) + } + } if (!Array.isArray(migrated.customOpenAIProviders)) dirty = true + const hasCanonicalProviderIdCollision = (providerId) => + normalizedProviderResults.filter(({ originalId }) => originalId === providerId).length > 1 + const resolveRenamedProviderId = (providerId, providerIdRaw, customUrl) => { + const providerIdCandidates = normalizedProviderResults.filter( + ({ originalId }) => originalId === providerId, + ) + if (providerIdCandidates.length > 1) { + const normalizedProviderIdRaw = normalizeText(providerIdRaw) + const rawIdMatches = providerIdCandidates.filter( + ({ originalRawId }) => normalizeText(originalRawId) === normalizedProviderIdRaw, + ) + if (rawIdMatches.length === 1) { + return rawIdMatches[0].provider.id + } + const normalizedCustomUrl = normalizeEndpointUrlForCompare(customUrl) + if (normalizedCustomUrl) { + const matchingProviderIds = providerIdCandidates + .filter( + ({ provider }) => + getProviderChatCompletionsUrlForCompare(provider) === normalizedCustomUrl, + ) + .map(({ provider }) => provider.id) + if (matchingProviderIds.length === 1) { + return matchingProviderIds[0] + } + } + return undefined + } + const renamedProviderId = providerIdRenameLookup.get(providerId) + if (renamedProviderId) return renamedProviderId + if (!NEWLY_RESERVED_BUILTIN_PROVIDER_IDS.has(providerId)) return undefined + + const legacyProviderIdMatches = customOpenAIProviders.filter((provider) => + getLegacyProviderIds(provider.legacyProviderIds).includes(providerId), + ) + if (legacyProviderIdMatches.length !== 1 || legacyProviderIdMatches[0].enabled === false) { + return undefined + } + return legacyProviderIdMatches[0].id + } for (const { sourceProviderOriginalId, @@ -1244,40 +1379,147 @@ function migrateUserConfig(options) { } } - for (let index = providerIdRenames.length - 1; index >= 0; index -= 1) { - const { - oldId: oldProviderId, - oldRawId: oldRawProviderId, - newId: newProviderId, - } = providerIdRenames[index] + const rawProviderSecretIdsToDelete = new Set() + const builtinProviderSecretIdsToDelete = new Map() + for (const { + oldId: oldProviderId, + oldRawId: oldRawProviderId, + newId: newProviderId, + } of providerIdRenames) { if (oldProviderId === newProviderId) continue if (!legacyCustomProviderIds.has(oldProviderId)) continue - const hasRawIdSecret = Object.hasOwn(providerSecrets, oldRawProviderId) - const hasNormalizedIdSecret = Object.hasOwn(providerSecrets, oldProviderId) + const hasRawIdSecret = Object.hasOwn(providerSecretSnapshot, oldRawProviderId) + const hasNormalizedIdSecret = Object.hasOwn(providerSecretSnapshot, oldProviderId) + const hasDistinctRawId = oldRawProviderId !== oldProviderId const usesBuiltinSecretSlot = builtinProviderIds.has(oldProviderId) - if (usesBuiltinSecretSlot && !hasRawIdSecret) continue - if (!usesBuiltinSecretSlot && !hasRawIdSecret && !hasNormalizedIdSecret) continue - const rawIdSecret = hasRawIdSecret ? providerSecrets[oldRawProviderId] : undefined - const normalizedIdSecret = hasNormalizedIdSecret ? providerSecrets[oldProviderId] : undefined - const oldSecret = usesBuiltinSecretSlot - ? rawIdSecret - : hasRawIdSecret && rawIdSecret !== '' - ? rawIdSecret - : hasNormalizedIdSecret - ? normalizedIdSecret - : rawIdSecret + const isPendingBuiltinProviderIdMigration = + usesBuiltinSecretSlot && pendingBuiltinProviderIdMigrations.has(oldProviderId) + const hasMultipleProviderIdRenames = + normalizedProviderResults.filter(({ originalId }) => originalId === oldProviderId).length > 1 + const hasUniqueCanonicalRawIdOwner = + normalizedProviderResults.filter( + ({ originalId, originalRawId }) => + originalId === oldProviderId && originalRawId === oldProviderId, + ).length === 1 + const rawIdSecret = hasRawIdSecret ? providerSecretSnapshot[oldRawProviderId] : undefined + const normalizedIdSecret = hasNormalizedIdSecret + ? providerSecretSnapshot[oldProviderId] + : undefined + const rawSecretValue = normalizeText(rawIdSecret) + const normalizedSecretValue = normalizeText(normalizedIdSecret) + const builtinLegacyKey = LEGACY_API_KEY_FIELD_BY_PROVIDER_ID[oldProviderId] + const builtinLegacySecret = builtinLegacyKey ? normalizeText(migrated[builtinLegacyKey]) : '' + const normalizedSecretBelongsToBuiltin = + isPendingBuiltinProviderIdMigration && + builtinLegacySecret && + builtinLegacySecret === normalizeText(normalizedIdSecret) + if (hasDistinctRawId && hasRawIdSecret && !unchangedProviderIds.has(oldProviderId)) { + rawProviderSecretIdsToDelete.add(oldRawProviderId) + } + if ( + !usesBuiltinSecretSlot && + hasMultipleProviderIdRenames && + hasUniqueCanonicalRawIdOwner && + !hasDistinctRawId && + hasRawIdSecret + ) { + rawProviderSecretIdsToDelete.add(oldProviderId) + } + let hasOldSecret = false + let oldSecret + let usesNormalizedBuiltinSecretSlot = false if ( - !Object.hasOwn(providerSecrets, newProviderId) || - providerSecrets[newProviderId] !== oldSecret + hasDistinctRawId && + hasRawIdSecret && + (hasMultipleProviderIdRenames || + !isPendingBuiltinProviderIdMigration || + !hasNormalizedIdSecret || + normalizedSecretBelongsToBuiltin || + rawSecretValue) + ) { + hasOldSecret = true + oldSecret = + !usesBuiltinSecretSlot && rawIdSecret === '' && hasNormalizedIdSecret + ? normalizedIdSecret + : rawIdSecret + } else if ( + isPendingBuiltinProviderIdMigration && + hasNormalizedIdSecret && + !normalizedSecretBelongsToBuiltin && + (!hasMultipleProviderIdRenames || !hasUniqueCanonicalRawIdOwner || !hasDistinctRawId) && + (normalizedSecretValue || !rawSecretValue) + ) { + hasOldSecret = true + oldSecret = normalizedIdSecret + usesNormalizedBuiltinSecretSlot = true + } else if ( + !usesBuiltinSecretSlot && + (!hasMultipleProviderIdRenames || !hasDistinctRawId) && + (hasRawIdSecret || hasNormalizedIdSecret) + ) { + hasOldSecret = true + oldSecret = + hasRawIdSecret && rawIdSecret !== '' + ? rawIdSecret + : hasNormalizedIdSecret + ? normalizedIdSecret + : rawIdSecret + } + if ( + hasOldSecret && + (!Object.hasOwn(providerSecrets, newProviderId) || + providerSecrets[newProviderId] !== oldSecret) ) { providerSecrets[newProviderId] = oldSecret dirty = true } - if (hasRawIdSecret && oldRawProviderId !== oldProviderId) { - delete providerSecrets[oldRawProviderId] + if ( + usesNormalizedBuiltinSecretSlot || + (isPendingBuiltinProviderIdMigration && + hasNormalizedIdSecret && + !normalizedSecretBelongsToBuiltin && + !normalizedSecretValue && + rawSecretValue) + ) { + builtinProviderSecretIdsToDelete.set(oldProviderId, normalizedIdSecret) + } + } + for (const providerId of rawProviderSecretIdsToDelete) { + if (Object.hasOwn(providerSecrets, providerId)) { + delete providerSecrets[providerId] dirty = true } } + for (const [providerId, migratedCustomSecret] of builtinProviderSecretIdsToDelete) { + const legacyKey = LEGACY_API_KEY_FIELD_BY_PROVIDER_ID[providerId] + const builtinLegacySecret = legacyKey ? normalizeText(migrated[legacyKey]) : '' + if (builtinLegacySecret && builtinLegacySecret !== normalizeText(migratedCustomSecret)) { + if (providerSecrets[providerId] !== builtinLegacySecret) { + providerSecrets[providerId] = builtinLegacySecret + dirty = true + } + continue + } + if (Object.hasOwn(providerSecrets, providerId)) { + delete providerSecrets[providerId] + dirty = true + } + if (legacyKey && normalizeText(migrated[legacyKey])) { + migrated[legacyKey] = '' + dirty = true + } + } + for (const providerId of NEWLY_RESERVED_BUILTIN_PROVIDER_IDS) { + completedBuiltinProviderIdMigrations.add(providerId) + } + const completedBuiltinProviderIdMigrationList = [...completedBuiltinProviderIdMigrations] + if ( + JSON.stringify(migrated.completedBuiltinProviderIdMigrations) !== + JSON.stringify(completedBuiltinProviderIdMigrationList) + ) { + migrated.completedBuiltinProviderIdMigrations = completedBuiltinProviderIdMigrationList + dirty = true + } const activeCustomProviderIds = new Set( customOpenAIProviders.map((provider) => normalizeText(provider?.id)).filter(Boolean), @@ -1287,11 +1529,25 @@ function migrateUserConfig(options) { const rawProviderId = normalizeText(originalRawId) const normalizedProviderId = normalizeText(provider?.id) if (!rawProviderId || !normalizedProviderId || rawProviderId === normalizedProviderId) continue + if (builtinProviderIds.has(normalizeProviderId(rawProviderId))) continue if (!Object.hasOwn(providerSecrets, rawProviderId)) continue - const rawSecret = providerSecrets[rawProviderId] + const snapshotHasRawSecret = Object.hasOwn(providerSecretSnapshot, rawProviderId) + const snapshotRawSecret = snapshotHasRawSecret + ? providerSecretSnapshot[rawProviderId] + : undefined + const hasCanonicalProviderIdCollision = + normalizedProviderResults.filter( + ({ originalId }) => originalId === normalizeProviderId(rawProviderId), + ).length > 1 + if (hasCanonicalProviderIdCollision && !snapshotHasRawSecret) continue + const shouldPreferDistinctRawSecret = + snapshotHasRawSecret && snapshotRawSecret !== '' && hasCanonicalProviderIdCollision + const rawSecret = shouldPreferDistinctRawSecret + ? snapshotRawSecret + : providerSecrets[rawProviderId] const shouldPreserveRawSecretSlot = builtinProviderIds.has(rawProviderId) || activeCustomProviderIds.has(rawProviderId) - if (!Object.hasOwn(providerSecrets, normalizedProviderId)) { + if (shouldPreferDistinctRawSecret || !Object.hasOwn(providerSecrets, normalizedProviderId)) { providerSecrets[normalizedProviderId] = rawSecret dirty = true } @@ -1312,6 +1568,7 @@ function migrateUserConfig(options) { JSON.stringify(customApiModes) !== JSON.stringify(migrated.customApiModes) let customProvidersDirty = false const migratedCustomModeProviderIds = new Map() + const migratedCustomModeProviderIdsByCanonicalSignature = new Map() const pendingLegacyCustomUrlProviderSecretBackfillIds = new Set() const getLegacyCustomProviderSecret = () => normalizeText(providerSecrets['legacy-custom-default']) @@ -1345,13 +1602,20 @@ function migrateUserConfig(options) { } } } - const getCustomModeMigrationSignature = (apiMode) => + const getCustomModeMigrationSignature = (apiMode, includeRawProviderId = true) => JSON.stringify({ groupName: normalizeText(apiMode?.groupName), itemName: normalizeText(apiMode?.itemName), isCustom: Boolean(apiMode?.isCustom), customName: normalizeText(apiMode?.customName), customUrl: normalizeEndpointUrlForCompare(normalizeText(apiMode?.customUrl)), + ...(includeRawProviderId + ? { + providerIdRaw: normalizeText( + typeof apiMode?.providerId === 'string' ? apiMode.providerId : '', + ), + } + : {}), providerId: normalizeProviderId( typeof apiMode?.providerId === 'string' ? apiMode.providerId : '', ), @@ -1377,11 +1641,21 @@ function migrateUserConfig(options) { `custom-provider-${customProviderCounter}` const providerId = ensureUniqueProviderId(providerIdSet, preferredId) providerIdSet.add(providerId) + const legacyProviderIds = getLegacyProviderIds( + [ + ...(Array.isArray(sourceProvider?.legacyProviderIds) + ? sourceProvider.legacyProviderIds + : []), + ...(targetProviderId === 'legacy-custom-default' ? [] : [targetProviderId]), + ], + providerId, + ) const provider = sourceProvider ? { ...sourceProvider, id: providerId, name: providerName, + ...(legacyProviderIds.length > 0 ? { legacyProviderIds } : {}), } : { id: providerId, @@ -1394,6 +1668,7 @@ function migrateUserConfig(options) { completionsUrl: '', enabled: true, allowLegacyResponseField: true, + ...(legacyProviderIds.length > 0 ? { legacyProviderIds } : {}), } customOpenAIProviders.push(provider) customProvidersDirty = true @@ -1401,7 +1676,12 @@ function migrateUserConfig(options) { return providerId } const promoteCustomModeApiKeyToProvider = (apiMode, apiModeKey) => { - const targetProviderId = normalizeText(apiMode.providerId) || 'legacy-custom-default' + const targetProviderId = normalizeProviderId(apiMode.providerId) || 'legacy-custom-default' + const targetsUnresolvedReservedBuiltin = + NEWLY_RESERVED_BUILTIN_PROVIDER_IDS.has(targetProviderId) && + !customOpenAIProviders.some((provider) => provider.id === targetProviderId) + if (targetsUnresolvedReservedBuiltin) return '' + const existingProviderSecret = normalizeText(providerSecrets[targetProviderId]) if (!hasOwnProviderSecret(targetProviderId)) { providerSecrets[targetProviderId] = apiModeKey @@ -1444,6 +1724,7 @@ function migrateUserConfig(options) { } const originalCustomModeSignature = getCustomModeMigrationSignature(apiMode) + const originalCanonicalCustomModeSignature = getCustomModeMigrationSignature(apiMode, false) const existingProviderIdRaw = typeof apiMode.providerId === 'string' ? apiMode.providerId : '' const existingProviderId = normalizeProviderId(existingProviderIdRaw) if (existingProviderId && existingProviderIdRaw !== existingProviderId) { @@ -1451,9 +1732,14 @@ function migrateUserConfig(options) { customApiModesDirty = true } let providerIdAssignedFromLegacyCustomUrl = false - const renamedProviderId = providerIdRenameLookup.get(existingProviderId) + const renamedProviderId = resolveRenamedProviderId( + existingProviderId, + existingProviderIdRaw, + apiMode.customUrl, + ) if (renamedProviderId && normalizeText(apiMode.providerId) !== renamedProviderId) { apiMode.providerId = renamedProviderId + addLegacyProviderId(apiMode, existingProviderId) customApiModesDirty = true } @@ -1502,15 +1788,19 @@ function migrateUserConfig(options) { const apiModeKey = normalizeText(apiMode.apiKey) if (apiModeKey) { const promotedProviderId = promoteCustomModeApiKeyToProvider(apiMode, apiModeKey) - if (normalizeText(apiMode.providerId) !== promotedProviderId) { - apiMode.providerId = promotedProviderId - customApiModesDirty = true - } - if (normalizeText(apiMode.apiKey)) { - // Mode-level custom keys are treated as legacy data; after migration, - // providerSecrets is the single source of truth. - apiMode.apiKey = '' - customApiModesDirty = true + if (promotedProviderId) { + if (normalizeText(apiMode.providerId) !== promotedProviderId) { + const previousProviderId = apiMode.providerId + apiMode.providerId = promotedProviderId + addLegacyProviderId(apiMode, previousProviderId) + customApiModesDirty = true + } + if (normalizeText(apiMode.apiKey)) { + // Mode-level custom keys are treated as legacy data; after migration, + // providerSecrets is the single source of truth. + apiMode.apiKey = '' + customApiModesDirty = true + } } } else if (providerIdAssignedFromLegacyCustomUrl) { queueLegacyCustomUrlProviderSecretBackfill(apiMode.providerId) @@ -1520,6 +1810,24 @@ function migrateUserConfig(options) { originalCustomModeSignature, normalizeText(apiMode.providerId), ) + const migratedProviderId = normalizeText(apiMode.providerId) + if ( + !migratedCustomModeProviderIdsByCanonicalSignature.has(originalCanonicalCustomModeSignature) + ) { + migratedCustomModeProviderIdsByCanonicalSignature.set( + originalCanonicalCustomModeSignature, + migratedProviderId, + ) + } else if ( + migratedCustomModeProviderIdsByCanonicalSignature.get( + originalCanonicalCustomModeSignature, + ) !== migratedProviderId + ) { + migratedCustomModeProviderIdsByCanonicalSignature.set( + originalCanonicalCustomModeSignature, + '', + ) + } } backfillPendingLegacyCustomUrlProviderSecrets() @@ -1531,6 +1839,14 @@ function migrateUserConfig(options) { const originalSelectedCustomModeSignature = selectedIsCustom ? getCustomModeMigrationSignature(selectedApiMode) : '' + const originalSelectedCanonicalCustomModeSignature = selectedIsCustom + ? getCustomModeMigrationSignature(selectedApiMode, false) + : '' + const originalSelectedCanonicalProviderId = selectedIsCustom + ? normalizeProviderId(selectedApiMode.providerId) + : '' + const selectedCanonicalProviderIdHasCollision = + selectedIsCustom && hasCanonicalProviderIdCollision(originalSelectedCanonicalProviderId) if (selectedIsCustom) { const existingSelectedProviderIdRaw = @@ -1543,22 +1859,37 @@ function migrateUserConfig(options) { selectedApiMode.providerId = existingSelectedProviderId selectedApiModeDirty = true } - const renamedSelectedProviderId = providerIdRenameLookup.get(existingSelectedProviderId) + const renamedSelectedProviderId = resolveRenamedProviderId( + existingSelectedProviderId, + existingSelectedProviderIdRaw, + selectedApiMode.customUrl, + ) if ( renamedSelectedProviderId && normalizeText(selectedApiMode.providerId) !== renamedSelectedProviderId ) { selectedApiMode.providerId = renamedSelectedProviderId + addLegacyProviderId(selectedApiMode, existingSelectedProviderId) selectedApiModeDirty = true } } if (selectedIsCustom) { - const migratedProviderId = migratedCustomModeProviderIds.get( + const exactMigratedProviderId = migratedCustomModeProviderIds.get( originalSelectedCustomModeSignature, ) + const migratedProviderId = + exactMigratedProviderId || + (!selectedCanonicalProviderIdHasCollision && + normalizeText(selectedApiMode.providerId) === originalSelectedCanonicalProviderId + ? migratedCustomModeProviderIdsByCanonicalSignature.get( + originalSelectedCanonicalCustomModeSignature, + ) + : '') if (migratedProviderId && normalizeText(selectedApiMode.providerId) !== migratedProviderId) { + const previousSelectedProviderId = selectedApiMode.providerId selectedApiMode.providerId = migratedProviderId + addLegacyProviderId(selectedApiMode, previousSelectedProviderId) selectedApiModeDirty = true } } @@ -1626,30 +1957,31 @@ function migrateUserConfig(options) { const migratedProviderId = selectedIsCustom ? migratedCustomModeProviderIds.get(originalSelectedCustomModeSignature) : '' - if (migratedProviderId) { - if (normalizeText(selectedApiMode.providerId) !== migratedProviderId) { - selectedApiMode.providerId = migratedProviderId - selectedApiModeDirty = true + if (migratedProviderId && normalizeText(selectedApiMode.providerId) !== migratedProviderId) { + const previousSelectedProviderId = selectedApiMode.providerId + selectedApiMode.providerId = migratedProviderId + addLegacyProviderId(selectedApiMode, previousSelectedProviderId) + selectedApiModeDirty = true + } + const targetProviderId = selectedIsCustom + ? promoteCustomModeApiKeyToProvider(selectedApiMode, selectedApiModeKey) + : API_MODE_GROUP_TO_PROVIDER_ID[normalizeText(selectedApiMode.groupName)] || + normalizeText(selectedApiMode.providerId) + if (targetProviderId && normalizeText(selectedApiMode.providerId) !== targetProviderId) { + const previousSelectedProviderId = selectedApiMode.providerId + selectedApiMode.providerId = targetProviderId + if (selectedIsCustom) { + addLegacyProviderId(selectedApiMode, previousSelectedProviderId) } + selectedApiModeDirty = true + } + if (targetProviderId && !selectedIsCustom && !hasOwnProviderSecret(targetProviderId)) { + providerSecrets[targetProviderId] = selectedApiModeKey + dirty = true + } + if (targetProviderId) { selectedApiMode.apiKey = '' selectedApiModeDirty = true - } else { - const targetProviderId = selectedIsCustom - ? promoteCustomModeApiKeyToProvider(selectedApiMode, selectedApiModeKey) - : API_MODE_GROUP_TO_PROVIDER_ID[normalizeText(selectedApiMode.groupName)] || - normalizeText(selectedApiMode.providerId) - if (targetProviderId && normalizeText(selectedApiMode.providerId) !== targetProviderId) { - selectedApiMode.providerId = targetProviderId - selectedApiModeDirty = true - } - if (targetProviderId && !selectedIsCustom && !hasOwnProviderSecret(targetProviderId)) { - providerSecrets[targetProviderId] = selectedApiModeKey - dirty = true - } - if (targetProviderId) { - selectedApiMode.apiKey = '' - selectedApiModeDirty = true - } } } backfillPendingLegacyCustomUrlProviderSecrets() @@ -1865,6 +2197,12 @@ export async function getUserConfig() { if (options.configSchemaVersion !== migrated.configSchemaVersion) { payload.configSchemaVersion = migrated.configSchemaVersion } + if ( + JSON.stringify(options.completedBuiltinProviderIdMigrations) !== + JSON.stringify(migrated.completedBuiltinProviderIdMigrations) + ) { + payload.completedBuiltinProviderIdMigrations = migrated.completedBuiltinProviderIdMigrations + } if (migrated.customChatGptWebApiUrl !== undefined) { if (options.customChatGptWebApiUrl !== migrated.customChatGptWebApiUrl) { payload.customChatGptWebApiUrl = migrated.customChatGptWebApiUrl diff --git a/src/popup/sections/ApiModes.jsx b/src/popup/sections/ApiModes.jsx index 56ef7cd0e..5f39e0fa0 100644 --- a/src/popup/sections/ApiModes.jsx +++ b/src/popup/sections/ApiModes.jsx @@ -17,6 +17,7 @@ import { applySelectedProviderToApiMode, applyDeletedProviderSecrets, applyPendingProviderChanges, + areProviderIdsEquivalent, buildEditedProvider, createProviderId, getApiModeDisplayLabel, @@ -400,7 +401,7 @@ export function ApiModes({ config, updateConfig }) { return } const shouldClearProviderDerivedFields = - editingIndex !== -1 && selectedProviderId !== previousProviderId + editingIndex !== -1 && !areProviderIdsEquivalent(selectedProviderId, previousProviderId) const isEndpointProviderManaged = editingIndex === -1 nextApiMode = applySelectedProviderToApiMode( nextApiMode, diff --git a/src/popup/sections/api-modes-provider-utils.mjs b/src/popup/sections/api-modes-provider-utils.mjs index 90d417804..a4eaf32c0 100644 --- a/src/popup/sections/api-modes-provider-utils.mjs +++ b/src/popup/sections/api-modes-provider-utils.mjs @@ -25,6 +25,15 @@ function normalizeProviderId(value) { .replace(/^-+|-+$/g, '') } +export function areProviderIdsEquivalent(firstProviderId, secondProviderId) { + const normalizedFirstProviderId = normalizeProviderId(firstProviderId) + const normalizedSecondProviderId = normalizeProviderId(secondProviderId) + if (!normalizedFirstProviderId || !normalizedSecondProviderId) { + return normalizeText(firstProviderId) === normalizeText(secondProviderId) + } + return normalizedFirstProviderId === normalizedSecondProviderId +} + function normalizeProviderEndpointUrl(value) { return normalizeText(value).replace(/\/+$/, '') } @@ -95,6 +104,11 @@ export function createProviderId(providerName, existingProviders, reservedProvid const usedIds = new Set([ ...reservedProviderIds.map((providerId) => normalizeProviderId(providerId)), ...providers.map((provider) => normalizeProviderId(provider.id)), + ...providers.flatMap((provider) => + (Array.isArray(provider?.legacyProviderIds) ? provider.legacyProviderIds : []).map( + normalizeProviderId, + ), + ), ]) const baseId = normalizeProviderId(providerName) || `custom-provider-${providers.length + 1}` @@ -310,6 +324,9 @@ export function applySelectedProviderToApiMode( const currentProviderId = normalizeText(nextApiMode.providerId) const nextProviderId = normalizeText(selectedProviderId) nextApiMode.providerId = nextProviderId + if (!areProviderIdsEquivalent(currentProviderId, nextProviderId)) { + delete nextApiMode.legacyProviderIds + } const shouldPreserveLegacyCustomUrl = !isEndpointProviderManaged && !shouldClearProviderDerivedFields && @@ -332,6 +349,7 @@ export function sanitizeApiModeForSave(apiMode) { nextApiMode.apiKey = '' nextApiMode.customUrl = '' delete nextApiMode.sourceProviderId + delete nextApiMode.legacyProviderIds } return nextApiMode } @@ -425,7 +443,7 @@ export function isProviderReferencedByApiModes(providerId, apiModes = []) { return (Array.isArray(apiModes) ? apiModes : []).some( (apiMode) => normalizeText(apiMode?.groupName) === 'customApiModelKeys' && - normalizeText(apiMode?.providerId) === normalizedProviderId, + areProviderIdsEquivalent(apiMode?.providerId, normalizedProviderId), ) } @@ -444,12 +462,16 @@ export function getProviderDeleteDisabledReasonKey( return '' } -function getProvidersMatchingLegacySessionUrl(providers = [], session = null) { +function getProvidersMatchingLegacySessionUrl( + providers = [], + session = null, + { includeDisabled = false } = {}, +) { const customUrl = normalizeProviderEndpointUrl(session?.apiMode?.customUrl) if (!customUrl) return [] return (Array.isArray(providers) ? providers : []).filter((provider) => { - if (provider?.enabled === false) return false + if (!includeDisabled && provider?.enabled === false) return false const directChatCompletionsUrl = normalizeProviderEndpointUrl(provider?.chatCompletionsUrl) if (directChatCompletionsUrl && directChatCompletionsUrl === customUrl) return true @@ -460,21 +482,25 @@ function getProvidersMatchingLegacySessionUrl(providers = [], session = null) { }) } -function getProvidersMatchingSessionProviderId(providers = [], providerId = '') { +function getProvidersMatchingSessionProviderId( + providers = [], + providerId = '', + { includeDisabled = false } = {}, +) { const normalizedProviderId = normalizeText(providerId) if (!normalizedProviderId) return [] - const exactMatches = (Array.isArray(providers) ? providers : []).filter( - (provider) => - provider?.enabled !== false && normalizeText(provider?.id) === normalizedProviderId, - ) - if (exactMatches.length > 0) return exactMatches - const migratedProviderId = normalizeProviderId(normalizedProviderId) - if (!migratedProviderId || migratedProviderId === normalizedProviderId) return [] - - return (Array.isArray(providers) ? providers : []).filter( - (provider) => provider?.enabled !== false && normalizeText(provider?.id) === migratedProviderId, + if (!migratedProviderId) return [] + const availableProviders = (Array.isArray(providers) ? providers : []).filter( + (provider) => includeDisabled || provider?.enabled !== false, + ) + return availableProviders.filter( + (provider) => + normalizeText(provider?.id) === normalizedProviderId || + (Array.isArray(provider?.legacyProviderIds) && + provider.legacyProviderIds.map(normalizeProviderId).includes(migratedProviderId)) || + normalizeProviderId(provider?.id) === migratedProviderId, ) } @@ -527,17 +553,20 @@ export function getReferencedCustomProviderIdsFromSessions( const referencedProviderIds = new Set() for (const session of Array.isArray(sessions) ? sessions : []) { if (normalizeText(session?.apiMode?.groupName) !== 'customApiModelKeys') continue + let ambiguousProviderIdMatches = [] const providerId = normalizeText(session?.apiMode?.providerId) if (providerId && providerId !== 'legacy-custom-default') { - const matchedProviders = getProvidersMatchingSessionProviderId(providers, providerId) - if (matchedProviders.length > 0) { - for (const provider of matchedProviders) { - const matchedProviderId = normalizeText(provider?.id) - if (matchedProviderId && matchedProviderId !== 'legacy-custom-default') { - referencedProviderIds.add(matchedProviderId) - } + const matchedProviders = getProvidersMatchingSessionProviderId(providers, providerId, { + includeDisabled: true, + }) + if (matchedProviders.length === 1) { + const matchedProviderId = normalizeText(matchedProviders[0]?.id) + if (matchedProviderId && matchedProviderId !== 'legacy-custom-default') { + referencedProviderIds.add(matchedProviderId) + continue } - continue + } else if (matchedProviders.length > 1) { + ambiguousProviderIdMatches = matchedProviders } if (!(Array.isArray(providers) ? providers : []).length) { referencedProviderIds.add(providerId) @@ -545,7 +574,11 @@ export function getReferencedCustomProviderIdsFromSessions( } } - const matchedByCustomUrl = getProvidersMatchingLegacySessionUrl(providers, session) + const recoveryProviders = + ambiguousProviderIdMatches.length > 0 ? ambiguousProviderIdMatches : providers + const matchedByCustomUrl = getProvidersMatchingLegacySessionUrl(recoveryProviders, session, { + includeDisabled: ambiguousProviderIdMatches.length > 0, + }) if (matchedByCustomUrl.length > 0) { for (const provider of matchedByCustomUrl) { const matchedProviderId = normalizeText(provider?.id) @@ -556,15 +589,34 @@ export function getReferencedCustomProviderIdsFromSessions( continue } - for (const matchedProviderId of getProviderIdsMatchingSessionLabel( - session, - providers, - apiModes, - )) { + for (const provider of ambiguousProviderIdMatches) { + const matchedProviderId = normalizeText(provider?.id) + if ( + provider?.enabled === false && + areProviderIdsEquivalent(matchedProviderId, providerId) && + matchedProviderId !== 'legacy-custom-default' + ) { + referencedProviderIds.add(matchedProviderId) + } + } + + const matchedProviderIdsByLabel = + ambiguousProviderIdMatches.length === 0 + ? getProviderIdsMatchingSessionLabel(session, providers, apiModes) + : [] + for (const matchedProviderId of matchedProviderIdsByLabel) { if (matchedProviderId && matchedProviderId !== 'legacy-custom-default') { referencedProviderIds.add(matchedProviderId) } } + if (matchedProviderIdsByLabel.length === 0) { + for (const provider of ambiguousProviderIdMatches) { + const matchedProviderId = normalizeText(provider?.id) + if (matchedProviderId && matchedProviderId !== 'legacy-custom-default') { + referencedProviderIds.add(matchedProviderId) + } + } + } } return Array.from(referencedProviderIds) } @@ -577,15 +629,30 @@ export function getApiModeDisplayLabel(apiMode, t, providers = []) { return fallbackLabel } - const providerId = normalizeProviderId(apiMode?.providerId) + const rawProviderId = apiMode?.providerId + const providerId = normalizeProviderId(rawProviderId) const customModelName = normalizeText(apiMode?.customName) if (!providerId || providerId === 'legacy-custom-default') { return fallbackLabel } - const provider = (Array.isArray(providers) ? providers : []).find( - (item) => normalizeProviderId(item?.id) === providerId, - ) + const matchedProviders = getProvidersMatchingSessionProviderId(providers, rawProviderId, { + includeDisabled: true, + }) + const matchedProvidersByUrl = + matchedProviders.length > 1 + ? getProvidersMatchingLegacySessionUrl( + matchedProviders, + { apiMode }, + { includeDisabled: true }, + ) + : [] + const provider = + matchedProviders.length === 1 + ? matchedProviders[0] + : matchedProvidersByUrl.length === 1 + ? matchedProvidersByUrl[0] + : null const providerName = normalizeText(provider?.name) if (!providerName) return fallbackLabel if (!customModelName) return providerName @@ -599,9 +666,9 @@ export function getConversationAiName(session, t, providers = []) { normalizeText(apiMode?.groupName) === 'customApiModelKeys' && normalizeProviderId(apiMode?.providerId) && normalizeProviderId(apiMode?.providerId) !== 'legacy-custom-default' && - !(Array.isArray(providers) ? providers : []).some( - (provider) => normalizeProviderId(provider?.id) === normalizeProviderId(apiMode?.providerId), - ) + getProvidersMatchingSessionProviderId(providers, apiMode?.providerId, { + includeDisabled: true, + }).length === 0 if (hasMissingCustomProvider) { providerAwareName = '' diff --git a/src/popup/sections/import-data-cleanup.mjs b/src/popup/sections/import-data-cleanup.mjs index 125a0ebc7..83e307b7a 100644 --- a/src/popup/sections/import-data-cleanup.mjs +++ b/src/popup/sections/import-data-cleanup.mjs @@ -4,6 +4,7 @@ import { canonicalizeModelKeyArray, canonicalizeSessionModelFields, } from '../../config/model-key-migrations.mjs' +import { LEGACY_API_KEY_FIELD_BY_PROVIDER_ID } from '../../config/openai-provider-mappings.mjs' const conflictingKeyPairs = [ ['claudeApiKey', 'anthropicApiKey'], @@ -13,9 +14,82 @@ const conflictingKeyPairs = [ const apiModeListKeys = ['activeApiModes', 'customApiModes', 'knownApiModeDefaultIds'] const apiModeSelectionKeys = ['modelName', 'apiMode'] +function normalizeProviderId(value) { + return typeof value === 'string' + ? value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + : '' +} + +async function preserveExistingBuiltinSecrets(storageArea, data, normalizedData) { + if ( + !Object.hasOwn(data, 'customOpenAIProviders') || + Object.hasOwn(data, 'providerSecrets') || + !Array.isArray(data.customOpenAIProviders) || + typeof storageArea.get !== 'function' + ) { + return + } + + const importedProviderIds = new Set( + data.customOpenAIProviders.map((provider) => normalizeProviderId(provider?.id)).filter(Boolean), + ) + const relevantProviderIds = [...importedProviderIds].filter((providerId) => + Object.hasOwn(LEGACY_API_KEY_FIELD_BY_PROVIDER_ID, providerId), + ) + if (relevantProviderIds.length === 0) return + + const legacyKeys = relevantProviderIds.map( + (providerId) => LEGACY_API_KEY_FIELD_BY_PROVIDER_ID[providerId], + ) + const stored = await storageArea.get([ + 'completedBuiltinProviderIdMigrations', + 'customOpenAIProviders', + 'providerSecrets', + ...legacyKeys, + ]) + const completedMigrations = new Set( + Array.isArray(stored.completedBuiltinProviderIdMigrations) + ? stored.completedBuiltinProviderIdMigrations.map(normalizeProviderId).filter(Boolean) + : [], + ) + const existingProviderIds = new Set( + (Array.isArray(stored.customOpenAIProviders) ? stored.customOpenAIProviders : []) + .map((provider) => normalizeProviderId(provider?.id)) + .filter(Boolean), + ) + const existingProviderSecrets = + stored.providerSecrets && typeof stored.providerSecrets === 'object' + ? stored.providerSecrets + : {} + + for (const providerId of relevantProviderIds) { + const legacyKey = LEGACY_API_KEY_FIELD_BY_PROVIDER_ID[providerId] + if ( + completedMigrations.has(providerId) && + !existingProviderIds.has(providerId) && + Object.hasOwn(existingProviderSecrets, providerId) + ) { + normalizedData[legacyKey] = existingProviderSecrets[providerId] + } + } +} + export function prepareImportData(data) { const normalizedData = { ...data } const keysToRemove = [] + const importsCompleteProviderState = + Object.hasOwn(data, 'customOpenAIProviders') && Object.hasOwn(data, 'providerSecrets') + + if ( + importsCompleteProviderState && + !Object.hasOwn(data, 'completedBuiltinProviderIdMigrations') + ) { + normalizedData.completedBuiltinProviderIdMigrations = [] + } if (apiModeListKeys.some((key) => Object.hasOwn(data, key))) { for (const key of apiModeListKeys) { @@ -69,6 +143,8 @@ export function prepareImportData(data) { export async function importDataIntoStorage(storageArea, data) { const { normalizedData, keysToRemove } = prepareImportData(data) + await preserveExistingBuiltinSecrets(storageArea, data, normalizedData) + await storageArea.set(normalizedData) if (keysToRemove.length > 0) { diff --git a/src/services/apis/provider-registry.mjs b/src/services/apis/provider-registry.mjs index 0f0724675..206cc4481 100644 --- a/src/services/apis/provider-registry.mjs +++ b/src/services/apis/provider-registry.mjs @@ -198,7 +198,7 @@ function getConfiguredCustomApiModes(config) { itemName: toStringOrEmpty(apiMode.itemName).trim(), isCustom: Boolean(apiMode.isCustom), customName: toStringOrEmpty(apiMode.customName).trim(), - providerId: normalizeProviderId(apiMode.providerId), + providerId: toStringOrEmpty(apiMode.providerId).trim(), }) if (seen.has(signature)) return false seen.add(signature) @@ -207,21 +207,27 @@ function getConfiguredCustomApiModes(config) { } function getConfiguredCustomApiModesForProvider(config, providerId) { + const exactProviderId = toStringOrEmpty(providerId).trim() const normalizedProviderId = normalizeProviderId(providerId) if (!normalizedProviderId) return [] - return getConfiguredCustomApiModes(config).filter( - (apiMode) => normalizeProviderId(apiMode?.providerId) === normalizedProviderId, + const hasCanonicalProviderIdCollision = + getAllOpenAIProviders(config).filter( + (provider) => normalizeProviderId(provider.id) === normalizedProviderId, + ).length > 1 + return getConfiguredCustomApiModes(config).filter((apiMode) => + hasCanonicalProviderIdCollision + ? toStringOrEmpty(apiMode?.providerId).trim() === exactProviderId + : normalizeProviderId(apiMode?.providerId) === normalizedProviderId, ) } function findConfiguredCustomApiMode(config, sessionApiMode, providerId) { - const normalizedProviderId = normalizeProviderId(providerId) const normalizedSessionApiMode = normalizeStableCustomApiModeIdentity(sessionApiMode, providerId) if (!normalizedSessionApiMode || normalizedSessionApiMode.groupName !== 'customApiModelKeys') { return null } - const providerCandidates = getConfiguredCustomApiModesForProvider(config, normalizedProviderId) + const providerCandidates = getConfiguredCustomApiModesForProvider(config, providerId) const exactCandidates = providerCandidates.filter((apiMode) => { const normalizedCandidate = normalizeStableCustomApiModeIdentity(apiMode) return ( @@ -234,7 +240,11 @@ function findConfiguredCustomApiMode(config, sessionApiMode, providerId) { return null } -function findConfiguredCustomApiModeBySessionLabel(config, sessionApiMode) { +function findConfiguredCustomApiModeBySessionLabel( + config, + sessionApiMode, + allowedProviderIds = [], +) { if (!sessionApiMode || typeof sessionApiMode !== 'object') return null if (toStringOrEmpty(sessionApiMode.groupName).trim() !== 'customApiModelKeys') return null @@ -244,8 +254,15 @@ function findConfiguredCustomApiModeBySessionLabel(config, sessionApiMode) { isCustom: Boolean(sessionApiMode.isCustom), customName: toStringOrEmpty(sessionApiMode.customName).trim(), } + const normalizedAllowedProviderIds = new Set(allowedProviderIds.map(normalizeProviderId)) const allCandidates = getConfiguredCustomApiModes(config).filter((apiMode) => { if (!apiMode || typeof apiMode !== 'object') return false + if ( + normalizedAllowedProviderIds.size > 0 && + !normalizedAllowedProviderIds.has(normalizeProviderId(apiMode.providerId)) + ) { + return false + } return ( toStringOrEmpty(apiMode.groupName).trim() === normalizedSessionLabel.groupName && toStringOrEmpty(apiMode.customName).trim() === normalizedSessionLabel.customName @@ -324,6 +341,13 @@ function normalizeCustomProvider(provider, index) { if (!provider || typeof provider !== 'object') return null const id = toStringOrEmpty(provider.id).trim() || `custom-provider-${index + 1}` const sourceProviderId = normalizeProviderId(provider.sourceProviderId) + const legacyProviderIds = Array.from( + new Set( + (Array.isArray(provider.legacyProviderIds) ? provider.legacyProviderIds : []) + .map(normalizeProviderId) + .filter((providerId) => providerId && providerId !== normalizeProviderId(id)), + ), + ) const chatCompletionsPath = ensureLeadingSlash(provider.chatCompletionsPath, DEFAULT_CHAT_PATH) const completionsPath = ensureLeadingSlash(provider.completionsPath, DEFAULT_COMPLETION_PATH) const chatCompletionsUrl = toStringOrEmpty(provider.chatCompletionsUrl).trim() @@ -349,6 +373,7 @@ function normalizeCustomProvider(provider, index) { enabled: provider.enabled !== false, allowLegacyResponseField: provider.allowLegacyResponseField !== false, ...(sourceProviderId ? { sourceProviderId } : {}), + ...(legacyProviderIds.length > 0 ? { legacyProviderIds } : {}), } } @@ -364,6 +389,16 @@ export function getAllOpenAIProviders(config) { return [...buildBuiltinProviders(config), ...customProviders] } +function resolveSecretProviderId(config, providerId) { + const exactProviderId = toStringOrEmpty(providerId).trim() + const normalizedProviderId = normalizeProviderId(providerId) + const hasCanonicalProviderIdCollision = + getAllOpenAIProviders(config).filter( + (provider) => normalizeProviderId(provider.id) === normalizedProviderId, + ).length > 1 + return hasCanonicalProviderIdCollision ? exactProviderId : normalizedProviderId +} + export function resolveProviderIdForSession(session) { const apiMode = session?.apiMode if (apiMode && typeof apiMode === 'object') { @@ -422,18 +457,31 @@ function hasConfiguredProviderSecretEntry(config, providerId) { export function getProviderSecret(config, providerId, session) { if (!providerId) return '' + const exactProviderId = toStringOrEmpty(providerId).trim() const normalizedProviderId = normalizeProviderId(providerId) + const hasCanonicalProviderIdCollision = + getAllOpenAIProviders(config).filter( + (provider) => normalizeProviderId(provider.id) === normalizedProviderId, + ).length > 1 + const configuredProviderId = hasConfiguredProviderSecretEntry(config, exactProviderId) + ? exactProviderId + : hasCanonicalProviderIdCollision + ? exactProviderId + : normalizedProviderId + const sessionProviderId = toStringOrEmpty(session?.apiMode?.providerId).trim() + const canUseApiModeApiKey = + !hasCanonicalProviderIdCollision || !sessionProviderId || sessionProviderId === exactProviderId const apiModeApiKey = - session?.apiMode && typeof session.apiMode === 'object' + canUseApiModeApiKey && session?.apiMode && typeof session.apiMode === 'object' ? toStringOrEmpty(session.apiMode.apiKey).trim() : '' - const hasConfiguredSecretEntry = hasConfiguredProviderSecretEntry(config, normalizedProviderId) - const configuredSecret = getConfiguredProviderSecret(config, normalizedProviderId) + const hasConfiguredSecretEntry = hasConfiguredProviderSecretEntry(config, configuredProviderId) + const configuredSecret = getConfiguredProviderSecret(config, configuredProviderId) if (session?.apiMode?.groupName === 'customApiModelKeys') { const configuredCustomApiMode = findConfiguredCustomApiMode( config, session.apiMode, - normalizedProviderId, + exactProviderId, ) if (configuredCustomApiMode) { const configuredModeApiKey = toStringOrEmpty(configuredCustomApiMode.apiKey).trim() @@ -441,7 +489,7 @@ export function getProviderSecret(config, providerId, session) { if (configuredSecret || hasConfiguredSecretEntry) return configuredSecret return apiModeApiKey } - const providerCandidates = getConfiguredCustomApiModesForProvider(config, normalizedProviderId) + const providerCandidates = getConfiguredCustomApiModesForProvider(config, exactProviderId) if (providerCandidates.length > 0) { const hasAnyModeSpecificKey = providerCandidates.some((apiMode) => toStringOrEmpty(apiMode.apiKey).trim(), @@ -589,6 +637,17 @@ function hasEnabledCustomProviderMatchByLegacySessionUrl(customProviders, sessio }) } +function hasNormalizedProviderIdMatch(provider, normalizedProviderId) { + if (!normalizedProviderId) return false + return ( + normalizeProviderId(provider?.id) === normalizedProviderId || + (Array.isArray(provider?.legacyProviderIds) && + provider.legacyProviderIds.some( + (legacyProviderId) => normalizeProviderId(legacyProviderId) === normalizedProviderId, + )) + ) +} + export function getOpenAICompatibleRequestDiagnostic(config, session) { const apiMode = session?.apiMode && typeof session.apiMode === 'object' ? session.apiMode : null const rawProviderId = apiMode ? toStringOrEmpty(apiMode.providerId) : '' @@ -603,14 +662,15 @@ export function getOpenAICompatibleRequestDiagnostic(config, session) { hasCustomUrl: Boolean(normalizeEndpointUrlForCompare(apiMode?.customUrl)), hasMatchingCustomProvider: normalizedProviderId ? customProviders.some( - (item) => item.enabled !== false && normalizeProviderId(item.id) === normalizedProviderId, + (item) => + item.enabled !== false && hasNormalizedProviderIdMatch(item, normalizedProviderId), ) : false, hasDisabledMatchingCustomProvider: normalizedProviderId ? Array.isArray(config?.customOpenAIProviders) && config.customOpenAIProviders.some( (item) => - item?.enabled === false && normalizeProviderId(item?.id) === normalizedProviderId, + item?.enabled === false && hasNormalizedProviderIdMatch(item, normalizedProviderId), ) : false, hasMatchingCustomProviderByLegacyUrl: hasEnabledCustomProviderMatchByLegacySessionUrl( @@ -681,31 +741,49 @@ export function resolveOpenAICompatibleRequest(config, session) { let recoveredProviderId = '' if (session?.apiMode?.groupName === 'customApiModelKeys') { const customProviders = getCustomOpenAIProviders(config) - const hasAmbiguousLegacyCustomUrlMatch = hasAmbiguousCustomProviderMatchByLegacySessionUrl( - customProviders, - config, - session, - ) - const matchedByProviderId = customProviders.find( - (item) => item.enabled !== false && item.id === providerId, - ) - if (matchedByProviderId) { - provider = matchedByProviderId - resolvedProviderId = matchedByProviderId.id - } const normalizedProviderId = normalizeProviderId(providerId) - if (!provider && normalizedProviderId) { - const matchedByNormalizedProviderId = customProviders.find( - (item) => item.enabled !== false && item.id === normalizedProviderId, + let matchedByProviderId = [] + let matchedByLegacyProviderId = [] + let matchedByNormalizedProviderId = [] + let providerIdCandidates = [] + if (normalizedProviderId) { + matchedByProviderId = customProviders.filter((item) => item.id === providerId) + matchedByLegacyProviderId = customProviders.filter( + (item) => + Array.isArray(item.legacyProviderIds) && + item.legacyProviderIds.includes(normalizedProviderId), + ) + matchedByNormalizedProviderId = customProviders.filter( + (item) => normalizeProviderId(item.id) === normalizedProviderId, ) - if (matchedByNormalizedProviderId) { - provider = matchedByNormalizedProviderId - resolvedProviderId = matchedByNormalizedProviderId.id + providerIdCandidates = Array.from( + new Map( + [ + ...matchedByProviderId, + ...matchedByLegacyProviderId, + ...matchedByNormalizedProviderId, + ].map((item) => [item.id, item]), + ).values(), + ) + if (providerIdCandidates.length === 1 && providerIdCandidates[0].enabled !== false) { + provider = providerIdCandidates[0] + resolvedProviderId = provider.id } } - if (!provider && !hasAmbiguousLegacyCustomUrlMatch) { + const recoveryProviders = + providerIdCandidates.length > 0 ? providerIdCandidates : customProviders + const hasDisabledRecoveryProviderMatchByUrl = hasDisabledCustomProviderMatchByLegacySessionUrl( + recoveryProviders, + session, + ) + const hasAmbiguousLegacyCustomUrlMatch = hasAmbiguousCustomProviderMatchByLegacySessionUrl( + recoveryProviders, + config, + session, + ) + if (!provider && !hasDisabledRecoveryProviderMatchByUrl && !hasAmbiguousLegacyCustomUrlMatch) { const matchedByCustomUrl = resolveCustomProviderByLegacySessionUrl( - customProviders, + recoveryProviders, config, session, ) @@ -714,30 +792,61 @@ export function resolveOpenAICompatibleRequest(config, session) { resolvedProviderId = matchedByCustomUrl.id } } - if (!provider) { + const hasStableSessionModeLabel = Boolean( + toStringOrEmpty(session?.apiMode?.itemName).trim() && + typeof session?.apiMode?.isCustom === 'boolean', + ) + const canUseStableLabelToDisambiguateCandidates = + hasStableSessionModeLabel && providerIdCandidates.length > 1 + if ( + !provider && + ((providerIdCandidates.length <= 1 && !hasDisabledRecoveryProviderMatchByUrl) || + canUseStableLabelToDisambiguateCandidates) + ) { const matchedConfiguredApiMode = findConfiguredCustomApiModeBySessionLabel( config, session?.apiMode, + providerIdCandidates.map((item) => item.id), ) - const matchedConfiguredProviderId = normalizeProviderId(matchedConfiguredApiMode?.providerId) - if (matchedConfiguredProviderId) { - const matchedConfiguredProvider = customProviders.find( - (item) => item.enabled !== false && item.id === matchedConfiguredProviderId, + const matchedConfiguredProviderId = toStringOrEmpty( + matchedConfiguredApiMode?.providerId, + ).trim() + const normalizedMatchedConfiguredProviderId = normalizeProviderId(matchedConfiguredProviderId) + if (normalizedMatchedConfiguredProviderId) { + const configuredProviderCandidates = + providerIdCandidates.length > 0 ? providerIdCandidates : customProviders + const exactConfiguredProviderCandidates = configuredProviderCandidates.filter( + (item) => item.id === matchedConfiguredProviderId, ) + const exactConfiguredProviderMatches = exactConfiguredProviderCandidates.filter( + (item) => item.enabled !== false, + ) + const canonicalConfiguredProviderMatches = configuredProviderCandidates.filter( + (item) => + item.enabled !== false && + normalizeProviderId(item.id) === normalizedMatchedConfiguredProviderId, + ) + const matchedConfiguredProvider = + exactConfiguredProviderMatches.length === 1 + ? exactConfiguredProviderMatches[0] + : exactConfiguredProviderCandidates.length === 0 && + canonicalConfiguredProviderMatches.length === 1 + ? canonicalConfiguredProviderMatches[0] + : null if (matchedConfiguredProvider) { provider = matchedConfiguredProvider resolvedProviderId = matchedConfiguredProvider.id - } else if (matchedConfiguredProviderId === 'legacy-custom-default') { - provider = getProviderById(config, matchedConfiguredProviderId) + } else if (normalizedMatchedConfiguredProviderId === 'legacy-custom-default') { + provider = getProviderById(config, normalizedMatchedConfiguredProviderId) if (provider) { - resolvedProviderId = matchedConfiguredProviderId + resolvedProviderId = normalizedMatchedConfiguredProviderId } } } } - if (!provider && hasAmbiguousLegacyCustomUrlMatch) { + if (!provider && !hasDisabledRecoveryProviderMatchByUrl && hasAmbiguousLegacyCustomUrlMatch) { const matchedByCustomUrl = resolveCustomProviderByLegacySessionUrl( - customProviders, + recoveryProviders, config, session, ) @@ -747,11 +856,13 @@ export function resolveOpenAICompatibleRequest(config, session) { } } if (!provider) { - const normalizedProviderId = normalizeProviderId(providerId) const hasDisabledCustomProviderMatch = Array.isArray(config?.customOpenAIProviders) ? config.customOpenAIProviders.some( (item) => - normalizeProviderId(item?.id) === normalizedProviderId && item?.enabled === false, + item?.enabled === false && + (normalizeProviderId(item?.id) === normalizedProviderId || + (Array.isArray(item?.legacyProviderIds) && + item.legacyProviderIds.map(normalizeProviderId).includes(normalizedProviderId))), ) : false const hasDisabledCustomProviderMatchByUrl = hasDisabledCustomProviderMatchByLegacySessionUrl( @@ -812,7 +923,7 @@ export function resolveOpenAICompatibleRequest(config, session) { if (!requestUrl) return null return { providerId: resolvedProviderId, - secretProviderId: normalizeProviderId(recoveredProviderId || resolvedProviderId), + secretProviderId: resolveSecretProviderId(config, recoveredProviderId || resolvedProviderId), provider, endpointType, requestUrl, diff --git a/src/utils/model-name-convert.mjs b/src/utils/model-name-convert.mjs index 838456c29..fdda5457b 100644 --- a/src/utils/model-name-convert.mjs +++ b/src/utils/model-name-convert.mjs @@ -1,5 +1,28 @@ import { AlwaysCustomGroups, ModelGroups, ModelMode, Models } from '../config/index.mjs' +function normalizeProviderId(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function areProviderIdsEquivalent(firstProviderId, secondProviderId) { + const normalizedFirstProviderId = normalizeProviderId(firstProviderId) + const normalizedSecondProviderId = normalizeProviderId(secondProviderId) + if (!normalizedFirstProviderId || !normalizedSecondProviderId) { + return String(firstProviderId || '').trim() === String(secondProviderId || '').trim() + } + return normalizedFirstProviderId === normalizedSecondProviderId +} + +function normalizeProviderEndpointUrl(value) { + return String(value || '') + .trim() + .replace(/\/+$/, '') +} + export function modelNameToDesc(modelName, t, extraCustomModelName = '') { if (!t) t = (x) => x if (modelName in Models) { @@ -348,6 +371,17 @@ export function isApiModeSelected(apiMode, configOrSession, { sessionCompat = fa if (!selectedApiMode.providerId) return true if (selectedApiMode.providerId === targetApiMode.providerId) return true + if (areProviderIdsEquivalent(selectedApiMode.providerId, targetApiMode.providerId)) { + return true + } + if ( + Array.isArray(targetApiMode.legacyProviderIds) && + targetApiMode.legacyProviderIds.some((providerId) => + areProviderIdsEquivalent(providerId, selectedApiMode.providerId), + ) + ) { + return true + } if (!targetApiMode.providerId) return isLegacyCustomSession return isLegacyCustomSession } @@ -390,14 +424,21 @@ export function getUniquelySelectedApiModeIndex( ) { if (!Array.isArray(apiModes) || apiModes.length === 0) return -1 - let selectedIndex = -1 + const selectedIndexes = [] for (const [index, apiMode] of apiModes.entries()) { if (!isApiModeSelected(apiMode, configOrSession, { sessionCompat })) continue - if (selectedIndex !== -1) return -1 - selectedIndex = index + selectedIndexes.push(index) } - return selectedIndex + if (selectedIndexes.length === 1) return selectedIndexes[0] + if (!sessionCompat || selectedIndexes.length === 0) return -1 + + const selectedCustomUrl = normalizeProviderEndpointUrl(configOrSession?.apiMode?.customUrl) + if (!selectedCustomUrl) return -1 + const urlMatchedIndexes = selectedIndexes.filter( + (index) => normalizeProviderEndpointUrl(apiModes[index]?.customUrl) === selectedCustomUrl, + ) + return urlMatchedIndexes.length === 1 ? urlMatchedIndexes[0] : -1 } // also match custom modelName, e.g. when modelName is bingFree4, configOrSession model is bingFree4-fast, it returns true diff --git a/tests/unit/config/migrate-user-config.test.mjs b/tests/unit/config/migrate-user-config.test.mjs index 992e1afe4..e4104b02d 100644 --- a/tests/unit/config/migrate-user-config.test.mjs +++ b/tests/unit/config/migrate-user-config.test.mjs @@ -6,6 +6,13 @@ import { getApiModesFromConfig, modelNameToApiMode, } from '../../../src/utils/model-name-convert.mjs' +import { resolveOpenAICompatibleRequest } from '../../../src/services/apis/provider-registry.mjs' + +const newlyReservedBuiltinProviders = [ + { id: 'xai', legacyKey: 'xaiApiKey' }, + { id: 'nvidia-nim', legacyKey: 'nvidiaNimApiKey' }, + { id: 'mistral', legacyKey: 'mistralApiKey' }, +] function createCustomApiMode(overrides = {}) { return { @@ -279,6 +286,38 @@ test('getUserConfig persists normalization-only sourceProviderId migrations', as assert.equal(storedProvider.sourceProviderId, 'openai') }) +test('getUserConfig persists provider lineage and path normalization by itself', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + completedBuiltinProviderIdMigrations: ['xai', 'nvidia-nim', 'mistral'], + providerSecrets: {}, + customApiModes: [], + activeApiModes: [], + knownApiModeDefaultIds: defaultApiModeIds, + customOpenAIProviders: [ + { + id: 'foo', + name: 'Foo Proxy', + baseUrl: '', + chatCompletionsPath: 'v1/chat/completions', + completionsPath: 'v1/completions', + chatCompletionsUrl: 'https://foo.example.com/v1/chat/completions', + completionsUrl: '', + enabled: true, + allowLegacyResponseField: true, + legacyProviderIds: ['FOO', 'Old_Id', 'old-id'], + }, + ], + }) + + await getUserConfig() + const storedProvider = globalThis.__TEST_BROWSER_SHIM__.getStorage().customOpenAIProviders[0] + + assert.equal(storedProvider.chatCompletionsPath, '/v1/chat/completions') + assert.equal(storedProvider.completionsPath, '/v1/completions') + assert.deepEqual(storedProvider.legacyProviderIds, ['old-id']) +}) + test('getUserConfig remaps preserved custom sourceProviderId when provider ids are renamed', async () => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ configSchemaVersion: 0, @@ -446,20 +485,20 @@ test('getUserConfig does not reuse builtin provider secret for renamed colliding assert.equal(config.providerSecrets['openai-2'], undefined) }) -test('getUserConfig keeps original secret when colliding custom provider raw id is already normalized', async () => { +test('getUserConfig keeps a raw-id secret on unchanged and renamed duplicate providers', async () => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ configSchemaVersion: 0, providerSecrets: { - myproxy: 'shared-provider-secret', + MyProxy: 'shared-provider-secret', }, customOpenAIProviders: [ { - id: 'myproxy', + id: 'MyProxy', name: 'Primary Proxy', chatCompletionsUrl: 'https://primary.example.com/v1/chat/completions', }, { - id: 'myproxy', + id: 'MyProxy', name: 'Duplicate Proxy', chatCompletionsUrl: 'https://duplicate.example.com/v1/chat/completions', }, @@ -478,6 +517,340 @@ test('getUserConfig keeps original secret when colliding custom provider raw id assert.equal(duplicateProvider.id, 'myproxy-2') assert.equal(config.providerSecrets.myproxy, 'shared-provider-secret') assert.equal(config.providerSecrets['myproxy-2'], 'shared-provider-secret') + assert.equal(Object.hasOwn(config.providerSecrets, 'MyProxy'), false) +}) + +test('getUserConfig keeps a shared secret on already-normalized duplicate providers', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + myproxy: 'shared-provider-secret', + }, + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'Primary Proxy', + chatCompletionsUrl: 'https://primary.example.com/v1/chat/completions', + }, + { + id: 'myproxy', + name: 'Duplicate Proxy', + chatCompletionsUrl: 'https://duplicate.example.com/v1/chat/completions', + }, + ], + }) + + const config = await getUserConfig() + + assert.equal(config.providerSecrets.myproxy, 'shared-provider-secret') + assert.equal(config.providerSecrets['myproxy-2'], 'shared-provider-secret') +}) + +test('getUserConfig keeps distinct secrets for canonically duplicate provider IDs', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + Foo: 'primary-provider-secret', + foo: 'duplicate-provider-secret', + }, + customOpenAIProviders: [ + { + id: 'Foo', + name: 'Primary Proxy', + chatCompletionsUrl: 'https://primary.example.com/v1/chat/completions', + }, + { + id: 'foo', + name: 'Duplicate Proxy', + chatCompletionsUrl: 'https://duplicate.example.com/v1/chat/completions', + }, + ], + }) + + const config = await getUserConfig() + + assert.equal(config.providerSecrets.foo, 'primary-provider-secret') + assert.equal(config.providerSecrets['foo-2'], 'duplicate-provider-secret') + assert.equal(Object.hasOwn(config.providerSecrets, 'Foo'), false) +}) + +test('getUserConfig keeps asymmetric secrets on their raw canonical provider', async () => { + const providers = [ + { + id: 'Foo', + name: 'Uppercase Proxy', + chatCompletionsUrl: 'https://uppercase.example.com/v1/chat/completions', + }, + { + id: 'foo', + name: 'Lowercase Proxy', + chatCompletionsUrl: 'https://lowercase.example.com/v1/chat/completions', + }, + ] + + for (const orderedProviders of [providers, [...providers].reverse()]) { + for (const [secretId, secret, expectedUpperSecret, expectedLowerSecret] of [ + ['Foo', 'uppercase-secret', 'uppercase-secret', undefined], + ['foo', 'lowercase-secret', undefined, 'lowercase-secret'], + ]) { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { [secretId]: secret }, + customOpenAIProviders: orderedProviders, + }) + + const config = await getUserConfig() + const uppercaseProvider = config.customOpenAIProviders.find( + (provider) => provider.name === 'Uppercase Proxy', + ) + const lowercaseProvider = config.customOpenAIProviders.find( + (provider) => provider.name === 'Lowercase Proxy', + ) + + assert.equal(config.providerSecrets[uppercaseProvider.id], expectedUpperSecret) + assert.equal(config.providerSecrets[lowercaseProvider.id], expectedLowerSecret) + + const remigratedConfig = await getUserConfig() + assert.deepEqual(remigratedConfig.customOpenAIProviders, config.customOpenAIProviders) + assert.deepEqual(remigratedConfig.providerSecrets, config.providerSecrets) + } + } +}) + +test('getUserConfig uses raw IDs to remap modes for canonically duplicate providers', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + Foo: 'primary-provider-secret', + foo: 'duplicate-provider-secret', + }, + customOpenAIProviders: [ + { + id: 'Foo', + name: 'Primary Proxy', + chatCompletionsUrl: 'https://primary.example.com/v1/chat/completions', + }, + { + id: 'foo', + name: 'Duplicate Proxy', + chatCompletionsUrl: 'https://duplicate.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ customName: 'Primary mode', providerId: 'Foo' }), + createCustomApiMode({ customName: 'Duplicate mode', providerId: 'foo' }), + ], + apiMode: createCustomApiMode({ customName: 'Duplicate mode', providerId: 'foo' }), + }) + + const config = await getUserConfig() + const primaryMode = config.customApiModes.find((mode) => mode.customName === 'Primary mode') + const duplicateMode = config.customApiModes.find((mode) => mode.customName === 'Duplicate mode') + + assert.equal(primaryMode.providerId, 'foo') + assert.equal(duplicateMode.providerId, 'foo-2') + assert.equal(config.apiMode.providerId, 'foo-2') + assert.equal( + resolveOpenAICompatibleRequest(config, { apiMode: primaryMode })?.apiKey, + 'primary-provider-secret', + ) + assert.equal( + resolveOpenAICompatibleRequest(config, { apiMode: duplicateMode })?.apiKey, + 'duplicate-provider-secret', + ) +}) + +test('getUserConfig keeps selected raw provider ID distinct in matching mode signatures', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + Foo: 'primary-provider-secret', + foo: 'duplicate-provider-secret', + }, + customOpenAIProviders: [ + { + id: 'Foo', + name: 'Primary Proxy', + chatCompletionsUrl: 'https://primary.example.com/v1/chat/completions', + }, + { + id: 'foo', + name: 'Duplicate Proxy', + chatCompletionsUrl: 'https://duplicate.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ customName: 'Shared mode', providerId: 'Foo' }), + createCustomApiMode({ customName: 'Shared mode', providerId: 'foo' }), + ], + apiMode: createCustomApiMode({ customName: 'Shared mode', providerId: 'Foo' }), + }) + + const config = await getUserConfig() + + assert.deepEqual( + config.customApiModes + .filter((mode) => mode.customName === 'Shared mode') + .map((mode) => mode.providerId), + ['foo', 'foo-2'], + ) + assert.equal(config.apiMode.providerId, 'foo') + assert.equal(config.providerSecrets[config.apiMode.providerId], 'primary-provider-secret') +}) + +test('getUserConfig preserves selected raw provider disambiguation without a listed match', async () => { + for (const [listedProviderId, selectedProviderId, migratedListedId, migratedSelectedId] of [ + ['Foo', 'foo', 'foo', 'foo-2'], + ['foo', 'Foo', 'foo-2', 'foo'], + ]) { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + Foo: 'primary-provider-secret', + foo: 'duplicate-provider-secret', + }, + customOpenAIProviders: [ + { + id: 'Foo', + name: 'Primary Proxy', + chatCompletionsUrl: 'https://primary.example.com/v1/chat/completions', + }, + { + id: 'foo', + name: 'Duplicate Proxy', + chatCompletionsUrl: 'https://duplicate.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ customName: 'Shared mode', providerId: listedProviderId }), + ], + apiMode: createCustomApiMode({ + customName: 'Shared mode', + providerId: selectedProviderId, + }), + }) + + const config = await getUserConfig() + const migratedMode = config.customApiModes.find((mode) => mode.customName === 'Shared mode') + + assert.equal(migratedMode.providerId, migratedListedId) + assert.equal(config.apiMode.providerId, migratedSelectedId) + } +}) + +test('getUserConfig reuses key promotion for canonically equivalent selected provider ID', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { MyProxy: 'provider-key' }, + customOpenAIProviders: [ + { + id: 'MyProxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ + customName: 'Shared mode', + providerId: 'MyProxy', + apiKey: 'mode-key', + }), + ], + apiMode: createCustomApiMode({ + customName: 'Shared mode', + providerId: 'myproxy', + apiKey: 'mode-key', + }), + }) + + const config = await getUserConfig() + const migratedMode = config.customApiModes.find((mode) => mode.customName === 'Shared mode') + + assert.equal(migratedMode.providerId, 'shared-mode') + assert.equal(config.apiMode.providerId, migratedMode.providerId) + assert.equal(config.providerSecrets[migratedMode.providerId], 'mode-key') + assert.equal( + config.customOpenAIProviders.some((provider) => provider.id === 'shared-mode-2'), + false, + ) +}) + +test('getUserConfig remaps modes for duplicate newly reserved provider IDs', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + providerSecrets: { + XAI: 'uppercase-provider-secret', + xai: 'lowercase-provider-secret', + }, + customOpenAIProviders: [ + { + id: 'XAI', + name: 'Uppercase xAI Proxy', + chatCompletionsUrl: 'https://uppercase.example.com/v1/chat/completions', + }, + { + id: 'xai', + name: 'Lowercase xAI Proxy', + chatCompletionsUrl: 'https://lowercase.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ customName: 'Uppercase mode', providerId: 'XAI' }), + createCustomApiMode({ customName: 'Lowercase mode', providerId: 'xai' }), + ], + }) + + const config = await getUserConfig() + const uppercaseMode = config.customApiModes.find((mode) => mode.customName === 'Uppercase mode') + const lowercaseMode = config.customApiModes.find((mode) => mode.customName === 'Lowercase mode') + + assert.equal(uppercaseMode.providerId, 'xai-2') + assert.equal(lowercaseMode.providerId, 'xai-3') + assert.equal( + resolveOpenAICompatibleRequest(config, { apiMode: uppercaseMode })?.apiKey, + 'uppercase-provider-secret', + ) + assert.equal( + resolveOpenAICompatibleRequest(config, { apiMode: lowercaseMode })?.apiKey, + 'lowercase-provider-secret', + ) +}) + +test('getUserConfig keeps a reserved canonical secret on its exact raw provider', async () => { + for (const { id } of newlyReservedBuiltinProviders) { + const canonicalAliasProvider = { + id: id.toUpperCase(), + name: 'Canonical alias provider', + chatCompletionsUrl: 'https://alias.example.com/v1/chat/completions', + } + const exactProvider = { + id, + name: 'Exact provider', + chatCompletionsUrl: 'https://exact.example.com/v1/chat/completions', + } + + for (const customOpenAIProviders of [ + [canonicalAliasProvider, exactProvider], + [exactProvider, canonicalAliasProvider], + ]) { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + providerSecrets: { [id]: 'exact-provider-secret' }, + customOpenAIProviders, + }) + + const config = await getUserConfig() + const canonicalAlias = config.customOpenAIProviders.find( + (provider) => provider.name === canonicalAliasProvider.name, + ) + const exact = config.customOpenAIProviders.find( + (provider) => provider.name === exactProvider.name, + ) + + assert.equal(config.providerSecrets[canonicalAlias.id], undefined) + assert.equal(config.providerSecrets[exact.id], 'exact-provider-secret') + } + } }) test('getUserConfig prefers normalized provider secret when raw alias is explicitly empty', async () => { @@ -810,10 +1183,27 @@ test('getUserConfig materializes distinct providers for legacy custom default ke assert.equal(config.apiMode.providerId, modeB.providerId) assert.equal(config.providerSecrets['legacy-custom-default'], 'key-a') assert.equal(config.providerSecrets[modeB.providerId], 'key-b') + assert.equal(materializedProvider.legacyProviderIds, undefined) assert.equal( materializedProvider.chatCompletionsUrl, 'https://legacy.example.com/v1/chat/completions', ) + const resolvedModeA = resolveOpenAICompatibleRequest(config, { + apiMode: createCustomApiMode({ + customName: 'legacy-a', + providerId: 'legacy-custom-default', + }), + }) + const resolvedModeB = resolveOpenAICompatibleRequest(config, { + apiMode: createCustomApiMode({ + customName: 'legacy-b', + providerId: 'legacy-custom-default', + }), + }) + assert.equal(resolvedModeA?.providerId, 'legacy-custom-default') + assert.equal(resolvedModeA?.apiKey, 'key-a') + assert.equal(resolvedModeB?.providerId, modeB.providerId) + assert.equal(resolvedModeB?.apiKey, 'key-b') assert.equal(modeA.apiKey, '') assert.equal(modeB.apiKey, '') assert.equal(config.apiMode.apiKey, '') @@ -1401,6 +1791,264 @@ test('getUserConfig persists generated custom provider ids when schema version i assert.equal(storage.customOpenAIProviders[0].id, 'custom-provider-1') }) +test('getUserConfig migrates custom providers that collide with newly reserved IDs', async () => { + for (const { id } of newlyReservedBuiltinProviders) { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() + const customUrl = `https://${id}.example.com/v1/chat/completions` + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + providerSecrets: { [id]: `${id}-custom-secret` }, + customOpenAIProviders: [ + { + id, + name: `${id} proxy`, + chatCompletionsUrl: customUrl, + }, + ], + customApiModes: [ + createCustomApiMode({ + customName: `${id} proxy`, + providerId: id, + }), + ], + }) + + const config = await getUserConfig() + const migratedProvider = config.customOpenAIProviders[0] + const migratedMode = config.customApiModes.find( + (apiMode) => apiMode.customName === `${id} proxy`, + ) + + assert.equal(migratedProvider.id, `${id}-2`) + assert.deepEqual(migratedProvider.legacyProviderIds, [id]) + assert.equal(migratedMode.providerId, `${id}-2`) + assert.deepEqual(migratedMode.legacyProviderIds, [id]) + assert.equal(config.providerSecrets[`${id}-2`], `${id}-custom-secret`) + assert.equal(Object.hasOwn(config.providerSecrets, id), false) + assert.ok(config.completedBuiltinProviderIdMigrations.includes(id)) + + const resolved = resolveOpenAICompatibleRequest(config, { + apiMode: createCustomApiMode({ + customName: `${id} proxy`, + customUrl, + providerId: id, + }), + }) + assert.equal(resolved?.providerId, `${id}-2`) + assert.equal(resolved?.apiKey, `${id}-custom-secret`) + + const snapshot = JSON.stringify(globalThis.__TEST_BROWSER_SHIM__.getStorage()) + await getUserConfig() + assert.equal(JSON.stringify(globalThis.__TEST_BROWSER_SHIM__.getStorage()), snapshot) + + globalThis.__TEST_BROWSER_SHIM__.clearStorage() + const rawId = id.toUpperCase() + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + providerSecrets: { + [rawId]: `${id}-raw-secret`, + [id]: `${id}-normalized-secret`, + }, + customOpenAIProviders: [ + { + id: rawId, + name: `${id} raw proxy`, + chatCompletionsUrl: customUrl, + }, + ], + }) + + const rawIdConfig = await getUserConfig() + + assert.equal(rawIdConfig.providerSecrets[`${id}-2`], `${id}-raw-secret`) + assert.equal(rawIdConfig.providerSecrets[id], `${id}-normalized-secret`) + assert.equal(Object.hasOwn(rawIdConfig.providerSecrets, rawId), false) + } +}) + +test('getUserConfig keeps builtin secrets out of colliding custom providers', async () => { + for (const { id, legacyKey } of newlyReservedBuiltinProviders) { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + [legacyKey]: `${id}-builtin-secret`, + providerSecrets: { [id]: `${id}-builtin-secret` }, + customOpenAIProviders: [ + { + id, + name: `${id} proxy`, + chatCompletionsUrl: `https://${id}.example.com/v1/chat/completions`, + }, + ], + }) + + const config = await getUserConfig() + + assert.equal(config.customOpenAIProviders[0].id, `${id}-2`) + assert.equal(config.providerSecrets[id], `${id}-builtin-secret`) + assert.equal(Object.hasOwn(config.providerSecrets, `${id}-2`), false) + assert.equal(config[legacyKey], `${id}-builtin-secret`) + } +}) + +test('getUserConfig reruns a completed builtin ID migration for a new collision', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + completedBuiltinProviderIdMigrations: ['xai', 'nvidia-nim', 'mistral'], + providerSecrets: { + xai: 'custom-xai-secret', + }, + customOpenAIProviders: [ + { + id: 'xai', + name: 'Synced xAI Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + }) + + const config = await getUserConfig() + + assert.equal(config.customOpenAIProviders[0].id, 'xai-2') + assert.equal(config.providerSecrets['xai-2'], 'custom-xai-secret') + assert.equal(Object.hasOwn(config.providerSecrets, 'xai'), false) +}) + +test('getUserConfig does not move a known builtin secret for a later custom collision', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + completedBuiltinProviderIdMigrations: ['xai', 'nvidia-nim', 'mistral'], + xaiApiKey: 'builtin-xai-secret', + providerSecrets: { + xai: 'builtin-xai-secret', + }, + customOpenAIProviders: [ + { + id: 'xai', + name: 'Synced xAI Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + }) + + const config = await getUserConfig() + + assert.equal(config.customOpenAIProviders[0].id, 'xai-2') + assert.equal(config.providerSecrets.xai, 'builtin-xai-secret') + assert.equal(Object.hasOwn(config.providerSecrets, 'xai-2'), false) +}) + +test('getUserConfig reconnects modes only for a unique enabled legacy provider match', async () => { + const cases = [ + { + providers: [{ id: 'xai-2', legacyProviderIds: ['xai'] }], + expectedProviderId: 'xai-2', + expectedModeKey: '', + expectedProviderKey: 'custom-xai-secret', + }, + { + providers: [ + { id: 'xai-2', legacyProviderIds: ['xai'] }, + { id: 'xai-3', legacyProviderIds: ['xai'] }, + ], + expectedProviderId: 'xai', + expectedModeKey: 'custom-xai-secret', + expectedProviderKey: undefined, + }, + { + providers: [{ id: 'xai-2', legacyProviderIds: ['xai'], enabled: false }], + expectedProviderId: 'xai', + expectedModeKey: 'custom-xai-secret', + expectedProviderKey: undefined, + }, + ] + + for (const testCase of cases) { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + completedBuiltinProviderIdMigrations: ['xai', 'nvidia-nim', 'mistral'], + xaiApiKey: 'builtin-xai-secret', + providerSecrets: { + xai: 'builtin-xai-secret', + }, + customOpenAIProviders: testCase.providers.map((provider) => ({ + ...provider, + name: provider.id, + chatCompletionsUrl: `https://${provider.id}.example.com/v1/chat/completions`, + })), + customApiModes: [ + createCustomApiMode({ + customName: 'Existing xAI Proxy mode', + providerId: 'xai', + apiKey: 'custom-xai-secret', + }), + ], + }) + + const config = await getUserConfig() + const migratedMode = config.customApiModes.find( + (apiMode) => apiMode.customName === 'Existing xAI Proxy mode', + ) + + assert.equal(migratedMode.providerId, testCase.expectedProviderId) + assert.equal(migratedMode.apiKey, testCase.expectedModeKey) + assert.equal(config.providerSecrets.xai, 'builtin-xai-secret') + assert.equal(config.providerSecrets['xai-2'], testCase.expectedProviderKey) + assert.equal(Object.hasOwn(config.providerSecrets, 'xai-3'), false) + } +}) + +test('getUserConfig uses URLs to disambiguate unchanged and renamed provider IDs', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + providerSecrets: { + proxy: 'first-key', + }, + customOpenAIProviders: [ + { + id: 'proxy', + name: 'First proxy', + baseUrl: 'https://first.example.com/v1', + chatCompletionsPath: 'v1/chat/completions', + completionsPath: 'v1/completions', + }, + { + id: 'proxy', + name: 'Second proxy', + baseUrl: 'https://second.example.com/v1', + chatCompletionsPath: 'v1/chat/completions', + completionsPath: 'v1/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ + customName: 'Second proxy mode', + customUrl: 'https://second.example.com/v1/chat/completions', + apiKey: 'second-key', + providerId: 'proxy', + }), + ], + }) + + const config = await getUserConfig() + + assert.deepEqual( + config.customOpenAIProviders.map((provider) => provider.id), + ['proxy', 'proxy-2', 'second-proxy-mode'], + ) + const secondProxyMode = config.customApiModes.find( + (apiMode) => apiMode.customName === 'Second proxy mode', + ) + assert.equal(secondProxyMode.providerId, 'second-proxy-mode') + assert.deepEqual(secondProxyMode.legacyProviderIds, ['proxy', 'proxy-2']) + assert.equal(config.customOpenAIProviders[2].baseUrl, 'https://second.example.com/v1') + assert.deepEqual(config.customOpenAIProviders[2].legacyProviderIds, ['proxy', 'proxy-2']) + assert.equal(config.providerSecrets['second-proxy-mode'], 'second-key') + assert.equal(config.customOpenAIProviders[0].chatCompletionsPath, '/v1/chat/completions') + assert.equal(config.customOpenAIProviders[1].completionsPath, '/v1/completions') +}) + test('getUserConfig normalizes providerSecrets when legacy data is not a plain object', async () => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ configSchemaVersion: 1, diff --git a/tests/unit/popup/api-modes-provider-utils.test.mjs b/tests/unit/popup/api-modes-provider-utils.test.mjs index 520e57d73..64dc14da0 100644 --- a/tests/unit/popup/api-modes-provider-utils.test.mjs +++ b/tests/unit/popup/api-modes-provider-utils.test.mjs @@ -131,6 +131,12 @@ test('createProviderId does not reuse ids reserved by staged provider deletions' assert.equal(createProviderId('My Proxy', existingProviders, reservedProviderIds), 'my-proxy-3') }) +test('createProviderId does not reuse legacy provider IDs', () => { + const existingProviders = [{ id: 'renamed-provider', legacyProviderIds: ['my-proxy'] }] + + assert.equal(createProviderId('My Proxy', existingProviders), 'my-proxy-2') +}) + test('parseChatCompletionsEndpointUrl accepts full chat endpoint url', () => { const parsed = parseChatCompletionsEndpointUrl('https://api.example.com/v1/chat/completions/') @@ -607,6 +613,7 @@ test('sanitizeApiModeForSave clears custom-provider metadata for non-custom mode apiKey: 'sk-test', customUrl: 'https://proxy.example.com/v1/chat/completions', sourceProviderId: 'openai', + legacyProviderIds: ['legacy-provider'], }) assert.deepEqual(sanitizedApiMode, { @@ -635,6 +642,7 @@ test('applySelectedProviderToApiMode clears provider-derived fields when provide providerId: 'selected-mode-2', apiKey: 'override-key', sourceProviderId: 'openai', + legacyProviderIds: ['selected-mode'], customUrl: 'https://example.com', }, 'myproxy', @@ -756,6 +764,7 @@ test('isProviderReferencedByApiModes only matches custom modes with the same pro ] assert.equal(isProviderReferencedByApiModes('provider-a', apiModes), true) + assert.equal(isProviderReferencedByApiModes('Provider_A', apiModes), true) assert.equal(isProviderReferencedByApiModes('provider-b', apiModes), false) }) @@ -1497,3 +1506,84 @@ test('getConversationAiName does not treat canonical provider id matches as miss assert.equal(getConversationAiName(session, t, providers), 'My Proxy (deepseek-v3.2)') }) + +test('legacy provider IDs keep saved conversations linked to renamed providers', () => { + const providers = [ + { + id: 'renamed-provider', + name: 'Renamed Provider', + legacyProviderIds: ['legacy-provider'], + enabled: false, + }, + ] + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'legacy-provider', + customName: 'proxy-model', + }, + } + const t = (value) => value + + assert.deepEqual(getReferencedCustomProviderIdsFromSessions([session], providers), [ + 'renamed-provider', + ]) + assert.equal(getConversationAiName(session, t, providers), 'Renamed Provider (proxy-model)') +}) + +test('canonical and legacy provider ID collisions use the session URL for display and references', () => { + const providers = [ + { + id: 'proxy-v1', + name: 'Current ID Match', + chatCompletionsUrl: 'https://current.example.com/v1/chat/completions', + }, + { + id: 'renamed-provider', + name: 'Legacy ID Match', + chatCompletionsUrl: 'https://legacy.example.com/v1/chat/completions', + legacyProviderIds: ['proxy-v1'], + enabled: false, + }, + { + id: 'unrelated-provider', + name: 'Unrelated', + chatCompletionsUrl: 'https://unrelated.example.com/v1/chat/completions', + }, + ] + const createSession = (customUrl = '') => ({ + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'proxy_v1', + customName: 'custom-model', + customUrl, + }, + }) + const apiModes = [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'custom-model', + providerId: 'proxy-v1', + }, + ] + const t = (value) => value + const legacySession = createSession('https://legacy.example.com/v1/chat/completions') + + assert.deepEqual( + getReferencedCustomProviderIdsFromSessions([legacySession], providers, apiModes), + ['renamed-provider'], + ) + assert.equal(getConversationAiName(legacySession, t, providers), 'Legacy ID Match (custom-model)') + assert.deepEqual( + getReferencedCustomProviderIdsFromSessions([createSession()], providers, apiModes), + ['proxy-v1', 'renamed-provider'], + ) + const unrelatedSession = createSession('https://unrelated.example.com/v1/chat/completions') + assert.deepEqual( + getReferencedCustomProviderIdsFromSessions([unrelatedSession], providers, apiModes), + ['proxy-v1', 'renamed-provider'], + ) + assert.equal(getConversationAiName(unrelatedSession, t, providers), 'Custom Model (custom-model)') +}) diff --git a/tests/unit/popup/import-data-cleanup.test.mjs b/tests/unit/popup/import-data-cleanup.test.mjs index c1571ce01..e5979aed5 100644 --- a/tests/unit/popup/import-data-cleanup.test.mjs +++ b/tests/unit/popup/import-data-cleanup.test.mjs @@ -74,6 +74,43 @@ test('prepareImportData leaves unrelated imports untouched', () => { assert.deepEqual(keysToRemove, []) }) +test('prepareImportData reruns builtin provider ID migrations for legacy provider state', () => { + const { normalizedData, keysToRemove } = prepareImportData({ + customOpenAIProviders: [{ id: 'legacy-provider' }], + providerSecrets: { 'legacy-provider': 'legacy-secret' }, + }) + + assert.deepEqual(normalizedData, { + customOpenAIProviders: [{ id: 'legacy-provider' }], + providerSecrets: { 'legacy-provider': 'legacy-secret' }, + completedBuiltinProviderIdMigrations: [], + }) + assert.deepEqual(keysToRemove, []) +}) + +test('prepareImportData preserves migration markers for provider-only imports', () => { + const { normalizedData, keysToRemove } = prepareImportData({ + customOpenAIProviders: [{ id: 'xai' }], + }) + + assert.deepEqual(normalizedData, { + customOpenAIProviders: [{ id: 'xai' }], + }) + assert.deepEqual(keysToRemove, []) +}) + +test('prepareImportData preserves imported builtin provider ID migration markers', () => { + const input = { + customOpenAIProviders: [{ id: 'current-provider' }], + providerSecrets: { 'current-provider': 'current-secret' }, + completedBuiltinProviderIdMigrations: ['current-provider'], + } + const { normalizedData, keysToRemove } = prepareImportData(input) + + assert.deepEqual(normalizedData, input) + assert.deepEqual(keysToRemove, []) +}) + test('prepareImportData migrates legacy model keys in imported config and sessions', () => { const { normalizedData, keysToRemove } = prepareImportData({ modelName: 'chatgptFree4o', @@ -177,6 +214,29 @@ test('importDataIntoStorage writes normalized data before removing legacy keys', ]) }) +test('importDataIntoStorage ignores inherited provider mapping names', async () => { + const calls = [] + const storageArea = { + async get(keys) { + calls.push(['get', keys]) + return {} + }, + async set(data) { + calls.push(['set', data]) + }, + async remove(keys) { + calls.push(['remove', keys]) + }, + } + const data = { + customOpenAIProviders: [{ id: 'constructor' }], + } + + await importDataIntoStorage(storageArea, data) + + assert.deepEqual(calls, [['set', data]]) +}) + test('importDataIntoStorage replaces stale API mode state before legacy migration', async () => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ configSchemaVersion: 2, @@ -207,6 +267,93 @@ test('importDataIntoStorage replaces stale API mode state before legacy migratio assert.equal(Object.hasOwn(migratedStorage, 'knownApiModeDefaultIds'), false) }) +test('importDataIntoStorage does not assign an existing builtin secret to an imported provider', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + completedBuiltinProviderIdMigrations: ['xai', 'nvidia-nim', 'mistral'], + providerSecrets: { xai: 'builtin-xai-key' }, + customOpenAIProviders: [], + }) + + await importDataIntoStorage(Browser.storage.local, { + customOpenAIProviders: [ + { + id: 'xai', + name: 'Imported xAI proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + }) + const config = await getUserConfig() + + assert.equal(config.customOpenAIProviders[0].id, 'xai-2') + assert.equal(config.providerSecrets.xai, 'builtin-xai-key') + assert.equal(config.providerSecrets['xai-2'], undefined) +}) + +test('importDataIntoStorage preserves a builtin secret when its legacy mirror is stale', async () => { + for (const { storedXaiApiKey, importedXaiApiKey } of [ + { storedXaiApiKey: '' }, + { storedXaiApiKey: 'stale-stored-key' }, + { storedXaiApiKey: 'builtin-xai-key', importedXaiApiKey: '' }, + { storedXaiApiKey: 'builtin-xai-key', importedXaiApiKey: 'stale-imported-key' }, + ]) { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + completedBuiltinProviderIdMigrations: ['xai', 'nvidia-nim', 'mistral'], + xaiApiKey: storedXaiApiKey, + providerSecrets: { xai: 'builtin-xai-key' }, + customOpenAIProviders: [], + }) + + await importDataIntoStorage(Browser.storage.local, { + ...(importedXaiApiKey !== undefined ? { xaiApiKey: importedXaiApiKey } : {}), + customOpenAIProviders: [ + { + id: 'xai', + name: 'Imported xAI proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + }) + const config = await getUserConfig() + + assert.equal(config.customOpenAIProviders[0].id, 'xai-2') + assert.equal(config.providerSecrets.xai, 'builtin-xai-key') + assert.equal(config.providerSecrets['xai-2'], undefined) + } +}) + +test('importDataIntoStorage still migrates a secret from an existing custom collision', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + completedBuiltinProviderIdMigrations: ['xai', 'nvidia-nim', 'mistral'], + providerSecrets: { xai: 'custom-xai-key' }, + customOpenAIProviders: [ + { + id: 'xai', + name: 'Existing xAI proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + }) + + await importDataIntoStorage(Browser.storage.local, { + customOpenAIProviders: [ + { + id: 'xai', + name: 'Imported xAI proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + }) + const config = await getUserConfig() + + assert.equal(config.customOpenAIProviders[0].id, 'xai-2') + assert.equal(config.providerSecrets['xai-2'], 'custom-xai-key') + assert.equal(Object.hasOwn(config.providerSecrets, 'xai'), false) +}) + test('importDataIntoStorage does not remove existing keys when set fails', async () => { const calls = [] const storageArea = { diff --git a/tests/unit/services/apis/provider-registry.test.mjs b/tests/unit/services/apis/provider-registry.test.mjs index b23cba502..df2765fdd 100644 --- a/tests/unit/services/apis/provider-registry.test.mjs +++ b/tests/unit/services/apis/provider-registry.test.mjs @@ -9,6 +9,40 @@ import { resolveProviderIdForSession, } from '../../../../src/services/apis/provider-registry.mjs' +function createCanonicalSiblingProviders() { + return [ + { + id: 'Foo', + name: 'Uppercase provider', + chatCompletionsUrl: 'https://uppercase.example.com/v1/chat/completions', + }, + { + id: 'foo', + name: 'Lowercase provider', + chatCompletionsUrl: 'https://lowercase.example.com/v1/chat/completions', + }, + ] +} + +function createCanonicalSiblingMode(providerId, customName) { + return { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName, + providerId, + } +} + +function createCanonicalSiblingSession(customName) { + return { + apiMode: { + ...createCanonicalSiblingMode('foo', customName), + customUrl: '', + }, + } +} + test('resolveEndpointTypeForSession prefers apiMode when present', () => { const session = { apiMode: { @@ -97,6 +131,262 @@ test('resolveOpenAICompatibleRequest resolves custom provider from normalized se assert.equal(resolved.apiKey, 'proxy-key') }) +test('resolveOpenAICompatibleRequest disambiguates legacy and normalized ID matches by URL', () => { + const config = { + customOpenAIProviders: [ + { + id: 'proxy-v1', + name: 'Normalized Match', + chatCompletionsUrl: 'https://normalized.example.com/v1/chat/completions', + }, + { + id: 'renamed-provider', + name: 'Legacy Match', + chatCompletionsUrl: 'https://legacy.example.com/v1/chat/completions', + legacyProviderIds: ['proxy-v1'], + }, + ], + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'collision-model', + providerId: 'proxy-v1', + }, + ], + providerSecrets: { + 'proxy-v1': 'normalized-key', + 'renamed-provider': 'legacy-key', + }, + } + const createSession = (customUrl = '') => ({ + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'proxy_v1', + customName: 'collision-model', + customUrl, + }, + }) + + assert.equal( + resolveOpenAICompatibleRequest( + config, + createSession('https://legacy.example.com/v1/chat/completions'), + )?.providerId, + 'renamed-provider', + ) + assert.equal( + resolveOpenAICompatibleRequest( + config, + createSession('https://normalized.example.com/v1/chat/completions'), + )?.providerId, + 'proxy-v1', + ) + assert.equal(resolveOpenAICompatibleRequest(config, createSession()), null) +}) + +test('resolveOpenAICompatibleRequest disambiguates exact and legacy provider ID collisions by URL', () => { + const config = { + customOpenAIProviders: [ + { + id: 'proxy-v1', + name: 'Current ID Match', + chatCompletionsUrl: 'https://current.example.com/v1/chat/completions', + }, + { + id: 'renamed-provider', + name: 'Legacy ID Match', + chatCompletionsUrl: 'https://legacy.example.com/v1/chat/completions', + legacyProviderIds: ['proxy-v1'], + }, + { + id: 'unrelated-provider', + name: 'Unrelated', + chatCompletionsUrl: 'https://unrelated.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'collision-model', + providerId: 'unrelated-provider', + }, + ], + } + const createSession = (customUrl = '') => ({ + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'proxy-v1', + customName: 'collision-model', + customUrl, + }, + }) + + assert.equal( + resolveOpenAICompatibleRequest( + config, + createSession('https://legacy.example.com/v1/chat/completions'), + )?.providerId, + 'renamed-provider', + ) + assert.equal( + resolveOpenAICompatibleRequest( + config, + createSession('https://current.example.com/v1/chat/completions'), + )?.providerId, + 'proxy-v1', + ) + assert.equal(resolveOpenAICompatibleRequest(config, createSession()), null) + assert.equal( + resolveOpenAICompatibleRequest( + config, + createSession('https://unrelated.example.com/v1/chat/completions'), + ), + null, + ) +}) + +test('resolveOpenAICompatibleRequest disambiguates exact and legacy provider ID collisions by mode label', () => { + const config = { + customOpenAIProviders: [ + { + id: 'proxy-v1', + name: 'Current ID Match', + chatCompletionsUrl: 'https://current.example.com/v1/chat/completions', + }, + { + id: 'renamed-provider', + name: 'Legacy ID Match', + chatCompletionsUrl: 'https://legacy.example.com/v1/chat/completions', + legacyProviderIds: ['proxy-v1'], + }, + ], + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'current-model', + providerId: 'proxy-v1', + }, + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: false, + customName: 'current-model', + providerId: 'renamed-provider', + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'proxy-v1', + customName: 'current-model', + customUrl: '', + }, + } + + assert.equal(resolveOpenAICompatibleRequest(config, session)?.providerId, 'proxy-v1') + delete session.apiMode.isCustom + assert.equal(resolveOpenAICompatibleRequest(config, session), null) +}) + +test('resolveOpenAICompatibleRequest preserves raw provider ID disambiguation by mode label', () => { + const config = { + customOpenAIProviders: createCanonicalSiblingProviders(), + customApiModes: [createCanonicalSiblingMode('Foo', 'uppercase-model')], + providerSecrets: { + Foo: 'uppercase-key', + foo: 'lowercase-key', + }, + } + const session = createCanonicalSiblingSession('uppercase-model') + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved?.providerId, 'Foo') + assert.equal(resolved?.requestUrl, 'https://uppercase.example.com/v1/chat/completions') + assert.equal(resolved?.apiKey, 'uppercase-key') +}) + +test('resolveOpenAICompatibleRequest does not borrow a canonical sibling secret', () => { + const config = { + customOpenAIProviders: createCanonicalSiblingProviders(), + customApiModes: [createCanonicalSiblingMode('Foo', 'uppercase-model')], + providerSecrets: { + foo: 'lowercase-key', + }, + } + const session = createCanonicalSiblingSession('uppercase-model') + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved?.providerId, 'Foo') + assert.equal(resolved?.secretProviderId, 'Foo') + assert.equal(resolved?.apiKey, '') +}) + +test('resolveOpenAICompatibleRequest does not borrow canonical sibling mode or session secrets', () => { + const config = { + customOpenAIProviders: createCanonicalSiblingProviders(), + customApiModes: [ + { + ...createCanonicalSiblingMode('foo', 'lowercase-model'), + apiKey: 'lowercase-mode-key', + }, + ], + providerSecrets: {}, + } + const session = createCanonicalSiblingSession('lowercase-model') + session.apiMode.customUrl = 'https://uppercase.example.com/v1/chat/completions' + session.apiMode.apiKey = 'lowercase-session-key' + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved?.providerId, 'Foo') + assert.equal(resolved?.requestUrl, 'https://uppercase.example.com/v1/chat/completions') + assert.equal(resolved?.apiKey, '') +}) + +test('resolveOpenAICompatibleRequest fails closed for duplicate labels on canonical siblings', () => { + const config = { + customOpenAIProviders: createCanonicalSiblingProviders(), + customApiModes: [ + createCanonicalSiblingMode('Foo', 'shared-model'), + createCanonicalSiblingMode('foo', 'shared-model'), + ], + providerSecrets: { + Foo: 'uppercase-key', + foo: 'lowercase-key', + }, + } + const session = createCanonicalSiblingSession('shared-model') + + assert.equal(resolveOpenAICompatibleRequest(config, session), null) +}) + +test('resolveOpenAICompatibleRequest does not replace a disabled exact label provider', () => { + const providers = createCanonicalSiblingProviders() + providers[0].enabled = false + const config = { + customOpenAIProviders: providers, + customApiModes: [createCanonicalSiblingMode('Foo', 'uppercase-model')], + providerSecrets: { + Foo: 'uppercase-key', + foo: 'lowercase-key', + }, + } + const session = createCanonicalSiblingSession('uppercase-model') + + assert.equal(resolveOpenAICompatibleRequest(config, session), null) +}) + test('getOpenAICompatibleRequestDiagnostic reports safe context for missing custom provider', () => { const config = { customOpenAIProviders: [ @@ -864,6 +1154,7 @@ test('resolveOpenAICompatibleRequest recovers by legacy customUrl when provider const resolved = resolveOpenAICompatibleRequest(config, session) assert.equal(resolved.providerId, 'legacy-custom-default') + assert.equal(resolved.secretProviderId, 'openai') assert.equal(resolved.requestUrl, 'https://derived.example.com/v1/chat/completions') assert.equal(resolved.apiKey, '') }) @@ -909,8 +1200,23 @@ test('resolveOpenAICompatibleRequest does not fall back when customUrl points at chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', enabled: false, }, + { + id: 'label-provider', + name: 'Label Provider', + chatCompletionsUrl: 'https://other.example.com/v1/chat/completions', + enabled: true, + }, + ], + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'label-provider', + customName: 'proxy-model', + active: true, + }, ], - customApiModes: [], } const session = { apiMode: { @@ -2354,6 +2660,94 @@ test('resolveOpenAICompatibleRequest fails closed when shared legacy custom url assert.equal(resolved, null) }) +test('resolveOpenAICompatibleRequest does not bypass a disabled provider through its legacy ID', () => { + const config = { + customOpenAIProviders: [ + { + id: 'stale-provider', + name: 'Current Provider', + chatCompletionsUrl: 'https://current.example.com/v1/chat/completions', + }, + { + id: 'renamed-provider', + name: 'Disabled Provider', + chatCompletionsUrl: 'https://disabled.example.com/v1/chat/completions', + legacyProviderIds: ['stale-provider'], + enabled: false, + }, + ], + customApiModes: [ + { + groupName: 'customApiModelKeys', + customName: 'shared-label', + providerId: 'stale-provider', + }, + ], + providerSecrets: { + 'stale-provider': 'current-key', + 'renamed-provider': 'disabled-key', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'stale-provider', + customName: 'shared-label', + customUrl: 'https://disabled.example.com/v1/chat/completions', + }, + } + + assert.equal(resolveOpenAICompatibleRequest(config, session), null) +}) + +test('resolveOpenAICompatibleRequest uses a stable mode label despite a disabled legacy URL match', () => { + const config = { + customOpenAIProviders: [ + { + id: 'provider-a', + name: 'Current Provider', + chatCompletionsUrl: 'https://current.example.com/v1/chat/completions', + }, + { + id: 'provider-b-renamed', + name: 'Disabled Legacy Provider', + chatCompletionsUrl: 'https://legacy.example.com/v1/chat/completions', + legacyProviderIds: ['provider-a'], + enabled: false, + }, + ], + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'current-model', + providerId: 'provider-a', + }, + ], + providerSecrets: { + 'provider-a': 'current-key', + 'provider-b-renamed': 'legacy-key', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'current-model', + providerId: 'provider-a', + customUrl: 'https://legacy.example.com/v1/chat/completions', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved?.providerId, 'provider-a') + assert.equal(resolved?.requestUrl, 'https://current.example.com/v1/chat/completions') + assert.equal(resolved?.apiKey, 'current-key') +}) + test('resolveOpenAICompatibleRequest fails closed when legacy customUrl has only provider secrets and no session key signal', () => { const config = { customOpenAIProviders: [ diff --git a/tests/unit/utils/model-name-convert.test.mjs b/tests/unit/utils/model-name-convert.test.mjs index 5222f6a57..4bc15040b 100644 --- a/tests/unit/utils/model-name-convert.test.mjs +++ b/tests/unit/utils/model-name-convert.test.mjs @@ -1121,6 +1121,65 @@ test('isApiModeSelected keeps provider mismatch fail-closed for non-legacy sessi assert.equal(isApiModeSelected(apiMode, session, { sessionCompat: true }), false) }) +test('isApiModeSelected accepts a canonically equivalent session provider ID', () => { + const apiMode = { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + providerId: 'proxy-v1', + active: true, + } + const session = { + apiMode: { + ...apiMode, + providerId: 'Proxy_V1', + }, + } + + assert.equal(isApiModeSelected(apiMode, session), false) + assert.equal(isApiModeSelected(apiMode, session, { sessionCompat: true }), true) +}) + +test('getUniquelySelectedApiModeIndex disambiguates provider ID collisions by customUrl', () => { + const apiModes = [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: 'https://current.example.com/v1/chat/completions', + providerId: 'legacy-provider', + active: true, + }, + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: 'https://renamed.example.com/v1/chat/completions/', + providerId: 'renamed-provider', + legacyProviderIds: ['legacy-provider'], + active: true, + }, + ] + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: 'https://renamed.example.com/v1/chat/completions', + providerId: 'legacy-provider', + active: true, + }, + } + + assert.equal(getUniquelySelectedApiModeIndex(apiModes, session, { sessionCompat: true }), 1) + session.apiMode.customUrl = 'https://current.example.com/v1/chat/completions/' + assert.equal(getUniquelySelectedApiModeIndex(apiModes, session, { sessionCompat: true }), 0) +}) + test('isApiModeSelected keeps modern custom session provider mismatch fail-closed with customUrl and apiKey', () => { const apiMode = { groupName: 'customApiModelKeys',