introduce model migration script

This commit is contained in:
Zero Liu 2026-05-28 15:14:59 -07:00
parent c3ebcddfd6
commit 4ec05a7571
No known key found for this signature in database
11 changed files with 1020 additions and 0 deletions

View file

@ -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

View file

@ -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"] }
}
}

View file

@ -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

View file

@ -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<string, unknown>)._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<string, unknown>).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();

View file

@ -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<string, unknown>)._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;

View file

@ -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>): CustomModel {
return {
name: "test-model",
provider: ChatModelProviders.OPENAI,
enabled: true,
isBuiltIn: false,
...overrides,
};
}
function settingsWith(
models: CustomModel[],
overrides: Partial<CopilotSettings> = {}
): 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>): 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);
});
});

View file

@ -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<Record<string, LegacyProviderMapping>> = {
[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<string, ProviderMetadata | undefined>)[provider];
}
/** Catalog default base URL for a provider, or `undefined` for placeholder
* URLs (azure `<resource>`, 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<string, unknown> | undefined {
if (providerType === "azure") {
const extras: Record<string, unknown> = {};
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<string, unknown>;
}
/**
* 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<string, ModelInfo> }
>();
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<void> {
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`
);
}

View file

@ -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<void> {
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 });
}

View file

@ -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<typeof import("@/settings/model")>("@/settings/model");
return { ...actual, getSettings: jest.fn(), setSettings: jest.fn() };
});
const mockGetSettings = getSettings as jest.MockedFunction<typeof getSettings>;
const mockSetSettings = setSettings as jest.MockedFunction<typeof setSettings>;
function settings(
overrides: Partial<CopilotSettings>,
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();
});

View file

@ -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;

View file

@ -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;