diff --git a/src/config/index.mjs b/src/config/index.mjs index 54cef4cef..48c2d98b2 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -2,9 +2,11 @@ import { defaults } from 'lodash-es' import Browser from 'webextension-polyfill' import { isMobile } from '../utils/is-mobile.mjs' import { + getApiModesFromConfig, isInApiModeGroup, isUsingModelName, modelNameToDesc, + reconcileMaterializedApiModeDefaults, } from '../utils/model-name-convert.mjs' import { t } from 'i18next' import { @@ -734,6 +736,27 @@ for (const modelName in Models) { /** * @typedef {typeof defaultConfig} UserConfig */ +export const defaultApiModeIds = [ + 'claude2WebFree', + 'moonshotWebFree', + 'ollamaModel', + 'customModel', + 'azureOpenAi', + 'chatgptApi5_6Sol', + 'chatgptApi5_6Terra', + 'chatgptApi5_6Luna', + 'xaiGrok4_5', + 'claudeOpus48Api', + 'claudeSonnet5Api', + 'claudeHaiku45Api', + 'googleGemini3_1Pro', + 'googleGemini3_5Flash', + 'mistralMediumLatest', + 'openRouter_auto', + 'openRouter_free', + 'nvidiaNim_nemotron_3_super', +] + export const defaultConfig = { // general @@ -812,30 +835,9 @@ export const defaultConfig = { // others alwaysCreateNewConversationWindow: false, - // The handling of activeApiModes and customApiModes is somewhat complex. - // It does not directly convert activeApiModes into customApiModes, which is for compatibility considerations. - // It allows the content of activeApiModes to change with version updates when the user has not customized ApiModes. - // If it were directly written into customApiModes, the value would become fixed, even if the user has not made any customizations. - activeApiModes: [ - 'claude2WebFree', - 'moonshotWebFree', - 'ollamaModel', - 'customModel', - 'azureOpenAi', - 'chatgptApi5_6Sol', - 'chatgptApi5_6Terra', - 'chatgptApi5_6Luna', - 'xaiGrok4_5', - 'claudeOpus48Api', - 'claudeSonnet5Api', - 'claudeHaiku45Api', - 'googleGemini3_1Pro', - 'googleGemini3_5Flash', - 'mistralMediumLatest', - 'openRouter_auto', - 'openRouter_free', - 'nvidiaNim_nemotron_3_super', - ], + // Untouched profiles read this live default list. Customized profiles materialize the + // visible rows in customApiModes and use knownApiModeDefaultIds to append future defaults. + activeApiModes: [...defaultApiModeIds], customApiModes: [ { groupName: '', @@ -848,9 +850,10 @@ export const defaultConfig = { active: false, }, ], + knownApiModeDefaultIds: [], customOpenAIProviders: [], providerSecrets: {}, - configSchemaVersion: 1, + configSchemaVersion: 2, activeSelectionTools: ['translate', 'translateToEn', 'summary', 'polish', 'code', 'ask'], customSelectionTools: [ { @@ -1039,7 +1042,7 @@ export async function getPreferredLanguageKey() { return config.preferredLanguage } -const CONFIG_SCHEMA_VERSION = 1 +const CONFIG_SCHEMA_VERSION = 2 function normalizeText(value) { return typeof value === 'string' ? value.trim() : '' @@ -1120,6 +1123,7 @@ function normalizeCustomProviderForStorage(provider, index, providerIdSet) { function migrateUserConfig(options) { const migrated = { ...options } let dirty = false + const storageKeysToRemove = [] if (migrated.customChatGptWebApiUrl === 'https://chat.openai.com') { migrated.customChatGptWebApiUrl = 'https://chatgpt.com' @@ -1132,7 +1136,18 @@ function migrateUserConfig(options) { dirty = true } - const canonicalActiveApiModes = canonicalizeModelKeyArray(migrated.activeApiModes) + const activeApiModesForCanonicalization = + Array.isArray(migrated.activeApiModes) && + migrated.activeApiModes.some( + (modelName) => + typeof modelName !== 'string' || !modelName.trim() || modelName !== modelName.trim(), + ) + ? migrated.activeApiModes + .filter((modelName) => typeof modelName === 'string') + .map((modelName) => modelName.trim()) + .filter(Boolean) + : migrated.activeApiModes + const canonicalActiveApiModes = canonicalizeModelKeyArray(activeApiModesForCanonicalization) if (canonicalActiveApiModes !== migrated.activeApiModes) { migrated.activeApiModes = canonicalActiveApiModes dirty = true @@ -1286,7 +1301,7 @@ function migrateUserConfig(options) { } } - const customApiModes = Array.isArray(migrated.customApiModes) + let customApiModes = Array.isArray(migrated.customApiModes) ? migrated.customApiModes.map((apiMode) => canonicalizeApiMode({ ...apiMode })) : [] if (!Array.isArray(migrated.customApiModes)) dirty = true @@ -1650,6 +1665,101 @@ function migrateUserConfig(options) { } } + const currentDefaultIds = canonicalizeModelKeyArray([...defaultApiModeIds]) + const migrationConfig = { + ...defaultConfig, + ...migrated, + customApiModes, + customOpenAIProviders, + providerSecrets, + } + const configuredCustomApiModes = getApiModesFromConfig( + { + ...migrationConfig, + activeApiModes: [], + }, + false, + ) + const hasStoredActiveApiModes = Array.isArray(migrated.activeApiModes) + const rawKnownApiModeDefaultIds = migrated.knownApiModeDefaultIds + const hasCurrentBaseline = + Number(migrated.configSchemaVersion) >= 2 && + Array.isArray(rawKnownApiModeDefaultIds) && + (currentDefaultIds.length === 0 || rawKnownApiModeDefaultIds.length > 0) && + rawKnownApiModeDefaultIds.every( + (modelName) => typeof modelName === 'string' && Boolean(modelName.trim()), + ) + const isLiveDefaultProfile = + !hasStoredActiveApiModes && !hasCurrentBaseline && configuredCustomApiModes.length === 0 + + if (isLiveDefaultProfile) { + if (Object.hasOwn(options, 'activeApiModes')) { + delete migrated.activeApiModes + storageKeysToRemove.push('activeApiModes') + dirty = true + } + if (Object.hasOwn(options, 'knownApiModeDefaultIds')) { + delete migrated.knownApiModeDefaultIds + storageKeysToRemove.push('knownApiModeDefaultIds') + dirty = true + } + } else { + const activeApiModesForMaterialization = hasStoredActiveApiModes + ? migrated.activeApiModes + : hasCurrentBaseline + ? [] + : [...currentDefaultIds] + + if (!hasStoredActiveApiModes && hasCurrentBaseline) { + migrated.activeApiModes = [] + dirty = true + } + + if (!hasCurrentBaseline || activeApiModesForMaterialization.length > 0) { + customApiModes = getApiModesFromConfig( + { + ...migrationConfig, + activeApiModes: activeApiModesForMaterialization, + customApiModes, + }, + false, + ) + migrated.activeApiModes = [] + dirty = true + } + + let knownApiModeDefaultIds = hasCurrentBaseline + ? canonicalizeModelKeyArray( + migrated.knownApiModeDefaultIds + .filter((modelName) => typeof modelName === 'string') + .map((modelName) => modelName.trim()) + .filter(Boolean), + ) + : currentDefaultIds + + if (hasCurrentBaseline) { + const reconciled = reconcileMaterializedApiModeDefaults( + { + ...migrationConfig, + activeApiModes: [], + customApiModes, + }, + currentDefaultIds, + knownApiModeDefaultIds, + ) + customApiModes = reconciled.customApiModes + knownApiModeDefaultIds = reconciled.knownApiModeDefaultIds + if (reconciled.changed) dirty = true + } + + if ( + JSON.stringify(migrated.knownApiModeDefaultIds) !== JSON.stringify(knownApiModeDefaultIds) + ) { + dirty = true + } + migrated.knownApiModeDefaultIds = knownApiModeDefaultIds + } + if (customProvidersDirty) dirty = true if (customApiModesDirty) dirty = true @@ -1676,7 +1786,7 @@ function migrateUserConfig(options) { } } - return { migrated, dirty } + return { migrated, dirty, storageKeysToRemove } } /** @@ -1721,7 +1831,7 @@ export async function getUserConfig() { } } - const { migrated, dirty } = migrateUserConfig(options) + const { migrated, dirty, storageKeysToRemove } = migrateUserConfig(options) if (dirty) { const payload = {} if (JSON.stringify(options.customApiModes) !== JSON.stringify(migrated.customApiModes)) { @@ -1730,9 +1840,19 @@ export async function getUserConfig() { if (options.modelName !== migrated.modelName) { payload.modelName = migrated.modelName } - if (JSON.stringify(options.activeApiModes) !== JSON.stringify(migrated.activeApiModes)) { + if ( + Object.hasOwn(migrated, 'activeApiModes') && + JSON.stringify(options.activeApiModes) !== JSON.stringify(migrated.activeApiModes) + ) { payload.activeApiModes = migrated.activeApiModes } + if ( + Object.hasOwn(migrated, 'knownApiModeDefaultIds') && + JSON.stringify(options.knownApiModeDefaultIds) !== + JSON.stringify(migrated.knownApiModeDefaultIds) + ) { + payload.knownApiModeDefaultIds = migrated.knownApiModeDefaultIds + } if ( JSON.stringify(options.customOpenAIProviders) !== JSON.stringify(migrated.customOpenAIProviders) @@ -1762,8 +1882,19 @@ export async function getUserConfig() { } } } + let payloadPersisted = true if (Object.keys(payload).length > 0) { - await Browser.storage.local.set(payload).catch(() => {}) + payloadPersisted = await Browser.storage.local + .set(payload) + .then(() => true) + .catch(() => false) + } + if (payloadPersisted && storageKeysToRemove.length > 0) { + try { + await Browser.storage.local.remove(storageKeysToRemove) + } catch { + // Invalid live-default sentinels remain non-authoritative and are retried on the next read. + } } } return defaults(migrated, defaultConfig) diff --git a/src/popup/Popup.jsx b/src/popup/Popup.jsx index bbaaf41bf..e9ecff9a1 100644 --- a/src/popup/Popup.jsx +++ b/src/popup/Popup.jsx @@ -19,6 +19,7 @@ import { mergeConfigUpdate, queueConfigWrite, } from './popup-config-utils.mjs' +import { expandApiModeListConfigUpdate } from './api-mode-config-utils.mjs' import { GeneralPart } from './sections/GeneralPart' import { FeaturePages } from './sections/FeaturePages' import { AdvancedPart } from './sections/AdvancedPart' @@ -87,7 +88,8 @@ function Popup() { // Most popup field edits are fire-and-forget. Callers that must abort // follow-up work on persist failure opt into propagateError. const updateConfig = async (value, options = {}) => { - const nextValue = value && typeof value === 'object' ? value : {} + const rawValue = value && typeof value === 'object' ? value : {} + const nextValue = expandApiModeListConfigUpdate(latestConfigRef.current, rawValue) const { propagateError = false } = options && typeof options === 'object' ? options : {} const requestId = ++updateConfigRequestIdRef.current for (const key of Object.keys(nextValue)) { diff --git a/src/popup/api-mode-config-utils.mjs b/src/popup/api-mode-config-utils.mjs new file mode 100644 index 000000000..2bfeae273 --- /dev/null +++ b/src/popup/api-mode-config-utils.mjs @@ -0,0 +1,59 @@ +import { defaultApiModeIds } from '../config/index.mjs' +import { canonicalizeModelKeyArray } from '../config/model-key-migrations.mjs' +import { isApiModeSelected } from '../utils/model-name-convert.mjs' + +export const API_MODE_LIST_CONFIG_KEYS = [ + 'activeApiModes', + 'customApiModes', + 'knownApiModeDefaultIds', +] + +export function buildApiModeListConfigUpdate(config, nextApiModes, { selectionPatch = {} } = {}) { + const knownApiModeDefaultIds = canonicalizeModelKeyArray([ + ...(Array.isArray(config?.knownApiModeDefaultIds) ? config.knownApiModeDefaultIds : []), + ...defaultApiModeIds, + ]) + + return { + ...selectionPatch, + activeApiModes: [], + customApiModes: Array.isArray(nextApiModes) ? nextApiModes : [], + knownApiModeDefaultIds, + } +} + +export function getSelectionPatchWhenApiModeDisabled(apiMode, apiModes, config) { + if (!isApiModeSelected(apiMode, config)) return {} + + const fallbackConfig = { ...config, apiMode: null } + const hasFallback = apiModes.some((candidate) => isApiModeSelected(candidate, fallbackConfig)) + const disabledModeIsFallback = isApiModeSelected(apiMode, fallbackConfig) + + return { + modelName: hasFallback && !disabledModeIsFallback ? config.modelName : 'customModel', + apiMode: null, + } +} + +export function expandApiModeListConfigUpdate(currentConfig, value) { + const nextValue = value && typeof value === 'object' ? value : {} + if (!API_MODE_LIST_CONFIG_KEYS.some((key) => Object.hasOwn(nextValue, key))) { + return nextValue + } + + const includesSelectionUpdate = + Object.hasOwn(nextValue, 'modelName') || Object.hasOwn(nextValue, 'apiMode') + + return { + activeApiModes: currentConfig.activeApiModes, + customApiModes: currentConfig.customApiModes, + knownApiModeDefaultIds: currentConfig.knownApiModeDefaultIds, + ...(includesSelectionUpdate + ? { + modelName: currentConfig.modelName, + apiMode: currentConfig.apiMode, + } + : {}), + ...nextValue, + } +} diff --git a/src/popup/sections/ApiModes.jsx b/src/popup/sections/ApiModes.jsx index 37b649795..56ef7cd0e 100644 --- a/src/popup/sections/ApiModes.jsx +++ b/src/popup/sections/ApiModes.jsx @@ -1,15 +1,14 @@ import { useTranslation } from 'react-i18next' import PropTypes from 'prop-types' import Browser from 'webextension-polyfill' -import { - apiModeToModelName, - getApiModesFromConfig, - isApiModeSelected, - modelNameToDesc, -} from '../../utils/index.mjs' +import { getApiModesFromConfig, isApiModeSelected, modelNameToDesc } from '../../utils/index.mjs' import { PencilIcon, TrashIcon } from '@primer/octicons-react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { AlwaysCustomGroups, ModelGroups } from '../../config/index.mjs' +import { + buildApiModeListConfigUpdate, + getSelectionPatchWhenApiModeDisabled, +} from '../api-mode-config-utils.mjs' import { getCustomOpenAIProviders, OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID, @@ -78,7 +77,6 @@ export function ApiModes({ config, updateConfig }) { const [editingApiMode, setEditingApiMode] = useState(defaultApiMode) const [editingIndex, setEditingIndex] = useState(-1) const [apiModes, setApiModes] = useState([]) - const [apiModeStringArray, setApiModeStringArray] = useState([]) const [customProviders, setCustomProviders] = useState([]) const [pendingNewProvider, setPendingNewProvider] = useState(null) const [pendingEditedProvidersById, setPendingEditedProvidersById] = useState({}) @@ -101,7 +99,6 @@ export function ApiModes({ config, updateConfig }) { useLayoutEffect(() => { const nextApiModes = getApiModesFromConfig(config) setApiModes(nextApiModes) - setApiModeStringArray(nextApiModes.map(apiModeToModelName)) setCustomProviders(getCustomOpenAIProviders(config)) }, [ config.activeApiModes, @@ -142,18 +139,6 @@ export function ApiModes({ config, updateConfig }) { } }, []) - const updateWhenApiModeDisabled = (apiMode) => { - if (isApiModeSelected(apiMode, config)) - updateConfig({ - modelName: - apiModeStringArray.includes(config.modelName) && - config.modelName !== apiModeToModelName(apiMode) - ? config.modelName - : 'customModel', - apiMode: null, - }) - } - const shouldEditProvider = editingApiMode.groupName === 'customApiModelKeys' const effectiveProviders = useMemo( () => @@ -232,13 +217,15 @@ export function ApiModes({ config, updateConfig }) { } const persistApiMode = async (nextApiMode) => { - const payload = { - activeApiModes: [], - customApiModes: - editingIndex === -1 - ? [...apiModes, nextApiMode] - : apiModes.map((apiMode, index) => (index === editingIndex ? nextApiMode : apiMode)), - } + const nextApiModes = + editingIndex === -1 + ? [...apiModes, nextApiMode] + : apiModes.map((apiMode, index) => (index === editingIndex ? nextApiMode : apiMode)) + const selectionPatch = + editingIndex !== -1 && isApiModeSelected(apiModes[editingIndex], config) + ? { apiMode: nextApiMode } + : {} + const payload = buildApiModeListConfigUpdate(config, nextApiModes, { selectionPatch }) if ( shouldPersistPendingProviderChanges(hasPendingProviderChanges) || shouldPersistDeletedProviderChanges(pendingDeletedProviderIds) @@ -251,9 +238,6 @@ export function ApiModes({ config, updateConfig }) { ) } } - if (editingIndex !== -1 && isApiModeSelected(apiModes[editingIndex], config)) { - payload.apiMode = nextApiMode - } await persistApiModeConfigUpdate(updateConfig, payload, clearPendingProviderChanges) } @@ -632,10 +616,14 @@ export function ApiModes({ config, updateConfig }) { type="checkbox" checked={apiMode.active} onChange={(e) => { - if (!e.target.checked) updateWhenApiModeDisabled(apiMode) const customApiModes = [...apiModes] customApiModes[index] = { ...apiMode, active: e.target.checked } - updateConfig({ activeApiModes: [], customApiModes }) + const selectionPatch = e.target.checked + ? {} + : getSelectionPatchWhenApiModeDisabled(apiMode, apiModes, config) + updateConfig( + buildApiModeListConfigUpdate(config, customApiModes, { selectionPatch }), + ) }} /> {getApiModeDisplayLabel(apiMode, t, effectiveProviders)} @@ -675,10 +663,17 @@ export function ApiModes({ config, updateConfig }) { style={{ cursor: 'pointer' }} onClick={(e) => { e.preventDefault() - updateWhenApiModeDisabled(apiMode) const customApiModes = [...apiModes] customApiModes.splice(index, 1) - updateConfig({ activeApiModes: [], customApiModes }) + updateConfig( + buildApiModeListConfigUpdate(config, customApiModes, { + selectionPatch: getSelectionPatchWhenApiModeDisabled( + apiMode, + apiModes, + config, + ), + }), + ) }} > diff --git a/src/popup/sections/import-data-cleanup.mjs b/src/popup/sections/import-data-cleanup.mjs index 65cb4a04b..125a0ebc7 100644 --- a/src/popup/sections/import-data-cleanup.mjs +++ b/src/popup/sections/import-data-cleanup.mjs @@ -10,10 +10,22 @@ const conflictingKeyPairs = [ ['customClaudeApiUrl', 'customAnthropicApiUrl'], ] +const apiModeListKeys = ['activeApiModes', 'customApiModes', 'knownApiModeDefaultIds'] +const apiModeSelectionKeys = ['modelName', 'apiMode'] + export function prepareImportData(data) { const normalizedData = { ...data } const keysToRemove = [] + if (apiModeListKeys.some((key) => Object.hasOwn(data, key))) { + for (const key of apiModeListKeys) { + if (!Object.hasOwn(data, key)) normalizedData[key] = null + } + for (const key of apiModeSelectionKeys) { + if (!Object.hasOwn(data, key)) keysToRemove.push(key) + } + } + for (const [legacyKey, anthropicKey] of conflictingKeyPairs) { const hasLegacyKey = Object.hasOwn(data, legacyKey) const hasAnthropicKey = Object.hasOwn(data, anthropicKey) @@ -39,6 +51,14 @@ export function prepareImportData(data) { if (Array.isArray(normalizedData.activeApiModes)) { normalizedData.activeApiModes = canonicalizeModelKeyArray(normalizedData.activeApiModes) } + if (Array.isArray(normalizedData.knownApiModeDefaultIds)) { + normalizedData.knownApiModeDefaultIds = canonicalizeModelKeyArray( + normalizedData.knownApiModeDefaultIds + .filter((modelName) => typeof modelName === 'string') + .map((modelName) => modelName.trim()) + .filter(Boolean), + ) + } if (Array.isArray(normalizedData.sessions)) { normalizedData.sessions = normalizedData.sessions.map(canonicalizeSessionModelFields) } diff --git a/src/utils/model-name-convert.mjs b/src/utils/model-name-convert.mjs index fcbecd786..838456c29 100644 --- a/src/utils/model-name-convert.mjs +++ b/src/utils/model-name-convert.mjs @@ -89,7 +89,7 @@ export function normalizeApiMode(apiMode) { groupName: apiMode.groupName || '', itemName: apiMode.itemName || '', isCustom: Boolean(apiMode.isCustom), - customName: apiMode.customName || '', + customName: typeof apiMode.customName === 'string' ? apiMode.customName : '', customUrl: apiMode.customUrl || '', apiKey: apiMode.apiKey || '', providerId: typeof apiMode.providerId === 'string' ? apiMode.providerId.trim() : '', @@ -97,6 +97,15 @@ export function normalizeApiMode(apiMode) { } } +const alwaysCustomPresetItemNames = { + azureOpenAiApiModelKeys: 'azureOpenAi', + ollamaApiModelKeys: 'ollamaModel', +} + +function isBuiltInAlwaysCustomPreset(apiMode) { + return !apiMode.isCustom && alwaysCustomPresetItemNames[apiMode.groupName] === apiMode.itemName +} + export function apiModeToModelName(apiMode) { apiMode = normalizeApiMode(apiMode) if (!apiMode) return '' @@ -136,7 +145,9 @@ export function getApiModesFromConfig(config, onlyActive) { .filter((apiMode) => { if (!apiMode || !apiMode.groupName) return false if (AlwaysCustomGroups.includes(apiMode.groupName)) { - return Boolean(apiMode.customName && apiMode.customName.trim()) + return Boolean( + (apiMode.customName && apiMode.customName.trim()) || isBuiltInAlwaysCustomPreset(apiMode), + ) } return Boolean(apiMode.itemName) }) @@ -219,6 +230,69 @@ export function getApiModesFromConfig(config, onlyActive) { ] } +function getApiModeDefaultIdentity(apiMode) { + const normalized = normalizeApiMode(apiMode) + if (!normalized) return '' + const customName = normalized.customName.trim() + const presetItemName = alwaysCustomPresetItemNames[normalized.groupName] || '' + const itemName = customName && presetItemName ? presetItemName : normalized.itemName + const isNamedAlwaysCustomPreset = + Boolean(customName) && Boolean(presetItemName) && itemName === presetItemName + return JSON.stringify({ + groupName: normalized.groupName, + itemName, + isCustom: isNamedAlwaysCustomPreset ? true : normalized.isCustom, + customName, + providerId: normalized.providerId, + }) +} + +export function reconcileMaterializedApiModeDefaults(config, defaultIds, knownDefaultIds) { + const currentModes = Array.isArray(config.customApiModes) + ? config.customApiModes.map((apiMode) => ({ ...apiMode })) + : [] + const knownIds = Array.isArray(knownDefaultIds) ? [...knownDefaultIds] : [] + const knownIdSet = new Set(knownIds) + let changed = false + + for (const defaultId of Array.isArray(defaultIds) ? defaultIds : []) { + if (knownIdSet.has(defaultId)) continue + + if (defaultId === 'customModel') { + knownIds.push(defaultId) + knownIdSet.add(defaultId) + changed = true + continue + } + + const materializedModes = getApiModesFromConfig( + { + ...config, + activeApiModes: [defaultId], + customApiModes: [], + }, + false, + ) + const materializedMode = materializedModes[0] + if (!materializedMode) continue + + const identity = getApiModeDefaultIdentity(materializedMode) + const alreadyExists = currentModes.some( + (apiMode) => getApiModeDefaultIdentity(apiMode) === identity, + ) + if (!alreadyExists) currentModes.push({ ...materializedMode, active: true }) + knownIds.push(defaultId) + knownIdSet.add(defaultId) + changed = true + } + + return { + customApiModes: currentModes, + knownApiModeDefaultIds: knownIds, + changed, + } +} + export function getApiModesStringArrayFromConfig(config, onlyActive) { return getApiModesFromConfig(config, onlyActive).map(apiModeToModelName) } diff --git a/tests/unit/config/migrate-user-config.test.mjs b/tests/unit/config/migrate-user-config.test.mjs index 5c4d23f98..992e1afe4 100644 --- a/tests/unit/config/migrate-user-config.test.mjs +++ b/tests/unit/config/migrate-user-config.test.mjs @@ -1,6 +1,11 @@ import assert from 'node:assert/strict' import { beforeEach, test } from 'node:test' -import { getUserConfig } from '../../../src/config/index.mjs' +import Browser from 'webextension-polyfill' +import { defaultApiModeIds, getUserConfig } from '../../../src/config/index.mjs' +import { + getApiModesFromConfig, + modelNameToApiMode, +} from '../../../src/utils/model-name-convert.mjs' function createCustomApiMode(overrides = {}) { return { @@ -85,19 +90,25 @@ test('getUserConfig migrates legacy model keys in selected config fields', async assert.equal(config.modelName, 'chatgptFree4oMini') assert.equal(storage.modelName, 'chatgptFree4oMini') - assert.deepEqual(config.activeApiModes, [ - 'chatgptFree4oMini', - 'claudeSonnet46Api', - 'moonshot_k2_5', - 'openRouter_deepseek_v4_flash', - ]) + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual( + config.customApiModes.map((apiMode) => apiMode.itemName), + [ + 'chatgptFree4oMini', + 'claudeSonnet46Api', + 'moonshot_k2_5', + 'openRouter_deepseek_v4_flash', + 'aiml_openai_gpt_5_5', + ], + ) assert.deepEqual(storage.activeApiModes, config.activeApiModes) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) assert.equal(config.apiMode.groupName, 'claudeApiModelKeys') assert.equal(config.apiMode.itemName, 'claudeSonnet46Api') assert.equal(storage.apiMode.itemName, 'claudeSonnet46Api') - assert.equal(config.customApiModes[0].groupName, 'aimlModelKeys') - assert.equal(config.customApiModes[0].itemName, 'aiml_openai_gpt_5_5') - assert.equal(storage.customApiModes[0].itemName, 'aiml_openai_gpt_5_5') + assert.equal(config.customApiModes.at(-1).groupName, 'aimlModelKeys') + assert.equal(config.customApiModes.at(-1).itemName, 'aiml_openai_gpt_5_5') + assert.equal(storage.customApiModes.at(-1).itemName, 'aiml_openai_gpt_5_5') }) test('getUserConfig reuses custom mode key promoted earlier in the same migration pass', async () => { @@ -927,8 +938,311 @@ test('getUserConfig writes current config schema version during migration', asyn const config = await getUserConfig() const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() - assert.equal(config.configSchemaVersion, 1) - assert.equal(storage.configSchemaVersion, 1) + assert.equal(config.configSchemaVersion, 2) + assert.equal(storage.configSchemaVersion, 2) +}) + +test('getUserConfig keeps untouched profiles on live defaults without persisting a baseline', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + }) + + const config = await getUserConfig() + await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(config.activeApiModes, defaultApiModeIds) + assert.deepEqual(config.knownApiModeDefaultIds, []) + assert.equal(Object.hasOwn(storage, 'activeApiModes'), false) + assert.equal(Object.hasOwn(storage, 'knownApiModeDefaultIds'), false) +}) + +test('getUserConfig treats imported null sentinels as an untouched live-default profile', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: null, + customApiModes: null, + knownApiModeDefaultIds: null, + }) + + const config = await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(config.activeApiModes, defaultApiModeIds) + assert.deepEqual(config.customApiModes, []) + assert.deepEqual(config.knownApiModeDefaultIds, []) + assert.equal(Object.hasOwn(storage, 'activeApiModes'), false) + assert.equal(Object.hasOwn(storage, 'knownApiModeDefaultIds'), false) +}) + +test('getUserConfig retries live-default sentinel cleanup after remove failure', async (t) => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: null, + customApiModes: null, + knownApiModeDefaultIds: null, + }) + + const originalRemove = Browser.storage.local.remove + let removeCalls = 0 + t.mock.method(Browser.storage.local, 'remove', async (keys) => { + removeCalls += 1 + if (removeCalls === 1) throw new Error('remove failed') + return originalRemove.call(Browser.storage.local, keys) + }) + + const firstConfig = await getUserConfig() + const storageAfterFailure = globalThis.__TEST_BROWSER_SHIM__.getStorage() + const secondConfig = await getUserConfig() + const storageAfterRetry = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(firstConfig.activeApiModes, defaultApiModeIds) + assert.deepEqual(secondConfig.activeApiModes, defaultApiModeIds) + assert.equal(storageAfterFailure.activeApiModes, null) + assert.equal(storageAfterFailure.knownApiModeDefaultIds, null) + assert.equal(Object.hasOwn(storageAfterRetry, 'activeApiModes'), false) + assert.equal(Object.hasOwn(storageAfterRetry, 'knownApiModeDefaultIds'), false) + assert.equal(removeCalls, 2) +}) + +test('getUserConfig establishes a baseline for old snapshots without backfilling missing defaults', async () => { + const existingMode = modelNameToApiMode('chatgptFree35') + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + activeApiModes: [], + customApiModes: [existingMode], + }) + + const config = await getUserConfig() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual(config.customApiModes, [existingMode]) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig materializes a legacy active-only profile', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + activeApiModes: ['chatgptFree35'], + }) + + const config = await getUserConfig() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual( + config.customApiModes.map((apiMode) => apiMode.itemName), + ['chatgptFree35'], + ) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) + assert.notEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig sanitizes malformed legacy active API mode ids before materializing', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + activeApiModes: [null, 1, '', ' chatgptFree35 '], + }) + + const config = await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual( + config.customApiModes.map((apiMode) => apiMode.itemName), + ['chatgptFree35'], + ) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) + assert.deepEqual(storage.activeApiModes, []) + assert.deepEqual( + storage.customApiModes.map((apiMode) => apiMode.itemName), + ['chatgptFree35'], + ) + assert.deepEqual(storage.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig sanitizes malformed active ids without rematerializing a current baseline', async () => { + const existingMode = modelNameToApiMode('chatgptFree35') + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [null, 1, ''], + customApiModes: [existingMode], + knownApiModeDefaultIds: defaultApiModeIds, + }) + + const config = await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual(config.customApiModes, [existingMode]) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) + assert.deepEqual(storage.activeApiModes, []) + assert.deepEqual(storage.customApiModes, [existingMode]) + assert.deepEqual(storage.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig keeps unresolved built-in defaults in the effective materialized list', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + activeApiModes: defaultApiModeIds, + azureDeploymentName: '', + ollamaModelName: '', + }) + + const config = await getUserConfig() + const effectiveApiModes = getApiModesFromConfig(config, false) + + assert.equal( + effectiveApiModes.some((apiMode) => apiMode.itemName === 'azureOpenAi'), + true, + ) + assert.equal( + effectiveApiModes.some((apiMode) => apiMode.itemName === 'ollamaModel'), + true, + ) +}) + +test('getUserConfig materializes live defaults before a legacy custom-only row', async () => { + const customMode = createCustomApiMode({ customName: 'legacy-custom-only' }) + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + customApiModes: [customMode], + }) + + const config = await getUserConfig() + + assert.deepEqual(config.activeApiModes, []) + assert.equal(config.customApiModes.at(-1).customName, 'legacy-custom-only') + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig treats a valid baseline as materialized when active modes are missing', async () => { + const existingMode = modelNameToApiMode('chatgptFree35') + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + customApiModes: [existingMode], + knownApiModeDefaultIds: defaultApiModeIds, + }) + + const config = await getUserConfig() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual(config.customApiModes, [existingMode]) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) + assert.deepEqual(globalThis.__TEST_BROWSER_SHIM__.getStorage().activeApiModes, []) +}) + +test('getUserConfig does not restore removed defaults when only a valid baseline remains', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + customApiModes: [], + knownApiModeDefaultIds: defaultApiModeIds, + }) + + const config = await getUserConfig() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual(config.customApiModes, []) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig replaces a malformed baseline without backfilling the snapshot', async () => { + const existingMode = modelNameToApiMode('chatgptFree35') + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [existingMode], + knownApiModeDefaultIds: [null], + }) + + const config = await getUserConfig() + + assert.deepEqual(config.customApiModes, [existingMode]) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig appends defaults added after the stored baseline', async () => { + const newDefaultId = defaultApiModeIds.at(-1) + const previousDefaultIds = defaultApiModeIds.slice(0, -1) + const existingMode = modelNameToApiMode('chatgptFree35') + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [existingMode], + knownApiModeDefaultIds: previousDefaultIds, + }) + + const firstConfig = await getUserConfig() + const secondConfig = await getUserConfig() + + assert.equal(firstConfig.customApiModes.at(-1).itemName, newDefaultId) + assert.deepEqual(firstConfig.knownApiModeDefaultIds, defaultApiModeIds) + assert.deepEqual(secondConfig.customApiModes, firstConfig.customApiModes) +}) + +test('getUserConfig retries future-default reconciliation deterministically after write failure', async (t) => { + const newDefaultId = defaultApiModeIds.at(-1) + const storedConfig = { + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [modelNameToApiMode('chatgptFree35')], + knownApiModeDefaultIds: defaultApiModeIds.slice(0, -1), + } + globalThis.__TEST_BROWSER_SHIM__.replaceStorage(storedConfig) + t.mock.method(Browser.storage.local, 'set', async () => { + throw new Error('write failed') + }) + + const firstConfig = await getUserConfig() + const secondConfig = await getUserConfig() + + assert.equal(firstConfig.customApiModes.at(-1).itemName, newDefaultId) + assert.deepEqual(secondConfig.customApiModes, firstConfig.customApiModes) + assert.deepEqual(globalThis.__TEST_BROWSER_SHIM__.getStorage(), storedConfig) +}) + +test('getUserConfig does not reactivate an equivalent inactive row for a new default', async () => { + const newDefaultId = defaultApiModeIds.at(-1) + const inactiveMode = { ...modelNameToApiMode(newDefaultId), active: false } + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [inactiveMode], + knownApiModeDefaultIds: defaultApiModeIds.slice(0, -1), + }) + + const config = await getUserConfig() + + assert.equal(config.customApiModes.length, 1) + assert.equal(config.customApiModes[0].active, false) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig does not restore a removed default whose id is already known', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [], + knownApiModeDefaultIds: defaultApiModeIds, + }) + + const config = await getUserConfig() + + assert.deepEqual(config.customApiModes, []) +}) + +test('getUserConfig canonicalizes legacy ids while preserving the cumulative baseline', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [], + knownApiModeDefaultIds: [...defaultApiModeIds, 'chatgptFree4o'], + }) + + const config = await getUserConfig() + + assert.equal(config.knownApiModeDefaultIds.includes('chatgptFree4o'), false) + assert.equal(config.knownApiModeDefaultIds.includes('chatgptFree4oMini'), true) + assert.deepEqual(config.customApiModes, []) }) test('getUserConfig creates separate providers when same URL has different API keys', async () => { @@ -1050,7 +1364,7 @@ test('getUserConfig reverse-syncs providerSecrets to legacy fields for backward test('getUserConfig converges missing provider migration keys when schema version is current', async () => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ - configSchemaVersion: 1, + configSchemaVersion: 2, }) await getUserConfig() @@ -1069,7 +1383,7 @@ test('getUserConfig converges missing provider migration keys when schema versio test('getUserConfig persists generated custom provider ids when schema version is current', async () => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ - configSchemaVersion: 1, + configSchemaVersion: 2, providerSecrets: {}, customApiModes: [], customOpenAIProviders: [ diff --git a/tests/unit/popup/api-mode-config-utils.test.mjs b/tests/unit/popup/api-mode-config-utils.test.mjs new file mode 100644 index 000000000..862d09e3a --- /dev/null +++ b/tests/unit/popup/api-mode-config-utils.test.mjs @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { defaultApiModeIds } from '../../../src/config/index.mjs' +import { + buildApiModeListConfigUpdate, + expandApiModeListConfigUpdate, + getSelectionPatchWhenApiModeDisabled, +} from '../../../src/popup/api-mode-config-utils.mjs' +import { modelNameToApiMode } from '../../../src/utils/model-name-convert.mjs' +import { + buildConfigRollbackPatch, + mergeConfigUpdate, +} from '../../../src/popup/popup-config-utils.mjs' + +test('buildApiModeListConfigUpdate materializes the list with the current default baseline', () => { + const nextApiModes = [{ itemName: 'chatgptFree35', active: true }] + const result = buildApiModeListConfigUpdate( + { + knownApiModeDefaultIds: ['retired-default'], + }, + nextApiModes, + { + selectionPatch: { + modelName: 'customModel', + apiMode: null, + }, + }, + ) + + assert.deepEqual(result, { + modelName: 'customModel', + apiMode: null, + activeApiModes: [], + customApiModes: nextApiModes, + knownApiModeDefaultIds: ['retired-default', ...defaultApiModeIds], + }) +}) + +test('getSelectionPatchWhenApiModeDisabled preserves canonicalized legacy fallbacks', () => { + const selectedApiMode = modelNameToApiMode('chatgptFree35') + const fallbacks = [ + { + modelName: 'ollamaModel', + ollamaModelName: 'llama4', + apiMode: modelNameToApiMode('ollamaModel-llama4'), + }, + { + modelName: 'azureOpenAi', + azureDeploymentName: 'deploy-a', + apiMode: modelNameToApiMode('azureOpenAi-deploy-a'), + }, + ] + + for (const fallback of fallbacks) { + const config = { + ...fallback, + apiMode: selectedApiMode, + } + assert.deepEqual( + getSelectionPatchWhenApiModeDisabled( + selectedApiMode, + [fallback.apiMode, selectedApiMode], + config, + ), + { + modelName: fallback.modelName, + apiMode: null, + }, + ) + } +}) + +test('getSelectionPatchWhenApiModeDisabled clears a disabled legacy fallback', () => { + const fallbackApiMode = modelNameToApiMode('ollamaModel-llama4') + const config = { + modelName: 'ollamaModel', + ollamaModelName: 'llama4', + apiMode: fallbackApiMode, + } + + assert.deepEqual( + getSelectionPatchWhenApiModeDisabled(fallbackApiMode, [fallbackApiMode], config), + { + modelName: 'customModel', + apiMode: null, + }, + ) +}) + +test('getSelectionPatchWhenApiModeDisabled preserves duplicate custom fallbacks', () => { + const selectedApiMode = modelNameToApiMode('chatgptFree35') + const fallbackApiMode = modelNameToApiMode('customApiModelKeys-shared-model') + const config = { + modelName: 'customApiModelKeys-shared-model', + apiMode: selectedApiMode, + } + + assert.deepEqual( + getSelectionPatchWhenApiModeDisabled( + selectedApiMode, + [ + { ...fallbackApiMode, providerId: 'provider-a' }, + { ...fallbackApiMode, providerId: 'provider-b' }, + selectedApiMode, + ], + config, + ), + { + modelName: config.modelName, + apiMode: null, + }, + ) +}) + +test('expandApiModeListConfigUpdate fills the complete atomic list bundle', () => { + const currentConfig = { + activeApiModes: [], + customApiModes: [{ itemName: 'old-mode' }], + knownApiModeDefaultIds: ['known-mode'], + modelName: 'old-mode', + apiMode: { itemName: 'old-mode' }, + themeMode: 'light', + } + + const result = expandApiModeListConfigUpdate(currentConfig, { + customApiModes: [{ itemName: 'new-mode' }], + }) + + assert.deepEqual(result, { + activeApiModes: [], + customApiModes: [{ itemName: 'new-mode' }], + knownApiModeDefaultIds: ['known-mode'], + }) +}) + +test('expandApiModeListConfigUpdate fills the selection bundle when explicitly updated', () => { + const currentConfig = { + activeApiModes: [], + customApiModes: [{ itemName: 'old-mode' }], + knownApiModeDefaultIds: ['known-mode'], + modelName: 'old-mode', + apiMode: { itemName: 'old-mode' }, + } + + const result = expandApiModeListConfigUpdate(currentConfig, { + customApiModes: [{ itemName: 'new-mode' }], + apiMode: { itemName: 'new-mode' }, + }) + + assert.deepEqual(result, { + activeApiModes: [], + customApiModes: [{ itemName: 'new-mode' }], + knownApiModeDefaultIds: ['known-mode'], + modelName: 'old-mode', + apiMode: { itemName: 'new-mode' }, + }) +}) + +test('expandApiModeListConfigUpdate leaves unrelated payloads unchanged', () => { + const payload = { themeMode: 'dark' } + assert.equal(expandApiModeListConfigUpdate({}, payload), payload) +}) + +test('a newer list request owns the complete atomic bundle after an earlier failure', () => { + const persisted = { + activeApiModes: [], + customApiModes: [{ itemName: 'persisted' }], + knownApiModeDefaultIds: ['persisted'], + modelName: 'persisted', + apiMode: { itemName: 'persisted' }, + } + const requestA = expandApiModeListConfigUpdate(persisted, { + customApiModes: [{ itemName: 'optimistic-a' }], + }) + const optimisticA = mergeConfigUpdate(persisted, requestA) + const requestB = expandApiModeListConfigUpdate(optimisticA, { + customApiModes: [{ itemName: 'persisted-b' }], + }) + const owners = Object.fromEntries(Object.keys(requestB).map((key) => [key, 2])) + const rollbackA = buildConfigRollbackPatch(persisted, requestA, owners, 1) + + assert.deepEqual(rollbackA, {}) + assert.deepEqual(mergeConfigUpdate(optimisticA, requestB).customApiModes, [ + { itemName: 'persisted-b' }, + ]) +}) + +test('a failed newer list request rolls the complete bundle back to the prior write', () => { + const persisted = { + activeApiModes: [], + customApiModes: [{ itemName: 'persisted-a' }], + knownApiModeDefaultIds: ['persisted-a'], + modelName: 'persisted-a', + apiMode: { itemName: 'persisted-a' }, + } + const requestB = expandApiModeListConfigUpdate(persisted, { + customApiModes: [{ itemName: 'optimistic-b' }], + }) + const owners = Object.fromEntries(Object.keys(requestB).map((key) => [key, 2])) + const rollbackB = buildConfigRollbackPatch(persisted, requestB, owners, 2) + const optimisticB = mergeConfigUpdate(persisted, requestB) + + assert.deepEqual(mergeConfigUpdate(optimisticB, rollbackB), persisted) +}) + +test('a list request does not commit an earlier failed selection request', () => { + const persisted = { + activeApiModes: [], + customApiModes: [{ itemName: 'persisted' }], + knownApiModeDefaultIds: ['persisted'], + modelName: 'persisted', + apiMode: { itemName: 'persisted' }, + } + const selectionRequest = { + modelName: 'optimistic-selection', + apiMode: { itemName: 'optimistic-selection' }, + } + const optimisticSelection = mergeConfigUpdate(persisted, selectionRequest) + const listRequest = expandApiModeListConfigUpdate(optimisticSelection, { + customApiModes: [{ itemName: 'persisted-list' }], + }) + const owners = { + modelName: 1, + apiMode: 1, + ...Object.fromEntries(Object.keys(listRequest).map((key) => [key, 2])), + } + const selectionRollback = buildConfigRollbackPatch(persisted, selectionRequest, owners, 1) + const optimisticList = mergeConfigUpdate(optimisticSelection, listRequest) + + assert.deepEqual(selectionRollback, { + modelName: 'persisted', + apiMode: { itemName: 'persisted' }, + }) + assert.equal(Object.hasOwn(listRequest, 'modelName'), false) + assert.equal(Object.hasOwn(listRequest, 'apiMode'), false) + assert.deepEqual(mergeConfigUpdate(optimisticList, selectionRollback), { + ...persisted, + customApiModes: [{ itemName: 'persisted-list' }], + }) +}) diff --git a/tests/unit/popup/import-data-cleanup.test.mjs b/tests/unit/popup/import-data-cleanup.test.mjs index d8b782f6d..c1571ce01 100644 --- a/tests/unit/popup/import-data-cleanup.test.mjs +++ b/tests/unit/popup/import-data-cleanup.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' +import Browser from 'webextension-polyfill' +import { defaultApiModeIds, defaultConfig, getUserConfig } from '../../../src/config/index.mjs' import { importDataIntoStorage, prepareImportData, @@ -119,6 +121,7 @@ test('prepareImportData migrates legacy model keys in imported config and sessio assert.equal(normalizedData.modelName, 'chatgptFree4oMini') assert.deepEqual(normalizedData.activeApiModes, ['chatgptFree4oMini', 'moonshot_k2_5']) + assert.equal(normalizedData.knownApiModeDefaultIds, null) assert.equal(normalizedData.apiMode.itemName, 'openRouter_deepseek_v4_flash') assert.equal(normalizedData.customApiModes[0].groupName, 'aimlModelKeys') assert.equal(normalizedData.customApiModes[0].itemName, 'aiml_openai_gpt_5_5') @@ -130,6 +133,29 @@ test('prepareImportData migrates legacy model keys in imported config and sessio assert.deepEqual(keysToRemove, []) }) +test('prepareImportData atomically clears stale API mode fields missing from an old backup', () => { + const { normalizedData, keysToRemove } = prepareImportData({ + customApiModes: [], + }) + + assert.deepEqual(normalizedData, { + activeApiModes: null, + customApiModes: [], + knownApiModeDefaultIds: null, + }) + assert.deepEqual(keysToRemove, ['modelName', 'apiMode']) +}) + +test('prepareImportData canonicalizes a stored API mode default baseline', () => { + const { normalizedData } = prepareImportData({ + activeApiModes: [], + customApiModes: [], + knownApiModeDefaultIds: [null, ' chatgptFree4o ', 'chatgptFree4oMini'], + }) + + assert.deepEqual(normalizedData.knownApiModeDefaultIds, ['chatgptFree4oMini']) +}) + test('importDataIntoStorage writes normalized data before removing legacy keys', async () => { const calls = [] const storageArea = { @@ -151,6 +177,36 @@ test('importDataIntoStorage writes normalized data before removing legacy keys', ]) }) +test('importDataIntoStorage replaces stale API mode state before legacy migration', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [{ itemName: 'stale-mode' }], + knownApiModeDefaultIds: ['stale-default'], + modelName: 'stale-model', + apiMode: { itemName: 'stale-mode' }, + }) + + await importDataIntoStorage(Browser.storage.local, { + configSchemaVersion: 1, + customApiModes: [], + }) + const importedStorage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + assert.equal(importedStorage.activeApiModes, null) + assert.equal(importedStorage.knownApiModeDefaultIds, null) + assert.equal(Object.hasOwn(importedStorage, 'modelName'), false) + assert.equal(Object.hasOwn(importedStorage, 'apiMode'), false) + + const config = await getUserConfig() + const migratedStorage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(config.activeApiModes, defaultApiModeIds) + assert.equal(config.modelName, defaultConfig.modelName) + assert.equal(config.apiMode, null) + assert.equal(Object.hasOwn(migratedStorage, 'activeApiModes'), false) + assert.equal(Object.hasOwn(migratedStorage, 'knownApiModeDefaultIds'), false) +}) + test('importDataIntoStorage does not remove existing keys when set fails', async () => { const calls = [] const storageArea = { diff --git a/tests/unit/utils/model-name-convert.test.mjs b/tests/unit/utils/model-name-convert.test.mjs index 3bdf3ee83..5222f6a57 100644 --- a/tests/unit/utils/model-name-convert.test.mjs +++ b/tests/unit/utils/model-name-convert.test.mjs @@ -15,6 +15,7 @@ import { modelNameToValue, getModelValue, normalizeApiMode, + reconcileMaterializedApiModeDefaults, } from '../../../src/utils/model-name-convert.mjs' import { ModelGroups } from '../../../src/config/index.mjs' @@ -99,6 +100,195 @@ test('getApiModesFromConfig merges active and custom API modes correctly', () => ) }) +test('reconcileMaterializedApiModeDefaults appends unseen defaults in order', () => { + const existingMode = modelNameToApiMode('chatgptFree35') + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [existingMode], + azureDeploymentName: '', + ollamaModelName: 'llama4', + }, + ['chatgptFree35', 'claude2WebFree', 'moonshotWebFree'], + ['chatgptFree35'], + ) + + assert.deepEqual(result.customApiModes.map(apiModeToModelName), [ + 'chatgptFree35', + 'claude2WebFree', + 'moonshotWebFree', + ]) + assert.deepEqual(result.knownApiModeDefaultIds, [ + 'chatgptFree35', + 'claude2WebFree', + 'moonshotWebFree', + ]) + assert.equal(result.changed, true) +}) + +test('reconcileMaterializedApiModeDefaults preserves an inactive equivalent row', () => { + const inactiveMode = { ...modelNameToApiMode('claude2WebFree'), active: false } + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [inactiveMode], + azureDeploymentName: '', + ollamaModelName: 'llama4', + }, + ['claude2WebFree'], + [], + ) + + assert.equal(result.customApiModes.length, 1) + assert.equal(result.customApiModes[0].active, false) + assert.deepEqual(result.knownApiModeDefaultIds, ['claude2WebFree']) +}) + +test('reconcileMaterializedApiModeDefaults preserves exact duplicates without adding another row', () => { + const duplicateMode = modelNameToApiMode('chatgptFree35') + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [duplicateMode, { ...duplicateMode }], + azureDeploymentName: '', + ollamaModelName: 'llama4', + }, + ['chatgptFree35'], + [], + ) + + assert.equal(result.customApiModes.length, 2) + assert.deepEqual(result.knownApiModeDefaultIds, ['chatgptFree35']) +}) + +test('reconcileMaterializedApiModeDefaults keeps provider variants distinct from a default row', () => { + const providerVariant = { + ...modelNameToApiMode('chatgptFree35'), + providerId: 'custom-provider', + } + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [providerVariant], + azureDeploymentName: '', + ollamaModelName: 'llama4', + }, + ['chatgptFree35'], + [], + ) + + assert.equal(result.customApiModes.length, 2) + assert.equal(result.customApiModes[0].providerId, 'custom-provider') + assert.equal(result.customApiModes[1].providerId, '') +}) + +test('reconcileMaterializedApiModeDefaults is idempotent for known defaults', () => { + const existingMode = modelNameToApiMode('chatgptFree35') + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [existingMode], + azureDeploymentName: '', + ollamaModelName: 'llama4', + }, + ['chatgptFree35'], + ['chatgptFree35'], + ) + + assert.deepEqual(result.customApiModes, [existingMode]) + assert.deepEqual(result.knownApiModeDefaultIds, ['chatgptFree35']) + assert.equal(result.changed, false) +}) + +test('reconcileMaterializedApiModeDefaults resolves configured Azure and Ollama defaults', () => { + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [], + azureDeploymentName: 'deployment-a', + ollamaModelName: 'llama4', + }, + ['azureOpenAi', 'ollamaModel'], + [], + ) + + assert.deepEqual(result.customApiModes.map(apiModeToModelName), [ + 'azureOpenAiApiModelKeys-deployment-a', + 'ollamaApiModelKeys-llama4', + ]) +}) + +test('reconcileMaterializedApiModeDefaults reuses legacy Azure and Ollama rows', () => { + const legacyAzureMode = { + ...modelNameToApiMode('azureOpenAi-deployment-a'), + itemName: '', + active: false, + } + const legacyOllamaMode = { + ...modelNameToApiMode('ollamaModel-llama4'), + itemName: '', + active: false, + } + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [legacyAzureMode, legacyOllamaMode], + azureDeploymentName: 'deployment-a', + ollamaModelName: 'llama4', + }, + ['azureOpenAi', 'ollamaModel'], + [], + ) + + assert.deepEqual(result.customApiModes, [legacyAzureMode, legacyOllamaMode]) + assert.deepEqual(result.knownApiModeDefaultIds, ['azureOpenAi', 'ollamaModel']) +}) + +test('reconcileMaterializedApiModeDefaults reuses UI-shaped Azure and Ollama rows', () => { + const uiAzureMode = { + ...modelNameToApiMode('azureOpenAi'), + customName: 'deployment-a', + active: false, + } + const uiOllamaMode = { + ...modelNameToApiMode('ollamaModel'), + customName: 'llama4', + active: false, + } + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [uiAzureMode, uiOllamaMode], + azureDeploymentName: 'deployment-a', + ollamaModelName: 'llama4', + }, + ['azureOpenAi', 'ollamaModel'], + [], + ) + + assert.deepEqual(result.customApiModes, [uiAzureMode, uiOllamaMode]) + assert.deepEqual(result.knownApiModeDefaultIds, ['azureOpenAi', 'ollamaModel']) +}) + +test('reconcileMaterializedApiModeDefaults reuses named rows with legacy item names', () => { + const legacyAzureMode = { + ...modelNameToApiMode('azureOpenAi'), + itemName: 'azureOpenAiApiModelKeys', + customName: 'deployment-a', + active: false, + } + const legacyOllamaMode = { + ...modelNameToApiMode('ollamaModel'), + itemName: 'ollamaApiModelKeys', + customName: 'llama4', + active: false, + } + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [legacyAzureMode, legacyOllamaMode], + azureDeploymentName: 'deployment-a', + ollamaModelName: 'llama4', + }, + ['azureOpenAi', 'ollamaModel'], + [], + ) + + assert.deepEqual(result.customApiModes, [legacyAzureMode, legacyOllamaMode]) + assert.deepEqual(result.knownApiModeDefaultIds, ['azureOpenAi', 'ollamaModel']) +}) + test('getApiModesFromConfig keeps AlwaysCustomGroups modes when itemName is empty', () => { const config = { activeApiModes: ['customModel'], @@ -129,6 +319,39 @@ test('getApiModesFromConfig keeps AlwaysCustomGroups modes when itemName is empt assert.equal(apiModeToModelName(onlyActive[0]), 'ollamaApiModelKeys-llama3.2') }) +test('getApiModesFromConfig drops malformed non-custom custom API mode rows', () => { + const allModes = getApiModesFromConfig( + { + activeApiModes: [], + customApiModes: [ + { + ...modelNameToApiMode('customModel'), + active: true, + }, + { + groupName: 'azureOpenAiApiModelKeys', + itemName: 'wrong-azure-preset', + isCustom: false, + customName: '', + active: true, + }, + { + groupName: 'ollamaApiModelKeys', + itemName: 'wrong-ollama-preset', + isCustom: false, + customName: '', + active: true, + }, + ], + azureDeploymentName: '', + ollamaModelName: '', + }, + false, + ) + + assert.deepEqual(allModes, []) +}) + test('getApiModesFromConfig drops nameless Azure row instead of hiding the legacy active mode', () => { const config = { activeApiModes: ['azureOpenAi'], @@ -201,6 +424,53 @@ test('getApiModesFromConfig drops nameless Ollama row instead of hiding the lega ) }) +test('getApiModesFromConfig keeps nameless built-in Azure and Ollama preset rows', () => { + const config = { + activeApiModes: [], + customApiModes: [ + { + ...modelNameToApiMode('azureOpenAi'), + customName: '', + }, + { + ...modelNameToApiMode('ollamaModel'), + customName: '', + }, + ], + azureDeploymentName: '', + ollamaModelName: '', + } + + const allModes = getApiModesFromConfig(config, false) + + assert.equal( + allModes.some((mode) => mode.itemName === 'azureOpenAi'), + true, + ) + assert.equal( + allModes.some((mode) => mode.itemName === 'ollamaModel'), + true, + ) +}) + +test('reconcileMaterializedApiModeDefaults tolerates a non-string custom name', () => { + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [ + { + ...modelNameToApiMode('chatgptFree35'), + customName: 1, + }, + ], + }, + ['claude2WebFree'], + [], + ) + + assert.equal(result.customApiModes.length, 2) + assert.deepEqual(result.knownApiModeDefaultIds, ['claude2WebFree']) +}) + test('getApiModesFromConfig deduplicates migrated Ollama legacy row against kept AlwaysCustomGroups mode', () => { const config = { activeApiModes: ['ollamaModel-llama3.2'],