diff --git a/src/config/index.mjs b/src/config/index.mjs index 48c2d98b..22b64df3 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 56ef7cd0..5f39e0fa 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 90d41780..a4eaf32c 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 125a0ebc..83e307b7 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 0f072467..206cc448 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 838456c2..fdda5457 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 992e1afe..e4104b02 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 520e57d7..64dc14da 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 c1571ce0..e5979aed 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 b23cba50..df2765fd 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 5222f6a5..4bc15040 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',