mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
introduce model migration script
This commit is contained in:
parent
c3ebcddfd6
commit
4ec05a7571
11 changed files with 1020 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
74
scripts/test-fixtures/data.legacy-byok.json
Normal file
74
scripts/test-fixtures/data.legacy-byok.json
Normal 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"] }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
405
src/settings/migrations/byokMigration.test.ts
Normal file
405
src/settings/migrations/byokMigration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
335
src/settings/migrations/byokMigration.ts
Normal file
335
src/settings/migrations/byokMigration.ts
Normal 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(" | ||||