diff --git a/designdocs/agents/TESTING_GUIDE.md b/designdocs/agents/TESTING_GUIDE.md index 16b91087..2c2d5831 100644 --- a/designdocs/agents/TESTING_GUIDE.md +++ b/designdocs/agents/TESTING_GUIDE.md @@ -74,6 +74,35 @@ fixture omits `_keychainOnly`, so the plugin loads in disk mode and ignores any API keys left in the OS keychain from prior runs — a deterministic clean state. The source file is validated as JSON before the target is touched. +### `data.legacy-byok.json` — one-time BYOK migration + +`npm run test:reset-data -- scripts/test-fixtures/data.legacy-byok.json` loads a +pre-versioned legacy install (no `settingsVersion`; legacy `activeModels` + +top-level provider keys; empty new slices apart from one pre-seeded BYOK +Anthropic provider that proves dedup). On the next plugin load, +`runSettingsMigrations` converts the legacy BYOK providers/models into the new +`providers` / `configuredModels` / `backends` shape so they work in OpenCode, and +stamps `settingsVersion`. It runs in disk mode with obvious fake `sk-test-…` +keys. After reset, read back and verify the migrated shape: + +```bash +$OBS vault=$VAULT eval code='app.plugins.plugins.copilot.loadData().then(d=>JSON.stringify({ + settingsVersion: d.settingsVersion, + providers: Object.values(d.providers).map(p=>({type:p.providerType, origin:p.origin.kind, catalog:p.origin.catalogProviderId, hasKey: p.apiKeyKeychainId!=null})), + configuredModels: d.configuredModels.map(m=>m.info.id), + chat: d.backends.chat?.enabledModels?.length, + opencode: d.backends.opencode?.enabledModels?.length, + legacyUntouched: { anthropicApiKey: d.anthropicApiKey, activeModels: d.activeModels.length } +}))' +``` + +Expect: `settingsVersion` stamped; routable providers carry a `catalogProviderId` +and a key; `backends.opencode.enabledModels` is non-empty and excludes the +embedding (`BAAI/bge-m3`) and Bedrock rows; exactly one Anthropic BYOK provider +(the pre-seeded one — its descriptor deduped, not re-created); legacy +`anthropicApiKey` and `activeModels` untouched. A second reload must not change +`settingsVersion` or create duplicates (the version gate holds). + ## 0. Golden rule: pick the right window first Obsidian is a single Electron app with one renderer per open vault. The CLI diff --git a/scripts/test-fixtures/data.legacy-byok.json b/scripts/test-fixtures/data.legacy-byok.json new file mode 100644 index 00000000..d562518c --- /dev/null +++ b/scripts/test-fixtures/data.legacy-byok.json @@ -0,0 +1,74 @@ +{ + "userId": "test-legacy-byok", + "anthropicApiKey": "sk-test-anthropic-0000", + "openAIApiKey": "sk-test-openai-0000", + "openAIOrgId": "org-test-0000", + "openRouterAiApiKey": "sk-test-openrouter-toplevel", + "siliconflowApiKey": "sk-test-siliconflow-0000", + "googleApiKey": "", + "amazonBedrockApiKey": "sk-test-bedrock-0000", + "amazonBedrockRegion": "us-east-1", + "activeModels": [ + { "name": "claude-sonnet-4-5", "provider": "anthropic", "enabled": true, "isBuiltIn": false }, + { "name": "gpt-4o", "provider": "openai", "enabled": true, "isBuiltIn": false }, + { "name": "gpt-5.5", "provider": "openai", "enabled": true, "isBuiltIn": true, "core": true }, + { "name": "x-ai/grok-4.3", "provider": "openrouterai", "enabled": true, "isBuiltIn": false }, + { + "name": "qwen/qwen-2.5-coder", + "provider": "openrouterai", + "enabled": true, + "isBuiltIn": false, + "apiKey": "sk-test-openrouter-per-model" + }, + { + "name": "deepseek-ai/DeepSeek-V3", + "provider": "siliconflow", + "enabled": true, + "isBuiltIn": false, + "baseUrl": "https://api.siliconflow.com/v1" + }, + { + "name": "BAAI/bge-m3", + "provider": "siliconflow", + "enabled": true, + "isBuiltIn": false, + "isEmbeddingModel": true + }, + { + "name": "anthropic.claude-3-5-sonnet-v2", + "provider": "amazon-bedrock", + "enabled": true, + "isBuiltIn": false + }, + { "name": "gemini-2.5-pro", "provider": "google", "enabled": true, "isBuiltIn": false }, + { + "name": "deepseek/deepseek-chat", + "provider": "openrouterai", + "enabled": false, + "isBuiltIn": false + }, + { "name": "copilot-plus-flash", "provider": "copilot-plus", "enabled": true, "isBuiltIn": true } + ], + "providers": { + "existing-anthropic-1": { + "providerId": "existing-anthropic-1", + "providerType": "anthropic", + "displayName": "My Anthropic (pre-existing)", + "baseUrl": "https://api.anthropic.com", + "origin": { "kind": "byok", "catalogProviderId": "anthropic" }, + "apiKeyKeychainId": null, + "addedAt": 1735689600000 + } + }, + "configuredModels": [ + { + "configuredModelId": "existing-cm-1", + "providerId": "existing-anthropic-1", + "info": { "id": "claude-sonnet-4-5", "displayName": "Claude Sonnet 4.5" }, + "configuredAt": 1735689600000 + } + ], + "backends": { + "chat": { "enabledModels": ["existing-cm-1"] } + } +} diff --git a/src/main.ts b/src/main.ts index 6cab9caa..99d3821e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -66,6 +66,7 @@ import { } from "@/services/webViewerService/webViewerServiceSingleton"; import { WebSelectionTracker } from "@/services/webViewerService/webViewerServiceSelection"; import VectorStoreManager from "@/search/vectorStoreManager"; +import { runSettingsMigrations } from "@/settings/migrations"; import { CopilotSettingTab } from "@/settings/SettingsPage"; import { getModelKeyFromModel, @@ -195,6 +196,12 @@ export default class CopilotPlugin extends Plugin { } })(); }); + // One-time settings migrations. Runs after the persist subscriber is wired + // (so every mutation is saved) and after createModelManagement, and before + // agent/model-discovery init below — so migrated BYOK providers are present + // when OpenCode first enumerates models. Awaited for deterministic ordering; + // it's a fast, one-time, no-op for already-migrated/fresh vaults. + await runSettingsMigrations(this.modelManagement); this.addSettingTab(new CopilotSettingTab(this.app, this)); // Core plugin initialization diff --git a/src/services/settingsPersistence.test.ts b/src/services/settingsPersistence.test.ts index 460e9201..bd8cc464 100644 --- a/src/services/settingsPersistence.test.ts +++ b/src/services/settingsPersistence.test.ts @@ -8,6 +8,7 @@ if (typeof window.structuredClone === "undefined") { import { DEFAULT_SETTINGS } from "@/constants"; import type { CustomModel } from "@/aiParams"; +import { CURRENT_SETTINGS_VERSION } from "@/settings/migrations/version"; import type { CopilotSettings } from "@/settings/model"; /** Match the production secret-key heuristic without importing the real module. */ @@ -168,6 +169,11 @@ describe("loadSettingsWithKeychain", () => { const loaded = await mod.loadSettingsWithKeychain(null, saveData); expect((loaded as unknown as Record)._keychainOnly).toBe(true); + // Fresh installs are stamped at the current schema version so the one-time + // migrations never run for them. + expect((loaded as unknown as Record).settingsVersion).toBe( + CURRENT_SETTINGS_VERSION + ); // Reason: even on fresh install, hydrateFromKeychain still runs so // tombstones / pre-existing keychain entries are honored. expect(keychain.hydrateFromKeychain).toHaveBeenCalled(); diff --git a/src/services/settingsPersistence.ts b/src/services/settingsPersistence.ts index be82afbf..58489692 100644 --- a/src/services/settingsPersistence.ts +++ b/src/services/settingsPersistence.ts @@ -39,6 +39,7 @@ import { // but only used in code paths that run AFTER settings are loaded. Functions that // run DURING settings loading (loadSettingsWithKeychain) use console.* directly. import { logError, logWarn } from "@/logger"; +import { CURRENT_SETTINGS_VERSION } from "@/settings/migrations/version"; import { Notice } from "obsidian"; // --------------------------------------------------------------------------- @@ -357,6 +358,9 @@ export async function loadSettingsWithKeychain( // nothing changes" rule for users who manually deleted their keys. if (isFreshInstall) { (settings as unknown as Record)._keychainOnly = true; + // Stamp the current schema version so brand-new vaults skip the one-time + // migrations entirely (nothing to migrate; avoids a needless save). + settings.settingsVersion = CURRENT_SETTINGS_VERSION; } // ---- Vault namespace ID bootstrap (shared by both modes). ---- @@ -399,6 +403,7 @@ export async function loadSettingsWithKeychain( }; if (isFreshInstall) { currentDisk._keychainOnly = true; + currentDisk.settingsVersion = CURRENT_SETTINGS_VERSION; } await saveData(currentDisk as unknown as CopilotSettings); rawDiskData = currentDisk; diff --git a/src/settings/migrations/byokMigration.test.ts b/src/settings/migrations/byokMigration.test.ts new file mode 100644 index 00000000..3f056b81 --- /dev/null +++ b/src/settings/migrations/byokMigration.test.ts @@ -0,0 +1,405 @@ +/** + * Unit tests for the BYOK migration. `planByokMigration` is pure, so the bulk + * of the coverage builds a legacy settings object and asserts the resulting + * `SetupProviderInput[]`. `executeByokMigration` is exercised against a fake + * `ModelManagementApi` (the real enrollment behavior of `setupProvider` is + * covered by `ByokSetupApi.test.ts`; here we only assert which descriptors flow + * through and that dedup / per-provider-failure handling hold). + * + * All `@/modelManagement` imports are type-only so the model-management barrel + * (and its UI deps) never loads in this unit test. + */ + +import type { CustomModel } from "@/aiParams"; +import { ChatModelProviders, DEFAULT_SETTINGS } from "@/constants"; +import type { ModelManagementApi, Provider, SetupProviderInput } from "@/modelManagement"; +import type { CopilotSettings } from "@/settings/model"; + +import { executeByokMigration, planByokMigration } from "./byokMigration"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +function model(overrides: Partial): CustomModel { + return { + name: "test-model", + provider: ChatModelProviders.OPENAI, + enabled: true, + isBuiltIn: false, + ...overrides, + }; +} + +function settingsWith( + models: CustomModel[], + overrides: Partial = {} +): CopilotSettings { + return { ...DEFAULT_SETTINGS, ...overrides, activeModels: models }; +} + +const normalizeUrl = (url: string | undefined) => + (url ?? "").trim().replace(/\/+$/, "").toLowerCase(); + +const byCatalog = (plan: SetupProviderInput[], catalogProviderId: string) => + plan.find((p) => p.catalogProviderId === catalogProviderId); + +const byBaseUrl = (plan: SetupProviderInput[], baseUrl: string) => + plan.find((p) => !p.catalogProviderId && normalizeUrl(p.baseUrl) === normalizeUrl(baseUrl)); + +describe("planByokMigration — provider mapping", () => { + it("maps catalog-backed providers to the right providerType + catalogProviderId", () => { + const plan = planByokMigration( + settingsWith( + [ + model({ name: "claude-sonnet-4-5", provider: ChatModelProviders.ANTHROPIC }), + model({ name: "gpt-4o", provider: ChatModelProviders.OPENAI }), + model({ name: "gemini-2.5-pro", provider: ChatModelProviders.GOOGLE }), + model({ name: "x-ai/grok-4.3", provider: ChatModelProviders.OPENROUTERAI }), + model({ name: "grok-4", provider: ChatModelProviders.XAI }), + model({ name: "llama-3.3-70b", provider: ChatModelProviders.GROQ }), + model({ name: "mistral-large", provider: ChatModelProviders.MISTRAL }), + model({ name: "deepseek-chat", provider: ChatModelProviders.DEEPSEEK }), + ], + { + anthropicApiKey: "k", + openAIApiKey: "k", + googleApiKey: "k", + openRouterAiApiKey: "k", + xaiApiKey: "k", + groqApiKey: "k", + mistralApiKey: "k", + deepseekApiKey: "k", + } + ) + ); + + expect(byCatalog(plan, "anthropic")).toMatchObject({ providerType: "anthropic" }); + expect(byCatalog(plan, "openai")).toMatchObject({ providerType: "openai-compatible" }); + expect(byCatalog(plan, "google")).toMatchObject({ providerType: "google" }); + expect(byCatalog(plan, "openrouter")).toMatchObject({ providerType: "openai-compatible" }); + expect(byCatalog(plan, "xai")).toMatchObject({ providerType: "openai-compatible" }); + expect(byCatalog(plan, "groq")).toMatchObject({ providerType: "openai-compatible" }); + expect(byCatalog(plan, "mistral")).toMatchObject({ providerType: "openai-compatible" }); + expect(byCatalog(plan, "deepseek")).toMatchObject({ providerType: "openai-compatible" }); + }); + + it("maps catalog-less SiliconFlow to openai-compatible with its default base URL", () => { + const plan = planByokMigration( + settingsWith( + [model({ name: "deepseek-ai/DeepSeek-V3", provider: ChatModelProviders.SILICONFLOW })], + { + siliconflowApiKey: "k", + } + ) + ); + const sf = byBaseUrl(plan, "https://api.siliconflow.com/v1"); + expect(sf).toMatchObject({ providerType: "openai-compatible" }); + expect(sf?.catalogProviderId).toBeUndefined(); + }); + + it("enrolls routable providers into chat + opencode", () => { + const plan = planByokMigration( + settingsWith([model({ name: "x", provider: ChatModelProviders.OPENROUTERAI })], { + openRouterAiApiKey: "k", + }) + ); + expect(byCatalog(plan, "openrouter")?.autoEnrollIn).toEqual(["chat", "opencode"]); + }); +}); + +describe("planByokMigration — grouping, keys, base URLs", () => { + it("groups multiple models of one provider under a single descriptor", () => { + const plan = planByokMigration( + settingsWith( + [ + model({ name: "x-ai/grok-4.3", provider: ChatModelProviders.OPENROUTERAI }), + model({ name: "deepseek/deepseek-chat", provider: ChatModelProviders.OPENROUTERAI }), + model({ name: "qwen/qwen-2.5", provider: ChatModelProviders.OPENROUTERAI }), + ], + { openRouterAiApiKey: "k" } + ) + ); + expect(plan).toHaveLength(1); + expect(plan[0].models.map((m) => m.id).sort()).toEqual([ + "deepseek/deepseek-chat", + "qwen/qwen-2.5", + "x-ai/grok-4.3", + ]); + }); + + it("prefers a per-model apiKey over the top-level provider key", () => { + const plan = planByokMigration( + settingsWith( + [model({ name: "x", provider: ChatModelProviders.OPENROUTERAI, apiKey: "sk-per-model" })], + { openRouterAiApiKey: "sk-top-level" } + ) + ); + expect(byCatalog(plan, "openrouter")?.apiKey).toBe("sk-per-model"); + }); + + it("falls back to the top-level provider key", () => { + const plan = planByokMigration( + settingsWith([model({ name: "x", provider: ChatModelProviders.OPENROUTERAI })], { + openRouterAiApiKey: "sk-top-level", + }) + ); + expect(byCatalog(plan, "openrouter")?.apiKey).toBe("sk-top-level"); + }); + + it("uses the model's baseUrl when set, else the provider default", () => { + const custom = planByokMigration( + settingsWith( + [ + model({ + name: "x", + provider: ChatModelProviders.OPENROUTERAI, + baseUrl: "https://proxy.local/v1", + }), + ], + { openRouterAiApiKey: "k" } + ) + ); + expect(byCatalog(custom, "openrouter")?.baseUrl).toBe("https://proxy.local/v1"); + + const fallback = planByokMigration( + settingsWith([model({ name: "claude", provider: ChatModelProviders.ANTHROPIC })], { + anthropicApiKey: "k", + }) + ); + expect(byCatalog(fallback, "anthropic")?.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("captures openAIOrgId in extras for OpenAI", () => { + const plan = planByokMigration( + settingsWith([model({ name: "gpt-4o", provider: ChatModelProviders.OPENAI })], { + openAIApiKey: "k", + openAIOrgId: "org-123", + }) + ); + expect(byCatalog(plan, "openai")?.extras).toEqual({ openAIOrgId: "org-123" }); + }); +}); + +describe("planByokMigration — scope filters", () => { + it("skips embedding models (flag or id heuristic) but keeps chat models", () => { + const plan = planByokMigration( + settingsWith( + [ + model({ name: "gpt-4o", provider: ChatModelProviders.OPENAI }), + model({ + name: "text-embedding-3-large", + provider: ChatModelProviders.OPENAI, + isEmbeddingModel: true, + }), + model({ name: "nomic-embed-text", provider: ChatModelProviders.OPENAI }), + ], + { openAIApiKey: "k" } + ) + ); + expect(byCatalog(plan, "openai")?.models.map((m) => m.id)).toEqual(["gpt-4o"]); + }); + + it("skips disabled models", () => { + const plan = planByokMigration( + settingsWith( + [model({ name: "gpt-4o", provider: ChatModelProviders.OPENAI, enabled: false })], + { + openAIApiKey: "k", + } + ) + ); + expect(plan).toEqual([]); + }); + + it("skips copilot-plus and github-copilot", () => { + const plan = planByokMigration( + settingsWith( + [ + model({ + name: "copilot-plus-flash", + provider: ChatModelProviders.COPILOT_PLUS, + isBuiltIn: true, + }), + model({ name: "gpt-5", provider: ChatModelProviders.GITHUB_COPILOT }), + ], + { plusLicenseKey: "lic", githubCopilotToken: "tok" } + ) + ); + expect(plan).toEqual([]); + }); + + it("skips providers without a usable key", () => { + const plan = planByokMigration( + settingsWith([model({ name: "gpt-4o", provider: ChatModelProviders.OPENAI })], { + openAIApiKey: "", + }) + ); + expect(plan).toEqual([]); + }); + + it("includes enabled built-in models when the provider has a key", () => { + const plan = planByokMigration( + settingsWith( + [ + model({ + name: "gpt-5.5", + provider: ChatModelProviders.OPENAI, + isBuiltIn: true, + core: true, + }), + ], + { openAIApiKey: "k" } + ) + ); + expect(byCatalog(plan, "openai")?.models.map((m) => m.id)).toEqual(["gpt-5.5"]); + }); + + it("returns [] for empty activeModels", () => { + expect(planByokMigration(settingsWith([]))).toEqual([]); + }); +}); + +describe("planByokMigration — Azure / Bedrock (chat only)", () => { + it("maps Azure to chat-only with azure extras", () => { + const plan = planByokMigration( + settingsWith([model({ name: "gpt-4o", provider: ChatModelProviders.AZURE_OPENAI })], { + azureOpenAIApiKey: "k", + azureOpenAIApiInstanceName: "my-instance", + azureOpenAIApiDeploymentName: "my-deploy", + azureOpenAIApiVersion: "2024-06-01", + }) + ); + const azure = plan.find((p) => p.providerType === "azure"); + expect(azure).toBeDefined(); + expect(azure?.catalogProviderId).toBeUndefined(); + expect(azure?.autoEnrollIn).toEqual(["chat"]); + expect(azure?.extras).toEqual({ + azureInstanceName: "my-instance", + azureDeploymentName: "my-deploy", + azureApiVersion: "2024-06-01", + }); + }); + + it("maps Bedrock to chat-only with the region in extras", () => { + const plan = planByokMigration( + settingsWith([model({ name: "claude-3-5", provider: ChatModelProviders.AMAZON_BEDROCK })], { + amazonBedrockApiKey: "k", + amazonBedrockRegion: "us-east-1", + }) + ); + const bedrock = plan.find((p) => p.providerType === "bedrock"); + expect(bedrock?.autoEnrollIn).toEqual(["chat"]); + expect(bedrock?.extras).toEqual({ bedrockRegion: "us-east-1" }); + }); +}); + +describe("planByokMigration — local providers (custom URL required)", () => { + it("migrates Ollama / LM Studio only when an explicit baseUrl is set", () => { + const withUrl = planByokMigration( + settingsWith([ + model({ + name: "llama3.2", + provider: ChatModelProviders.OLLAMA, + baseUrl: "http://192.168.1.5:11434/v1", + }), + ]) + ); + expect(withUrl).toHaveLength(1); + expect(withUrl[0]).toMatchObject({ + providerType: "openai-compatible", + baseUrl: "http://192.168.1.5:11434/v1", + autoEnrollIn: ["chat", "opencode"], + }); + + const withoutUrl = planByokMigration( + settingsWith([model({ name: "llama3.2", provider: ChatModelProviders.LM_STUDIO })]) + ); + expect(withoutUrl).toEqual([]); + }); +}); + +describe("executeByokMigration", () => { + let idSeq = 0; + function makeApi(existing: Provider[] = []) { + const setupProvider = jest.fn(async (input: SetupProviderInput) => ({ + providerId: `prov-${++idSeq}`, + configuredModelIds: input.models.map((_, i) => `cm-${idSeq}-${i}`), + })); + const listByOrigin = jest.fn((kind: string) => existing.filter((p) => p.origin.kind === kind)); + const api = { + providerRegistry: { listByOrigin }, + setup: { byok: { setupProvider } }, + } as unknown as ModelManagementApi; + return { api, setupProvider }; + } + + function byokProvider(overrides: Partial): Provider { + return { + providerId: "existing", + providerType: "anthropic", + displayName: "Existing", + origin: { kind: "byok", catalogProviderId: "anthropic" }, + addedAt: 0, + apiKeyKeychainId: null, + ...overrides, + }; + } + + it("creates one provider per planned descriptor", async () => { + const { api, setupProvider } = makeApi(); + await executeByokMigration( + api, + settingsWith( + [ + model({ name: "claude", provider: ChatModelProviders.ANTHROPIC }), + model({ name: "or-x", provider: ChatModelProviders.OPENROUTERAI }), + ], + { anthropicApiKey: "k", openRouterAiApiKey: "k" } + ) + ); + expect(setupProvider).toHaveBeenCalledTimes(2); + }); + + it("skips a descriptor that duplicates an existing BYOK provider", async () => { + const existing = byokProvider({ + providerType: "anthropic", + baseUrl: "https://api.anthropic.com", + origin: { kind: "byok", catalogProviderId: "anthropic" }, + }); + const { api, setupProvider } = makeApi([existing]); + await executeByokMigration( + api, + settingsWith( + [ + model({ name: "claude", provider: ChatModelProviders.ANTHROPIC }), + model({ name: "gpt-4o", provider: ChatModelProviders.OPENAI }), + ], + { anthropicApiKey: "k", openAIApiKey: "k" } + ) + ); + // Anthropic deduped; only OpenAI created. + expect(setupProvider).toHaveBeenCalledTimes(1); + expect(setupProvider.mock.calls[0][0]).toMatchObject({ catalogProviderId: "openai" }); + }); + + it("continues after a per-provider failure", async () => { + const { api, setupProvider } = makeApi(); + setupProvider.mockRejectedValueOnce(new Error("keychain unavailable")); + await expect( + executeByokMigration( + api, + settingsWith( + [ + model({ name: "claude", provider: ChatModelProviders.ANTHROPIC }), + model({ name: "or-x", provider: ChatModelProviders.OPENROUTERAI }), + ], + { anthropicApiKey: "k", openRouterAiApiKey: "k" } + ) + ) + ).resolves.toBeUndefined(); + expect(setupProvider).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/settings/migrations/byokMigration.ts b/src/settings/migrations/byokMigration.ts new file mode 100644 index 00000000..ecdb35df --- /dev/null +++ b/src/settings/migrations/byokMigration.ts @@ -0,0 +1,335 @@ +/** + * One-time migration: legacy BYOK models + provider keys → the new + * provider / configured-model / backend data model, so a user's own keys keep + * working in OpenCode (and Simple Chat) when agent mode lands. + * + * Two halves so the mapping logic is trivially unit-testable: + * - `planByokMigration` is PURE — legacy settings in, `SetupProviderInput[]` + * out, no side effects (reads keys from the already-hydrated in-memory + * settings, never disk). + * - `executeByokMigration` is the thin side-effecting wrapper that dedups + * against existing BYOK providers and feeds each descriptor through the + * battle-tested `ByokSetupApi.setupProvider` (provider row → keychain → + * configured models → backend enrollment, with its own rollback). + * + * Scope (locked with the product owner): + * - Credential-driven: every legacy provider with a user-supplied key (or, for + * local/openai-format providers, an explicit base URL) and its ENABLED + * models — built-in and custom. + * - Azure / Bedrock migrate to Simple Chat only (OpenCode can't route them). + * - Skip embeddings, disabled models, and copilot-plus / github-copilot + * (owned by Plus sign-in and agent setup). + * - Non-destructive: legacy keys and `activeModels` are left untouched. + */ + +import type { CustomModel } from "@/aiParams"; +import { + ChatModelProviders, + ProviderInfo, + type ProviderMetadata, + ProviderSettingsKeyMap, + type SettingKeyProviders, +} from "@/constants"; +import { logError, logInfo } from "@/logger"; +// Type-only imports: nothing here pulls the model-management barrel at runtime, +// keeping this settings-layer module (and its unit tests) light. +import type { + BackendType, + ModelInfo, + ModelManagementApi, + Provider, + ProviderType, + SetupProviderInput, +} from "@/modelManagement"; +import type { CopilotSettings } from "@/settings/model"; + +// Token-bounded "embed"/"embedding" id match. Mirrors `looksLikeEmbeddingModel` +// in `@/modelManagement/catalog/catalogTransform`; kept local so this module +// stays type-only against the model-management barrel. +const EMBEDDING_ID = /(^|[-_/.\s])embed(ding)?($|[-_/.\s])/i; + +interface LegacyProviderMapping { + providerType: ProviderType; + /** models.dev / OpenCode provider id; absent for catalog-less providers. */ + catalogProviderId?: string; + /** OpenCode can route this provider → enroll in `opencode` too. */ + opencodeRoutable: boolean; + /** + * Keyless providers (Ollama / LM Studio / generic OpenAI-format) that are + * only migrated when the model carries an explicit `baseUrl` — i.e. the user + * actually pointed them somewhere, not a bare unconfigured default. + */ + requiresBaseUrl?: boolean; +} + +/** + * Legacy `CustomModel.provider` → new-format mapping. Providers absent here + * (copilot-plus, github-copilot, anything unrecognized) are skipped. The + * top-level API-key field is derived from `ProviderSettingsKeyMap`, not + * duplicated here. + */ +const LEGACY_PROVIDER_MAP: Partial> = { + [ChatModelProviders.ANTHROPIC]: { + providerType: "anthropic", + catalogProviderId: "anthropic", + opencodeRoutable: true, + }, + [ChatModelProviders.OPENAI]: { + providerType: "openai-compatible", + catalogProviderId: "openai", + opencodeRoutable: true, + }, + [ChatModelProviders.GOOGLE]: { + providerType: "google", + catalogProviderId: "google", + opencodeRoutable: true, + }, + [ChatModelProviders.OPENROUTERAI]: { + providerType: "openai-compatible", + catalogProviderId: "openrouter", + opencodeRoutable: true, + }, + [ChatModelProviders.XAI]: { + providerType: "openai-compatible", + catalogProviderId: "xai", + opencodeRoutable: true, + }, + [ChatModelProviders.GROQ]: { + providerType: "openai-compatible", + catalogProviderId: "groq", + opencodeRoutable: true, + }, + [ChatModelProviders.MISTRAL]: { + providerType: "openai-compatible", + catalogProviderId: "mistral", + opencodeRoutable: true, + }, + [ChatModelProviders.DEEPSEEK]: { + providerType: "openai-compatible", + catalogProviderId: "deepseek", + opencodeRoutable: true, + }, + // Catalog-less but OpenAI-compatible: routable via their base URL. + [ChatModelProviders.SILICONFLOW]: { providerType: "openai-compatible", opencodeRoutable: true }, + [ChatModelProviders.COHEREAI]: { providerType: "openai-compatible", opencodeRoutable: true }, + [ChatModelProviders.OLLAMA]: { + providerType: "openai-compatible", + opencodeRoutable: true, + requiresBaseUrl: true, + }, + [ChatModelProviders.LM_STUDIO]: { + providerType: "openai-compatible", + opencodeRoutable: true, + requiresBaseUrl: true, + }, + [ChatModelProviders.OPENAI_FORMAT]: { + providerType: "openai-compatible", + opencodeRoutable: true, + requiresBaseUrl: true, + }, + // Not OpenCode-routable → Simple Chat only. + [ChatModelProviders.AZURE_OPENAI]: { providerType: "azure", opencodeRoutable: false }, + [ChatModelProviders.AMAZON_BEDROCK]: { providerType: "bedrock", opencodeRoutable: false }, +}; + +// Frozen enrollment targets — referential stability (see AGENTS.md). +const ENROLL_CHAT_AND_OPENCODE: readonly BackendType[] = Object.freeze(["chat", "opencode"]); +const ENROLL_CHAT_ONLY: readonly BackendType[] = Object.freeze(["chat"]); + +/** Trim, drop a trailing slash, lowercase — for grouping / dedup comparison. */ +function normalizeUrl(url: string | undefined): string { + return (url ?? "").trim().replace(/\/+$/, "").toLowerCase(); +} + +/** Runtime-safe lookup: `ProviderInfo`'s typed keys are the provider enum, but + * `model.provider` is a raw string, so widen the index to allow `undefined`. */ +function providerMetaFor(provider: string): ProviderMetadata | undefined { + return (ProviderInfo as unknown as Record)[provider]; +} + +/** Catalog default base URL for a provider, or `undefined` for placeholder + * URLs (azure ``, bedrock `{region}`) that aren't real endpoints. */ +function defaultBaseUrlFor(provider: string): string | undefined { + const url = providerMetaFor(provider)?.curlBaseURL; + if (!url || url.includes("<") || url.includes("{")) return undefined; + return url; +} + +function displayNameFor(provider: string): string { + return providerMetaFor(provider)?.label ?? provider; +} + +/** Per-`providerType` opaque payload the adapters can't function without. */ +function buildExtras( + model: CustomModel, + settings: CopilotSettings, + providerType: ProviderType +): Record | undefined { + if (providerType === "azure") { + const extras: Record = {}; + const instance = model.azureOpenAIApiInstanceName || settings.azureOpenAIApiInstanceName; + const deployment = model.azureOpenAIApiDeploymentName || settings.azureOpenAIApiDeploymentName; + const apiVersion = model.azureOpenAIApiVersion || settings.azureOpenAIApiVersion; + if (instance) extras.azureInstanceName = instance; + if (deployment) extras.azureDeploymentName = deployment; + if (apiVersion) extras.azureApiVersion = apiVersion; + return Object.keys(extras).length > 0 ? extras : undefined; + } + if (providerType === "bedrock") { + const region = model.bedrockRegion || settings.amazonBedrockRegion; + return region ? { bedrockRegion: region } : undefined; + } + if (model.provider === (ChatModelProviders.OPENAI as string)) { + const orgId = model.openAIOrgId || settings.openAIOrgId; + return orgId ? { openAIOrgId: orgId } : undefined; + } + return undefined; +} + +interface ResolvedCandidate { + mapping: LegacyProviderMapping; + apiKey?: string; + baseUrl?: string; + extras?: Record; +} + +/** + * Decide whether a single legacy model migrates, and resolve its credential / + * base URL / extras. Returns `null` for anything out of scope. + */ +function resolveCandidate(model: CustomModel, settings: CopilotSettings): ResolvedCandidate | null { + const mapping = LEGACY_PROVIDER_MAP[model.provider]; + if (!mapping) return null; // unknown / copilot-plus / github-copilot + if (!model.enabled) return null; // disabled models skipped per scope + if (model.isEmbeddingModel ?? EMBEDDING_ID.test(model.name)) return null; // embeddings skipped + + const keyField = ProviderSettingsKeyMap[model.provider as SettingKeyProviders]; + const rawKey = keyField ? settings[keyField] : undefined; + const topLevelKey = typeof rawKey === "string" ? rawKey.trim() : ""; + const apiKey = model.apiKey?.trim() || topLevelKey || undefined; + + let baseUrl: string | undefined; + if (mapping.requiresBaseUrl) { + // Local / generic OpenAI-format: only migrate an explicitly-pointed endpoint. + baseUrl = model.baseUrl?.trim() || undefined; + if (!baseUrl) return null; + } else { + baseUrl = model.baseUrl?.trim() || defaultBaseUrlFor(model.provider); + if (!apiKey) return null; // key-based providers need a usable key + } + + return { mapping, apiKey, baseUrl, extras: buildExtras(model, settings, mapping.providerType) }; +} + +function toModelInfo(model: CustomModel): ModelInfo { + return { id: model.name, displayName: model.displayName?.trim() || model.name }; +} + +/** + * Pure: legacy settings → BYOK provider-setup descriptors. Models are grouped + * into one provider per `(providerType, catalogProviderId, baseUrl, apiKey)` so + * distinct credentials become distinct provider instances; model ids are + * de-duped within a group (last wins) to satisfy `bulkSet`. + */ +export function planByokMigration(settings: CopilotSettings): SetupProviderInput[] { + const groups = new Map< + string, + { input: SetupProviderInput; modelsById: Map } + >(); + + for (const model of settings.activeModels ?? []) { + const candidate = resolveCandidate(model, settings); + if (!candidate) continue; + const { mapping, apiKey, baseUrl, extras } = candidate; + + const groupKey = [ + mapping.providerType, + mapping.catalogProviderId ?? "", + normalizeUrl(baseUrl), + apiKey ?? "", + ].join(""); + + let group = groups.get(groupKey); + if (!group) { + const input: SetupProviderInput = { + providerType: mapping.providerType, + displayName: displayNameFor(model.provider), + models: [], + autoEnrollIn: mapping.opencodeRoutable ? ENROLL_CHAT_AND_OPENCODE : ENROLL_CHAT_ONLY, + }; + if (mapping.catalogProviderId) input.catalogProviderId = mapping.catalogProviderId; + if (baseUrl) input.baseUrl = baseUrl; + if (apiKey) input.apiKey = apiKey; + if (extras) input.extras = extras; + group = { input, modelsById: new Map() }; + groups.set(groupKey, group); + } + + const info = toModelInfo(model); + group.modelsById.set(info.id, info); + } + + return [...groups.values()].map(({ input, modelsById }) => ({ + ...input, + models: [...modelsById.values()], + })); +} + +/** A pre-existing BYOK provider equivalent to a planned descriptor (same + * identity: type + catalog id + base URL). Key is intentionally NOT part of + * the match — a keyless existing row still counts as "already present". */ +function isDuplicateByok(provider: Provider, descriptor: SetupProviderInput): boolean { + if (provider.origin.kind !== "byok") return false; + return ( + provider.providerType === descriptor.providerType && + (provider.origin.catalogProviderId ?? "") === (descriptor.catalogProviderId ?? "") && + normalizeUrl(provider.baseUrl) === normalizeUrl(descriptor.baseUrl) + ); +} + +/** + * Side-effecting executor. Builds a one-shot plan from `settings`, skips + * descriptors that match an existing BYOK provider, and creates the rest via + * `ByokSetupApi.setupProvider`. Never throws: a single provider failure is + * logged and the rest proceed (the version bump in the caller is unconditional). + */ +export async function executeByokMigration( + api: ModelManagementApi, + settings: CopilotSettings +): Promise { + const descriptors = planByokMigration(settings); + if (descriptors.length === 0) { + logInfo("[byok-migration] no legacy BYOK providers to migrate"); + return; + } + + // Snapshot existing BYOK providers once; the planner already de-dups within + // this run, so a stale snapshot only matters for pre-existing rows. + const existing = api.providerRegistry.listByOrigin("byok"); + let created = 0; + let skipped = 0; + let failed = 0; + + for (const descriptor of descriptors) { + if (existing.some((provider) => isDuplicateByok(provider, descriptor))) { + skipped++; + logInfo(`[byok-migration] skipping already-present provider "${descriptor.displayName}"`); + continue; + } + try { + const result = await api.setup.byok.setupProvider(descriptor); + created++; + logInfo( + `[byok-migration] migrated "${descriptor.displayName}" ` + + `(${result.configuredModelIds.length} models, enroll=${descriptor.autoEnrollIn?.join("+")})` + ); + } catch (err) { + failed++; + logError(`[byok-migration] failed to migrate "${descriptor.displayName}"; continuing`, err); + } + } + + logInfo( + `[byok-migration] done: ${created} migrated, ${skipped} already present, ${failed} failed` + ); +} diff --git a/src/settings/migrations/index.ts b/src/settings/migrations/index.ts new file mode 100644 index 00000000..9cb9ea64 --- /dev/null +++ b/src/settings/migrations/index.ts @@ -0,0 +1,34 @@ +/** + * One-time settings migrations, run once on plugin load (from `Plugin.onload`, + * after the settings-persistence subscriber is wired so every mutation is + * persisted, and before agent/model-discovery init so OpenCode first sees the + * migrated backends). + * + * The gate is deliberately a plain version comparison — there is a single + * one-time migration today (legacy BYOK → model-management). Promote this to an + * ordered runner only if a second migration ever accrues. + */ + +import { logInfo } from "@/logger"; +import type { ModelManagementApi } from "@/modelManagement"; +import { getSettings, setSettings } from "@/settings/model"; + +import { executeByokMigration } from "./byokMigration"; +import { CURRENT_SETTINGS_VERSION } from "./version"; + +export { CURRENT_SETTINGS_VERSION } from "./version"; + +/** + * Run pending one-time migrations and stamp the new version. No-op when + * settings are already at/above the target (migrated vaults, fresh installs). + */ +export async function runSettingsMigrations(api: ModelManagementApi): Promise { + const fromVersion = getSettings().settingsVersion ?? 0; + if (fromVersion >= CURRENT_SETTINGS_VERSION) return; + + logInfo(`[settings-migration] migrating from v${fromVersion} to v${CURRENT_SETTINGS_VERSION}`); + await executeByokMigration(api, getSettings()); + // Bump unconditionally after the migration so a per-provider failure can't + // wedge the gate into re-running on every load. Persists via the subscriber. + setSettings({ settingsVersion: CURRENT_SETTINGS_VERSION }); +} diff --git a/src/settings/migrations/runSettingsMigrations.test.ts b/src/settings/migrations/runSettingsMigrations.test.ts new file mode 100644 index 00000000..85132a5a --- /dev/null +++ b/src/settings/migrations/runSettingsMigrations.test.ts @@ -0,0 +1,106 @@ +/** + * Version-gate tests for `runSettingsMigrations`. `@/settings/model` is mocked + * (getSettings / setSettings) and a fake `ModelManagementApi` stands in, so the + * gate logic is exercised in isolation from the real store and registries. + */ + +import type { CustomModel } from "@/aiParams"; +import { ChatModelProviders, DEFAULT_SETTINGS } from "@/constants"; +import type { ModelManagementApi } from "@/modelManagement"; +import { getSettings, setSettings, type CopilotSettings } from "@/settings/model"; + +import { CURRENT_SETTINGS_VERSION, runSettingsMigrations } from "./index"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +jest.mock("@/settings/model", () => { + const actual = jest.requireActual("@/settings/model"); + return { ...actual, getSettings: jest.fn(), setSettings: jest.fn() }; +}); + +const mockGetSettings = getSettings as jest.MockedFunction; +const mockSetSettings = setSettings as jest.MockedFunction; + +function settings( + overrides: Partial, + models: CustomModel[] = [] +): CopilotSettings { + return { ...DEFAULT_SETTINGS, activeModels: models, ...overrides }; +} + +function makeApi() { + const setupProvider = jest.fn(async () => ({ providerId: "p1", configuredModelIds: ["cm1"] })); + const api = { + providerRegistry: { listByOrigin: jest.fn(() => []) }, + setup: { byok: { setupProvider } }, + } as unknown as ModelManagementApi; + return { api, setupProvider }; +} + +const keyedAnthropic = () => + settings({ settingsVersion: undefined, anthropicApiKey: "sk-ant" }, [ + { + name: "claude-sonnet-4-5", + provider: ChatModelProviders.ANTHROPIC, + enabled: true, + isBuiltIn: false, + }, + ]); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it("runs when settingsVersion is undefined (pre-versioned install)", async () => { + mockGetSettings.mockReturnValue(keyedAnthropic()); + const { api, setupProvider } = makeApi(); + + await runSettingsMigrations(api); + + expect(setupProvider).toHaveBeenCalledTimes(1); + expect(mockSetSettings).toHaveBeenCalledWith({ settingsVersion: CURRENT_SETTINGS_VERSION }); +}); + +it("runs when settingsVersion is the orphaned prototype value 2", async () => { + mockGetSettings.mockReturnValue({ ...keyedAnthropic(), settingsVersion: 2 }); + const { api, setupProvider } = makeApi(); + + await runSettingsMigrations(api); + + expect(setupProvider).toHaveBeenCalledTimes(1); + expect(mockSetSettings).toHaveBeenCalledWith({ settingsVersion: CURRENT_SETTINGS_VERSION }); +}); + +it("bumps the version even when there is nothing to migrate", async () => { + mockGetSettings.mockReturnValue(settings({ settingsVersion: undefined }, [])); + const { api, setupProvider } = makeApi(); + + await runSettingsMigrations(api); + + expect(setupProvider).not.toHaveBeenCalled(); + expect(mockSetSettings).toHaveBeenCalledWith({ settingsVersion: CURRENT_SETTINGS_VERSION }); +}); + +it("skips when already at the current version", async () => { + mockGetSettings.mockReturnValue(settings({ settingsVersion: CURRENT_SETTINGS_VERSION })); + const { api, setupProvider } = makeApi(); + + await runSettingsMigrations(api); + + expect(setupProvider).not.toHaveBeenCalled(); + expect(mockSetSettings).not.toHaveBeenCalled(); +}); + +it("skips a future version", async () => { + mockGetSettings.mockReturnValue(settings({ settingsVersion: CURRENT_SETTINGS_VERSION + 1 })); + const { api, setupProvider } = makeApi(); + + await runSettingsMigrations(api); + + expect(setupProvider).not.toHaveBeenCalled(); + expect(mockSetSettings).not.toHaveBeenCalled(); +}); diff --git a/src/settings/migrations/version.ts b/src/settings/migrations/version.ts new file mode 100644 index 00000000..6be1c38f --- /dev/null +++ b/src/settings/migrations/version.ts @@ -0,0 +1,12 @@ +/** + * Settings schema version. Bumped by the one-time migrations in this folder. + * + * Kept in its own leaf module (no heavy imports) so low-level code like the + * settings-persistence layer can stamp fresh installs without pulling in the + * model-management barrel that `runSettingsMigrations` depends on. + * + * `= 4` matches the v4 launch. Gate is `(settingsVersion ?? 0) < CURRENT`, so + * pre-versioned installs (real users → `0`) and the orphaned prototype `2` + * both run; migrated vaults (`4`) and freshly-stamped installs skip. + */ +export const CURRENT_SETTINGS_VERSION = 4; diff --git a/src/settings/model.ts b/src/settings/model.ts index 0a23560d..eefcd1c8 100644 --- a/src/settings/model.ts +++ b/src/settings/model.ts @@ -221,6 +221,13 @@ export interface CopilotSettings { * renaming or moving the vault folder does not orphan keychain entries. */ _keychainVaultId?: string; + /** + * Schema version, bumped by one-time migrations in `src/settings/migrations`. + * Absent on pre-versioned installs (treated as `0`). Deliberately NOT in + * `DEFAULT_SETTINGS` — a default value would defeat the migration gate via + * the `setSettings` merge. See `runSettingsMigrations`. + */ + settingsVersion?: number; /** Agent Mode (ACP-backed BYOK agent harness). Desktop only. */ agentMode: { enabled: boolean;