From 05f8abf90a97b83933f527fb5d58058b52fd2fb3 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Fri, 22 May 2026 23:17:18 -0700 Subject: [PATCH] Add service APIs --- .../backends/BackendConfigRegistry.ts | 94 ++++++++++ .../catalog/CatalogDownloadService.test.ts | 99 +++++++++++ .../catalog/CatalogDownloadService.ts | 63 ++++--- .../catalog/builtinTemplates.ts | 62 +++++++ .../catalog/catalogTransform.test.ts | 4 +- .../catalog/catalogTransform.ts | 7 +- .../chatModel/ChatModelFactory.ts | 47 +++++ src/modelManagement/createModelManagement.ts | 168 ++++++++++++++++++ src/modelManagement/index.ts | 96 +++++++++- .../models/ConfiguredModelRegistry.ts | 89 ++++++++++ .../providers/ProviderRegistry.ts | 129 ++++++++++++++ .../providers/adapters/ProviderAdapter.ts | 85 +++++++++ .../adapters/ProviderAdapterRegistry.ts | 91 ++++++++++ .../providers/adapters/anthropicAdapter.ts | 38 ++++ .../providers/adapters/azureAdapter.ts | 50 ++++++ .../providers/adapters/bedrockAdapter.ts | 44 +++++ .../providers/adapters/googleAdapter.ts | 34 ++++ .../adapters/openaiCompatibleAdapter.ts | 49 +++++ src/modelManagement/setup/AgentSetupApi.ts | 99 +++++++++++ src/modelManagement/setup/ByokSetupApi.ts | 120 +++++++++++++ .../setup/CopilotPlusSetupApi.ts | 89 ++++++++++ src/modelManagement/state/atoms.ts | 134 ++++++++++++++ src/modelManagement/types/catalog.ts | 7 +- src/modelManagement/types/runtime.ts | 115 ++++++++++++ .../ui/ModelManagementContext.tsx | 54 ++++++ 25 files changed, 1839 insertions(+), 28 deletions(-) create mode 100644 src/modelManagement/backends/BackendConfigRegistry.ts create mode 100644 src/modelManagement/catalog/builtinTemplates.ts create mode 100644 src/modelManagement/chatModel/ChatModelFactory.ts create mode 100644 src/modelManagement/createModelManagement.ts create mode 100644 src/modelManagement/models/ConfiguredModelRegistry.ts create mode 100644 src/modelManagement/providers/ProviderRegistry.ts create mode 100644 src/modelManagement/providers/adapters/ProviderAdapter.ts create mode 100644 src/modelManagement/providers/adapters/ProviderAdapterRegistry.ts create mode 100644 src/modelManagement/providers/adapters/anthropicAdapter.ts create mode 100644 src/modelManagement/providers/adapters/azureAdapter.ts create mode 100644 src/modelManagement/providers/adapters/bedrockAdapter.ts create mode 100644 src/modelManagement/providers/adapters/googleAdapter.ts create mode 100644 src/modelManagement/providers/adapters/openaiCompatibleAdapter.ts create mode 100644 src/modelManagement/setup/AgentSetupApi.ts create mode 100644 src/modelManagement/setup/ByokSetupApi.ts create mode 100644 src/modelManagement/setup/CopilotPlusSetupApi.ts create mode 100644 src/modelManagement/state/atoms.ts create mode 100644 src/modelManagement/types/runtime.ts create mode 100644 src/modelManagement/ui/ModelManagementContext.tsx diff --git a/src/modelManagement/backends/BackendConfigRegistry.ts b/src/modelManagement/backends/BackendConfigRegistry.ts new file mode 100644 index 00000000..db9a09da --- /dev/null +++ b/src/modelManagement/backends/BackendConfigRegistry.ts @@ -0,0 +1,94 @@ +/** + * Source of truth for `BackendConfig` rows, keyed by `BackendType`. + * + * Wraps `settings.backends: Record` (added + * by the settings-wiring follow-up PR) with typed reads and + * mutations. Resolves `enabledModels: configuredModelId[]` into + * picker-ready entries via the joined `ConfiguredModelRegistry` + + * `ProviderRegistry` state — see `resolveEnabled()`. + * + * Invariants enforced here: + * - `defaultModel`, if non-null, must be in `enabledModels` (#4). + * - Broken refs (`enabledModels[i]` not found in + * `configuredModels`) are surfaced as `state: "broken"`, never + * silently dropped (#3). + * + * React components consume reactive reads through + * `state/atoms.ts`'s `backendPickerAtomFamily(backend)`. This class + * is for mutations and non-React callers. + */ + +import type { BackendConfig, BackendType } from "@/modelManagement/types/persisted"; +import type { EnabledBackendEntry } from "@/modelManagement/types/runtime"; +import type { ConfiguredModelRegistry } from "@/modelManagement/models/ConfiguredModelRegistry"; +import type { ProviderRegistry } from "@/modelManagement/providers/ProviderRegistry"; + +export class BackendConfigRegistry { + /** + * Constructor signature is final. Placeholder doesn't store deps; + * implementer wires them when bodies land. + */ + constructor( + providerRegistry: ProviderRegistry, + configuredModelRegistry: ConfiguredModelRegistry + ) {} + + // ------------------------------------------------------------------------- + // Reads + // ------------------------------------------------------------------------- + + /** Returns a `BackendConfig` even when none is persisted yet — + * empty default `{ enabledModels: [], defaultModel: null }` so + * callers don't have to null-check. */ + get(backend: BackendType): BackendConfig { + throw new Error("[modelManagement] BackendConfigRegistry.get not implemented yet"); + } + + /** + * Resolve `enabledModels` (an array of `configuredModelId`s) into + * picker-ready entries by joining against the current + * ConfiguredModel + Provider state. Order preserved. Broken refs + * surface as `state: "broken"`. + */ + resolveEnabled(backend: BackendType): readonly EnabledBackendEntry[] { + throw new Error("[modelManagement] BackendConfigRegistry.resolveEnabled not implemented yet"); + } + + // ------------------------------------------------------------------------- + // Mutations + // ------------------------------------------------------------------------- + + /** Replace the enabled-models list for a backend. The picker re-renders + * as a result (via `backendPickerAtomFamily`). */ + setEnabledModels(backend: BackendType, configuredModelIds: readonly string[]): Promise { + throw new Error("[modelManagement] BackendConfigRegistry.setEnabledModels not implemented yet"); + } + + /** Idempotent: appending an already-enabled id is a no-op. */ + enableModel(backend: BackendType, configuredModelId: string): Promise { + throw new Error("[modelManagement] BackendConfigRegistry.enableModel not implemented yet"); + } + + /** Idempotent. Clears `defaultModel` if it was the removed id. */ + disableModel(backend: BackendType, configuredModelId: string): Promise { + throw new Error("[modelManagement] BackendConfigRegistry.disableModel not implemented yet"); + } + + /** + * Setting a non-null id that isn't in `enabledModels` throws + * (invariant #4). Setting `null` clears the default. + */ + setDefaultModel(backend: BackendType, configuredModelId: string | null): Promise { + throw new Error("[modelManagement] BackendConfigRegistry.setDefaultModel not implemented yet"); + } + + /** + * Used by `ModelManagementCoordinator` to drop refs to deleted + * configured models. Sweeps every backend's `enabledModels`; + * updates `defaultModel` to `null` if it was one of the removed + * ids. + */ + removeRefs(configuredModelIds: readonly string[]): Promise { + throw new Error("[modelManagement] BackendConfigRegistry.removeRefs not implemented yet"); + } +} diff --git a/src/modelManagement/catalog/CatalogDownloadService.test.ts b/src/modelManagement/catalog/CatalogDownloadService.test.ts index 9aee6201..4bb4ec24 100644 --- a/src/modelManagement/catalog/CatalogDownloadService.test.ts +++ b/src/modelManagement/catalog/CatalogDownloadService.test.ts @@ -12,6 +12,8 @@ jest.mock("obsidian", () => ({ import { requestUrl, type RequestUrlParam, type RequestUrlResponse, type App } from "obsidian"; +import type { CatalogProvider } from "@/modelManagement/types/catalog"; + import { CatalogDownloadService } from "./CatalogDownloadService"; import type { WireCatalog } from "./modelsDevWire"; @@ -341,6 +343,103 @@ describe("CatalogDownloadService", () => { expect(unsubscribedAt).not.toHaveBeenCalled(); }); + it("does not poison the auto-retry counter when a fresh disk cache transforms to zero providers", async () => { + // Disk cache exists, fetchedAt is fresh (< 24h), but the wire + // payload transforms to zero providers (e.g. upstream returned an + // empty object that we persisted, or partial-write left `{}`). The + // service should take the fresh-disk branch without ever hitting + // the network — and crucially the empty result must NOT count + // against MAX_AUTO_ATTEMPTS, because no refresh was attempted. + nowSpy = freezeNow(FIXED_NOW); + const adapter = buildAdapter({ + fetchedAt: FIXED_NOW - 60 * 1000, + data: {}, + }); + const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR }); + + // Five fresh-disk loads — none should touch the network. + await svc.ensureLoaded(); + await svc.ensureLoaded(); + await svc.ensureLoaded(); + await svc.ensureLoaded(); + await svc.ensureLoaded(); + expect(mockedRequestUrl).not.toHaveBeenCalled(); + expect(svc.getAllProviders()).toEqual([]); + + // If the counter had been ratcheted by the empty-disk loads above, + // a subsequent ensureLoaded() with a STALE disk would short-circuit + // and never call requestUrl. Force a stale-disk state and verify + // refresh is still attempted. + nowSpy.mockReturnValue(FIXED_NOW + 25 * 60 * 60 * 1000); + mockedRequestUrl.mockResolvedValue(okResponse(FIXTURE)); + await svc.ensureLoaded(); + expect(mockedRequestUrl).toHaveBeenCalledTimes(1); + expect(svc.getAllProviders().length).toBe(3); + }); + + it("does not clobber a just-refreshed live snapshot with a slow disk read", async () => { + // Race scenario: a manual refresh() is in flight; concurrently + // ensureLoaded() starts and awaits a slow disk read. The HTTP + // fetch resolves first → memory swaps to live data. Then the disk + // read returns a (fresh-but-older) snapshot — the fresh-disk + // branch must NOT clobber live data. + nowSpy = freezeNow(FIXED_NOW); + const OLDER_DISK: WireCatalog = { + stale: { + id: "stale", + name: "Stale Provider", + npm: "@ai-sdk/openai-compatible", + api: "https://stale.example/v1", + models: {}, + }, + }; + const adapter = buildAdapter({ + fetchedAt: FIXED_NOW - 60 * 60 * 1000, + data: OLDER_DISK, + }); + // Hold the disk read open until we explicitly resolve it. + let resolveRead: (() => void) | null = null; + const stored = JSON.stringify({ fetchedAt: FIXED_NOW - 60 * 60 * 1000, data: OLDER_DISK }); + adapter.read.mockImplementation( + () => + new Promise((resolve) => { + resolveRead = () => resolve(stored); + }) + ); + mockedRequestUrl.mockResolvedValue(okResponse(FIXTURE)); + const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR }); + + // Kick off ensureLoaded — it blocks on the disk read. + const ensurePending = svc.ensureLoaded(); + // Concurrently run a manual refresh — it completes immediately. + await svc.refresh(); + expect(svc.getAllProviders().map((p) => p.id)).toEqual(["anthropic", "helicone", "openai"]); + + // Now let the disk read finish. The fresh-disk branch must see + // that memory is already populated and bail out without swapping. + resolveRead!(); + await ensurePending; + + expect(svc.getAllProviders().map((p) => p.id)).toEqual(["anthropic", "helicone", "openai"]); + }); + + it("getAllProviders() returns a copy so caller-side mutation can't corrupt internal state", async () => { + nowSpy = freezeNow(FIXED_NOW); + const adapter = buildAdapter({ fetchedAt: FIXED_NOW - 60 * 1000, data: FIXTURE }); + const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR }); + + await svc.ensureLoaded(); + + const snapshot = svc.getAllProviders() as CatalogProvider[]; + expect(snapshot.map((p) => p.id)).toEqual(["anthropic", "helicone", "openai"]); + + // Mutating the returned array must not affect the next read. + snapshot.reverse(); + snapshot.pop(); + + expect(svc.getAllProviders().map((p) => p.id)).toEqual(["anthropic", "helicone", "openai"]); + }); + it("never invokes the global fetch", async () => { nowSpy = freezeNow(FIXED_NOW); const fetchMock = jest.fn(() => { diff --git a/src/modelManagement/catalog/CatalogDownloadService.ts b/src/modelManagement/catalog/CatalogDownloadService.ts index 30724959..e87e39b8 100644 --- a/src/modelManagement/catalog/CatalogDownloadService.ts +++ b/src/modelManagement/catalog/CatalogDownloadService.ts @@ -70,30 +70,40 @@ export class CatalogDownloadService { /** * Idempotent: concurrent callers share the first invocation's promise. * Reads disk first, auto-refreshes when the cached payload is older - * than 24h or absent. If an attempt produces zero providers (no disk - * + refresh failed) the cached promise is cleared so the next call + * than 24h or absent. If a network refresh is attempted but produces + * zero providers the cached promise is cleared so the next call * retries — up to `MAX_AUTO_ATTEMPTS` total. After that we stop * hammering models.dev; the manual `refresh()` button stays as the - * recovery path and resets the counter on success. + * recovery path and resets the counter on success. A fresh-but-empty + * disk cache (no network attempted) does NOT count against the cap — + * otherwise a benignly-empty disk would poison auto-refresh. */ ensureLoaded(): Promise { if (this.loadPromise) return this.loadPromise; if (this.providers.length === 0 && this.failedAutoAttempts >= MAX_AUTO_ATTEMPTS) { return Promise.resolve(); } - this.loadPromise = this.doEnsureLoaded().finally(() => { - if (this.providers.length === 0) { - this.failedAutoAttempts += 1; - this.loadPromise = null; - if (this.failedAutoAttempts >= MAX_AUTO_ATTEMPTS) { - logWarn( - `[modelsCatalog] giving up auto-refresh after ${MAX_AUTO_ATTEMPTS} empty attempts; use the manual Refresh button to retry` - ); + this.loadPromise = this.doEnsureLoaded().then( + ({ attemptedRefresh }) => { + if (this.providers.length === 0) { + this.loadPromise = null; + if (attemptedRefresh) { + this.failedAutoAttempts += 1; + if (this.failedAutoAttempts >= MAX_AUTO_ATTEMPTS) { + logWarn( + `[modelsCatalog] giving up auto-refresh after ${MAX_AUTO_ATTEMPTS} empty attempts; use the manual Refresh button to retry` + ); + } + } + } else { + this.failedAutoAttempts = 0; } - } else { - this.failedAutoAttempts = 0; + }, + (err) => { + this.loadPromise = null; + throw err; } - }); + ); return this.loadPromise; } @@ -160,8 +170,12 @@ export class CatalogDownloadService { return { ok: true, providerCount: this.providers.length }; } - getAllProviders(): CatalogProvider[] { - return this.providers; + /** + * Returns a copy so callers can sort/splice their view without + * corrupting service state. + */ + getAllProviders(): readonly CatalogProvider[] { + return [...this.providers]; } getProvider(id: string): CatalogProvider | undefined { @@ -175,24 +189,33 @@ export class CatalogDownloadService { }; } - private async doEnsureLoaded(): Promise { + private async doEnsureLoaded(): Promise<{ attemptedRefresh: boolean }> { const disk = await this.readDisk(); + // Race guard: a concurrent manual refresh may have populated memory + // while we were reading disk. Don't clobber live data with an older + // disk snapshot. + if (this.providers.length > 0) { + return { attemptedRefresh: false }; + } + if (disk && Date.now() - disk.fetchedAt < STALE_AFTER_MS) { this.swapMemory(disk.data); logInfo(`[modelsCatalog] loaded from disk: ${this.providers.length} providers`); - return; + return { attemptedRefresh: false }; } const result = await this.refresh(); - if (!result.ok && disk) { + if (!result.ok && disk && this.providers.length === 0) { // Network failed but we have stale data — surface it so the UI - // isn't empty offline. The user can hit "Refresh catalog" later. + // isn't empty offline. The `length === 0` guard avoids clobbering + // memory another caller populated during the await. this.swapMemory(disk.data); logInfo( `[modelsCatalog] refresh failed; falling back to stale disk cache (${this.providers.length} providers)` ); } + return { attemptedRefresh: true }; } private async readDisk(): Promise { diff --git a/src/modelManagement/catalog/builtinTemplates.ts b/src/modelManagement/catalog/builtinTemplates.ts new file mode 100644 index 00000000..780f9aec --- /dev/null +++ b/src/modelManagement/catalog/builtinTemplates.ts @@ -0,0 +1,62 @@ +/** + * Self-hosted / per-tenant provider templates. + * + * `models.dev` lists ~30 public providers (Anthropic, OpenAI, Google, + * Mistral, …) but does not — and will never — list providers whose + * configuration is per-user. Those are surfaced through this static + * list and rendered next to the catalog entries in the BYOK + * "Add provider" wizard. + * + * The shape (`ProviderTemplate`) carries no model list — the user + * hand-types models, and the wizard creates `ConfiguredModel` rows + * with just `id` + `displayName` populated. Wizards that want + * pre-fetched model metadata use `CatalogProvider` from + * `ModelCatalogService` instead. + * + * This is real data (not a stub) because UI components built against + * the placeholder need a stable template list to render. Adding a + * new template here is a one-line change — no other code path needs + * to learn about it. + */ + +import type { ProviderTemplate } from "@/modelManagement/types/runtime"; + +export const BUILTIN_PROVIDER_TEMPLATES: readonly ProviderTemplate[] = [ + { + id: "ollama", + displayName: "Ollama", + providerType: "openai-compatible", + defaultBaseUrl: "http://localhost:11434/v1", + requiresApiKey: false, + modelInputHint: "e.g. llama3.2, qwen2.5-coder:7b", + }, + { + id: "lmstudio", + displayName: "LM Studio", + providerType: "openai-compatible", + defaultBaseUrl: "http://localhost:1234/v1", + requiresApiKey: false, + modelInputHint: "e.g. lmstudio-community/Qwen2.5-7B-Instruct-GGUF", + }, + { + id: "custom-openai-compatible", + displayName: "Custom OpenAI-compatible", + providerType: "openai-compatible", + requiresApiKey: true, + modelInputHint: "e.g. gpt-4o-mini", + }, + { + id: "azure-openai", + displayName: "Azure OpenAI", + providerType: "azure", + requiresApiKey: true, + modelInputHint: "matches your Azure deployment name", + }, + { + id: "aws-bedrock", + displayName: "AWS Bedrock", + providerType: "bedrock", + requiresApiKey: true, + modelInputHint: "e.g. anthropic.claude-sonnet-4-5", + }, +]; diff --git a/src/modelManagement/catalog/catalogTransform.test.ts b/src/modelManagement/catalog/catalogTransform.test.ts index aeeec4d3..53cb00fb 100644 --- a/src/modelManagement/catalog/catalogTransform.test.ts +++ b/src/modelManagement/catalog/catalogTransform.test.ts @@ -30,11 +30,11 @@ describe("transformWireToCatalog", () => { }); }); - it("falls back to wire.id for displayName when name is missing", () => { + it("falls back to wire.id for displayName when name is missing, omits defaultBaseUrl when api absent", () => { const wire = makeWire({ ghost: { id: "ghost" } }); const [provider] = transformWireToCatalog(wire); expect(provider.displayName).toBe("ghost"); - expect(provider.defaultBaseUrl).toBe(""); + expect(provider.defaultBaseUrl).toBeUndefined(); expect(provider.models).toEqual({}); }); diff --git a/src/modelManagement/catalog/catalogTransform.ts b/src/modelManagement/catalog/catalogTransform.ts index 71a1fd28..2676c44a 100644 --- a/src/modelManagement/catalog/catalogTransform.ts +++ b/src/modelManagement/catalog/catalogTransform.ts @@ -91,13 +91,16 @@ function transformProvider(providerKey: string, wire: unknown): CatalogProvider ); } } - return { + const result: CatalogProvider = { id: wireProv.id, displayName: wireProv.name ?? wireProv.id, - defaultBaseUrl: wireProv.api ?? "", providerType: mapNpmToProviderType(wireProv.npm), models, }; + if (typeof wireProv.api === "string" && wireProv.api.length > 0) { + result.defaultBaseUrl = wireProv.api; + } + return result; } /** diff --git a/src/modelManagement/chatModel/ChatModelFactory.ts b/src/modelManagement/chatModel/ChatModelFactory.ts new file mode 100644 index 00000000..6ae2e440 --- /dev/null +++ b/src/modelManagement/chatModel/ChatModelFactory.ts @@ -0,0 +1,47 @@ +/** + * Pure dispatch — given a `configuredModelId`, build a LangChain + * `BaseChatModel`. The factory: + * + * 1. Looks up the `ConfiguredModel` row. + * 2. Resolves the parent `Provider`. + * 3. Fetches the API key from the keychain (via `ProviderRegistry`). + * 4. Parses `Provider.extras` through the adapter's `extrasSchema`. + * 5. Hands the resolved tuple to the adapter's + * `buildLangChainClient`. + * + * No state, no settings reads at the factory level — every dependency + * is injected. This is what keeps `ChatModelFactory` independent of + * LangChain imports (those live in the adapters) and easy to unit-test + * with a small mock `ProviderAdapterRegistry`. + */ + +import type { ConfiguredModel, Provider } from "@/modelManagement/types/persisted"; +import type { BuiltChatModel } from "@/modelManagement/types/runtime"; +import type { ConfiguredModelRegistry } from "@/modelManagement/models/ConfiguredModelRegistry"; +import type { ProviderAdapterRegistry } from "@/modelManagement/providers/adapters/ProviderAdapterRegistry"; +import type { ProviderRegistry } from "@/modelManagement/providers/ProviderRegistry"; + +export class ChatModelFactory { + constructor( + providerRegistry: ProviderRegistry, + configuredModelRegistry: ConfiguredModelRegistry, + adapters: ProviderAdapterRegistry + ) {} + + /** + * Resolve a configured-model id → built client. Throws when the + * configured model is missing, its parent provider is missing, or + * the adapter rejects `extras`. + */ + build(configuredModelId: string): Promise { + throw new Error("[modelManagement] ChatModelFactory.build not implemented yet"); + } + + /** + * Lower-level entry point for callers that already hold the rows + * (the setup APIs' verification path, in particular). + */ + buildFor(provider: Provider, configuredModel: ConfiguredModel): Promise { + throw new Error("[modelManagement] ChatModelFactory.buildFor not implemented yet"); + } +} diff --git a/src/modelManagement/createModelManagement.ts b/src/modelManagement/createModelManagement.ts new file mode 100644 index 00000000..bccdc8ac --- /dev/null +++ b/src/modelManagement/createModelManagement.ts @@ -0,0 +1,168 @@ +/** + * Top-level factory + cross-slice coordinator. + * + * Host code calls `createModelManagement({ app, pluginDir })` exactly + * once (in `Plugin.onload`), stores the returned `ModelManagementApi` + * on the plugin instance, threads it into React via + * ``, and passes it to non-React modules + * (chat manager, agent backends) via constructor injection. + * + * `ModelManagementCoordinator` is the only class authorized to mutate + * across registry slices. Single-slice mutations go through the + * relevant registry; multi-slice cascades (delete a provider → + * orphan ConfiguredModels → broken BackendConfig refs) go through + * the coordinator. This keeps each registry's surface focused on its + * slice without inviting cycles or duplicated cascade logic. + * + * The catalog tier is the already-real `CatalogDownloadService` + * (lazy two-tier downloader); there is no placeholder for it. The + * other tiers (registries, factory, setup APIs) are placeholders + * whose method bodies throw `not implemented`. + */ + +import type { App } from "obsidian"; + +import { BackendConfigRegistry } from "@/modelManagement/backends/BackendConfigRegistry"; +import { CatalogDownloadService } from "@/modelManagement/catalog/CatalogDownloadService"; +import { ChatModelFactory } from "@/modelManagement/chatModel/ChatModelFactory"; +import { ConfiguredModelRegistry } from "@/modelManagement/models/ConfiguredModelRegistry"; +import { + createDefaultAdapterRegistry, + ProviderAdapterRegistry, +} from "@/modelManagement/providers/adapters/ProviderAdapterRegistry"; +import { ProviderRegistry } from "@/modelManagement/providers/ProviderRegistry"; +import { AgentSetupApi } from "@/modelManagement/setup/AgentSetupApi"; +import { ByokSetupApi } from "@/modelManagement/setup/ByokSetupApi"; +import { CopilotPlusSetupApi } from "@/modelManagement/setup/CopilotPlusSetupApi"; + +export interface CreateModelManagementInput { + app: App; + /** From `Plugin.manifest.dir`. Used by `CatalogDownloadService` to + * locate its on-disk JSON cache. */ + pluginDir: string; +} + +export interface ModelManagementApi { + catalogService: CatalogDownloadService; + providerRegistry: ProviderRegistry; + configuredModelRegistry: ConfiguredModelRegistry; + backendConfigRegistry: BackendConfigRegistry; + chatModelFactory: ChatModelFactory; + adapters: ProviderAdapterRegistry; + setup: { + byok: ByokSetupApi; + agent: AgentSetupApi; + copilotPlus: CopilotPlusSetupApi; + }; + coordinator: ModelManagementCoordinator; + /** + * Teardown. Called from `Plugin.onunload` to stop any long-lived + * resources (catalog auto-refresh timers, subscribers). Idempotent. + */ + dispose(): void; +} + +export class ModelManagementCoordinator { + constructor( + providerRegistry: ProviderRegistry, + configuredModelRegistry: ConfiguredModelRegistry, + backendConfigRegistry: BackendConfigRegistry + ) {} + + /** + * Cascade: + * 1. Look up all ConfiguredModel ids under this provider. + * 2. Drop those ids from every BackendConfig (`removeRefs`). + * `defaultModel` becomes `null` if it pointed at a removed id. + * 3. Remove the ConfiguredModel rows (`removeByProvider`). + * 4. Remove the Provider row + clear its keychain entry. + * + * Order matters — backend refs must be cleared before the + * ConfiguredModels are removed; otherwise `resolveEnabled` would + * briefly surface them as broken. + */ + removeProvider(providerId: string): Promise { + throw new Error( + "[modelManagement] ModelManagementCoordinator.removeProvider not implemented yet" + ); + } + + /** + * Cascade: + * 1. Drop the id from every BackendConfig. + * 2. Remove the ConfiguredModel row. + */ + removeConfiguredModel(configuredModelId: string): Promise { + throw new Error( + "[modelManagement] ModelManagementCoordinator.removeConfiguredModel not implemented yet" + ); + } +} + +/** + * Wire everything together and return the public API. Each layer's + * deps are injected at construction time; the factory just expresses + * the dependency graph. + * + * Call exactly once per plugin load. Test isolation is achieved by + * constructing a fresh api per test (or per `describe` block) — no + * singleton, no reset helpers needed. + */ +export function createModelManagement(input: CreateModelManagementInput): ModelManagementApi { + const { app, pluginDir } = input; + + const adapters = createDefaultAdapterRegistry(); + + const catalogService = new CatalogDownloadService({ app, pluginDir }); + const providerRegistry = new ProviderRegistry(app, adapters); + const configuredModelRegistry = new ConfiguredModelRegistry(); + const backendConfigRegistry = new BackendConfigRegistry( + providerRegistry, + configuredModelRegistry + ); + const chatModelFactory = new ChatModelFactory( + providerRegistry, + configuredModelRegistry, + adapters + ); + // Coordinator is constructed before the setup APIs because both + // `AgentSetupApi` and `CopilotPlusSetupApi` depend on it for their + // cross-slice cascades (diff-reconcile drops, sign-out removal). + const coordinator = new ModelManagementCoordinator( + providerRegistry, + configuredModelRegistry, + backendConfigRegistry + ); + const setup = { + byok: new ByokSetupApi(providerRegistry, configuredModelRegistry, backendConfigRegistry), + agent: new AgentSetupApi( + providerRegistry, + configuredModelRegistry, + backendConfigRegistry, + catalogService, + coordinator + ), + copilotPlus: new CopilotPlusSetupApi( + providerRegistry, + configuredModelRegistry, + backendConfigRegistry, + coordinator + ), + }; + + return { + catalogService, + providerRegistry, + configuredModelRegistry, + backendConfigRegistry, + chatModelFactory, + adapters, + setup, + coordinator, + dispose: () => { + // Placeholder — implementer adds catalog-timer / subscriber + // teardown when the service bodies land. Idempotent by + // contract. + }, + }; +} diff --git a/src/modelManagement/index.ts b/src/modelManagement/index.ts index e213a650..4fe04834 100644 --- a/src/modelManagement/index.ts +++ b/src/modelManagement/index.ts @@ -1,6 +1,11 @@ // Public surface of the model-management module. Host code must import -// from this barrel — deep imports of `@/modelManagement/types/*` are -// blocked by `no-restricted-imports` patterns in eslint.config.mjs. +// from this barrel — deep imports of `@/modelManagement/types/*` and +// other internals are blocked by `no-restricted-imports` patterns in +// eslint.config.mjs. + +// --------------------------------------------------------------------------- +// Data-model types +// --------------------------------------------------------------------------- export type { CatalogProvider, ModelInfo, ProviderType } from "./types/catalog"; export type { @@ -11,3 +16,90 @@ export type { Provider, ProviderOrigin, } from "./types/persisted"; +export type { + BuiltChatModel, + EnabledBackendEntry, + ProviderTemplate, + RefreshResult, + VerificationResult, +} from "./types/runtime"; + +// --------------------------------------------------------------------------- +// Services +// --------------------------------------------------------------------------- + +export { CatalogDownloadService } from "./catalog/CatalogDownloadService"; +export type { CatalogDownloadDeps, CatalogRefreshResult } from "./catalog/CatalogDownloadService"; +export { BUILTIN_PROVIDER_TEMPLATES } from "./catalog/builtinTemplates"; + +export { ProviderRegistry } from "./providers/ProviderRegistry"; +export { ConfiguredModelRegistry } from "./models/ConfiguredModelRegistry"; +export { BackendConfigRegistry } from "./backends/BackendConfigRegistry"; +export { ChatModelFactory } from "./chatModel/ChatModelFactory"; + +// --------------------------------------------------------------------------- +// Provider adapter contract +// --------------------------------------------------------------------------- + +export { + createDefaultAdapterRegistry, + ProviderAdapterRegistry, +} from "./providers/adapters/ProviderAdapterRegistry"; +export type { + AdapterBuildContext, + AdapterVerifyContext, + ProviderAdapter, +} from "./providers/adapters/ProviderAdapter"; + +// --------------------------------------------------------------------------- +// Setup APIs (one per ProviderOrigin.kind) +// --------------------------------------------------------------------------- + +export { ByokSetupApi, BYOK_DEFAULT_AUTO_ENROLL } from "./setup/ByokSetupApi"; +export type { + AddCatalogProviderInput, + AddModelsInput, + AddTemplateProviderInput, + ByokSetupResult, +} from "./setup/ByokSetupApi"; + +export { AgentSetupApi } from "./setup/AgentSetupApi"; +export type { + AgentSetupResult, + AgentSyncResult, + RegisterAgentProviderInput, + SyncAgentModelsInput, +} from "./setup/AgentSetupApi"; + +export { CopilotPlusSetupApi } from "./setup/CopilotPlusSetupApi"; +export type { PlusSetupResult, RegisterPlusProviderInput } from "./setup/CopilotPlusSetupApi"; + +// --------------------------------------------------------------------------- +// Top-level factory + coordinator +// --------------------------------------------------------------------------- + +export { createModelManagement, ModelManagementCoordinator } from "./createModelManagement"; +export type { CreateModelManagementInput, ModelManagementApi } from "./createModelManagement"; + +// --------------------------------------------------------------------------- +// Reactive atoms (Jotai) +// +// React: `useAtomValue(, { store: settingsStore })` +// Non-React subscribers: `settingsStore.sub(, listener)` +// --------------------------------------------------------------------------- + +export { + agentProvidersAtom, + backendPickerAtomFamily, + backendsAtom, + byokProvidersAtom, + configuredModelsAtom, + copilotPlusProvidersAtom, + providersAtom, +} from "./state/atoms"; + +// --------------------------------------------------------------------------- +// React context for mutation access (reads use atoms directly) +// --------------------------------------------------------------------------- + +export { ModelManagementProvider, useModelManagement } from "./ui/ModelManagementContext"; diff --git a/src/modelManagement/models/ConfiguredModelRegistry.ts b/src/modelManagement/models/ConfiguredModelRegistry.ts new file mode 100644 index 00000000..2f23f2a1 --- /dev/null +++ b/src/modelManagement/models/ConfiguredModelRegistry.ts @@ -0,0 +1,89 @@ +/** + * Source of truth for `ConfiguredModel` rows. + * + * Wraps `settings.configuredModels: ConfiguredModel[]` (added by the + * settings-wiring follow-up PR) with typed reads and mutations. + * Uniqueness invariant: `(providerId, info.id)` — enforced on `add()` + * and `bulkSet()` writes. + * + * React components consume reactive reads through the atoms in + * `state/atoms.ts`. This class is for mutations and non-React callers. + */ + +import type { ConfiguredModel } from "@/modelManagement/types/persisted"; + +export class ConfiguredModelRegistry { + /** + * No constructor args — `settings.configuredModels` is read/written + * through the module-level helpers in `@/settings/model`, not + * through Obsidian APIs. + */ + constructor() {} + + // ------------------------------------------------------------------------- + // Reads + // ------------------------------------------------------------------------- + + list(): readonly ConfiguredModel[] { + throw new Error("[modelManagement] ConfiguredModelRegistry.list not implemented yet"); + } + + listByProvider(providerId: string): readonly ConfiguredModel[] { + throw new Error("[modelManagement] ConfiguredModelRegistry.listByProvider not implemented yet"); + } + + get(configuredModelId: string): ConfiguredModel | undefined { + throw new Error("[modelManagement] ConfiguredModelRegistry.get not implemented yet"); + } + + /** Resolve by the wire-form id under a specific provider. Used when + * reconciling external references — e.g. an agent picker rehydrating + * `/` strings into rows. */ + getByWireId(providerId: string, wireModelId: string): ConfiguredModel | undefined { + throw new Error("[modelManagement] ConfiguredModelRegistry.getByWireId not implemented yet"); + } + + // ------------------------------------------------------------------------- + // Mutations + // ------------------------------------------------------------------------- + + /** Mints `configuredModelId`, stamps `configuredAt`. Throws if the + * `(providerId, info.id)` pair already exists. */ + add(input: Omit): Promise { + throw new Error("[modelManagement] ConfiguredModelRegistry.add not implemented yet"); + } + + /** Patches `info` only — id / providerId / configuredAt are + * immutable. Used by `CopilotPlusSetupApi` to refresh model + * metadata when Plus updates context limits / pricing. */ + update( + configuredModelId: string, + patch: { info?: Partial } + ): Promise { + throw new Error("[modelManagement] ConfiguredModelRegistry.update not implemented yet"); + } + + remove(configuredModelId: string): Promise { + throw new Error("[modelManagement] ConfiguredModelRegistry.remove not implemented yet"); + } + + /** + * Replace the set of configured models under one provider in a + * single write. Used by "Configure Provider → save" where the user + * picked N catalog models at once. Existing rows with matching + * `(providerId, info.id)` are preserved by id so downstream + * `BackendConfig.enabledModels` refs don't churn. Returns the + * resulting `configuredModelId`s in input order. + */ + bulkSet(providerId: string, infos: readonly ConfiguredModel["info"][]): Promise { + throw new Error("[modelManagement] ConfiguredModelRegistry.bulkSet not implemented yet"); + } + + /** Used by `ModelManagementCoordinator.removeProvider` to drop all + * models under a provider during cascade. */ + removeByProvider(providerId: string): Promise { + throw new Error( + "[modelManagement] ConfiguredModelRegistry.removeByProvider not implemented yet" + ); + } +} diff --git a/src/modelManagement/providers/ProviderRegistry.ts b/src/modelManagement/providers/ProviderRegistry.ts new file mode 100644 index 00000000..6b3ccc39 --- /dev/null +++ b/src/modelManagement/providers/ProviderRegistry.ts @@ -0,0 +1,129 @@ +/** + * Source of truth for `Provider` rows. + * + * Wraps `settings.providers: Record` (added by + * the settings-wiring follow-up PR) with typed reads, mutations, and + * keychain bridging. React components consume reactive reads through + * the atoms in `state/atoms.ts`; this class is for mutations and for + * non-React callers (the chat-model factory, the setup APIs, the + * coordinator). + * + * Cascade semantics — `remove()` does NOT cascade to ConfiguredModels + * or BackendConfigs on its own. Call `ModelManagementCoordinator.removeProvider` + * from UI code; the coordinator orchestrates the cross-slice + * removal. This method exists for the coordinator's use. + */ + +import type { App } from "obsidian"; + +import type { ProviderType } from "@/modelManagement/types/catalog"; +import type { Provider, ProviderOrigin } from "@/modelManagement/types/persisted"; +import type { VerificationResult } from "@/modelManagement/types/runtime"; +import type { ProviderAdapterRegistry } from "./adapters/ProviderAdapterRegistry"; + +export class ProviderRegistry { + /** + * Constructor signature is final. Placeholder doesn't store deps + * because all method bodies throw; implementer adds private fields + * (`#app`, `#adapters`) when the implementation lands. + */ + constructor(app: App, adapters: ProviderAdapterRegistry) {} + + // ------------------------------------------------------------------------- + // Reads — synchronous, backed by `settings.providers`. + // ------------------------------------------------------------------------- + + /** All providers. Use the atoms in `state/atoms.ts` for reactive + * React reads; this method is for non-React callers. */ + list(): readonly Provider[] { + throw new Error("[modelManagement] ProviderRegistry.list not implemented yet"); + } + + get(providerId: string): Provider | undefined { + throw new Error("[modelManagement] ProviderRegistry.get not implemented yet"); + } + + /** Filter helper used by the BYOK tab (origin = "byok"), the agent + * setup flows (origin = "agent"), and the Plus sign-in handler + * (origin = "copilot-plus"). */ + listByOrigin(originKind: ProviderOrigin["kind"]): readonly Provider[] { + throw new Error("[modelManagement] ProviderRegistry.listByOrigin not implemented yet"); + } + + /** Used by the agent-setup idempotency check (one + * `(agentType, providerType)` row at most). */ + listByProviderType(providerType: ProviderType): readonly Provider[] { + throw new Error("[modelManagement] ProviderRegistry.listByProviderType not implemented yet"); + } + + // ------------------------------------------------------------------------- + // Mutations — persist via `updateSetting("providers", …)`. + // ------------------------------------------------------------------------- + + /** + * Mints a fresh `providerId` (UUID), stamps `addedAt`, persists. + * Returns the new `providerId`. Does NOT store the API key — callers + * invoke `setApiKey(...)` separately so the keychain pointer is owned + * by this registry. `apiKeyKeychainId` is excluded from the input + * shape so callers cannot create a row whose pointer references a + * keychain entry this code path never wrote. + */ + add(input: Omit): Promise { + throw new Error("[modelManagement] ProviderRegistry.add not implemented yet"); + } + + /** Partial update. `providerId` and `addedAt` are immutable. */ + update( + providerId: string, + patch: Partial> + ): Promise { + throw new Error("[modelManagement] ProviderRegistry.update not implemented yet"); + } + + /** + * Removes the row from `settings.providers` and clears its keychain + * entry. Cross-slice cascade (ConfiguredModels + BackendConfig refs) + * is the coordinator's job — see class docstring. + */ + remove(providerId: string): Promise { + throw new Error("[modelManagement] ProviderRegistry.remove not implemented yet"); + } + + // ------------------------------------------------------------------------- + // Secrets — Obsidian keychain via `app.secretStorage` / + // `KeychainService`. + // ------------------------------------------------------------------------- + + /** Reads the keychain entry referenced by the row's + * `apiKeyKeychainId`. Returns `null` for providers that don't take + * an API key (Ollama, LMStudio, agent-owned providers). */ + getApiKey(providerId: string): Promise { + throw new Error("[modelManagement] ProviderRegistry.getApiKey not implemented yet"); + } + + /** Generates a fresh `apiKeyKeychainId` if the provider doesn't + * yet have one; persists the row. Re-calling with a different key + * rotates in place (same keychain id, new value). */ + setApiKey(providerId: string, apiKey: string): Promise { + throw new Error("[modelManagement] ProviderRegistry.setApiKey not implemented yet"); + } + + /** Drops the keychain entry and clears `apiKeyKeychainId` on the row. */ + clearApiKey(providerId: string): Promise { + throw new Error("[modelManagement] ProviderRegistry.clearApiKey not implemented yet"); + } + + // ------------------------------------------------------------------------- + // Verification — dispatches to the adapter for `providerType`. + // ------------------------------------------------------------------------- + + /** + * Issues an adapter-defined "ping". Returns the verification + * result; does NOT persist it. Persistent + * `lastVerifiedAt` / `lastVerificationError` fields are deferred + * (see data-model spec §10). + */ + verify(providerId: string): Promise { + throw new Error("[modelManagement] ProviderRegistry.verify not implemented yet"); + } +} diff --git a/src/modelManagement/providers/adapters/ProviderAdapter.ts b/src/modelManagement/providers/adapters/ProviderAdapter.ts new file mode 100644 index 00000000..57efc585 --- /dev/null +++ b/src/modelManagement/providers/adapters/ProviderAdapter.ts @@ -0,0 +1,85 @@ +/** + * Provider adapter contract. + * + * Each `ProviderType` has exactly one adapter. The adapter: + * - declares the shape of `Provider.extras` via a Zod schema, so + * setup wizards can render the right "advanced" form fields; + * - instantiates a LangChain `BaseChatModel` for a (provider, + * configuredModel) tuple; + * - issues a minimal "ping" to verify credentials. + * + * Adapters are stateless. They receive everything they need through + * the `AdapterBuildContext` / `AdapterVerifyContext` argument — no + * registry lookups, no settings reads, no keychain access. The + * `ProviderRegistry` does those reads and hands the resolved values + * to the adapter. + * + * This isolation is what lets `ChatModelFactory` stay pure dispatch + * and the LangChain SDK imports stay confined to the five adapter + * files. + */ + +import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; +import type { z } from "zod"; + +import type { ProviderType } from "@/modelManagement/types/catalog"; +import type { ConfiguredModel, Provider } from "@/modelManagement/types/persisted"; +import type { VerificationResult } from "@/modelManagement/types/runtime"; + +/** Inputs to `ProviderAdapter.buildLangChainClient`. */ +export interface AdapterBuildContext { + provider: Provider; + configuredModel: ConfiguredModel; + /** Resolved from the keychain by the caller; `null` for providers + * that don't take an API key (Ollama, LMStudio, agent-owned + * providers using CLI-managed credentials). */ + apiKey: string | null; + /** Parsed against `extrasSchema` before being passed in. Adapters + * receive the typed shape directly — no further validation + * required inside `buildLangChainClient`. */ + extras: TExtras; +} + +/** Inputs to `ProviderAdapter.verifyCredentials`. */ +export interface AdapterVerifyContext { + provider: Provider; + apiKey: string | null; + extras: TExtras; + /** Optional probe model. Adapters that can't issue a ping without + * a real wire id (Azure deployment-name routing) may require this + * and throw if absent. */ + probeModel?: ConfiguredModel; +} + +/** + * The shape every provider adapter implements. Generic over the + * extras payload so adapters can type their schemas precisely. + */ +export interface ProviderAdapter { + /** Single dispatch key. Matches `Provider.providerType`. */ + readonly providerType: ProviderType; + + /** + * Zod schema for `Provider.extras`. Adapters with no extras export + * `z.object({}).strict()`. The Configure Provider dialog reads this + * schema to render advanced-section form fields, and the adapter + * registry uses it to validate `extras` before any + * `buildLangChainClient` call. + */ + readonly extrasSchema: z.ZodType; + + /** + * Instantiate a LangChain `BaseChatModel` for this (provider, + * configured-model) tuple. The caller has already parsed `extras` + * through `extrasSchema` — adapters can trust the typed shape. + */ + buildLangChainClient(ctx: AdapterBuildContext): BaseChatModel; + + /** + * Issue a minimal "ping" to validate credentials. Adapter-defined — + * Anthropic might check `/v1/models`, Bedrock might call STS, etc. + * Returns a structured result; throwing is reserved for unrecoverable + * misconfiguration (missing required extras). + */ + verifyCredentials(ctx: AdapterVerifyContext): Promise; +} diff --git a/src/modelManagement/providers/adapters/ProviderAdapterRegistry.ts b/src/modelManagement/providers/adapters/ProviderAdapterRegistry.ts new file mode 100644 index 00000000..a139a85e --- /dev/null +++ b/src/modelManagement/providers/adapters/ProviderAdapterRegistry.ts @@ -0,0 +1,91 @@ +/** + * Dispatch table from `ProviderType` to its adapter. Owned by the + * top-level `createModelManagement` factory; passed to + * `ProviderRegistry` and `ChatModelFactory` so they can dispatch by + * `Provider.providerType` without hard-coding adapter imports. + * + * Tests can substitute mocks via `register()`. `createDefaultAdapterRegistry` + * returns a registry pre-populated with the five built-in adapters. + */ + +import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; + +import type { ProviderType } from "@/modelManagement/types/catalog"; +import type { VerificationResult } from "@/modelManagement/types/runtime"; +import { anthropicAdapter } from "./anthropicAdapter"; +import { azureAdapter } from "./azureAdapter"; +import { bedrockAdapter } from "./bedrockAdapter"; +import { googleAdapter } from "./googleAdapter"; +import { openaiCompatibleAdapter } from "./openaiCompatibleAdapter"; +import type { AdapterBuildContext, AdapterVerifyContext, ProviderAdapter } from "./ProviderAdapter"; + +export class ProviderAdapterRegistry { + private readonly adapters = new Map(); + + /** Last registration for a given `providerType` wins, so tests can + * override built-in adapters without resetting the registry. */ + register(adapter: ProviderAdapter): void { + this.adapters.set(adapter.providerType, adapter); + } + + /** Throws if no adapter is registered for `providerType`. Callers + * treat this as an invariant violation — the closed + * `ProviderType` union guarantees the dispatch is total. Prefer the + * `buildLangChainClient` / `verifyCredentials` dispatch helpers + * below; reaching directly for the adapter skips the schema parse + * the adapter contract relies on. */ + get(providerType: ProviderType): ProviderAdapter { + const adapter = this.adapters.get(providerType); + if (!adapter) { + throw new Error(`[modelManagement] No adapter registered for providerType "${providerType}"`); + } + return adapter; + } + + list(): readonly ProviderAdapter[] { + return [...this.adapters.values()]; + } + + /** + * Dispatch helper: parse `extras` through the adapter's `extrasSchema`, + * then delegate to `buildLangChainClient`. Funnelling every build + * through this helper makes the contract at `ProviderAdapter.ts` + * ("extras is parsed before being passed in") impossible to bypass. + */ + buildLangChainClient( + providerType: ProviderType, + ctx: Omit & { extras: unknown } + ): BaseChatModel { + const adapter = this.get(providerType); + const extras = adapter.extrasSchema.parse(ctx.extras); + return adapter.buildLangChainClient({ ...ctx, extras }); + } + + /** + * Dispatch helper: parse `extras` through the adapter's `extrasSchema`, + * then delegate to `verifyCredentials`. + */ + verifyCredentials( + providerType: ProviderType, + ctx: Omit & { extras: unknown } + ): Promise { + const adapter = this.get(providerType); + const extras = adapter.extrasSchema.parse(ctx.extras); + return adapter.verifyCredentials({ ...ctx, extras }); + } +} + +/** + * Returns a registry pre-populated with the five built-in adapters + * (anthropic / openai-compatible / google / azure / bedrock). Matches + * the closed `ProviderType` union one-to-one. + */ +export function createDefaultAdapterRegistry(): ProviderAdapterRegistry { + const registry = new ProviderAdapterRegistry(); + registry.register(anthropicAdapter); + registry.register(openaiCompatibleAdapter); + registry.register(googleAdapter); + registry.register(azureAdapter); + registry.register(bedrockAdapter); + return registry; +} diff --git a/src/modelManagement/providers/adapters/anthropicAdapter.ts b/src/modelManagement/providers/adapters/anthropicAdapter.ts new file mode 100644 index 00000000..b3a3858a --- /dev/null +++ b/src/modelManagement/providers/adapters/anthropicAdapter.ts @@ -0,0 +1,38 @@ +/** + * Anthropic adapter. Dispatch key: `ProviderType === "anthropic"`. + * + * Anthropic has no provider-level extras. `baseUrl` and `apiKey` + * cover everything the SDK needs. + * + * Placeholder — `buildLangChainClient` throws; `verifyCredentials` + * returns a non-ok result so setup wizards that probe on every + * keystroke don't crash. + */ + +import { z } from "zod"; + +import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; + +import type { VerificationResult } from "@/modelManagement/types/runtime"; +import type { AdapterBuildContext, AdapterVerifyContext, ProviderAdapter } from "./ProviderAdapter"; + +const extrasSchema = z.object({}).strict(); + +type Extras = z.infer; + +export const anthropicAdapter: ProviderAdapter = { + providerType: "anthropic", + extrasSchema, + + buildLangChainClient(ctx: AdapterBuildContext): BaseChatModel { + throw new Error("[modelManagement] anthropicAdapter.buildLangChainClient not implemented yet"); + }, + + verifyCredentials(ctx: AdapterVerifyContext): Promise { + return Promise.resolve({ + ok: false, + message: "not implemented", + checkedAt: Date.now(), + }); + }, +}; diff --git a/src/modelManagement/providers/adapters/azureAdapter.ts b/src/modelManagement/providers/adapters/azureAdapter.ts new file mode 100644 index 00000000..dec812f0 --- /dev/null +++ b/src/modelManagement/providers/adapters/azureAdapter.ts @@ -0,0 +1,50 @@ +/** + * Azure OpenAI adapter. Dispatch key: `ProviderType === "azure"`. + * + * Azure OpenAI requires three pieces of routing information beyond + * the API key: + * - `azureInstanceName` — the resource name in your Azure + * subscription (e.g. `my-org-eastus`). + * - `azureDeploymentName` — the deployment id created in the Azure + * portal. Wire model id flows through + * the deployment, not directly to the + * SDK. + * - `azureApiVersion` — Azure pins clients to a dated API + * version (e.g. `2024-08-01-preview`). + * + * All three are required; the schema rejects missing or empty values. + */ + +import { z } from "zod"; + +import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; + +import type { VerificationResult } from "@/modelManagement/types/runtime"; +import type { AdapterBuildContext, AdapterVerifyContext, ProviderAdapter } from "./ProviderAdapter"; + +const extrasSchema = z + .object({ + azureInstanceName: z.string().min(1), + azureDeploymentName: z.string().min(1), + azureApiVersion: z.string().min(1), + }) + .strict(); + +type Extras = z.infer; + +export const azureAdapter: ProviderAdapter = { + providerType: "azure", + extrasSchema, + + buildLangChainClient(ctx: AdapterBuildContext): BaseChatModel { + throw new Error("[modelManagement] azureAdapter.buildLangChainClient not implemented yet"); + }, + + verifyCredentials(ctx: AdapterVerifyContext): Promise { + return Promise.resolve({ + ok: false, + message: "not implemented", + checkedAt: Date.now(), + }); + }, +}; diff --git a/src/modelManagement/providers/adapters/bedrockAdapter.ts b/src/modelManagement/providers/adapters/bedrockAdapter.ts new file mode 100644 index 00000000..370a428a --- /dev/null +++ b/src/modelManagement/providers/adapters/bedrockAdapter.ts @@ -0,0 +1,44 @@ +/** + * AWS Bedrock adapter. Dispatch key: `ProviderType === "bedrock"`. + * + * Bedrock routes by region instead of base URL — the region is part + * of the SDK construction, not a URL the user types. Always prefer a + * cross-region inference profile id at the wire layer (e.g. + * `global.anthropic.claude-sonnet-4-5-20250929-v1:0`, see + * AGENTS.md → "AWS Bedrock Usage"). That belongs in + * `ConfiguredModel.info.id`, not here. + */ + +import { z } from "zod"; + +import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; + +import type { VerificationResult } from "@/modelManagement/types/runtime"; +import type { AdapterBuildContext, AdapterVerifyContext, ProviderAdapter } from "./ProviderAdapter"; + +const extrasSchema = z + .object({ + /** AWS region (e.g. `us-east-1`). Required — Bedrock's SDK + * rejects calls without a region. */ + bedrockRegion: z.string().min(1), + }) + .strict(); + +type Extras = z.infer; + +export const bedrockAdapter: ProviderAdapter = { + providerType: "bedrock", + extrasSchema, + + buildLangChainClient(ctx: AdapterBuildContext): BaseChatModel { + throw new Error("[modelManagement] bedrockAdapter.buildLangChainClient not implemented yet"); + }, + + verifyCredentials(ctx: AdapterVerifyContext): Promise { + return Promise.resolve({ + ok: false, + message: "not implemented", + checkedAt: Date.now(), + }); + }, +}; diff --git a/src/modelManagement/providers/adapters/googleAdapter.ts b/src/modelManagement/providers/adapters/googleAdapter.ts new file mode 100644 index 00000000..a41419c7 --- /dev/null +++ b/src/modelManagement/providers/adapters/googleAdapter.ts @@ -0,0 +1,34 @@ +/** + * Google Generative AI adapter. Dispatch key: + * `ProviderType === "google"`. + * + * Backs Gemini via `@langchain/google-genai`. No provider-level extras. + */ + +import { z } from "zod"; + +import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; + +import type { VerificationResult } from "@/modelManagement/types/runtime"; +import type { AdapterBuildContext, AdapterVerifyContext, ProviderAdapter } from "./ProviderAdapter"; + +const extrasSchema = z.object({}).strict(); + +type Extras = z.infer; + +export const googleAdapter: ProviderAdapter = { + providerType: "google", + extrasSchema, + + buildLangChainClient(ctx: AdapterBuildContext): BaseChatModel { + throw new Error("[modelManagement] googleAdapter.buildLangChainClient not implemented yet"); + }, + + verifyCredentials(ctx: AdapterVerifyContext): Promise { + return Promise.resolve({ + ok: false, + message: "not implemented", + checkedAt: Date.now(), + }); + }, +}; diff --git a/src/modelManagement/providers/adapters/openaiCompatibleAdapter.ts b/src/modelManagement/providers/adapters/openaiCompatibleAdapter.ts new file mode 100644 index 00000000..2742faed --- /dev/null +++ b/src/modelManagement/providers/adapters/openaiCompatibleAdapter.ts @@ -0,0 +1,49 @@ +/** + * OpenAI-compatible adapter. Dispatch key: + * `ProviderType === "openai-compatible"`. + * + * Covers the OpenAI SDK family — OpenAI proper, plus everything that + * speaks the OpenAI Chat Completions wire format with a custom + * `baseUrl`: Mistral, Groq, OpenRouter, Together, DeepSeek, xAI, + * SiliconFlow, Ollama, LMStudio, and arbitrary custom proxies. + * + * The only optional extra is the OpenAI organization id, which a + * subset of OpenAI accounts require. Everything else (base URL, API + * key) lives on `Provider` directly. + */ + +import { z } from "zod"; + +import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; + +import type { VerificationResult } from "@/modelManagement/types/runtime"; +import type { AdapterBuildContext, AdapterVerifyContext, ProviderAdapter } from "./ProviderAdapter"; + +const extrasSchema = z + .object({ + /** OpenAI organization id (e.g. `org-…`). Only meaningful for + * OpenAI proper; ignored by other OpenAI-compatible endpoints. */ + openAIOrgId: z.string().optional(), + }) + .strict(); + +type Extras = z.infer; + +export const openaiCompatibleAdapter: ProviderAdapter = { + providerType: "openai-compatible", + extrasSchema, + + buildLangChainClient(ctx: AdapterBuildContext): BaseChatModel { + throw new Error( + "[modelManagement] openaiCompatibleAdapter.buildLangChainClient not implemented yet" + ); + }, + + verifyCredentials(ctx: AdapterVerifyContext): Promise { + return Promise.resolve({ + ok: false, + message: "not implemented", + checkedAt: Date.now(), + }); + }, +}; diff --git a/src/modelManagement/setup/AgentSetupApi.ts b/src/modelManagement/setup/AgentSetupApi.ts new file mode 100644 index 00000000..5096fe17 --- /dev/null +++ b/src/modelManagement/setup/AgentSetupApi.ts @@ -0,0 +1,99 @@ +/** + * Agent setup workflow. Called by an agent's own setup flow (Claude + * Code installer, Codex CLI installer, OpenCode installer) when the + * agent reports its supported model list. + * + * Creates one `Provider` row per `(agentType, providerType)` pair + * with `origin: { kind: "agent", agentType }`, snapshots + * `ConfiguredModel` rows for each `wireModelId`, and auto-enrolls + * them into `backends[agentType]` only — agent-owned models stay + * exclusive to their agent's picker. + * + * `registerAgentProvider` is idempotent on + * `(agentType, providerType)` — re-running upgrades the model list + * (adds new wire ids, drops vanished ones). `syncAgentModels` is the + * narrower variant for callers that only need the model-list refresh + * without provider metadata changes. + */ + +import type { CatalogDownloadService } from "@/modelManagement/catalog/CatalogDownloadService"; +import type { ModelManagementCoordinator } from "@/modelManagement/createModelManagement"; +import type { ProviderType } from "@/modelManagement/types/catalog"; +import type { AgentType } from "@/modelManagement/types/persisted"; +import type { BackendConfigRegistry } from "@/modelManagement/backends/BackendConfigRegistry"; +import type { ConfiguredModelRegistry } from "@/modelManagement/models/ConfiguredModelRegistry"; +import type { ProviderRegistry } from "@/modelManagement/providers/ProviderRegistry"; + +export interface RegisterAgentProviderInput { + agentType: AgentType; + providerType: ProviderType; + displayName: string; + baseUrl?: string; + /** Agents that use CLI-managed credentials (Claude Code, Codex) + * pass `null`. The Provider row's `apiKeyKeychainId` stays + * `null`; the chat-model factory's `getApiKey()` returns `null` + * and the adapter must tolerate that. */ + apiKey?: string | null; + /** Per-providerType payload — see `Provider.extras`. */ + extras?: Record; + /** Full set of wire ids the agent reports as supported. The + * existing ConfiguredModel set is diffed against this list. */ + wireModelIds: readonly string[]; + /** Fallback for wire ids the catalog doesn't know — used when the + * agent supports a model that hasn't been added to `models.dev` + * yet. Keyed by wire id. */ + fallbackDisplayNames?: Record; +} + +export interface SyncAgentModelsInput { + agentType: AgentType; + wireModelIds: readonly string[]; +} + +export interface AgentSetupResult { + providerId: string; + configuredModelIds: string[]; +} + +export interface AgentSyncResult { + added: string[]; + removed: string[]; +} + +export class AgentSetupApi { + constructor( + providerRegistry: ProviderRegistry, + configuredModelRegistry: ConfiguredModelRegistry, + backendConfigRegistry: BackendConfigRegistry, + catalogService: CatalogDownloadService, + /** Required for the diff-reconcile cascade: when a wire id vanishes + * on agent upgrade, the orphan ConfiguredModel and its backend + * refs are removed via `coordinator.removeConfiguredModel`. */ + coordinator: ModelManagementCoordinator + ) {} + + /** + * Idempotent on `(agentType, providerType)`. First call creates; + * subsequent calls update the Provider row in place and reconcile + * the configured-model list (add new wire ids, drop vanished + * ones). Each new ConfiguredModel is auto-enrolled into + * `backends[agentType]`. + * + * Catalog lookups (via `catalogService`) enrich each + * `ConfiguredModel.info` snapshot where the wire id matches a + * catalog entry; unmatched wire ids fall back to + * `fallbackDisplayNames[wireId] ?? wireId`. + */ + registerAgentProvider(input: RegisterAgentProviderInput): Promise { + throw new Error("[modelManagement] AgentSetupApi.registerAgentProvider not implemented yet"); + } + + /** + * Reconcile the configured-model list for an existing agent-owned + * provider. Same diff logic as `registerAgentProvider` but doesn't + * touch the Provider row. + */ + syncAgentModels(input: SyncAgentModelsInput): Promise { + throw new Error("[modelManagement] AgentSetupApi.syncAgentModels not implemented yet"); + } +} diff --git a/src/modelManagement/setup/ByokSetupApi.ts b/src/modelManagement/setup/ByokSetupApi.ts new file mode 100644 index 00000000..dd28f13a --- /dev/null +++ b/src/modelManagement/setup/ByokSetupApi.ts @@ -0,0 +1,120 @@ +/** + * BYOK setup workflow. The user-facing wizard ("Add Provider" → pick + * catalog or template → enter key → select models → Verify & save) + * calls into this class. Bundles the "create Provider row + create N + * ConfiguredModel rows + auto-enroll in default backends" recipe so + * the wizard doesn't have to know the order or invariants. + * + * Two flows: + * - `addCatalogProvider` — user picked a `models.dev` entry. + * Catalog supplies `ModelInfo` snapshots. + * - `addTemplateProvider` — user picked a built-in template + * (Ollama, LMStudio, custom OpenAI-compat, + * Azure, Bedrock) and hand-typed model + * ids. Only `id` + `displayName` + * populated on each `ModelInfo`. + * + * Both produce identical `Provider` + `ConfiguredModel` shapes; the + * difference is only in how `ModelInfo` was sourced. + * + * Default auto-enrollment is `BYOK_DEFAULT_AUTO_ENROLL` + * = `["chat", "opencode"]` so BYOK models surface in both Simple + * Chat and the OpenCode agent picker out of the box. + */ + +import type { CatalogProvider, ModelInfo } from "@/modelManagement/types/catalog"; +import type { BackendType } from "@/modelManagement/types/persisted"; +import type { ProviderTemplate } from "@/modelManagement/types/runtime"; +import type { BackendConfigRegistry } from "@/modelManagement/backends/BackendConfigRegistry"; +import type { ConfiguredModelRegistry } from "@/modelManagement/models/ConfiguredModelRegistry"; +import type { ProviderRegistry } from "@/modelManagement/providers/ProviderRegistry"; + +/** + * Default set of backends that new BYOK / Plus models are + * auto-enrolled into. Exported so other origins (Plus, future flows) + * reuse the same default and the constant is greppable. + */ +export const BYOK_DEFAULT_AUTO_ENROLL: readonly BackendType[] = ["chat", "opencode"]; + +export interface AddCatalogProviderInput { + /** From `ModelCatalogService.getProvider(id)`. */ + template: CatalogProvider; + displayName: string; + /** Overrides `template.defaultBaseUrl`. */ + baseUrl?: string; + /** Set for providers that need an API key. Stored in the keychain. */ + apiKey?: string; + /** Per-providerType payload (Azure deployment, Bedrock region, + * OpenAI org id, …). Validated by the adapter's `extrasSchema` + * at `ChatModelFactory.build()` time. */ + extras?: Record; + /** Subset of `template.models` keys the user checked. */ + selectedWireModelIds: readonly string[]; + /** Defaults to `BYOK_DEFAULT_AUTO_ENROLL`. Pass `[]` to skip + * auto-enrollment entirely (user will enable models manually + * through Configure Provider). */ + autoEnrollIn?: readonly BackendType[]; +} + +export interface AddTemplateProviderInput { + /** From `ModelCatalogService.listBuiltinTemplates()`. */ + template: ProviderTemplate; + displayName: string; + baseUrl?: string; + apiKey?: string; + extras?: Record; + /** User-typed models — `id` is the wire form, `displayName` is + * whatever the user labelled it. Other `ModelInfo` fields stay + * empty until the user edits the row. */ + selectedModels: readonly Pick[]; + autoEnrollIn?: readonly BackendType[]; +} + +export interface AddModelsInput { + providerId: string; + /** Catalog-snapshotted or hand-typed `ModelInfo`s — same shape + * either way. */ + models: readonly ModelInfo[]; + autoEnrollIn?: readonly BackendType[]; +} + +export interface ByokSetupResult { + providerId: string; + configuredModelIds: string[]; +} + +export class ByokSetupApi { + constructor( + providerRegistry: ProviderRegistry, + configuredModelRegistry: ConfiguredModelRegistry, + backendConfigRegistry: BackendConfigRegistry + ) {} + + /** + * Catalog-driven flow. Creates the Provider (with + * `origin: { kind: "byok" }`), creates N ConfiguredModels from the + * catalog snapshots, stores the API key in the keychain, and + * enrolls the new models into `autoEnrollIn ?? BYOK_DEFAULT_AUTO_ENROLL`. + */ + addCatalogProvider(input: AddCatalogProviderInput): Promise { + throw new Error("[modelManagement] ByokSetupApi.addCatalogProvider not implemented yet"); + } + + /** + * Template-driven flow. Same shape as catalog flow, but model + * metadata is whatever the user typed in (no catalog snapshot). + */ + addTemplateProvider(input: AddTemplateProviderInput): Promise { + throw new Error("[modelManagement] ByokSetupApi.addTemplateProvider not implemented yet"); + } + + /** + * Add more configured models to an existing BYOK provider without + * touching the Provider row. Skips models that already exist on + * the provider (uniqueness invariant). Returns the resulting + * `configuredModelId`s in input order. + */ + addModels(input: AddModelsInput): Promise { + throw new Error("[modelManagement] ByokSetupApi.addModels not implemented yet"); + } +} diff --git a/src/modelManagement/setup/CopilotPlusSetupApi.ts b/src/modelManagement/setup/CopilotPlusSetupApi.ts new file mode 100644 index 00000000..8c7d77d6 --- /dev/null +++ b/src/modelManagement/setup/CopilotPlusSetupApi.ts @@ -0,0 +1,89 @@ +/** + * Copilot Plus setup workflow. Called by the Plus auth handler on + * sign-in and sign-out. Persists exactly one `Provider` row with + * `origin: { kind: "copilot-plus" }` and a `ConfiguredModel` per + * Plus-hosted model. + * + * Plus is a singleton origin — there's at most one Plus provider at + * any time. `registerPlusProvider` is idempotent: first call creates, + * subsequent calls re-sync (rotate the API key, refresh model + * metadata, diff the model list). Sign-out is + * `unregisterPlusProvider`, which cascade-removes through + * `ModelManagementCoordinator`. + * + * Default auto-enrollment mirrors BYOK: Plus models surface in both + * Simple Chat and the OpenCode agent picker + * (`BYOK_DEFAULT_AUTO_ENROLL`). + */ + +import type { ModelManagementCoordinator } from "@/modelManagement/createModelManagement"; +import type { ProviderType } from "@/modelManagement/types/catalog"; +import type { BackendType } from "@/modelManagement/types/persisted"; +import type { ModelInfo } from "@/modelManagement/types/catalog"; +import type { BackendConfigRegistry } from "@/modelManagement/backends/BackendConfigRegistry"; +import type { ConfiguredModelRegistry } from "@/modelManagement/models/ConfiguredModelRegistry"; +import type { ProviderRegistry } from "@/modelManagement/providers/ProviderRegistry"; + +export interface RegisterPlusProviderInput { + /** Which adapter family backs the Plus relay (Plus may add more + * over time — Anthropic-flavoured, OpenAI-flavoured, etc.). */ + providerType: ProviderType; + displayName: string; + /** The Plus relay endpoint. */ + baseUrl: string; + /** Plus-issued long-lived token. Rotated on each sign-in. */ + apiKey?: string; + /** Authoritative list of currently-available Plus models. Existing + * ConfiguredModel rows under the Plus provider are diff-reconciled + * against this list (add new, update changed, remove gone). */ + models: readonly ModelInfo[]; + /** Defaults to `BYOK_DEFAULT_AUTO_ENROLL` (= chat + opencode). */ + autoEnrollIn?: readonly BackendType[]; +} + +export interface PlusSetupResult { + providerId: string; + configuredModelIds: string[]; +} + +export class CopilotPlusSetupApi { + constructor( + providerRegistry: ProviderRegistry, + configuredModelRegistry: ConfiguredModelRegistry, + backendConfigRegistry: BackendConfigRegistry, + /** Required for the diff-reconcile + sign-out cascade: vanished + * Plus models and the Plus provider on sign-out are removed via + * `coordinator.removeConfiguredModel` / + * `coordinator.removeProvider`. */ + coordinator: ModelManagementCoordinator + ) {} + + /** + * Idempotent. First call creates a `Provider` row with + * `origin: { kind: "copilot-plus" }` and ConfiguredModels for each + * input model, then auto-enrolls them. Subsequent calls update the + * Provider in place, rotate the API key, and diff-reconcile the + * ConfiguredModel list. + * + * Newly-added ConfiguredModels are auto-enrolled into + * `autoEnrollIn ?? BYOK_DEFAULT_AUTO_ENROLL`. Existing models that + * are no longer in `input.models` are removed (cascading their + * backend refs through the coordinator). + */ + registerPlusProvider(input: RegisterPlusProviderInput): Promise { + throw new Error( + "[modelManagement] CopilotPlusSetupApi.registerPlusProvider not implemented yet" + ); + } + + /** + * Sign-out. Cascade-removes the (at most one) Plus provider, + * dropping its ConfiguredModels and clearing backend refs. No-op + * when no Plus provider exists. + */ + unregisterPlusProvider(): Promise { + throw new Error( + "[modelManagement] CopilotPlusSetupApi.unregisterPlusProvider not implemented yet" + ); + } +} diff --git a/src/modelManagement/state/atoms.ts b/src/modelManagement/state/atoms.ts new file mode 100644 index 00000000..b44c4f71 --- /dev/null +++ b/src/modelManagement/state/atoms.ts @@ -0,0 +1,134 @@ +/** + * Reactive read layer for model management. Single source of + * reactivity for the whole module — registries no longer carry + * `onChange` APIs; subscribers (React or otherwise) attach to these + * atoms instead. + * + * Derived from the existing `settingsAtom` so any settings write + * fans out automatically through Jotai. React components use + * `useAtomValue(, { store: settingsStore })`; non-React + * subscribers use `settingsStore.sub(, listener)`. + * + * The three persisted slices (`providers`, `configuredModels`, + * `backends`) are not yet typed fields on `CopilotSettings` — the + * settings-wiring follow-up PR adds them. Until then, the local + * helper below reads them with fallbacks so the atoms compile and + * downstream UI components can bind to them today. + */ + +import { atom } from "jotai"; +import { atomFamily } from "jotai/utils"; + +import { settingsAtom, type CopilotSettings } from "@/settings/model"; + +import type { + BackendConfig, + BackendType, + ConfiguredModel, + Provider, + ProviderOrigin, +} from "@/modelManagement/types/persisted"; +import type { EnabledBackendEntry } from "@/modelManagement/types/runtime"; + +// Stable empty fallbacks. Allocated once so Jotai's `===` short-circuit +// holds when the underlying slices are absent (pre-settings-wiring) or +// unchanged between settings writes — otherwise every settings write +// would invalidate every derived picker atom. +const EMPTY_PROVIDERS: Readonly> = Object.freeze({}); +const EMPTY_CONFIGURED_MODELS: readonly ConfiguredModel[] = Object.freeze([]); +const EMPTY_BACKENDS: Readonly>> = Object.freeze({}); + +/** + * Reads the three persisted slices with fallbacks. Pre-settings-wiring, + * `CopilotSettings` doesn't declare these fields, so we widen via the + * type intersection below — the cast is intentional and isolated to + * this one helper. + */ +function readSlices(settings: CopilotSettings): { + providers: Readonly>; + configuredModels: readonly ConfiguredModel[]; + backends: Readonly>>; +} { + const widened = settings as CopilotSettings & { + providers?: Record; + configuredModels?: ConfiguredModel[]; + backends?: Partial>; + }; + return { + providers: widened.providers ?? EMPTY_PROVIDERS, + configuredModels: widened.configuredModels ?? EMPTY_CONFIGURED_MODELS, + backends: widened.backends ?? EMPTY_BACKENDS, + }; +} + +// ----------------------------------------------------------------------------- +// Raw slice atoms — derived directly from settings. +// ----------------------------------------------------------------------------- + +export const providersAtom = atom>>( + (get) => readSlices(get(settingsAtom)).providers +); + +export const configuredModelsAtom = atom( + (get) => readSlices(get(settingsAtom)).configuredModels +); + +export const backendsAtom = atom>>>( + (get) => readSlices(get(settingsAtom)).backends +); + +// ----------------------------------------------------------------------------- +// Common filtered views. +// ----------------------------------------------------------------------------- + +function filterByOrigin( + providers: Readonly>, + kind: ProviderOrigin["kind"] +): readonly Provider[] { + return Object.values(providers).filter((p) => p.origin.kind === kind); +} + +/** All providers with `origin.kind === "byok"`. Used by the BYOK + * settings tab. */ +export const byokProvidersAtom = atom((get) => + filterByOrigin(get(providersAtom), "byok") +); + +/** All providers with `origin.kind === "agent"`. Used by the agent + * setup panels (each panel filters further by `origin.agentType`). */ +export const agentProvidersAtom = atom((get) => + filterByOrigin(get(providersAtom), "agent") +); + +/** The (at most one) provider with `origin.kind === "copilot-plus"`. */ +export const copilotPlusProvidersAtom = atom((get) => + filterByOrigin(get(providersAtom), "copilot-plus") +); + +// ----------------------------------------------------------------------------- +// Picker-ready join view per backend. +// ----------------------------------------------------------------------------- + +/** + * Resolves a backend's `enabledModels` into picker-ready entries. + * Order preserved. Broken refs (configured model deleted, provider + * deleted) surface as `state: "broken"` rather than being silently + * dropped — see data-model spec invariant #3. + * + * Use as: `useAtomValue(backendPickerAtomFamily("chat"), { store: settingsStore })`. + */ +export const backendPickerAtomFamily = atomFamily((backend: BackendType) => + atom((get) => { + const config = get(backendsAtom)[backend] ?? { enabledModels: [], defaultModel: null }; + const models = get(configuredModelsAtom); + const providers = get(providersAtom); + return config.enabledModels.map((configuredModelId) => { + const configuredModel = models.find((m) => m.configuredModelId === configuredModelId); + const provider = configuredModel ? providers[configuredModel.providerId] : undefined; + if (configuredModel && provider) { + return { configuredModelId, state: "ok", configuredModel, provider }; + } + return { configuredModelId, state: "broken" }; + }); + }) +); diff --git a/src/modelManagement/types/catalog.ts b/src/modelManagement/types/catalog.ts index c342b40e..67c3ab22 100644 --- a/src/modelManagement/types/catalog.ts +++ b/src/modelManagement/types/catalog.ts @@ -62,8 +62,11 @@ export interface CatalogProvider { id: string; /** From the catalog's `name` field. */ displayName: string; - /** From the catalog's `api` field. Pre-fills `Provider.baseUrl`. */ - defaultBaseUrl: string; + /** From the catalog's `api` field. Pre-fills `Provider.baseUrl`. + * Omitted when the catalog entry has no `api` URL — the BYOK wizard + * treats this as "user must enter a base URL" rather than blindly + * copying an empty string into the persisted row. */ + defaultBaseUrl?: string; /** * Derived from the catalog's `npm` field by the catalog fetcher. * "@ai-sdk/anthropic" → "anthropic" diff --git a/src/modelManagement/types/runtime.ts b/src/modelManagement/types/runtime.ts new file mode 100644 index 00000000..16be9a41 --- /dev/null +++ b/src/modelManagement/types/runtime.ts @@ -0,0 +1,115 @@ +/** + * Runtime types — produced by services / consumed by UI. Never written + * to disk; never persisted in settings. Live alongside `catalog.ts` + * and `persisted.ts` so the barrel can re-export everything from one + * `types/` directory. + */ + +import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; + +import type { ProviderType } from "./catalog"; +import type { ConfiguredModel, Provider } from "./persisted"; + +/** + * Outcome of a credential / endpoint check. + * + * Adapter-defined. Setup wizards surface `message` next to the API key + * field on failure; pickers may surface `ok === true` as a "verified" + * badge. `code` is optional machine-readable detail + * ("invalid_api_key", "network", "rate_limited", …) — adapters use + * whatever taxonomy fits. + */ +export interface VerificationResult { + ok: boolean; + /** Empty when `ok`; human-readable when not. */ + message?: string; + /** Optional adapter-specific machine code. */ + code?: string; + checkedAt: number; +} + +/** + * Result of `ModelCatalogService.refresh()`. + * + * On `ok: false` the in-memory catalog is unchanged — callers can keep + * serving whatever was loaded before. + */ +export interface RefreshResult { + ok: boolean; + /** Which tier provided the data the service is currently serving. */ + source: "live" | "disk" | "memory"; + /** When the currently-served data was fetched. `null` before any + * successful load. */ + fetchedAt: number | null; + /** Set when `ok: false`. */ + error?: string; +} + +/** + * Picker-ready view of a single entry in a backend's `enabledModels`. + * + * Resolved against the current `providers` + `configuredModels` state. + * Broken refs (configured model deleted, provider deleted) surface as + * `state: "broken"` so UI can show them with a ⚠ instead of silently + * dropping — see data-model spec invariant #3. + * + * Discriminated union — narrowing on `state === "ok"` types + * `configuredModel` and `provider` as required, so consumers don't + * need to `!`-assert. + */ +export type EnabledBackendEntry = + | { + configuredModelId: string; + state: "ok"; + configuredModel: ConfiguredModel; + provider: Provider; + } + | { + configuredModelId: string; + state: "broken"; + }; + +/** + * Output of `ChatModelFactory.build()` / `buildFor()`. Carries the + * LangChain client plus the (provider, configuredModel) pair it was + * built from, in case the caller needs to inspect those for logging + * or telemetry. + */ +export interface BuiltChatModel { + client: BaseChatModel; + provider: Provider; + configuredModel: ConfiguredModel; +} + +/** + * A built-in self-hosted provider template. + * + * Templates cover providers that `models.dev` does not (and never + * will) list — local runners (Ollama, LMStudio), per-tenant deploys + * (Azure, Bedrock), and the catch-all custom OpenAI-compatible + * endpoint. Surfaced by `ModelCatalogService.listBuiltinTemplates()` + * for the BYOK wizard's "Add provider" picker, next to the catalog + * entries. + * + * Unlike `CatalogProvider`, a template carries no model list — the + * user hand-types models, and the wizard creates `ConfiguredModel` + * rows with just `id` + `displayName` populated. + */ +export interface ProviderTemplate { + /** Template id ("ollama", "lmstudio", "custom-openai-compatible", + * "azure-openai", "aws-bedrock"). Not the resulting provider id — + * that gets minted at setup time. */ + id: string; + displayName: string; + /** Which adapter family the wizard will dispatch to. */ + providerType: ProviderType; + /** Pre-fills the base URL field in the wizard. Omitted when the + * endpoint is per-user (custom proxy, Azure deployment URL, etc.). */ + defaultBaseUrl?: string; + /** Hides the API-key field in the wizard when `false` (Ollama, + * LMStudio). */ + requiresApiKey: boolean; + /** Placeholder shown in the wizard's "Add model" text input, since + * the user types model ids by hand. */ + modelInputHint: string; +} diff --git a/src/modelManagement/ui/ModelManagementContext.tsx b/src/modelManagement/ui/ModelManagementContext.tsx new file mode 100644 index 00000000..1f6bb174 --- /dev/null +++ b/src/modelManagement/ui/ModelManagementContext.tsx @@ -0,0 +1,54 @@ +/** + * React context for the model-management API. + * + * Reactive READS go through Jotai atoms in `state/atoms.ts` — those + * don't need a context provider. This context is for MUTATIONS: + * setup APIs, the coordinator, registry write methods, the catalog + * service's `refresh()`. UI components that mutate state pull the + * `ModelManagementApi` through `useModelManagement()`. + * + * The host (`main.ts`) wraps its React tree in + * `` once. Throwing on missing + * context surfaces wiring bugs immediately rather than handing back + * an undefined api. + */ + +import React, { createContext, useContext } from "react"; + +import type { ModelManagementApi } from "@/modelManagement/createModelManagement"; + +const ModelManagementContext = createContext(undefined); + +interface ModelManagementProviderProps { + api: ModelManagementApi; + children: React.ReactNode; +} + +/** + * Wraps a React subtree with the model-management api. The host + * provides the api once near the root; descendants pull it through + * `useModelManagement()`. + */ +export function ModelManagementProvider({ + api, + children, +}: ModelManagementProviderProps): JSX.Element { + return {children}; +} + +/** + * Hook for components that need to MUTATE model-management state. + * Throws if no `` ancestor is mounted — + * always wrap the relevant subtree at plugin init. + * + * For reactive reads, prefer the atoms exported from + * `@/modelManagement` (e.g. `byokProvidersAtom`, + * `backendPickerAtomFamily`) over a registry method on the api. + */ +export function useModelManagement(): ModelManagementApi { + const api = useContext(ModelManagementContext); + if (api === undefined) { + throw new Error("useModelManagement must be used inside "); + } + return api; +}