Add service APIs

This commit is contained in:
Zero Liu 2026-05-22 23:17:18 -07:00
parent e4f24ec749
commit 05f8abf90a
No known key found for this signature in database
25 changed files with 1839 additions and 28 deletions

View file

@ -0,0 +1,94 @@
/**
* Source of truth for `BackendConfig` rows, keyed by `BackendType`.
*
* Wraps `settings.backends: Record<BackendType, BackendConfig>` (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<void> {
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<void> {
throw new Error("[modelManagement] BackendConfigRegistry.enableModel not implemented yet");
}
/** Idempotent. Clears `defaultModel` if it was the removed id. */
disableModel(backend: BackendType, configuredModelId: string): Promise<void> {
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<void> {
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<void> {
throw new Error("[modelManagement] BackendConfigRegistry.removeRefs not implemented yet");
}
}

View file

@ -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<string>((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(() => {

View file

@ -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<void> {
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<void> {
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<DiskPayload | null> {

View file

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

View file

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

View file

@ -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;
}
/**

View file

@ -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<BuiltChatModel> {
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<BuiltChatModel> {
throw new Error("[modelManagement] ChatModelFactory.buildFor not implemented yet");
}
}

View file

@ -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
* `<ModelManagementProvider>`, 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<void> {
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<void> {
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.
},
};
}

View file

@ -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(<atom>, { store: settingsStore })`
// Non-React subscribers: `settingsStore.sub(<atom>, 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";

View file

@ -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
* `<providerId>/<wireId>` 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<ConfiguredModel, "configuredModelId" | "configuredAt">): Promise<string> {
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<ConfiguredModel["info"]> }
): Promise<void> {
throw new Error("[modelManagement] ConfiguredModelRegistry.update not implemented yet");
}
remove(configuredModelId: string): Promise<void> {
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<string[]> {
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<void> {
throw new Error(
"[modelManagement] ConfiguredModelRegistry.removeByProvider not implemented yet"
);
}
}

View file

@ -0,0 +1,129 @@
/**
* Source of truth for `Provider` rows.
*
* Wraps `settings.providers: Record<providerId, Provider>` (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<Provider, "providerId" | "addedAt" | "apiKeyKeychainId">): Promise<string> {
throw new Error("[modelManagement] ProviderRegistry.add not implemented yet");
}
/** Partial update. `providerId` and `addedAt` are immutable. */
update(
providerId: string,
patch: Partial<Omit<Provider, "providerId" | "addedAt">>
): Promise<void> {
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<void> {
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<string | null> {
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<void> {
throw new Error("[modelManagement] ProviderRegistry.setApiKey not implemented yet");
}
/** Drops the keychain entry and clears `apiKeyKeychainId` on the row. */
clearApiKey(providerId: string): Promise<void> {
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<VerificationResult> {
throw new Error("[modelManagement] ProviderRegistry.verify not implemented yet");
}
}

View file

@ -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<TExtras = unknown> {
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<TExtras = unknown> {
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<TExtras = unknown> {
/** 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<TExtras>;
/**
* 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<TExtras>): 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<TExtras>): Promise<VerificationResult>;
}

View file

@ -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<ProviderType, ProviderAdapter>();
/** 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<AdapterBuildContext, "extras"> & { 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<AdapterVerifyContext, "extras"> & { extras: unknown }
): Promise<VerificationResult> {
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;
}

View file

@ -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<typeof extrasSchema>;
export const anthropicAdapter: ProviderAdapter<Extras> = {
providerType: "anthropic",
extrasSchema,
buildLangChainClient(ctx: AdapterBuildContext<Extras>): BaseChatModel {
throw new Error("[modelManagement] anthropicAdapter.buildLangChainClient not implemented yet");
},
verifyCredentials(ctx: AdapterVerifyContext<Extras>): Promise<VerificationResult> {
return Promise.resolve({
ok: false,
message: "not implemented",
checkedAt: Date.now(),
});
},
};

View file

@ -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<typeof extrasSchema>;
export const azureAdapter: ProviderAdapter<Extras> = {
providerType: "azure",
extrasSchema,
buildLangChainClient(ctx: AdapterBuildContext<Extras>): BaseChatModel {
throw new Error("[modelManagement] azureAdapter.buildLangChainClient not implemented yet");
},
verifyCredentials(ctx: AdapterVerifyContext<Extras>): Promise<VerificationResult> {
return Promise.resolve({
ok: false,
message: "not implemented",
checkedAt: Date.now(),
});
},
};

View file

@ -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<typeof extrasSchema>;
export const bedrockAdapter: ProviderAdapter<Extras> = {
providerType: "bedrock",
extrasSchema,
buildLangChainClient(ctx: AdapterBuildContext<Extras>): BaseChatModel {
throw new Error("[modelManagement] bedrockAdapter.buildLangChainClient not implemented yet");
},
verifyCredentials(ctx: AdapterVerifyContext<Extras>): Promise<VerificationResult> {
return Promise.resolve({
ok: false,
message: "not implemented",
checkedAt: Date.now(),
});
},
};

View file

@ -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<typeof extrasSchema>;
export const googleAdapter: ProviderAdapter<Extras> = {
providerType: "google",
extrasSchema,
buildLangChainClient(ctx: AdapterBuildContext<Extras>): BaseChatModel {
throw new Error("[modelManagement] googleAdapter.buildLangChainClient not implemented yet");
},
verifyCredentials(ctx: AdapterVerifyContext<Extras>): Promise<VerificationResult> {
return Promise.resolve({
ok: false,
message: "not implemented",
checkedAt: Date.now(),
});
},
};

View file

@ -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<typeof extrasSchema>;
export const openaiCompatibleAdapter: ProviderAdapter<Extras> = {
providerType: "openai-compatible",
extrasSchema,
buildLangChainClient(ctx: AdapterBuildContext<Extras>): BaseChatModel {
throw new Error(
"[modelManagement] openaiCompatibleAdapter.buildLangChainClient not implemented yet"
);
},
verifyCredentials(ctx: AdapterVerifyContext<Extras>): Promise<VerificationResult> {
return Promise.resolve({
ok: false,
message: "not implemented",
checkedAt: Date.now(),
});
},
};

View file

@ -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<string, unknown>;
/** 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<string, string>;
}
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<AgentSetupResult> {
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<AgentSyncResult> {
throw new Error("[modelManagement] AgentSetupApi.syncAgentModels not implemented yet");
}
}

View file

@ -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<string, unknown>;
/** 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<string, unknown>;
/** 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<ModelInfo, "id" | "displayName">[];
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<ByokSetupResult> {
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<ByokSetupResult> {
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<string[]> {
throw new Error("[modelManagement] ByokSetupApi.addModels not implemented yet");
}
}

View file

@ -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<PlusSetupResult> {
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<void> {
throw new Error(
"[modelManagement] CopilotPlusSetupApi.unregisterPlusProvider not implemented yet"
);
}
}

View file

@ -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(<atom>, { store: settingsStore })`; non-React
* subscribers use `settingsStore.sub(<atom>, 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<Record<string, Provider>> = Object.freeze({});
const EMPTY_CONFIGURED_MODELS: readonly ConfiguredModel[] = Object.freeze([]);
const EMPTY_BACKENDS: Readonly<Partial<Record<BackendType, BackendConfig>>> = 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<Record<string, Provider>>;
configuredModels: readonly ConfiguredModel[];
backends: Readonly<Partial<Record<BackendType, BackendConfig>>>;
} {
const widened = settings as CopilotSettings & {
providers?: Record<string, Provider>;
configuredModels?: ConfiguredModel[];
backends?: Partial<Record<BackendType, BackendConfig>>;
};
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<Readonly<Record<string, Provider>>>(
(get) => readSlices(get(settingsAtom)).providers
);
export const configuredModelsAtom = atom<readonly ConfiguredModel[]>(
(get) => readSlices(get(settingsAtom)).configuredModels
);
export const backendsAtom = atom<Readonly<Partial<Record<BackendType, BackendConfig>>>>(
(get) => readSlices(get(settingsAtom)).backends
);
// -----------------------------------------------------------------------------
// Common filtered views.
// -----------------------------------------------------------------------------
function filterByOrigin(
providers: Readonly<Record<string, Provider>>,
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<readonly Provider[]>((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<readonly Provider[]>((get) =>
filterByOrigin(get(providersAtom), "agent")
);
/** The (at most one) provider with `origin.kind === "copilot-plus"`. */
export const copilotPlusProvidersAtom = atom<readonly Provider[]>((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<readonly EnabledBackendEntry[]>((get) => {
const config = get(backendsAtom)[backend] ?? { enabledModels: [], defaultModel: null };
const models = get(configuredModelsAtom);
const providers = get(providersAtom);
return config.enabledModels.map<EnabledBackendEntry>((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" };
});
})
);

View file

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

View file

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

View file

@ -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
* `<ModelManagementProvider api={...}>` 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<ModelManagementApi | undefined>(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 <ModelManagementContext.Provider value={api}>{children}</ModelManagementContext.Provider>;
}
/**
* Hook for components that need to MUTATE model-management state.
* Throws if no `<ModelManagementProvider>` 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 <ModelManagementProvider>");
}
return api;
}