mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Add service APIs
This commit is contained in:
parent
e4f24ec749
commit
05f8abf90a
25 changed files with 1839 additions and 28 deletions
94
src/modelManagement/backends/BackendConfigRegistry.ts
Normal file
94
src/modelManagement/backends/BackendConfigRegistry.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
62
src/modelManagement/catalog/builtinTemplates.ts
Normal file
62
src/modelManagement/catalog/builtinTemplates.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
|
|
@ -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({});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
47
src/modelManagement/chatModel/ChatModelFactory.ts
Normal file
47
src/modelManagement/chatModel/ChatModelFactory.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
168
src/modelManagement/createModelManagement.ts
Normal file
168
src/modelManagement/createModelManagement.ts
Normal 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.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
89
src/modelManagement/models/ConfiguredModelRegistry.ts
Normal file
89
src/modelManagement/models/ConfiguredModelRegistry.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
129
src/modelManagement/providers/ProviderRegistry.ts
Normal file
129
src/modelManagement/providers/ProviderRegistry.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
85
src/modelManagement/providers/adapters/ProviderAdapter.ts
Normal file
85
src/modelManagement/providers/adapters/ProviderAdapter.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
38
src/modelManagement/providers/adapters/anthropicAdapter.ts
Normal file
38
src/modelManagement/providers/adapters/anthropicAdapter.ts
Normal 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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
50
src/modelManagement/providers/adapters/azureAdapter.ts
Normal file
50
src/modelManagement/providers/adapters/azureAdapter.ts
Normal 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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
44
src/modelManagement/providers/adapters/bedrockAdapter.ts
Normal file
44
src/modelManagement/providers/adapters/bedrockAdapter.ts
Normal 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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
34
src/modelManagement/providers/adapters/googleAdapter.ts
Normal file
34
src/modelManagement/providers/adapters/googleAdapter.ts
Normal 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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
99
src/modelManagement/setup/AgentSetupApi.ts
Normal file
99
src/modelManagement/setup/AgentSetupApi.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
120
src/modelManagement/setup/ByokSetupApi.ts
Normal file
120
src/modelManagement/setup/ByokSetupApi.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
89
src/modelManagement/setup/CopilotPlusSetupApi.ts
Normal file
89
src/modelManagement/setup/CopilotPlusSetupApi.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
134
src/modelManagement/state/atoms.ts
Normal file
134
src/modelManagement/state/atoms.ts
Normal 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" };
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
115
src/modelManagement/types/runtime.ts
Normal file
115
src/modelManagement/types/runtime.ts
Normal 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;
|
||||
}
|
||||
54
src/modelManagement/ui/ModelManagementContext.tsx
Normal file
54
src/modelManagement/ui/ModelManagementContext.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue