diff --git a/designdocs/MODEL_DATA_MODEL_SPEC.md b/designdocs/MODEL_DATA_MODEL_SPEC.md index ec99113b..2bd7c31d 100644 --- a/designdocs/MODEL_DATA_MODEL_SPEC.md +++ b/designdocs/MODEL_DATA_MODEL_SPEC.md @@ -1,15 +1,7 @@ # Model Management — Data-Model Spec -> **Status.** This spec supersedes the data-model sections of -> `MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md`. UX flows, migration mechanics, and -> non-data-model sections of that doc still apply. The implementation -> currently on `zero/model-settings-redesign` does **not** fully match this -> spec — see the [Reconciliation appendix](#reconciliation-appendix) at the -> bottom for a candid diff. Decide what to keep, change, or rename after -> reading the design. - -> **Audience.** A coding agent reconciling `src/modelManagement/` against this -> design, and reviewers vetting the data model before any code changes. +> **Audience.** A coding agent implementing or modifying +> `src/modelManagement/`, and reviewers vetting the data model. --- @@ -17,495 +9,277 @@ ### In scope -- Chat-model data model: providers, instances, catalog, custom models, - enrollments, consumers, per-consumer model selection. -- Catalog wiring (`models.dev`). -- Per-pipeline reachability (LangChain vs. OpenCode byokBridge). -- BYOK ↔ agent-backend reconciliation (how a user-enrolled model surfaces - in OpenCode). +- Chat-model data model: providers, configured models, per-backend + enabled-model selection. +- Catalog wiring (`models.dev`) — strictly setup-time. +- Provider origin discrimination (BYOK / agent-owned / Copilot Plus). +- A uniform on-disk shape that the chat-model factory and every + backend picker consume. -### Out of scope (deliberately) +### Out of scope -- **Embeddings parity.** The structurally identical pipeline (provider → - enrollment → consumer) is sketched in §3.9 for reference, but actual - rollout is deferred. The legacy `activeEmbeddingModels` / - `embeddingModelKey` shape continues to work until embeddings are folded in. -- **Global chat knobs.** No `temperature` / `maxTokens` / `reasoningEffort` / - `verbosity` / `topP` / `frequencyPenalty` settings live in the model - data model. Adapters use their SDK defaults. If user-tunable knobs are - ever needed, they belong elsewhere (per-invocation, per-skill, - per-command) — not here. -- **Per-agent capability filtering at picker render.** Pickers show whatever - the consumer has enabled. If a model is incompatible with the agent at - runtime, the runtime error path surfaces that. Catalog capability metadata - still exists for display badges; it doesn't silently hide models. -- **UX flows / dialog wireframes.** This spec is data-model only. -- **Migration mechanics.** Drop / rename moves are downstream — once the - target data model is agreed, a separate plan covers the v0→v3 (or - in-place v2→v3) migration. +- **Embeddings.** Embeddings follow the same shape (provider → + configured model → consumer) but are not landed in this iteration. + The legacy `activeEmbeddingModels` / `embeddingModelKey` shape + continues to work in the interim. +- **Global chat knobs.** No `temperature` / `maxTokens` / + `reasoningEffort` / `verbosity` / `topP` / `frequencyPenalty` + settings live in this data model. Adapters use their SDK defaults. + If user-tunable knobs are ever needed, they belong elsewhere + (per-invocation, per-skill, per-command). +- **Capability-based picker filtering.** Pickers show whatever the + backend has enabled. If a model is incompatible with the backend at + runtime, the runtime error path surfaces that. Catalog capability + metadata still exists for display badges; it doesn't silently hide + models. +- **Catalog at runtime.** Once a model is configured, the catalog is + no longer consulted. The plugin must work fully when `models.dev` + is unreachable. +- **Migration mechanics.** Migration from the current on-disk shape + to this design is a separate task. +- **`BackendInventory` and runtime ACP reconciliation.** A future + picker-reconciliation PR introduces a runtime types file for + greying out temporarily-unreachable models; until then the picker + shows every enabled `ConfiguredModel` and unreachable ones surface + at request time. --- ## 2. Behaviors the data model must support -1. **Provider configuration.** The user configures a provider by picking a - type (Anthropic, OpenAI, Google, Mistral, Groq, custom OpenAI-compatible, - Ollama, LM Studio, Azure, Bedrock, …), supplying name / URL / key, and any - per-adapter extras. - -2. **Multiple instances of the same provider type.** The user can have two - Anthropic credential sets ("prod" and "staging"), two Ollama endpoints, - etc. Each instance is independently configured and enrolled. Per-instance - display names disambiguate. - -3. **Catalog drives the provider list.** The "Add Provider" UI iterates the - `models.dev` catalog to show what's available. - -4. **Catalog drives the model list per provider.** Once a provider is - configured, the catalog tells us what models it serves. - -5. **BYOK enrollment.** The user picks specific models from a provider to - bring into the plugin's pool. This is a persistent decision, separate - from "do I have a key for this provider." - -6. **Custom models on custom providers.** For self-hosted endpoints - (Ollama, LM Studio, etc.) the catalog has nothing — the user enters - model details by hand. - -7. **Per-use-case model selection.** Each consumer (Simple Chat, Vault QA, - Project chain, OpenCode agent, Claude Code agent, Codex agent, Quick - Chat, …) holds its own selection of which BYOK models it can use, and - its own default. The same enrollment can be selected by several - consumers. - -8. **Backend-supplied additional models.** Agent backends (OpenCode - bundled, Claude Code subscription, Codex subscription, Copilot Plus - hosted) report models at runtime that aren't in BYOK. The picker for - those consumers is the union of BYOK + backend-reported, intersected - with the user's per-consumer selection. - -9. **Per-pipeline reachability is declared, not assumed.** Each BYOK - provider is reachable from one or both of two pipelines: - - (a) the LangChain-based direct path (Simple Chat, Vault QA, Quick - Chat, Project chain, custom commands) - - (b) the OpenCode byokBridge path (the OpenCode agent backend) - - Single-pipeline support is acceptable. The data model captures which - pipelines a `ProviderType` supports, and consumer pickers filter - accordingly. Subscription-bundled agent backends (Claude Code, Codex) - don't accept BYOK at all and sit outside this axis. - -10. **Embeddings parity (deferred but architecturally allowed).** Embeddings - follow the same five-layer pipeline (provider → enrollment → consumer). - Their entities mirror the chat side; rollout is sequenced separately. +1. **Provider configuration with multi-instance.** A user can have + two Anthropic credential sets ("prod" and "staging"), two Ollama + endpoints, etc. Each instance is independently configured. +2. **Catalog drives the setup UI only.** The "Add Provider" / "Add + Model" UI iterates `models.dev` (plus a small built-in template + list for self-hosted endpoints) to scaffold defaults. +3. **One persisted model entity regardless of provenance.** Whether + a model was seeded from the catalog (BYOK), auto-added at agent + setup (Claude Code → Anthropic, Codex → OpenAI, OpenCode → Zen), + or auto-added at Plus sign-in, the persisted row has the same + shape. Provenance lives on the parent `Provider.origin`. +4. **Per-backend curated model selection.** Each of the four + backends (`chat`, `opencode`, `claude-code`, `codex`) maintains + its own `BackendConfig` listing the configured-model UUIDs it + exposes in its picker, plus an optional default. +5. **Custom models on custom providers.** For self-hosted endpoints + (Ollama, LMStudio, custom proxies) the catalog has nothing — the + user adds a provider via a built-in template and types in models + by hand. +6. **BYOK settings tab visibility.** The BYOK settings UI lists + `Provider`s where `origin.kind === "byok"` only. Agent-owned and + Plus providers are managed by their respective setup flows. +7. **Backend-supplied models are uniform with BYOK.** OpenCode Zen, + the Anthropic provider that backs Claude Code, the OpenAI provider + that backs Codex, and Plus-hosted models all manifest as + `ConfiguredModel` rows under appropriate `Provider` rows. Pickers + treat them uniformly. --- ## 3. Entities -Seven persisted entities + two read-only entities (catalog metadata + runtime -inventory). +### 3.1 `ProviderType` (closed dispatch union) -### 3.1 ProviderType (catalog-derived, read-only) - -Describes a _kind_ of provider. Sourced from `models.dev` plus our own -corrections table. **Not persisted in user settings.** +The chat-model factory's dispatch key. Five values; closed. ```ts -interface ProviderType { - /** Canonical catalog id: "anthropic", "openai", "google", "mistral", - * "groq", "deepseek", "together", "openrouter", "siliconflow", … */ - id: string; - - /** Display label. "Anthropic", "Mistral", … */ - displayName: string; - - /** Protocol/SDK we wire to. See §4 — closed at six values. */ - adapter: AdapterKind; - - /** Provider's published API URL (catalog-supplied). May be overridden - * per-instance via `ProviderInstance.baseUrl`. */ - defaultBaseUrl?: string; - - /** Credential scheme the user supplies. */ - auth: "api-key" | "oauth" | "none"; - - /** Informational — env vars upstream tooling looks at. */ - envVars?: string[]; - - /** Which pipelines can serve this provider. See §5. - * At least one must be true to appear in BYOK Add Provider. */ - pipelines: { langchain: boolean; opencode: boolean }; - - /** Models keyed by id. See §3.3. Empty for synthetic types (ollama, lmstudio). */ - models: Record; -} - -type AdapterKind = - | "anthropic" - | "openai-compatible" - | "google" - | "azure" - | "bedrock" - | "github-copilot"; +type ProviderType = "anthropic" | "openai-compatible" | "google" | "azure" | "bedrock"; ``` -Synthetic / non-BYOK ProviderTypes (no catalog model list): +The user never types this. The BYOK setup wizard reads it from the +catalog (mapping `models.dev`'s `npm` field — see §4). Agent setup +flows hardcode the value matching the SDK each agent uses. Plus +sign-in does the same. -| id | adapter | auth | pipelines | notes | -| ------------------ | ------------------- | ------ | --------------------------------- | ------------------------------------------------------------------ | -| `ollama` | `openai-compatible` | `none` | `{langchain:true, opencode:true}` | Models come from `CustomModel`, not catalog. | -| `lmstudio` | `openai-compatible` | `none` | `{langchain:true, opencode:true}` | Same as ollama. | -| `copilot-plus` | n/a | n/a | n/a | Pseudo-type for Copilot Plus hosted models; no `ProviderInstance`. | -| `opencode-bundled` | n/a | n/a | n/a | Pseudo-type for OpenCode bundled models. | -| `claude-code-cli` | n/a | n/a | n/a | Subscription-bound; not BYOK. | -| `codex-cli` | n/a | n/a | n/a | Subscription-bound; not BYOK. | +### 3.2 `ModelInfo` (shared model description) -### 3.2 ProviderInstance (persisted) - -A user-configured credential set for a `ProviderType`. **Multiple instances of -the same `ProviderType` are allowed.** +One shape used both as the catalog's per-model record and embedded +into `ConfiguredModel`. Eliminates duplication between catalog and +persisted views. ```ts -interface ProviderInstance { - /** UUID. Primary key. Also used as the keychain namespace - * (`provider--apiKey`). */ - instanceId: string; - - /** FK to ProviderType.id. NON-UNIQUE — two Anthropic instances both have - * `providerTypeId: "anthropic"` with different `instanceId`. */ - providerTypeId: string; - - /** User-editable. Defaults to ProviderType.displayName. The UI suggests - * a numbered / qualified default ("Anthropic (prod)") when a second - * instance of the same type is added; uniqueness is recommended but - * not enforced by the data model. */ - displayName: string; - - /** Overrides ProviderType.defaultBaseUrl. */ - baseUrl?: string; - - /** Where the credential lives. `null` for auth=none providers. */ - apiKey?: KeychainRef | null; - - /** Adapter-specific opaque payload validated at instantiation time: - * - azure: { azureInstanceName, azureDeploymentName, azureApiVersion } - * - bedrock: { bedrockRegion } - * - openai (when used as adapter): { openAIOrgId } - * etc. */ - extras?: Record; - - /** Bookkeeping. */ - addedAt: number; - lastVerifiedAt?: number; - lastVerificationError?: string; -} - -type KeychainRef = - | { kind: "keychain"; id: string } // OS keychain entry id, vault-scoped - | { kind: "inline"; value: string }; // Plaintext fallback when keychain is unavailable -``` - -### 3.3 CatalogModel (catalog-derived, read-only) - -```ts -interface CatalogModel { - /** Wire-form id the provider's API accepts. "claude-sonnet-4-5". */ +interface ModelInfo { + /** Wire-form id passed to the SDK ("claude-sonnet-4-5", "gpt-5", …). */ id: string; displayName: string; - family?: string; - modalities: { input: string[]; output: string[] }; - limits: { context: number; output: number }; - capabilities: { - reasoning?: boolean; - toolCall?: boolean; - attachment?: boolean; - temperature?: boolean; - }; + modalities?: { input: string[]; output: string[] }; + limits?: { context: number; output: number; input?: number }; + reasoning?: boolean; + toolCall?: boolean; cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number }; - knowledge?: string; // Training cutoff "2024-04" releaseDate?: string; - lastUpdated?: string; - openWeights?: boolean; } ``` -### 3.4 CustomModel (persisted) +Catalog fetcher populates whatever `models.dev` exposes. Self-hosted +custom models populate `id` + `displayName` only. -For self-hosted endpoints. **Attached to a `ProviderInstance`, not a -`ProviderType`** — two Ollama instances on different machines can declare -different model lineups. +### 3.3 `CatalogProvider` (transient) + +A provider as listed by the catalog. Lives in memory during a setup +wizard pass; never persisted. ```ts -interface CustomModel { - /** FK to ProviderInstance.instanceId. Must reference an instance whose - * ProviderType has an empty `models` map (synthetic / custom-typed). */ - instanceId: string; +interface CatalogProvider { + id: string; // "anthropic", "openai", "opencode-zen", … + displayName: string; // from models.dev `name` + defaultBaseUrl: string; // from models.dev `api` + providerType: ProviderType; // derived from models.dev `npm` + models: Record; // keyed by ModelInfo.id +} +``` - /** Wire form. "llama3.3:70b". */ - modelId: string; +### 3.4 `ProviderOrigin` +```ts +type ProviderOrigin = + | { kind: "byok" } + | { kind: "agent"; agentType: AgentType } + | { kind: "copilot-plus" }; +``` + +- `byok` — user added via the BYOK settings tab. +- `agent` — auto-created when an agent was set up; the agent owns + credentials and routing. Chat doesn't appear here because chat + doesn't own `Provider`s. +- `copilot-plus` — auto-created when the user signed into Plus. + +### 3.5 `Provider` (persisted) + +A configured connection to a model provider. + +```ts +interface Provider { + providerId: string; // UUID, PK + providerType: ProviderType; // dispatch displayName: string; - - /** Declared by the user since no catalog metadata exists. */ - declaredCapabilities?: { - reasoning?: boolean; - toolCall?: boolean; - attachment?: boolean; - }; - contextLimit?: number; - extras?: Record; // ollama numCtx, etc. + baseUrl?: string; // overrides catalog default + apiKeyKeychainId?: string | null; // Obsidian keychain id; null if no key + extras?: Record; // per-providerType payload + origin: ProviderOrigin; addedAt: number; } ``` -Storage: `settings.customModels: CustomModel[]`. +Multi-instance is supported within `origin.kind === "byok"`: two BYOK +`Provider` rows can share the same `providerType` (e.g. two Anthropic +accounts), distinguished by `providerId` and `displayName`. Agent and +Plus origins typically have exactly one row each, but the data model +doesn't enforce singleton. -### 3.5 ByokEnrollment (persisted) — the unit of identity +`extras` is opaque, validated by the adapter on instantiation: -The central persistent record. One row per `(instanceId, modelId)` pair the -user has enrolled. Pure pointer + per-user metadata. **The handle the rest of -the app holds onto.** +| `providerType` | `extras` shape | +| ---------------------------------------- | ------------------------------------------------------------- | +| `azure` | `{ azureDeploymentName, azureApiVersion, azureInstanceName }` | +| `bedrock` | `{ bedrockRegion }` | +| `openai-compatible` (when org is needed) | `{ openAIOrgId }` | + +### 3.6 `ConfiguredModel` (persisted) + +A model the plugin knows about. ```ts -interface ByokEnrollment { - /** FK to ProviderInstance.instanceId. */ - instanceId: string; - - /** Either a CatalogModel.id on the instance's ProviderType, or a - * CustomModel.modelId scoped to (instanceId, modelId). Never both. */ - modelId: string; - - /** Optional override of the catalog/custom displayName. */ - displayName: string; - - enrolledAt: number; - - /** Adapter-validated per-model knobs: - * - openrouter: { enablePromptCaching } - * - openai-compatible: { enableCors } - * This is NOT where temperature/maxTokens live — those don't exist - * in this data model at all. */ - overrides?: Record; - - lastVerifiedAt?: number; - lastVerificationError?: string; +interface ConfiguredModel { + configuredModelId: string; // UUID, PK + providerId: string; // FK to Provider + info: ModelInfo; // embedded snapshot; uniqueness: (providerId, info.id) + configuredAt: number; } ``` -**Stable enrollment key**: `enrollmentRef = ${instanceId}::${modelId}`. Used -everywhere downstream as a single string handle. The double colon avoids -collisions with provider / model ids that legitimately contain `/`. +"Configured" means "set up in the plugin, ready to use." Applies to +BYOK (user-added), agent-owned (auto-added at agent setup), and Plus +(auto-added at Plus sign-in) models alike — the difference is which +`Provider` this row belongs to. -**Multi-instance implication**: the same `modelId` can be enrolled twice if -the user has two `ProviderInstance` rows of the same `ProviderType`. Both are -first-class; the consumer chooses which credential to route through. +"Configured" is distinct from "enrolled": a `ConfiguredModel` row +asserts the model exists on a provider; a backend separately enrolls +some subset of configured models for its picker via +`BackendConfig.enabledModels`. Auto-enrollment is the default UX, but +the two layers stay separate in the data model so per-backend pruning +is expressible. -Storage: `settings.enrollments: ByokEnrollment[]`. - -### 3.6 ConsumerConfig (persisted) - -A consumer is anything that needs a model: Simple Chat, Vault QA, the -OpenCode agent backend, a custom command, etc. Each is identified by a -stable `ConsumerId`. Each holds its own selection. +### 3.7 `AgentType` and `BackendType` ```ts -type ConsumerId = - | "chat" // Simple Chat - | "vault-qa" // Vault QA chain - | "project" // Project chain (per-project override layers on top — see Open Q) - | "copilot-plus" // Copilot Plus chain - | "quick-chat" // Quick-command chat - | "agent:opencode" // OpenCode agent backend - | "agent:claude-code" // Subscription-bound; ignores BYOK - | "agent:codex" // Subscription-bound; ignores BYOK - | `command:${string}`; // Custom commands that pin a model +type AgentType = "opencode" | "claude-code" | "codex"; -interface ConsumerConfig { - consumerId: ConsumerId; +type BackendType = AgentType | "chat"; +``` - /** Curated allow-list of model references this consumer may use. */ - enabledModels: ConsumerModelRef[]; +- `AgentType` — the three agent backends. Each can own `Provider`s. +- `BackendType` — `AgentType` plus `"chat"` (Simple Chat). The map + key for `BackendConfig`. Chat is a model destination but doesn't + own providers. - /** Preferred default for this consumer, when applicable. */ - defaultModel?: ConsumerModelRef | null; +### 3.8 `BackendConfig` (persisted) + +```ts +interface BackendConfig { + enabledModels: string[]; // each entry is a configuredModelId + defaultModel?: string | null; } ``` -Storage: `settings.consumers: Record`. - -An empty `enabledModels: []` means "use the default-selection heuristic" (the -picker shows everything the consumer is eligible for; nothing is pinned). - -### 3.7 ConsumerModelRef (polymorphic) - -A consumer pins three kinds of model references: - -```ts -type ConsumerModelRef = - /** A model the user enrolled via BYOK. */ - | { source: "byok"; enrollmentRef: string } - - /** A model the agent backend bundles itself (not BYOK, not Plus). */ - | { source: "backend-bundled"; backendId: BackendId; backendModelId: string } - - /** A model hosted by Copilot Plus. */ - | { source: "copilot-plus"; modelId: string }; - -type BackendId = "opencode" | "claude-code" | "codex"; -``` - -### 3.8 BackendInventory (runtime-only, NOT persisted) - -At session start, each agent backend reports what models it can serve. Held -in memory; never written to settings. - -```ts -interface BackendInventory { - backendId: BackendId; - models: BackendModelEntry[]; -} - -interface BackendModelEntry { - /** What the backend speaks at runtime. */ - backendModelId: string; - - origin: - | { kind: "bundled" } // Agent's own enumeration - | { kind: "byok"; enrollmentRef: string } // Bridged from user's BYOK - | { kind: "copilot-plus" }; // Hosted by Plus - - displayName?: string; - capabilities?: CatalogModel["capabilities"]; // If known -} -``` - -### 3.9 Embeddings (deferred) - -Embedding-side mirrors the chat-side entities. Out of scope for this rollout -but the spec reserves the shape so a follow-up doesn't have to redesign: - -```ts -type EmbeddingConsumerId = - | "vault-index" // The vector store for vault QA - | "project-index"; // Project-specific embedding (TBD) - -interface EmbeddingConsumerConfig { - consumerId: EmbeddingConsumerId; - enabledModels: ConsumerModelRef[]; - defaultModel?: ConsumerModelRef | null; -} -``` - -Embedding-capability detection: `CatalogModel.modalities.output.includes("embedding")` -(or whatever marker `models.dev` settles on). Until rolled in, the legacy -`activeEmbeddingModels` + `embeddingModelKey` fields continue to work. +Per-backend curated selection. Persisted as +`settings.backends: Record` — backend +identity is the map key, not a field on the row. --- -## 4. AdapterKind rationale +## 4. `ProviderType` rationale -`AdapterKind` is **not** the same thing as `ProviderType.id`. The id is the -catalog handle (one per `models.dev` entry — potentially 30+). The adapter is -the protocol / SDK we wire to internally — closed at six values. +The catalog lists 30+ providers (Anthropic, OpenAI, Google, Mistral, +Groq, OpenRouter, Together, DeepSeek, …) but the plugin only wires +five SDK families. Most providers ride on `openai-compatible` with a +custom `baseUrl`. The five values: -| AdapterKind | LangChain path | OpenCode path | -| ------------------- | ----------------------------------------------------- | ------------------------------------- | -| `anthropic` | `@langchain/anthropic` (`ChatAnthropic`) | `provider.set type=anthropic` | -| `openai-compatible` | `@langchain/openai` (`ChatOpenAI` + custom `baseUrl`) | `provider.set type=openai-compatible` | -| `google` | `@langchain/google-genai` | `provider.set type=google` | -| `azure` | `@langchain/openai` (Azure path) + deployment knobs | `provider.set type=azure` | -| `bedrock` | `@langchain/aws` | `provider.set type=bedrock` | -| `github-copilot` | Custom bridge (OAuth-based) | `provider.set type=github-copilot` | +| `ProviderType` | LangChain path | OpenCode `provider.set type` | +| ------------------- | -------------------------------------- | ---------------------------- | +| `anthropic` | `@langchain/anthropic` | `anthropic` | +| `openai-compatible` | `@langchain/openai` + custom `baseUrl` | `openai-compatible` | +| `google` | `@langchain/google-genai` | `google` | +| `azure` | `@langchain/openai` (Azure path) | `azure` | +| `bedrock` | `@langchain/aws` | `bedrock` | -Why exactly these six: +**Mapping from catalog to `ProviderType`**: the catalog fetcher reads +`models.dev`'s `npm` field on each provider entry: -- Each one is **wired** in both pipelines (or is openly understood to be - one-pipeline-only via `ProviderType.pipelines`). Adding an `AdapterKind` - is a real lift — you must ship LangChain code AND ensure OpenCode (or - document the one-pipeline limitation). -- The catalog (30+ providers) maps onto these six. Most providers ride on - `openai-compatible` with their own `defaultBaseUrl` from the catalog. A - hypothetical seventh adapter is only justified if a real provider's - protocol doesn't fit any of these six. +| `npm` value | `ProviderType` | +| ------------------- | ------------------- | +| `@ai-sdk/anthropic` | `anthropic` | +| `@ai-sdk/google` | `google` | +| `@ai-sdk/azure` | `azure` | +| `@ai-sdk/bedrock` | `bedrock` | +| (anything else) | `openai-compatible` | -**The catalog list shown in "Add Provider"** mirrors the full `models.dev` -provider set — dozens of entries. The provider→adapter mapping is part of the -plugin's catalog corrections layer (seeded from `models.dev`, hand-edited -where the catalog is silent). Providers whose adapter we don't support yet -are shown with a "not yet supported in this plugin" affordance — surfaced, -not silently filtered. +**Self-hosted providers** (Ollama, LMStudio, custom proxies) are not +listed by `models.dev`. The setup UI exposes them as built-in +templates and hardcodes `providerType: "openai-compatible"`. + +Adding a sixth `ProviderType` value is a significant lift: it +requires LangChain code, agent-backend integration, and catalog +mapping. Most new providers should fit `openai-compatible`. --- -## 5. Pipeline compatibility +## 5. `Provider.origin` discrimination -`ProviderType.pipelines: { langchain: boolean; opencode: boolean }` is a -**load-bearing field**. It decides which `Consumer` pickers a provider's -enrollments can appear in. +`origin` is the only field that varies by provenance. Downstream +consumption (chat-model factory dispatch, picker enumeration, +backend bridging) is uniform. -### 5.1 Consumer ↔ pipeline mapping +| Origin | Created by | Visible in BYOK tab? | +| -------------- | ---------------------------------------------------------------------------------------------------------- | -------------------- | +| `byok` | User via BYOK "Add Provider" UI | Yes | +| `agent` | Agent setup flow (Claude Code → Anthropic; Codex → OpenAI; OpenCode → its full provider set including Zen) | No | +| `copilot-plus` | Plus sign-in flow | No | -| ConsumerId | Pipeline | -| ------------------- | -------------- | -| `chat` | `langchain` | -| `vault-qa` | `langchain` | -| `project` | `langchain` | -| `copilot-plus` | `langchain` | -| `quick-chat` | `langchain` | -| `command:*` | `langchain` | -| `agent:opencode` | `opencode` | -| `agent:claude-code` | (subscription) | -| `agent:codex` | (subscription) | - -Subscription consumers (`agent:claude-code`, `agent:codex`) don't accept -BYOK at all. Their `enabledModels` are all `backend-bundled` refs. - -### 5.2 What causes a flag to flip false - -**`pipelines.langchain = false`** (OpenCode-only): - -- No LangChain SDK package available, and the provider's API isn't close - enough to OpenAI's for `@langchain/openai` + a custom `baseUrl` to work. -- Provider uses an auth scheme the in-process LangChain code can't bind - to: a CLI-issued session token, a hardware token, an OAuth flow that - requires browser interaction the OpenCode CLI handles but we can't - reproduce in-process. -- OpenCode bundles the integration end-to-end (SDK + OAuth) and exposing - the same provider through LangChain would require shipping that SDK in - the plugin bundle. - -**`pipelines.opencode = false`** (LangChain-only): - -- OpenCode's `provider.set` config doesn't recognize the protocol — we - have a LangChain integration but OpenCode upstream hasn't wired it up. -- Provider needs per-request state (custom headers, signed URLs) the - byokBridge has no way to inject into OpenCode's HTTP client. -- A local endpoint OpenCode's subprocess can't reach (rare). - -**Both true** — the common case. Anthropic, OpenAI, Google, Mistral, Groq, -DeepSeek, Together, xAI, OpenRouter, etc. - -**Both false** — does not appear in BYOK. If `models.dev` lists such a -provider, the Add Provider UI shows it with a "not yet supported" tooltip -so the user knows the gap exists. - -### 5.3 Worked examples - -| ProviderType.id | adapter | langchain | opencode | Notes | -| ---------------- | ------------------- | --------- | -------- | ---------------------------------------------------------------- | -| `anthropic` | `anthropic` | ✓ | ✓ | | -| `openai` | `openai-compatible` | ✓ | ✓ | | -| `google` | `google` | ✓ | ✓ | | -| `mistral` | `openai-compatible` | ✓ | ✓ | | -| `groq` | `openai-compatible` | ✓ | ✓ | | -| `openrouter` | `openai-compatible` | ✓ | ✓ | | -| `bedrock` | `bedrock` | ✓ | ✓ | Both pipelines wire AWS SDK auth differently. | -| `azure` | `azure` | ✓ | ✓ | Per-instance `extras` carries deployment name + API version. | -| `github-copilot` | `github-copilot` | ✓ | ✓ | OAuth token refresh handled in both pipelines. | -| `ollama` | `openai-compatible` | ✓ | ✓ | Synthetic type; CustomModels per instance. | -| (hypothetical) | `openai-compatible` | ✓ | ✗ | LangChain works; OpenCode upstream doesn't know the provider id. | -| (hypothetical) | (custom OAuth) | ✗ | ✓ | OpenCode CLI manages OAuth; we can't replicate in-process. | +The BYOK settings UI filters `providers` by +`origin.kind === "byok"`. Other surfaces (agent setup screens, Plus +account screen) manage their respective origins. --- @@ -515,309 +289,156 @@ so the user knows the gap exists. interface CopilotSettings { // … existing non-model fields … - settingsVersion: number; - providers: Record; - customModels: CustomModel[]; // keyed by (instanceId, modelId) - enrollments: ByokEnrollment[]; // keyed by (instanceId, modelId) - consumers: Record; + providers: Record; + configuredModels: ConfiguredModel[]; // keyed by configuredModelId + backends: Record; - // Out-of-scope but reserved: - // embeddingConsumers: Record; - // (or keep legacy activeEmbeddingModels + embeddingModelKey until embeddings parity ships) + // Reserved for embeddings parity (deferred). The legacy + // activeEmbeddingModels + embeddingModelKey fields continue to work + // until embeddings are folded in. } ``` -ProviderType / CatalogModel are catalog data, not settings. +`ProviderType` / `ModelInfo` / `CatalogProvider` are not persisted — +catalog data is consumed in memory. --- ## 7. Invariants -| # | Invariant | Checked by | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------- | -| 1 | `enrollment.instanceId ∈ keys(providers)` | Write path, migration | -| 2 | `(instanceId, modelId)` pair resolves either via the instance's ProviderType catalog OR via a `CustomModel` row — never both | Write path | -| 3 | At most one `ByokEnrollment` per `(instanceId, modelId)` | Write path | -| 4 | At most one `CustomModel` per `(instanceId, modelId)` | Write path | -| 5 | If `ProviderType.auth = "api-key"`, `ProviderInstance.apiKey` is non-null; otherwise the provider is "incomplete" and its enrollments are greyed in pickers | Runtime | -| 6 | Every `ConsumerModelRef` with `source = "byok"` points at a present enrollment; broken refs surface in the UI, never silently pruned | Runtime | -| 7 | When a consumer's `defaultModel` is removed from `enabledModels`, `defaultModel` is set to `null` | Write path | -| 8 | Deleting a `ProviderInstance` cascades to its `ByokEnrollment` rows and its `CustomModel` rows. Consumer refs pointing at those enrollments become broken refs (surfaced; not pruned) | Write path | -| 9 | A consumer whose pipeline is `langchain` only renders enrollments whose `ProviderType.pipelines.langchain = true`; same for `opencode`. Ineligible enrollments are shown as ineligible in BYOK panel (with reason), not hidden | Runtime | -| 10 | NO `providerTypeId` uniqueness constraint across `providers` (multi-instance is the whole point) | — (explicit non-constraint) | +| # | Invariant | Enforced by | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| 1 | `configuredModel.providerId ∈ keys(providers)` | Write path | +| 2 | `(providerId, info.id)` is unique across `configuredModels` | Write path | +| 3 | Every `BackendConfig.enabledModels[i]` is a valid `configuredModelId`; broken refs surface in the UI, never silently pruned | Runtime | +| 4 | `BackendConfig.defaultModel`, if non-null, is in `enabledModels` | Write path | +| 5 | Deleting a `Provider` cascades to its `ConfiguredModel`s. `BackendConfig.enabledModels` entries pointing at deleted models become broken refs (surfaced; not silently pruned) | Write path | +| 6 | `apiKeyKeychainId` is non-null when the provider needs an API key; `null` for Ollama, LMStudio, and agent-owned providers that route through other credentials | Runtime | +| 7 | BYOK settings UI shows only `providers` with `origin.kind === "byok"` | UI | +| 8 | No uniqueness constraint on `providerType` across `providers` — multi-instance is supported | (explicit non-constraint) | --- ## 8. Resolution traces -End-to-end walks of common flows. The HTML companion renders these as -sequence-style ASCII; this Markdown keeps the prose form. +### Trace A — BYOK Simple Chat, single Anthropic -### Trace A — Simple Chat, single OpenAI instance +1. User opens BYOK settings → "Add Provider" → picks "Anthropic" from + the catalog list. Wizard creates + `Provider{ providerId: "p1", providerType: "anthropic", origin: { kind: "byok" }, … }`. +2. User picks "Claude Sonnet 4.5" from the catalog's model list under + Anthropic. Wizard creates + `ConfiguredModel{ configuredModelId: "m1", providerId: "p1", info: ModelInfo{ id: "claude-sonnet-4-5", … } }`. +3. Auto-enrollment adds `"m1"` to `backends["chat"].enabledModels`. +4. User opens the Simple Chat picker → it reads + `backends["chat"].enabledModels`, resolves each id to a + `ConfiguredModel`, renders the list. User selects "Claude Sonnet + 4.5"; chat-model factory dispatches by + `providers["p1"].providerType === "anthropic"` to the Anthropic + LangChain adapter. -1. User opens BYOK panel → catalog shows OpenAI models. -2. User adds OpenAI provider → - `ProviderInstance{ instanceId: "openai-uuid-1", providerTypeId: "openai", apiKey: K }`. -3. User enrolls `gpt-5` → - `ByokEnrollment{ instanceId: "openai-uuid-1", modelId: "gpt-5" }`. -4. User opens Simple Chat settings → enables it → - `ConsumerConfig{ consumerId: "chat", enabledModels: [{source:"byok", enrollmentRef:"openai-uuid-1::gpt-5"}] }`. -5. User sets it as default → `ConsumerConfig.defaultModel` = same ref. -6. Chat picker iterates `chat.enabledModels`, resolves each through - `enrollments` + catalog, renders "GPT-5". +### Trace B — Agent-owned model (Claude Code → Anthropic) -### Trace B — OpenCode-bundled model (not BYOK) - -1. Plugin starts → OpenCode CLI launched → reports inventory including - `bigpickle/big-pickle` (bundled). -2. User opens Agent settings for OpenCode → enables it → - `ConsumerConfig{ consumerId: "agent:opencode", enabledModels: [{source:"backend-bundled", backendId:"opencode", backendModelId:"bigpickle/big-pickle"}] }`. -3. Picker resolves the ref against the live `BackendInventory["opencode"]` +1. User installs and configures the Claude Code agent. The agent + setup flow creates + `Provider{ providerId: "p2", providerType: "anthropic", origin: { kind: "agent", agentType: "claude-code" }, displayName: "Anthropic (Claude Code)" }` + and uses Claude Code's CLI-managed credentials. +2. Setup fetches `models.dev` for the catalog's Anthropic models and + creates `ConfiguredModel` rows for each one the agent supports, + under `providerId: "p2"`, with full `info` snapshots. +3. Auto-enrollment populates `backends["claude-code"].enabledModels` + with the new model ids. +4. BYOK settings UI doesn't show provider `"p2"` (filtered by + `origin.kind === "byok"`). +5. Claude Code's picker reads `backends["claude-code"].enabledModels` and renders. -### Trace C — BYOK shared with OpenCode (byokBridge) +### Trace C — Multi-instance BYOK (two Anthropic keys) -1. Existing enrollment: `anthropic-prod-uuid::claude-sonnet-4-5` on the - "Anthropic (prod)" instance. -2. User enables it in the OpenCode consumer → - `ConsumerConfig{ enabledModels: [..., {source:"byok", enrollmentRef:"anthropic-prod-uuid::claude-sonnet-4-5"}] }`. -3. At runtime, byokBridge has registered the prod Anthropic credentials with - OpenCode (one bridged provider per `ProviderInstance`, keyed by - `instanceId`). OpenCode's inventory now includes a - `backendModelId: "/claude-sonnet-4-5"` entry with - `origin: { kind: "byok", enrollmentRef: "anthropic-prod-uuid::claude-sonnet-4-5" }`. -4. Picker reconciles the consumer's `byok` ref with the inventory entry by - `enrollmentRef`. If the user also enabled the staging-instance enrollment - under OpenCode, both appear as separate entries — same model name, two - credentials — disambiguated by their parent `ProviderInstance.displayName`. +1. User adds an "Anthropic" provider in BYOK → + `Provider{ providerId: "p3", providerType: "anthropic", origin: { kind: "byok" }, displayName: "Anthropic (prod)" }` + with prod key. +2. User clicks "Add Provider → Anthropic" again. BYOK UI does **not** + filter Anthropic out (multi-instance allowed). A second provider: + `Provider{ providerId: "p4", providerType: "anthropic", origin: { kind: "byok" }, displayName: "Anthropic (staging)" }` + with staging key. +3. User configures Claude Sonnet 4.5 on both → two `ConfiguredModel` + rows with different `configuredModelId`s, both pointing at the + same wire-form `info.id: "claude-sonnet-4-5"`. +4. Simple Chat picker lists both; user disambiguates by parent + provider's `displayName`. +5. Deleting `"p4"` cascades: its `ConfiguredModel` is removed; any + `BackendConfig.enabledModels` ref to it becomes a broken ref + (surfaced in UI). `"p3"`'s configured model is untouched. -### Trace D — Multi-instance catalog-backed (two Anthropic keys) +### Trace D — Self-hosted (Ollama with hand-typed models) -1. User has `ProviderInstance{ instanceId: "anthropic-prod-uuid", providerTypeId: "anthropic", displayName: "Anthropic (prod)", apiKey: K1 }`. -2. User clicks "Add Provider → Anthropic" again. **The BYOK UI does not - filter Anthropic out** (multi-instance is allowed). A second instance is - created: `ProviderInstance{ instanceId: "anthropic-staging-uuid", providerTypeId: "anthropic", displayName: "Anthropic (staging)", apiKey: K2 }`. - The UI suggests the "(staging)" suffix on collision; user may rename. -3. User enrolls Claude Sonnet 4.5 on both → two enrollments, - `anthropic-prod-uuid::claude-sonnet-4-5` and - `anthropic-staging-uuid::claude-sonnet-4-5`. Catalog metadata identical; - credentials different. -4. Simple Chat can enable either or both. The picker renders them as two - rows, labeled by their parent instance's `displayName`. -5. Deleting the staging instance cascades: its enrollment is removed; any - consumer ref pointing at it becomes broken (surfaced in UI). The prod - enrollment is untouched. - -### Trace E — Custom provider, multi-instance, custom models - -1. User adds "Local Ollama" → - `ProviderInstance{ instanceId: "ollama-uuid-1", providerTypeId: "ollama", displayName: "Ollama (laptop)", baseUrl: "http://localhost:11434", apiKey: null }`. - `ProviderType` `"ollama"` is shared and has no catalog models. -2. User adds a second Ollama on a different machine → - `ProviderInstance{ instanceId: "ollama-uuid-2", providerTypeId: "ollama", displayName: "Ollama (workstation)", baseUrl: "http://192.168.1.50:11434" }`. -3. User declares model lineups: - `CustomModel{ instanceId: "ollama-uuid-1", modelId: "llama3.3:70b" }` and - `CustomModel{ instanceId: "ollama-uuid-2", modelId: "mistral:7b" }`. Each - instance owns its own list. -4. User enrolls them → two `ByokEnrollment` rows. From here, same path as - Trace A. +1. User picks "Ollama" from the BYOK setup UI's built-in template + list. Wizard creates + `Provider{ providerId: "p5", providerType: "openai-compatible", origin: { kind: "byok" }, displayName: "Ollama (laptop)", baseUrl: "http://localhost:11434", apiKeyKeychainId: null }`. +2. User types "llama3.3:70b" as a model name. Wizard creates + `ConfiguredModel{ providerId: "p5", info: ModelInfo{ id: "llama3.3:70b", displayName: "Llama 3.3 70B" } }` — + `info` has only `id` + `displayName`; metadata fields stay empty. +3. From here, same flow as Trace A: enrollment, picker render, + dispatch via `providerType: "openai-compatible"`. --- -## 9. Per-consumer picker formulas +## 9. Open questions -For each consumer at picker-render time: - -``` -visibleEntries(consumerId) = - let pipeline = pipelineOf(consumerId) - let inventory = (pipeline === "opencode") - ? BackendInventory[backendIdOf(consumerId)] - : null - - enabled = consumer.enabledModels // empty ⇒ fall back to default-selection heuristic - - for each ref in enabled: - case ref.source: - "byok": - let e = enrollments[ref.enrollmentRef] - let p = providers[e.instanceId] - let t = providerTypes[p.providerTypeId] - require t.pipelines[pipeline] - require p.apiKey != null OR t.auth == "none" - if pipeline == "opencode": - find matching inventory entry by enrollmentRef - emit entry - - "backend-bundled": - require inventory != null && inventory.includes(ref.backendModelId) - emit entry - - "copilot-plus": - require copilotPlusCatalog.includes(ref.modelId) - emit entry -``` - -No capability filtering. No runtime "this model doesn't do tool calls so -hide it" check — the runtime error path surfaces incompatibility. +1. **Embeddings parity timing.** When does the embedding side adopt + the same provider / configured-model / backend shape? +2. **Project-level model defaults.** Currently a project may pin a + model. Does this become a project-scoped override of + `backends["chat"].defaultModel`, or its own override mechanism? +3. **Per-invocation knobs.** If a user-facing temperature slider ever + ships, where does it live? Spec says "not in the data model" — + confirm. +4. **Migration mechanics.** A separate plan covers the migration + from the current v0/v2 on-disk shape to the entities above. +5. **`BackendInventory`.** A future picker-reconciliation feature + may need a runtime types file declaring ACP-reported model + inventories so unreachable models can be greyed out. Out of scope + here. +6. **Plus model registry shape.** Plus model metadata lives outside + this module (in a Plus-specific registry). What's the contract + between the Plus module and `ConfiguredModel` creation? Probably + "Plus calls the same setup-flow helper" but worth nailing down. --- -## 10. Open questions +## 10. Deferred fields -These remain unresolved by this spec — to be answered before implementation -starts. +Fields considered and deferred from this iteration. Each carries a +re-add trigger. -1. **Embeddings unification timing.** Stay legacy for now? Or fold in as a - follow-up after the chat side stabilizes? -2. **Project-level chain default storage.** Currently `ProjectConfig.projectModelKey` - holds a wire-form string. Does it become `ProjectConfig.defaultModelRef: -ConsumerModelRef | null`? Does each project override `consumers["project"]` - wholesale, or just the default? -3. **Per-invocation knobs.** If we ever surface a user-facing temperature - slider, where does it live? Per-message? Per-skill? Per-command? Spec - says "not in the data model" — does that hold? -4. **"Not yet supported in this plugin" affordance.** How does the Add - Provider UI render a `models.dev` provider whose adapter isn't in our - six? Disabled list item with tooltip? Separate section? Out of scope - here but downstream UX work depends on the answer. -5. **Per-pipeline-only badging.** Do we surface "works with chat only" / - "works with OpenCode only" copy on the BYOK enrollment row? Or surface - it only at the provider level? -6. **`backend-bundled` ref staleness.** Backend inventories change between - OpenCode releases. If a previously bundled model disappears, the - consumer's ref becomes broken. Surface as a broken ref (mirrors BYOK - broken-ref behavior) or auto-prune? -7. **Custom command consumers.** `command:` consumers proliferate. Do - we store one `ConsumerConfig` per command, or fold them into a single - record keyed by command id internally? +### `CatalogProvider` ---- +| Field | Re-add trigger | +| --------------- | ---------------------------------------------------- | +| `env: string[]` | "API key already in env?" detection feature | +| `doc: string` | Setup UI that shows provider doc links | +| `npm` (raw) | Never persisted; used only to compute `providerType` | -## Reconciliation appendix +### `ModelInfo` -Descriptive diff between this spec and `src/modelManagement/` on -`zero/model-settings-redesign` (as of the writing of this doc). No -prescription — the user decides whether the spec bends to match the code or -vice versa. +| Field | Re-add trigger | +| ------------------------------------------------ | --------------------------------------------------------------------- | +| `family` | Picker UI that groups models by family ("show latest in each family") | +| `temperature: boolean` | Setup UI that surfaces "temperature unsupported" | +| `attachment: boolean` | Picker badge for attachment-capable models | +| `knowledge: string` | Setup UI showing training cutoff | +| `lastUpdated`, `openWeights`, `structuredOutput` | Speculative; add when a UI consumes them | -### A.1 Provider records +### `Provider` -- **Spec**: `providers: Record` where - `providerTypeId` is a non-unique FK; multi-instance allowed. -- **Branch**: `providers: Record` keyed by the - _provider type id_ itself ("anthropic", "openai", "custom:", …). - **Singleton per type** — the BYOK Add Provider dialog filters out already- - configured providers, so multiple Anthropic keys are impossible without - routing through a `custom:` workaround. (See - `src/modelManagement/types.ts` `ProviderConfig`, and - `src/modelManagement/ui/dialogs/AddProviderDialog.tsx`.) -- **Branch has** a `kind: "builtin" | "custom" | "system"` discriminator on - `ProviderConfig` to support system providers (`opencode`, `copilot-plus`) - as first-class entries solely to satisfy the FK invariant of - `RegistryEntry.providerId`. -- **Spec does not** make system providers first-class `ProviderInstance` - rows — they sit as pseudo-`ProviderType`s with no instance. The FK - invariant on enrollments is unaffected because those models are not - `ByokEnrollment` records to begin with; they're `ConsumerModelRef`s of - `source: "backend-bundled" | "copilot-plus"`. +| Field | Re-add trigger | +| ----------------------------------------- | ---------------------- | +| `lastVerifiedAt`, `lastVerificationError` | "Verify connection" UI | -### A.2 ProviderType / catalog +### `ConfiguredModel` -- **Spec**: explicit `ProviderType` entity with `adapter: AdapterKind`, - `pipelines: {langchain, opencode}`, `auth`, `defaultBaseUrl`, `models: -Record`. -- **Branch**: catalog lives in `ModelCatalogService` (`src/modelManagement/catalog/`) - with `CatalogProvider` / `CatalogModel` types that mostly match the spec's - shape, **minus** the `pipelines` field and minus an explicit `adapter` - declaration. Provider→adapter mapping is implicit in the chat-model - factory; pipeline reachability isn't modeled at all. - -### A.3 Enrollment record - -- **Spec**: `ByokEnrollment` with `instanceId` FK, `displayName`, - `enrolledAt`, optional `overrides`, verification timestamps. Stable key - `${instanceId}::${modelId}`. -- **Branch**: `RegistryEntry` (`src/modelManagement/types.ts`) with - `providerId` (which is the singleton type id, not an `instanceId`), - `modelId`, `displayName`, `addedAt`, `lastVerifiedAt`, - `lastVerificationError`, optional `extra`. Functionally close, but - identity is `(providerId, modelId)` instead of `(instanceId, modelId)`, - which forecloses multi-instance. - -### A.4 Custom models - -- **Spec**: separate `customModels: CustomModel[]` table, FK'd to - `instanceId`, intentionally distinct from `ByokEnrollment` — a user - declares the model exists, then separately enrolls it. -- **Branch**: no `customModels` table. A "custom model" is simply a - `RegistryEntry` whose `providerId` is a `custom:` provider. Catalog - has no record of it; the entry IS the declaration. Functionally works, - but conflates "this model exists" with "I want to use this model" — the - user can't list out their declared-but-not-enrolled models. - -### A.5 Per-consumer model selection - -- **Spec**: `consumers: Record` with explicit - per-consumer `enabledModels: ConsumerModelRef[]` and `defaultModel`. A - single `ConsumerModelRef` shape (polymorphic) handles BYOK, backend- - bundled, and Copilot Plus uniformly. -- **Branch**: per-backend `modelEnabledOverrides: Record` - with **per-backend-different key formats** (OpenCode uses - `/`, Claude/Codex use bare model ids, Quick Chat - uses `:`). Not a single uniform shape; relies on - tribal knowledge about which key format each backend expects. (See - `src/agentMode/session/modelEnable.ts` and migration step 6 in - `src/modelManagement/migrations/v0-to-v2.ts`.) -- **Branch missing**: `consumers` for LangChain-side use cases (Simple Chat, - Vault QA, Project, Quick Chat). The current model still uses - `settings.defaultModelRef: { providerId, modelId } | null` as a single - "default chat model" pointer with no per-consumer allow-list. - -### A.6 Default model representation - -- **Spec**: every `ConsumerConfig` has its own `defaultModel: ConsumerModelRef | null`. -- **Branch**: `settings.defaultModelRef: { providerId, modelId } | null` is - a single global chat default. No per-consumer defaults beyond what - `agentMode.backends..defaultModel` carries for agent consumers. - -### A.7 Pipeline compatibility - -- **Spec**: `ProviderType.pipelines = {langchain, opencode}` is a first- - class field that drives per-consumer picker visibility. -- **Branch**: not modeled. The byokBridge attempts to register every BYOK - provider with OpenCode regardless of whether OpenCode actually supports - that provider type. Failures surface at runtime, not at the data model - level. - -### A.8 Capabilities & global knobs - -- **Spec**: no `capabilities` on `ByokEnrollment`; no global chat knobs in - the data model; no per-agent capability filtering. -- **Branch**: matches the spec on `RegistryEntry` (no capabilities stored). - **Differs** on global knobs — `temperature`, `maxTokens`, `reasoningEffort`, - `verbosity` still live in `CopilotSettings` and feed every chat invocation - via `ChatDefaults` (`src/modelManagement/types.ts`). The spec drops these. - -### A.9 Embedding side - -- **Spec**: parallel structure, deferred. Legacy `activeEmbeddingModels` + - `embeddingModelKey` stays in the interim. -- **Branch**: matches (legacy embedding fields untouched on `CopilotSettings`). - -### A.10 Migration status - -- **Spec**: assumes the agreed target shape is the destination. Migration - from current (v2) → target shape is a separate task, not specified here. -- **Branch**: `runModelManagementMigrations` runs a v0→v2 migration today. - A v2→v3 migration to land this spec needs to: - - Synthesize `instanceId`s for existing single-instance providers. - - Translate `RegistryEntry` → `ByokEnrollment` (keying change). - - Extract custom-provider `RegistryEntry`s into `CustomModel` rows + new - `ByokEnrollment`s. - - Translate per-backend `modelEnabledOverrides` into `ConsumerConfig`s - for the agent consumers; create new `ConsumerConfig`s for LangChain - consumers (probably initialized as "everything enabled" to preserve - current behavior). - - Add `pipelines: {langchain, opencode}` to the catalog corrections - layer. +| Field | Re-add trigger | +| -------------------------------------------------------------------------------- | -------------------------------------------------- | +| `extras` (Ollama `numCtx`, OpenRouter prompt caching, OpenAI-compatible CORS, …) | An adapter that consumes a specific per-model knob | +| `lastVerifiedAt`, `lastVerificationError` | Same as `Provider` | diff --git a/designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md b/designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md index 05bdfc10..449c8238 100644 --- a/designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md +++ b/designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md @@ -1,18 +1,19 @@ # Model Management Redesign — Technical Design & Implementation Plan -> **Companion to:** `designdocs/MODEL_MANAGEMENT_REDESIGN.md` (product / UX spec). -> **This doc owns:** library choice, data model, migration strategy, code-architecture changes, milestone breakdown with verification checklists. +> **Companion to:** `designdocs/MODEL_MANAGEMENT_REDESIGN.md` (product / UX spec) and `designdocs/MODEL_DATA_MODEL_SPEC.md` (the canonical data model — entity shapes, settings layout, invariants). +> **This doc owns:** library choice, migration strategy, code-architecture changes, UX flows, dialog wireframes, milestone breakdown with verification checklists. > **Audience:** A background implementation agent. Each milestone is self-contained and verifiable without human approval. -> **Status:** Updated 2026-05-20 (rev 3) — reflects the latest design bundle (`copilot-model-settings/project/screens/final.jsx`) and additional scope decisions: +> +> **Scope decisions reflected here:** > > - BYOK is the central registry for _user-brought_ keys only (no built-in models, no OpenCode/Plus models). -> - Removal of all per-model and per-provider "Availability" / "Capability" toggles; model rows are display-only inside one global table. -> - Quick Chat as a fourth agent sub-tab (skeleton only; routing in the follow-up doc). +> - No per-model or per-provider "Availability" / "Capability" toggles; model rows are display-only inside one global table. +> - No global chat knobs (`temperature` / `maxTokens` / `reasoningEffort` / `verbosity`); adapters use SDK defaults. +> - Quick Chat as a fourth agent sub-tab (skeleton only; runtime routing in the follow-up doc). > - Lazy `models.dev` fetching (BYOK-tab-triggered, not on plugin boot). > - `src/modelManagement/` module with enforced import boundary. -> - **Built-in models are eliminated** — migration drops any built-in `activeModels` entry whose provider lacks a configured API key; built-in entries with a key migrate as ordinary registry entries. -> - **Embedding models move to a renamed "Embedding" tab** (was "QA"); other QA settings stay where they are, embedding section sits at the bottom. No separate Embeddings tab. -> - **Welcome modal is out of scope** for this plan. +> - Embedding models live in the "Embedding" tab (other Embedding settings stay where they are, embedding section sits at the bottom). +> - Welcome modal is out of scope. --- @@ -26,19 +27,19 @@ Curation of which models surface in a specific agent's in-session picker is a pe This implementation plan also takes the opportunity to **separate concerns** that are currently tangled: -| Concern | Today | After redesign | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Provider credentials | ~25 ad-hoc fields on `CopilotSettings` (`openAIApiKey`, `anthropicApiKey`, `amazonBedrockRegion`, …) | One typed `providers: Record` map | -| Enabled models | `activeModels: CustomModel[]` (also carries API keys, base URLs, per-model overrides) | One `registry: RegistryEntry[]` referencing provider by id | -| Built-in catalog of pre-listed models | Hard-coded in `src/constants.ts` (`BUILTIN_CHAT_MODELS`) — pre-populated regardless of whether the user has the key | **Eliminated.** The registry contains _only_ models the user explicitly registered. Migration drops any pre-listed entry whose provider has no configured key (the user was never actually using it). | -| Available models per provider (for the picker) | Hard-coded built-in list | Lazy `models.dev/api.json` with disk cache | -| Default chat model | `defaultModelKey` field | **Removed** (kept temporarily as input to the Quick Chat agent's seeding; see follow-up doc) | -| Per-model overrides (temp, max_tokens, capabilities, …) | Per-`CustomModel` fields | **Removed** — all chains use global defaults; capabilities are not stored on registry entries (consulted from the catalog at the point of use) | -| Per-provider "Availability" toggles (chat/agent/mobile) | Implicit in code | **Removed** — registered = available; per-agent picker curation handles "show in X" | -| Per-model "Hide from picker" checkbox in BYOK table | `enabled: false` on `CustomModel` | **Removed** — to hide a model, uncheck it inside Configure Provider (this removes it from the registry; per-agent curation lives in Agent tab) | -| Settings versioning | Heuristic (presence/type checks) | Explicit `settingsVersion: number` with a registered migration chain | -| Embedding model management | Inside "Models" tab alongside chat models | Bottom section of the **renamed "Embedding" tab** (was "QA"); other QA settings stay in place. | -| LangChain chat | Implicit "chain mode" coupled to chat input | **Becomes the "Quick Chat" agent backend** — see `designdocs/QUICK_CHAT_AGENT_INTEGRATION.md` (follow-up doc; out of scope for this plan) | +| Concern | Today | After redesign | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Provider credentials | ~25 ad-hoc fields on `CopilotSettings` (`openAIApiKey`, `anthropicApiKey`, `amazonBedrockRegion`, …) | One typed `providers: Record` map. Multi-instance: same provider type can appear multiple times. | +| Enabled models | `activeModels: CustomModel[]` (also carries API keys, base URLs, per-model overrides) | One `enrollments: ByokEnrollment[]` keyed by `(instanceId, modelId)`. Catalog-backed and user-declared models share the same shape; catalog membership decides which. | +| Built-in catalog of pre-listed models | Hard-coded in `src/constants.ts` (`BUILTIN_CHAT_MODELS`) — pre-populated regardless of whether the user has the key | **Eliminated.** `enrollments` contains _only_ models the user explicitly enrolled. Migration drops any pre-listed entry whose provider has no configured key (the user was never actually using it). | +| Available models per provider (for the picker) | Hard-coded built-in list | Lazy `models.dev/api.json` with disk cache | +| Default chat model | `defaultModelKey` field | **Per-consumer** `defaultModel: ConsumerModelRef \| null` on each `ConsumerConfig`. No single global default. | +| Per-model overrides (temp, max_tokens, capabilities, …) | Per-`CustomModel` fields | **Removed.** No `temperature` / `maxTokens` / `reasoningEffort` / `verbosity` / `topP` / `frequencyPenalty` in the data model at all; adapters use SDK defaults. Capabilities come from the catalog. | +| Per-provider "Availability" toggles (chat/agent/mobile) | Implicit in code | **Removed** — pipeline reachability (`ProviderType.pipelines = {langchain, opencode}`) declares which consumers can use a provider; per-consumer `enabledModels` curates further. | +| Per-model "Hide from picker" checkbox in BYOK table | `enabled: false` on `CustomModel` | **Removed** — to hide a model, uncheck it inside Configure Provider (this removes it from `enrollments`; per-consumer curation lives in `consumers[].enabledModels`) | +| Settings versioning | Heuristic (presence/type checks) | Explicit `settingsVersion: number` with a registered migration chain | +| Embedding model management | Inside "Models" tab alongside chat models | Bottom section of the **renamed "Embedding" tab** (was "QA"); other QA settings stay in place. | +| LangChain chat | Implicit "chain mode" coupled to chat input | **Becomes the "Quick Chat" agent backend** — see `designdocs/QUICK_CHAT_AGENT_INTEGRATION.md` (follow-up doc; out of scope for this plan) | --- @@ -62,7 +63,7 @@ This implementation plan also takes the opportunity to **separate concerns** tha **Not triggered:** - ❌ Plugin `onload` — catalog service stays uninitialized until a BYOK-side caller asks. -- ❌ Chat session start — chat reads `ProviderRegistry` + `ModelRegistry` (which don't need the catalog at all; catalog only powers the BYOK _picker_). +- ❌ Chat session start — chat reads `ProviderRegistry` + `EnrollmentRegistry` (which don't need the catalog at all; catalog only powers the BYOK _picker_). - ❌ Agent session start — same reason. **Fetch behavior:** @@ -73,7 +74,7 @@ This implementation plan also takes the opportunity to **separate concerns** tha **Why lazy:** - Most plugin sessions never touch the BYOK tab (users configure once, then chat); fetching on boot wastes a network round-trip every launch. -- The catalog is metadata for the _picker_ — registered models live in `settings.registry` and are usable without ever calling `ModelCatalogService`. The runtime chat/agent paths don't need it. +- The catalog is metadata for the _picker_ — enrolled models live in `settings.enrollments` and are usable without ever calling `ModelCatalogService`. The runtime chat/agent paths don't need it. **Why two tiers:** @@ -94,7 +95,7 @@ openrouter, cohere, azure, amazon-bedrock, github-copilot, ollama, lmstudio, siliconflow, openai-compatible ``` -**Excluded** from the earlier draft: `togetherai`, `fireworks-ai`, `perplexity` — no first-class LangChain adapter in this plugin. Users who want them can still add them as a custom provider using the `openai-compatible` path. +**Not supported:** `togetherai`, `fireworks-ai`, `perplexity` — no first-class LangChain adapter in this plugin. Users who want them can still add them as a custom provider using the `openai-compatible` path. (Note on ids: `xai` not `x-ai`; `amazon-bedrock` not `aws-bedrock`. Verified against live `api.json`.) @@ -136,7 +137,7 @@ export interface CatalogModel { export type ModelsCatalog = Record; ``` -> **Registry entries do not store capabilities.** The `capabilities` field has been removed from `RegistryEntry`. The catalog's per-model capability fields (`reasoning`, `tool_call`, `modalities.input`) remain on `CatalogModel` for any consumer that needs to consult them at the moment of use, but they are no longer materialized into the registry. See the follow-up section "Future migration: CustomModel capability removal" for the legacy `CustomModel.capabilities` consumers still reading the v0-side enum. +> **Capability fields belong on the catalog, not on enrollments.** `CatalogModel.reasoning` / `tool_call` / `modalities.input` is consulted at the point of use. Enrollments carry `declaredCapabilities` only for user-declared models (no catalog match) — see `MODEL_DATA_MODEL_SPEC.md` §3.4. ### 1.4 Catalog service @@ -149,14 +150,18 @@ class ModelCatalogService { ensureLoaded(): Promise; // All reads are synchronous after ensureLoaded() resolves. - getProvider(id: ProviderId): CatalogProvider | undefined; - getModel(providerId: ProviderId, modelId: string): CatalogModel | undefined; + getProvider(id: ProviderTypeId): CatalogProvider | undefined; + getModel(providerTypeId: ProviderTypeId, modelId: string): CatalogModel | undefined; getAllProviders(): CatalogProvider[]; // sorted: recommended first (Anthropic / OpenAI / Google) - searchModels(providerId: ProviderId, query: string, filters: CatalogFilters): CatalogModel[]; + searchModels( + providerTypeId: ProviderTypeId, + query: string, + filters: CatalogFilters + ): CatalogModel[]; // Live data integration refresh(): Promise; // user-triggered or auto-triggered (BYOK tab open + 24h stale) - getMeta(): { fetchedAt: number | null; source: "live" | "disk" | "bundled" }; + getMeta(): { fetchedAt: number | null; source: "live" | "disk" | null }; onChange(listener: () => void): () => void; // emits when memory cache updates } @@ -167,153 +172,67 @@ interface CatalogFilters { } ``` -**No `capability` filter in `searchModels`.** Capability filters (Vision / Reasoning / Tool use) are removed from the UI per the latest design. Catalog code can still inspect `tool_call` / `reasoning` / `modalities.input` for internal routing decisions (the Quick Chat agent will need to know whether a model supports tool calls, for example). +**No `capability` filter in `searchModels`.** Capability filters (Vision / Reasoning / Tool use) don't exist in the UI. Catalog code can still inspect `tool_call` / `reasoning` / `modalities.input` for internal routing decisions (the Quick Chat agent will need to know whether a model supports tool calls, for example). -**No OpenCode source augmentation.** The previous draft routed OpenCode-enumerated models through this service. That's gone — `ModelCatalogService` is for the **BYOK picker only**, and BYOK never shows OpenCode-bundled or Copilot-Plus models. The OpenCode sub-tab in the Agent panel queries OpenCode directly for its bundled list (Big Pickle, etc.) and Copilot-Plus for its hosted list; those are merged with BYOK agent-capable registry entries to populate the OpenCode picker at render time. See §5.4.1 and M8 below. +**`ModelCatalogService` is for the BYOK picker only.** BYOK never shows OpenCode-bundled or Copilot-Plus models. The OpenCode sub-tab in the Agent panel queries OpenCode directly for its bundled list and Copilot-Plus for its hosted list; those are merged with BYOK agent-capable enrollments to populate the OpenCode picker at render time. See §5.4.1 and M8. --- -## 2. New data model +## 2. Data model -### 2.1 Top-level `CopilotSettings` additions +The canonical data model — entities (`ProviderType`, `ProviderInstance`, `ByokEnrollment`, `ConsumerConfig`, `ConsumerModelRef`, `BackendInventory`, `CatalogModel`), settings shape, invariants, and resolution traces — lives in `designdocs/MODEL_DATA_MODEL_SPEC.md`. Read that doc before touching any of the schemas, migrations, or registries described here. -```typescript +For orientation, the persisted slice of `CopilotSettings` that this redesign owns: + +```ts interface CopilotSettings { - // … existing unrelated fields … + // … existing non-model fields … - /** Monotonic settings schema version. Migrations run on load when this is < current. */ - settingsVersion: number; // current = 2 after this redesign + settingsVersion: number; // = 2 after migration - /** Provider credentials & display config, keyed by provider id. */ - providers: Record; + // Multi-instance providers, keyed by instanceId (UUID). Same provider type + // may appear multiple times (two Anthropic keys, two Ollama endpoints, …). + providers: Record; - /** User's enabled models — the BYOK registry. */ - registry: RegistryEntry[]; + // BYOK enrollments. FK by instanceId. Catalog-backed and user-declared + // (non-catalog) models share the same shape; catalog membership decides which. + enrollments: ByokEnrollment[]; - // existing fields like agentMode, activeEmbeddingModels (untouched) remain -} + // Per-consumer model curation (LangChain consumers + agent backends, uniform shape). + consumers: Record; -type ProviderId = string; -// Built-in providers use canonical models.dev ids: "anthropic", "openai", etc. -// Custom providers use uuid-prefixed ids: "custom:550e8400-e29b-41d4-a716-446655440000" -// System-managed: "opencode" and "copilot-plus" — never appear in `providers` map. - -interface ProviderConfig { - id: ProviderId; - kind: "builtin" | "custom"; // determines `type` editability - displayName: string; // "Anthropic" or user-given "Ollama (local)" - type: "openai-compatible" | "anthropic" | "google" | "azure" | "bedrock" | "github-copilot"; - baseUrl?: string; // optional override; for `custom` always present - apiKeyRef?: KeychainRef | null; // null = no key required (some local servers) - // Opaque provider-specific payload. Validated by the provider class's Zod schema - // (see §3.6) — keeps `ProviderConfig` flexible without ballooning the union type. - extra?: Record; - addedAt: number; - lastVerifiedAt?: number; - lastVerificationError?: string; -} - -interface RegistryEntry { - providerId: ProviderId; // "anthropic" | "custom:…" — never "opencode" / "copilot-plus" - modelId: string; // "claude-sonnet-4-5-20250929" - displayName: string; // "Claude Sonnet 4.5" - addedAt: number; - lastVerifiedAt?: number; - lastVerificationError?: string; + // Legacy embeddings fields stay until embedding-side rollout: + activeEmbeddingModels: ...; + embeddingModelKey: string | null; } ``` -**Capabilities are not stored on the registry.** Per-model capability tags (`reasoning`, `tool_call`, vision via `modalities.input`, context window, release date) live on `CatalogModel` and are consulted at the point of use — they're not materialized into a registry-side enum. See the follow-up section "Future migration: CustomModel capability removal" for the legacy CustomModel-based code paths that still carry their own pre-v2 `ModelCapability` enum. +### 2.1 API key storage -**Notable removals from earlier draft:** +The existing `KeychainService` (`src/services/keychainService.ts`) supports per-field keychain storage with a vault-scoped ID scheme. Extend it with a new namespace: -- `ProviderConfig.availability` (chat/opencode/mobile checkboxes) — removed entirely. Once a provider is registered, its models are available to whatever agent backend can use them. Per-agent curation moved to Agent tab. -- `RegistryEntry.visible` (per-model checkbox in BYOK table) — removed. Registered = visible. To hide a model: uncheck it inside Configure Provider's model picker and save (which removes it from the registry). -- `RegistryEntry.origin` — removed. Every registry entry is a BYOK entry. OpenCode-bundled and Copilot-Plus models **never become registry entries** (see §5.4.1 for how the OpenCode picker assembles its model list at render time instead). -- `ProviderConfig.extra` is now an opaque `Record` (was a typed union). Provider classes own the shape via their own Zod schemas (§3.6) — keeps the core type stable while letting providers evolve their own payloads. - -### 2.2 Per-agent model picker curation - -Each agent backend (including the new Quick Chat backend defined in the follow-up doc) maintains its own `modelEnabledOverrides`. The map is **scoped per backend by its storage path** (`agentMode.backends..modelEnabledOverrides`), so the key inside the map never repeats the backend id: - -```typescript -interface AgentBackendCommonSettings { - defaultModel?: ModelSelection | null; // { baseModelId, effort } - // Which registry models surface in this backend's in-session model picker. - // Missing entry = default to true (visible). Explicit false = hidden. - modelEnabledOverrides?: Record; -} +``` +copilot-v{8hex-vault-id}-provider-{instanceId}-apiKey +copilot-v{8hex-vault-id}-provider-{instanceId}-extra-{name} ``` -Applied to all backends: `agentMode.backends.opencode`, `agentMode.backends.claude`, `agentMode.backends.codex`, **and** the new `agentMode.backends.quickChat` (declared here as a structural placeholder; full integration is in the follow-up doc). +`ProviderInstance.apiKey` is `{ kind: "keychain"; id: string }` or `{ kind: "inline"; value: string }` depending on whether keychain is available (the `_keychainOnly` setting governs this). -**Source of truth for each backend's picker:** +### 2.2 Settings version field -- **OpenCode picker** — assembles its model list at render time from **three sources, unioned**: - 1. OpenCode's own enumeration of bundled models (Big Pickle, etc.) — queried directly from the running OpenCode binary. - 2. Copilot-Plus hosted models (Plus Flash, etc.) — queried from the Plus license endpoint when active. - 3. BYOK registry entries — `ModelRegistry.list()`. - Then filtered via `agentMode.backends.opencode.modelEnabledOverrides`. None of these enter the BYOK table; they're a per-backend picker concern. -- **Claude Code / Codex pickers** — read each backend's _bundled_ model list (still hard-coded per backend; these are subscription-bound and don't reference the BYOK registry), filtered via `modelEnabledOverrides`. -- **Quick Chat picker (follow-up doc)** — reads `ModelRegistry.list()`, filtered via `agentMode.backends.quickChat.modelEnabledOverrides`. +`settingsVersion: number` on `CopilotSettings`. `0` = original schema, `2` = post-redesign. -**Model-key format inside `modelEnabledOverrides`** depends on what uniquely identifies a model within a single backend's catalog: +Migration runner (`src/modelManagement/migrations/runMigrations.ts`): -- **OpenCode**: bare wire-form `baseModelId` (`anthropic/claude-sonnet-4-5`, `bigpickle/big-pickle`, `copilot-plus/copilot-plus-flash`, `custom:abc-uuid/llama-3.3`). The provider segment is already part of the wire form, so the same `modelId` from two providers (e.g. Anthropic vs OpenRouter) maps to two distinct keys — no collision. -- **Claude Code / Codex**: bare `baseModelId` (`claude-sonnet-4-5`, `gpt-5`). Single-provider backends; `modelId` alone is unique. -- **Quick Chat**: `:` (e.g. `anthropic:claude-opus-4-7`). Quick Chat routes through multiple BYOK providers within one backend slice, so the bare `modelId` would collide; pairing with `providerId` disambiguates. - -The runtime picker (`isAgentModelEnabled` in `src/agentMode/session/modelEnable.ts`) and every settings panel MUST agree on the same key shape per backend — divergence here was the bug behind the M9 follow-up that introduced this section. These keys exist only inside `modelEnabledOverrides` — never in the `registry` array. - -### 2.3 Removed from `CopilotSettings` (legacy → migrated) - -| Removed field | Migrated to | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `openAIApiKey`, `openAIOrgId`, `azureOpenAI*`, `anthropicApiKey`, `googleApiKey`, `cohereApiKey`, `mistralApiKey`, `deepseekApiKey`, `groqApiKey`, `xaiApiKey`, `openRouterAiApiKey`, `siliconflowApiKey`, `amazonBedrockApiKey`, `amazonBedrockRegion`, `huggingfaceApiKey`, `openAIProxyBaseUrl` | `providers[].apiKeyRef` + `providers[].baseUrl` + `providers[].extra` | -| `activeModels: CustomModel[]` (chat half) | `registry: RegistryEntry[]` | -| `defaultModelKey` | **Re-seeded as** `agentMode.backends.quickChat.defaultModel` (so the user's last-chosen chat model is what Quick Chat boots with). Field itself deleted. | -| Per-`CustomModel` overrides: `temperature`, `maxTokens`, `topP`, `frequencyPenalty`, `numCtx`, `reasoningEffort`, `verbosity`, `stream`, `streamUsage`, `useResponsesApi`, `enablePromptCaching`, `enableCors`, `capabilities` (user-set) | **Dropped** — surfaced via one-time toast (§4.4) | -| `agentMode.enabled` | Removed — desktop is always agent-capable | - -**Kept unchanged:** - -- `activeEmbeddingModels` (moved to a new "Embeddings" tab UI-wise in M3, but the data field is untouched) -- `agentMode.backends.opencode`, `agentMode.backends.claude`, `agentMode.backends.codex` (existing `modelEnabledOverrides` are now keyed by the bare wire-form `baseModelId` after migration; see §2.2) -- `agentMode.byok` (kept; OpenCode reads it for agent-mode credentials) -- `temperature`, `maxTokens`, `reasoningEffort`, `verbosity` (kept as **global** defaults, no longer per-model) -- GitHub Copilot OAuth fields (`githubCopilotAccessToken` etc.) — these are OAuth tokens not user-entered keys; they live alongside `providers["github-copilot"]` but aren't migrated into `apiKeyRef` - -### 2.4 Settings version field - -Add `settingsVersion: number` to `CopilotSettings` with a default of `0` for unmigrated settings. - -- `0` = original (any settings written before this redesign ships) -- `2` = post-migration - -**Migration runner** (`src/settings/migrations/runMigrations.ts`): - -```typescript +```ts type Migration = (raw: any) => any; -const MIGRATIONS: Record = { - 2: migrateV0toV2, -}; +const MIGRATIONS: Record = { 2: migrateV0toV2 }; function runMigrations(raw: any): { settings: any; migrationsApplied: number[] }; ``` -`runMigrations` is called once inside `sanitizeSettings` before any other normalization, on every settings load. Idempotent (no-op when `settingsVersion === current`). +Called once inside `sanitizeSettings` before any other normalization, on every settings load. Idempotent (no-op when `settingsVersion === current`). -**How we know a user has migrated:** `settings.settingsVersion >= 2`. We also write a sticky breadcrumb `settings._migrationBreadcrumbs: Array<{ from: number; to: number; appliedAt: number; droppedFields?: string[] }>` for forensics (also surfaces the toast content in M2). - -### 2.5 API key storage - -The existing `KeychainService` (`src/services/keychainService.ts`) already supports per-field keychain storage with a vault-scoped ID scheme. Extend it with a new namespace: - -``` -copilot-v{8hex-vault-id}-provider-{providerId}-apiKey -copilot-v{8hex-vault-id}-provider-{providerId}-extra-{name} -``` - -`ProviderConfig.apiKeyRef` is `{ kind: "keychain"; id: string }` or `{ kind: "inline"; value: string }` depending on whether keychain is available (the `_keychainOnly` setting governs this — preserved from current behavior). Migration moves keys from the existing top-level keychain entries (`copilot-v{id}-openai-api-key`) to the new namespace. +A sticky breadcrumb `settings._migrationBreadcrumbs: Array<{ from: number; to: number; appliedAt: number; droppedFields?: string[] }>` records what ran, for forensics and to power the one-time toast in §4.4. --- @@ -328,20 +247,24 @@ All provider, model, catalog, and BYOK-UI code lives in a single top-level modul ```typescript // src/modelManagement/index.ts — the ONLY file outside callers may import from export { ProviderRegistry } from "./providers/ProviderRegistry"; -export { ModelRegistry } from "./registry/ModelRegistry"; +export { EnrollmentRegistry } from "./registry/EnrollmentRegistry"; +export { ConsumerRegistry } from "./consumers/ConsumerRegistry"; export { ModelCatalogService } from "./catalog/ModelCatalogService"; export { ChatModelManager } from "./chatModel/ChatModelManager"; export type { - ProviderId, - ProviderConfig, - RegistryEntry, - ModelCapability, + InstanceId, + ProviderTypeId, + ProviderInstance, + ByokEnrollment, + ConsumerId, + ConsumerConfig, + ConsumerModelRef, KeychainRef, VerificationResult, } from "./types"; export { ByokPanel } from "./ui/tabs/ByokPanel"; export { runModelManagementMigrations } from "./migrations/runMigrations"; -export { SUPPORTED_PROVIDER_IDS } from "./providers/supportedProviders"; +export { SUPPORTED_PROVIDER_TYPE_IDS } from "./providers/supportedProviders"; ``` **Enforcement:** `eslint-plugin-import`'s `no-restricted-paths` rule: @@ -354,96 +277,106 @@ export { SUPPORTED_PROVIDER_IDS } from "./providers/supportedProviders"; from: "./src/modelManagement/!(index.ts)", message: "Import model management code via @/modelManagement (the module's public API), " + - "not from internal files. See designdocs/MODEL_MANAGEMENT_IMPLEMENTATION.md §3.0.", + "not from internal files. See designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §3.0.", }], }] ``` -**Why a single module:** Today provider knowledge is scattered across `src/LLMProviders/`, `src/settings/v2/components/ModelSettings.tsx`, `src/constants.ts` (BUILTIN_CHAT_MODELS), and ~25 top-level settings fields. Pulling all of that into one boundary makes the responsibility explicit: model management owns providers + models + catalog + the BYOK UI. Consumers (agent backends, chat input, embeddings) talk to it through a small surface and never reach inside. +The module owns providers + enrollments + consumers + catalog + the BYOK UI. Consumers (agent backends, chat input, embeddings) talk to it through this surface and never reach inside. **What stays outside the module:** -- Agent backend internals (`src/agentMode/backends/*`) — they consume `ModelRegistry` + `ProviderRegistry` via the public API. +- Agent backend internals (`src/agentMode/backends/*`) — they consume `EnrollmentRegistry` + `ProviderRegistry` + `ConsumerRegistry` via the public API. - Chat view + chat input — same. - Embeddings management — keeps its own surface in `src/embedding/` (separate workstream). - Settings shell / non-model tabs (`Chat`, `Commands`, etc.) — they just register `ByokPanel` as a tab component. -### 3.1 `src/modelManagement/providers/ProviderRegistry.ts` (NEW) +### 3.1 `src/modelManagement/providers/ProviderRegistry.ts` -Source of truth for provider credentials and metadata. Wraps `settings.providers` + keychain. +Source of truth for `ProviderInstance` rows. Wraps `settings.providers` + keychain. ```typescript class ProviderRegistry { - list(): ProviderConfig[]; - get(id: ProviderId): ProviderConfig | undefined; - add(config: Omit): Promise; - update(id: ProviderId, patch: Partial): Promise; - remove(id: ProviderId): Promise; // removes provider + all its registry entries - getApiKey(id: ProviderId): Promise; - verify(id: ProviderId): Promise; + list(): ProviderInstance[]; + get(id: InstanceId): ProviderInstance | undefined; + listByTypeId(typeId: ProviderTypeId): ProviderInstance[]; // multi-instance lookup + add(config: Omit): Promise; + update(id: InstanceId, patch: Partial): Promise; + remove(id: InstanceId): Promise; // cascades to enrollments + consumer refs + getApiKey(id: InstanceId): Promise; + verify(id: InstanceId): Promise; } ``` -### 3.2 `src/modelManagement/registry/ModelRegistry.ts` (NEW) +### 3.2 `src/modelManagement/registry/EnrollmentRegistry.ts` -Source of truth for enabled models (BYOK registry). Wraps `settings.registry`. +Source of truth for `ByokEnrollment` rows. Wraps `settings.enrollments`. ```typescript -class ModelRegistry { - list(filter?: { providerId?: ProviderId; capability?: ModelCapability }): RegistryEntry[]; - get(providerId: ProviderId, modelId: string): RegistryEntry | undefined; - add(entry: Omit): Promise; - remove(providerId: ProviderId, modelId: string): Promise; - // Used by chat input model picker — delegates to the active agent backend's picker - // (resolution logic lives in the follow-up doc). - // Used by Agent tab's per-backend picker section - listForAgentPicker(backendId: BackendId): RegistryEntry[]; - // Bulk operations for Configure Provider modal save - bulkSet(providerId: ProviderId, entries: RegistryEntry[]): Promise; +class EnrollmentRegistry { + list(filter?: { instanceId?: InstanceId }): ByokEnrollment[]; + get(instanceId: InstanceId, modelId: string): ByokEnrollment | undefined; + add(entry: Omit): Promise; + remove(instanceId: InstanceId, modelId: string): Promise; + bulkSet(instanceId: InstanceId, entries: ByokEnrollment[]): Promise; // Configure Provider save + isCustomModel(e: ByokEnrollment): boolean; // catalog membership check } ``` -Notable: `updateVisibility` is **removed** from the earlier draft. Visibility is no longer a per-registry-entry property — it's a per-agent-backend curation handled by each backend's `modelEnabledOverrides`. +### 3.3 `src/modelManagement/consumers/ConsumerRegistry.ts` -### 3.3 `src/modelManagement/catalog/ModelCatalogService.ts` (NEW) +Source of truth for `ConsumerConfig` rows. Wraps `settings.consumers`. -Already covered in §1.4. Lazy, read-only facade with two-tier fallback. +```typescript +class ConsumerRegistry { + get(id: ConsumerId): ConsumerConfig; + setEnabledModels(id: ConsumerId, refs: ConsumerModelRef[]): Promise; + setDefaultModel(id: ConsumerId, ref: ConsumerModelRef | null): Promise; + // Resolves enabledModels against current providers/enrollments + runtime BackendInventory. + // Returns the entries the picker should render for this consumer. + visibleEntries(id: ConsumerId): VisibleEntry[]; +} +``` -### 3.4 `ChatModelManager` refactor +### 3.4 `src/modelManagement/catalog/ModelCatalogService.ts` -Current: reads ~15 different fields off `CopilotSettings` to build LangChain clients. +Covered in §1.4. Lazy, read-only facade with two-tier fallback. -After: reads `ProviderRegistry.get(...)` + `ModelRegistry.get(...)` to assemble a `ChatModelConfig`. Per-model overrides removed; global `temperature` / `maxTokens` / `reasoningEffort` still honored. +### 3.5 `ChatModelManager` refactor -A pure helper `buildLangChainConfig(provider: ProviderConfig, entry: RegistryEntry, defaults: ChatDefaults): ChatModelConfig` lives in its own file (no LangChain imports — unit-testable per `AGENTS.md` testing guidance). +Builds a LangChain client by reading `ProviderRegistry.get(instanceId)` + `EnrollmentRegistry.get(instanceId, modelId)`. No `ChatDefaults`, no per-invocation `temperature` / `maxTokens` / `reasoningEffort` / `verbosity`. Adapter knobs (OpenRouter prompt caching, OpenAI-compatible CORS, Ollama `numCtx`, …) flow from `ByokEnrollment.overrides`. -**Out of scope here:** The integration between `ChatModelManager` and the new Quick Chat agent backend (so the chat input → Quick Chat → LangChain wiring works end-to-end) — see `designdocs/QUICK_CHAT_AGENT_INTEGRATION.md`. +A pure helper `buildLangChainConfig(provider: ProviderInstance, enrollment: ByokEnrollment): ChatModelConfig` lives in its own file (no LangChain imports — unit-testable per `AGENTS.md` testing guidance). -### 3.5 Folder layout (everything model-related lives here) +**Out of scope here:** Quick Chat integration (chat input → Quick Chat → LangChain wiring) — see `designdocs/QUICK_CHAT_AGENT_INTEGRATION.md`. + +### 3.6 Folder layout (everything model-related lives here) ``` src/ ├─ modelManagement/ ← THE MODULE (one boundary) │ ├─ index.ts ← public API surface (§3.0) -│ ├─ types.ts ← ProviderConfig, RegistryEntry, … +│ ├─ types.ts ← ProviderInstance, ByokEnrollment, ConsumerConfig, … │ ├─ catalog/ -│ │ ├─ ModelCatalogService.ts ← lazy + 3-tier read +│ │ ├─ ModelCatalogService.ts ← lazy + 2-tier read │ │ └─ modelsCatalog.types.ts │ ├─ providers/ │ │ ├─ ProviderRegistry.ts -│ │ ├─ supportedProviders.ts ← SUPPORTED_PROVIDER_IDS (single source) -│ │ └─ adapters/ ← was src/LLMProviders/ — relocated +│ │ ├─ supportedProviders.ts ← SUPPORTED_PROVIDER_TYPE_IDS (single source) +│ │ └─ adapters/ │ │ ├─ AnthropicAdapter.ts (each adapter exports langchain factory │ │ ├─ OpenAIAdapter.ts + extraSchema: z.ZodSchema) │ │ ├─ GoogleAdapter.ts │ │ ├─ AzureAdapter.ts ← uses extraSchema for instance/deployment/version │ │ ├─ BedrockAdapter.ts ← uses extraSchema for region │ │ ├─ ... -│ │ └─ index.ts ← adapter registry keyed by ProviderId +│ │ └─ index.ts ← adapter registry keyed by AdapterKind │ ├─ registry/ -│ │ └─ ModelRegistry.ts +│ │ └─ EnrollmentRegistry.ts +│ ├─ consumers/ +│ │ └─ ConsumerRegistry.ts │ ├─ chatModel/ -│ │ ├─ ChatModelManager.ts ← refactored to read Provider+Model registries +│ │ ├─ ChatModelManager.ts ← reads ProviderRegistry + EnrollmentRegistry │ │ └─ buildLangChainConfig.ts ← pure helper (no LangChain import) │ ├─ migrations/ │ │ ├─ runMigrations.ts @@ -462,10 +395,10 @@ src/ │ ├─ settings/ │ ├─ model.ts ← schema only; thin (uses modelManagement types) -│ ├─ v2/ ← legacy; selected files retired in M9 +│ ├─ v2/ ← Chat / Commands / Embedding tabs │ │ └─ components/ -│ │ ├─ QASettings.tsx (renamed "Embedding" tab, extended in M3) -│ │ └─ EmbeddingModelsSection.tsx (extracted in M3 — used by QASettings) +│ │ ├─ QASettings.tsx ← renamed "Embedding" tab +│ │ └─ EmbeddingModelsSection.tsx │ └─ v3/ ← only the NON-model tabs (Agent, …) │ ├─ tabs/ │ │ └─ AgentPanel.tsx (consumes @/modelManagement) @@ -478,7 +411,7 @@ src/ No build-time snapshot. The catalog is purely a runtime concern — live fetch + disk cache, no bundled JSON. -### 3.6 Provider adapters own their `extra` shape +### 3.7 Provider adapters own their `extra` shape Each adapter under `src/modelManagement/providers/adapters/` exports two things: @@ -493,18 +426,17 @@ export const extraSchema = z.object({ }).strict(); export function buildLangChainClient( - provider: ProviderConfig, - entry: RegistryEntry, - defaults: ChatDefaults, + provider: ProviderInstance, + enrollment: ByokEnrollment, ): BaseChatModel { - const extra = extraSchema.parse(provider.extra); // throws → caught + surfaced as verification error + const extra = extraSchema.parse(provider.extras); // throws → caught + surfaced as verification error return new AzureChatOpenAI({ ... extra ... }); } ``` `extraSchema` defaults to `z.object({}).strict()` for adapters with no extras. The Configure Provider dialog uses the schema (via a `getExtraFormFields(adapter)` helper) to render the right inputs in the "advanced" section — adding a new provider's extra field is a one-line schema change, no UI rewrite required. -This keeps `ProviderConfig.extra: Record` opaque at the core-type level while letting each provider declare exactly what it needs. +This keeps `ProviderInstance.extras: Record` opaque at the core-type level while letting each provider declare exactly what it needs. --- @@ -516,79 +448,80 @@ Inside `sanitizeSettings(raw)` in `src/settings/model.ts`, immediately after par ### 4.2 Migration steps (v0 → v2) -Implemented in `src/settings/migrations/v0-to-v2.ts`. Order matters: +Implemented in `src/modelManagement/migrations/v0-to-v2.ts`. Order matters: -1. **Initialize new shape** — `settings.providers = {}`, `settings.registry = []`, `settings._migrationBreadcrumbs = settings._migrationBreadcrumbs ?? []`. +1. **Initialize new shape** — `settings.providers = {}`, `settings.enrollments = []`, `settings.consumers = {}`, `settings._migrationBreadcrumbs = settings._migrationBreadcrumbs ?? []`. -2. **Provider keys → `providers` map.** For each non-empty legacy field, synthesize a `ProviderConfig`: +2. **Built-in provider keys → `ProviderInstance` rows.** For each non-empty legacy provider field, synthesize one `ProviderInstance` with a fresh UUID `instanceId` and `providerTypeId` from this table: - | Legacy field(s) | Synthesized provider | - | ------------------------------------------------------ | --------------------------------------------------------------------- | - | `openAIApiKey` (+ `openAIOrgId`, `openAIProxyBaseUrl`) | `providers["openai"]` | - | `anthropicApiKey` | `providers["anthropic"]` | - | `googleApiKey` | `providers["google"]` | - | `cohereApiKey` | `providers["cohere"]` | - | `mistralApiKey` | `providers["mistral"]` | - | `deepseekApiKey` | `providers["deepseek"]` | - | `groqApiKey` | `providers["groq"]` | - | `xaiApiKey` | `providers["xai"]` | - | `openRouterAiApiKey` | `providers["openrouter"]` | - | `siliconflowApiKey` | `providers["siliconflow"]` | - | `amazonBedrockApiKey` (+ `amazonBedrockRegion`) | `providers["amazon-bedrock"]` (with `extra.bedrockRegion`) | - | `azureOpenAIApiKey` (+ instance/deployment/version) | `providers["azure"]` (with `extra.azure*`) | - | `huggingfaceApiKey` | **Dropped** (not in design's provider allowlist — log to breadcrumbs) | + | Legacy field(s) | Synthesized provider | + | ------------------------------------------------------ | --------------------------------------------------------------------------------------- | + | `openAIApiKey` (+ `openAIOrgId`, `openAIProxyBaseUrl`) | `providerTypeId: "openai"` (extras: `openAIOrgId`, `baseUrl` if proxy set) | + | `anthropicApiKey` | `providerTypeId: "anthropic"` | + | `googleApiKey` | `providerTypeId: "google"` | + | `cohereApiKey` | `providerTypeId: "cohere"` | + | `mistralApiKey` | `providerTypeId: "mistral"` | + | `deepseekApiKey` | `providerTypeId: "deepseek"` | + | `groqApiKey` | `providerTypeId: "groq"` | + | `xaiApiKey` | `providerTypeId: "xai"` | + | `openRouterAiApiKey` | `providerTypeId: "openrouter"` | + | `siliconflowApiKey` | `providerTypeId: "siliconflow"` | + | `amazonBedrockApiKey` (+ `amazonBedrockRegion`) | `providerTypeId: "amazon-bedrock"` (extras: `bedrockRegion`) | + | `azureOpenAIApiKey` (+ instance/deployment/version) | `providerTypeId: "azure"` (extras: `azureInstanceName`, `azureDeploymentName`, version) | + | `huggingfaceApiKey` | **Dropped** (not in `SUPPORTED_PROVIDER_TYPE_IDS` — log to breadcrumbs) | - Each synthesized provider gets `kind: "builtin"`, `addedAt: Date.now()`. No `availability` field (removed from the data model). + Each synthesized `ProviderInstance` gets `displayName` defaulted from the `ProviderType.displayName`, `addedAt: Date.now()`. -3. **Custom-provider `CustomModel` entries → `providers["custom:"]`.** Group `activeModels` entries by unique `{baseUrl, apiKey}` tuple (when `provider` is `OPENAI_FORMAT`, `OLLAMA`, `LM_STUDIO`, or any `isBuiltIn: false` entry with a `baseUrl`). Each group becomes one custom `ProviderConfig`: +3. **Custom-provider `CustomModel` entries → custom `ProviderInstance` rows.** Group legacy `activeModels` entries by unique `{baseUrl, apiKey}` tuple (when `provider` is `OPENAI_FORMAT`, `OLLAMA`, `LM_STUDIO`, or any `isBuiltIn: false` entry with a `baseUrl`). Each group becomes one `ProviderInstance`: ``` - id: "custom:" - kind: "custom" - displayName: - type: provider type inferred from `provider` field (default: "openai-compatible") - baseUrl: - apiKeyRef: + instanceId: + providerTypeId: "openai-compatible" | "ollama" | "lmstudio" (inferred from `provider` field) + displayName: baseUrl-derived label, e.g. "Ollama (localhost:11434)" + baseUrl: + apiKey: { kind: "keychain", id: "…" } or { kind: "inline", value: "…" } ``` -4. **`activeModels` (chat half) → `registry`.** For each entry, decide first whether to drop or migrate: - - **Drop** (do not create a registry entry; log to breadcrumbs) if **any** of these are true: - - `entry.isEmbeddingModel === true` — handled by `activeEmbeddingModels`, unchanged. - - `entry.enabled === false` — the new model has no per-model visibility flag; treat as deleted. - - `entry.isBuiltIn === true` **and** the provider step (2 or 3) did NOT produce a `ProviderConfig` for that provider (i.e., the user never set up an API key for the provider that ships this built-in model). The user was never actually using it — it was just visual clutter from the legacy `BUILTIN_CHAT_MODELS` list. - - `entry.provider` is OpenCode-bundled or Copilot Plus — those no longer live in the registry. Their selections are forwarded into `agentMode.backends.opencode.modelEnabledOverrides` keyed by the bare wire-form `baseModelId` (e.g. `bigpickle/big-pickle`, `copilot-plus/copilot-plus-flash`) so the OpenCode picker keeps showing them. +4. **`activeModels` (chat half) → `enrollments`.** For each entry, drop-or-migrate: + - **Drop** (no enrollment row; log to breadcrumbs) if **any** is true: + - `entry.isEmbeddingModel === true` (handled by legacy `activeEmbeddingModels`). + - `entry.enabled === false` (no per-enrollment visibility flag in the new shape). + - `entry.isBuiltIn === true` **and** no `ProviderInstance` was synthesized for that provider type in step 2. The user never had a key — the entry was just legacy `BUILTIN_CHAT_MODELS` clutter. + - `entry.provider` is OpenCode-bundled or Copilot Plus. Those go through `consumers["agent:opencode"].enabledModels` (see step 6) with `source: "backend-bundled"` or `source: "copilot-plus"`, never into `enrollments`. - - Otherwise **migrate** to a `RegistryEntry`: + - Otherwise **migrate** to a `ByokEnrollment`: ``` - providerId: canonical-id-for-entry.provider OR the custom-provider id from step 3 - modelId: entry.name - displayName: entry.displayName ?? entry.name - addedAt: Date.now() + instanceId: + modelId: entry.name + displayName: entry.displayName ?? entry.name + enrolledAt: Date.now() + declaredContextLimit: entry.contextLength (only if custom — no catalog match) + declaredCapabilities: derived from entry.capabilities (only if custom) + overrides: { ollama numCtx / openrouter promptCaching / openai-compat CORS } + (only the adapter-knob fields; chat-tuning fields are dropped — see step 5) ``` - Migration does not read the catalog. Capability tags, context window, and release date are not stored on the registry — catalog data is consulted at the point of use instead. See the follow-up section "Future migration: CustomModel capability removal" for the legacy capability consumers still living on `CustomModel`. + Migration does not read the catalog. For catalog-backed enrollments, `declared*` fields stay undefined and runtime readers fall back to `CatalogModel`. Net effect: a user with no legacy keys ends up with `providers = {}` and `enrollments = []`; a user with the OpenAI key only keeps OpenAI enrollments (Anthropic/Google built-ins drop). - Net effect for built-in handling: a user who never configured any keys ends up with `providers = {}` and `registry = []` — nothing to migrate, nothing surfaced. A user who configured the OpenAI key keeps only the OpenAI models they were actually using (built-in entries for OpenAI migrate in; built-in entries for other providers without keys are dropped). The toast in §4.4 reports the dropped count. +5. **Per-`CustomModel` chat-tuning overrides → dropped, logged.** `temperature`, `maxTokens`, `topP`, `frequencyPenalty`, `reasoningEffort`, `verbosity`, `stream`, `streamUsage`, `useResponsesApi`, `capabilities` (user-set) get pushed to `_migrationBreadcrumbs[*].droppedFields`. The toast in §4.4 reads from this. (Adapter knobs like `numCtx`, `enablePromptCaching`, `enableCors` move to `ByokEnrollment.overrides` per step 4.) -5. **`activeModels` per-model overrides → dropped, logged to breadcrumbs.** For each entry that had any of `temperature`, `maxTokens`, `topP`, `frequencyPenalty`, `numCtx`, `reasoningEffort`, `verbosity`, `stream`, `streamUsage`, `useResponsesApi`, `enablePromptCaching`, `enableCors`, `capabilities`, push to `_migrationBreadcrumbs[*].droppedFields`. The toast in §4.4 reads from this. +6. **Build `settings.consumers` for ALL consumers.** + - LangChain-side consumers (`chat`, `vault-qa`, `project`, `copilot-plus`, `quick-chat`): initialize as `{ enabledModels: [], defaultModel: }`. Empty `enabledModels` is the "everything enabled" heuristic, preserving current behavior. `defaultModel` is resolved by looking up legacy `defaultModelKey` (`|` format) against the new `enrollments`; on a successful match, set `defaultModel: { source: "byok", enrollmentRef: "${instanceId}::${modelId}" }` for the `chat` consumer (other LangChain consumers get the same default for continuity). + - Agent consumers (`agent:opencode`, `agent:claude-code`, `agent:codex`): translate legacy `agentMode.backends..modelEnabledOverrides` into `enabledModels: ConsumerModelRef[]`. Each entry becomes: + - BYOK match (`/` or `|` resolvable against `enrollments`): `{ source: "byok", enrollmentRef }`. + - OpenCode bundled (`bigpickle/…` etc.): `{ source: "backend-bundled", backendId: "opencode", backendModelId }`. + - Copilot Plus (`copilot-plus/…`): `{ source: "copilot-plus", modelId }`. + - For Claude Code / Codex bare model ids: `{ source: "backend-bundled", backendId, backendModelId: modelId }`. + Drop unresolved entries; log to breadcrumbs. + - `command:` consumers: not created upfront. The commands subsystem creates them lazily the first time a command pins a model. -6. **Agent overrides re-keyed.** Existing `agentMode.backends..modelEnabledOverrides` uses model-name keys (or panel-prefixed `:` keys from an earlier draft of M9). Normalize per §2.2: - - **opencode**: bare wire-form `baseModelId`. Legacy `|` becomes `/` (resolved against the providers map; orphans dropped to breadcrumbs); panel-prefixed `opencode:` keeps `` (already wire form); `copilot-plus:` becomes `copilot-plus/`; `:` (longest-prefix match against the providers map) becomes `/`. - - **claude / codex**: bare model name. Legacy `|` keeps ``; panel-prefixed `:` keeps ``. - - **quickChat**: keep `:` form. - If a key cannot be resolved to a registered model (e.g. the model was disabled in step 4), drop the override (logged to breadcrumbs). Overrides are **kept** for all four backends — none are folded into a registry-level flag. +7. **Drop fields not represented in the new data model.** `defaultModelKey`, `temperature`, `maxTokens`, `reasoningEffort`, `verbosity`, `topP`, `frequencyPenalty`, `agentMode.enabled`, `agentMode.backends..modelEnabledOverrides`. Log each dropped field-path to breadcrumbs. -7. **`defaultModelKey` → Quick Chat default.** Resolve `defaultModelKey` (`|` format) to a `RegistryEntry`. Seed `agentMode.backends.quickChat = { defaultModel: { baseModelId: ":", effort: null }, modelEnabledOverrides: {} }`. If no match, leave Quick Chat without a default. Then delete `defaultModelKey`. +8. **API keys → keychain.** If keychain is available (`_keychainOnly` is true), each migrated provider's `apiKey` is moved into the new `provider--apiKey` keychain entry and the legacy entry (`-api-key`) is deleted. If keychain unavailable, `apiKey = { kind: "inline", value: }`. - _(The Quick Chat backend infrastructure itself is in the follow-up doc; this migration step only ensures the data is in place so the follow-up implementation has something to read.)_ +9. **Delete legacy top-level fields** — remove all the legacy provider-key fields (§4.2 step 2 table) from the settings object so they're gone from `data.json` after first save. -8. **`agentMode.enabled` → dropped.** Desktop is always agent-capable. - -9. **API keys → keychain.** If keychain is available (`_keychainOnly` is true), each migrated provider's `apiKeyRef` is moved into the new `provider--apiKey` keychain entry and the legacy entry (`-api-key`) is deleted. If keychain unavailable, `apiKeyRef = { kind: "inline", value: }`. - -10. **Delete legacy top-level fields** — remove all the legacy provider key fields (see §2.3 table) from the settings object so they're gone from `data.json` after first save. - -11. **Stamp version** — `settings.settingsVersion = 2`; append breadcrumb `{ from: 0, to: 2, appliedAt: Date.now(), droppedFields: [...all dropped per-model override field-paths] }`. +10. **Stamp version** — `settings.settingsVersion = 2`; append breadcrumb `{ from: 0, to: 2, appliedAt: Date.now(), droppedFields: [...] }`. ### 4.3 Backwards-compatibility safety net @@ -609,8 +542,8 @@ After successful v0→v2 migration, on the next plugin tick, surface a dismissib ``` Copilot settings upgraded. • Per-model temperature / max-tokens / capability overrides removed. -• Default chat model now lives under Agent → Quick Chat. -• Provider keys moved to the new BYOK tab. +• Provider keys moved to the BYOK tab. +• Default chat model is now per-consumer (Chat, Vault QA, Project, …). • Pre-listed built-in models removed for providers you hadn't configured. (4 removed) Open BYOK tab → Dismiss ``` @@ -632,14 +565,14 @@ A new dev command `Copilot: Show settings migration status` (registered in `main ## 5. UI surface map -| Tab (settings modal) | Implementation | -| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------- | -| **BYOK** (NEW; central registry — providers + models) | `src/modelManagement/ui/tabs/ByokPanel.tsx` | -| **Agent** (REPLACED; OpenCode / Claude Code / Codex / Quick Chat sub-tabs) | `src/settings/v3/tabs/AgentPanel.tsx` | -| **Embedding** (RENAMED from "QA"; existing QA settings stay where they are, embedding model section added at the bottom) | existing QA tab file, extended in-place | -| Chat (renamed from "Basic"; cosmetic only — content unchanged) | existing v2 component, header label tweak | -| Commands (renamed from "Chat & Commands"; cosmetic only) | existing v2 component, header label tweak | -| Advanced, etc. (UNCHANGED) | existing v2 components, no rewrite | +| Tab (settings modal) | Implementation | +| -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| **Chat** | `src/settings/v2/components/BasicSettings.tsx` (provider-key portion removed) | +| **BYOK** — central registry of providers + enrolled models | `src/modelManagement/ui/tabs/ByokPanel.tsx` | +| **Agent** — OpenCode / Claude Code / Codex / Quick Chat sub-tabs | `src/settings/v3/tabs/AgentPanel.tsx` | +| **Commands** | existing v2 component | +| **Embedding** — semantic-search settings up top, embedding-model section at the bottom | `src/settings/v2/components/QASettings.tsx` | +| **Advanced**, etc. | existing v2 components | | Modal | Implementation | | ----------------------------- | ------------------------------------------------------------ | @@ -777,7 +710,7 @@ Each sub-tab includes: #### 5.4.3 Quick chat sub-tab -- Picker section sources from `ModelRegistry.list()`. +- Picker section sources from `EnrollmentRegistry.list()` filtered by `ProviderType.pipelines.langchain`, persisted through `consumers["quick-chat"]`. - Implementation is a **placeholder** in this plan (UI shell + persistence wiring only). The complete wiring of "user picks a Quick-Chat-curated model in the chat input → LangChain chat fires in the new chat view" is the subject of the follow-up doc (`designdocs/QUICK_CHAT_AGENT_INTEGRATION.md`). --- @@ -798,7 +731,7 @@ Each milestone is independently shippable and verifiable. Verification = unit te - **Module skeleton:** - `src/modelManagement/index.ts` with the public API barrel per §3.0. - - `src/modelManagement/types.ts` with `ProviderConfig`, `RegistryEntry`, `ProviderId`, etc. + - `src/modelManagement/types.ts` with `ProviderInstance`, `ByokEnrollment`, `InstanceId`, etc. - `src/modelManagement/providers/supportedProviders.ts` — `SUPPORTED_PROVIDER_IDS` constant per §1.2. - ESLint configuration: add `import/no-restricted-paths` rule per §3.0. Verify a deliberate violation (an import of `src/modelManagement/catalog/ModelCatalogService` from anywhere outside the module) fails `npm run lint`. - **Catalog:** @@ -819,8 +752,8 @@ Each milestone is independently shippable and verifiable. Verification = unit te 1. `npm run test -- ModelCatalogService` — all green. 2. `npm run lint` — clean. Then add a deliberate `import { ModelCatalogService } from "@/modelManagement/catalog/ModelCatalogService"` in `src/main.ts` → `npm run lint` should report a boundary violation. Revert. 3. Manual smoke (load plugin): - - Open Settings → existing legacy tabs work normally; plugin onload **does not** trigger any models.dev request (verify via dev-tools network panel). - - `ModelCatalogService.getInstance().getMeta()` returns `{ fetchedAt: null, source: "bundled" }` until something invokes `ensureLoaded()`. (The `source` string still reads `"bundled"` as a legacy sentinel; semantically it means "no live data yet.") + - Open Settings → non-model tabs work normally; plugin onload **does not** trigger any models.dev request (verify via dev-tools network panel). + - `ModelCatalogService.getInstance().getMeta()` returns `{ fetchedAt: null, source: null }` until something invokes `ensureLoaded()`. - Calling `ensureLoaded()` (e.g. via a temporary dev command) reads disk cache or leaves memory empty; calling `refresh()` triggers a live fetch and updates disk + memory. **Out of scope:** No UI consumes the catalog yet beyond a dev-mode smoke logger; lazy hook into BYOK tab open is wired in M4. @@ -829,137 +762,135 @@ Each milestone is independently shippable and verifiable. Verification = unit te ### M2 — Schema, migration, and service skeleton -**Goal:** Introduce `settingsVersion`, `providers`, `registry`; write v0→v2 migration; refactor `ChatModelManager` to read the new shape. After this milestone, the plugin behaves identically to before from the user's perspective — only the internal data layout has changed. +**Goal:** Introduce `settingsVersion`, `providers`, `enrollments`, `consumers`; write v0 → v2 migration; refactor `ChatModelManager` to read the new shape. After this milestone the plugin behaves identically to before from the user's perspective — only the internal data layout has changed. **Deliverables:** -- `src/settings/model.ts` — slimmed to schema only. Re-exports `ProviderConfig` / `RegistryEntry` / `ProviderId` etc. from `@/modelManagement` rather than redefining. Add `settingsVersion`, `providers`, `registry`, `_migrationBreadcrumbs`, `_migrationNoticeDismissed` fields. Remove the legacy provider-key fields, `defaultModelKey`, and `agentMode.enabled` from the interface. Add `agentMode.backends.quickChat` skeleton type (per §2.2). -- `src/modelManagement/migrations/runMigrations.ts` + `v0-to-v2.ts` — runner + v0→v2 implementation. **Migration must run synchronously** — it cannot await the catalog service. It does not read the catalog at all; registry entries are built from v0 data alone (no capability inference, no contextWindow/releaseDate enrichment). -- `src/modelManagement/providers/ProviderRegistry.ts` and `src/modelManagement/registry/ModelRegistry.ts` — full implementations. -- `src/modelManagement/providers/adapters/` — relocate `src/LLMProviders/*` here. Each adapter file exports `buildLangChainClient(...)` + `extraSchema: z.ZodSchema` per §3.6. `index.ts` exposes the adapter registry. -- `src/modelManagement/chatModel/ChatModelManager.ts` — refactored to read from `ProviderRegistry` + `ModelRegistry` only; consults the adapter registry for instantiation. -- `src/modelManagement/chatModel/buildLangChainConfig.ts` — extracted pure helper + unit tests. +- `src/settings/model.ts` — slimmed to schema only. Re-exports `ProviderInstance` / `ByokEnrollment` / `ConsumerConfig` / `InstanceId` / `ProviderTypeId` etc. from `@/modelManagement`. Add `settingsVersion`, `providers`, `enrollments`, `consumers`, `_migrationBreadcrumbs`, `_migrationNoticeDismissed` fields. Remove the legacy provider-key fields, `defaultModelKey`, the global chat knobs (`temperature` / `maxTokens` / `reasoningEffort` / `verbosity` / `topP` / `frequencyPenalty`), and `agentMode.enabled` from the interface. Add `agentMode.backends.quickChat` skeleton type. +- `src/modelManagement/migrations/runMigrations.ts` + `v0-to-v2.ts` — runner + v0→v2 implementation per §4.2. **Migration must run synchronously** — it cannot await the catalog service. It does not read the catalog at all; enrollments are built from v0 data alone (no capability inference, no `contextWindow` / `releaseDate` enrichment). +- `src/modelManagement/providers/ProviderRegistry.ts`, `src/modelManagement/registry/EnrollmentRegistry.ts`, `src/modelManagement/consumers/ConsumerRegistry.ts` — full implementations per §3.1-3.3. +- `src/modelManagement/providers/adapters/` — adapter classes, one per `AdapterKind`. Each adapter file exports `buildLangChainClient(...)` + `extraSchema: z.ZodSchema` per §3.7. `index.ts` exposes the adapter registry keyed by `AdapterKind`. +- `src/modelManagement/chatModel/ChatModelManager.ts` — reads `ProviderRegistry` + `EnrollmentRegistry` only; consults the adapter registry for instantiation. +- `src/modelManagement/chatModel/buildLangChainConfig.ts` — pure helper + unit tests. - Migration notice toast (§4.4) wired in `main.ts` `onload` after settings load. - Dev command `Copilot: Show settings migration status` registered. - `src/modelManagement/migrations/__tests__/v0-to-v2.test.ts` — fixture-based tests: - - Fresh install (empty settings) → `settingsVersion = 2`, `providers = {}`, `registry = []`, `agentMode.backends.quickChat = { defaultModel: null, modelEnabledOverrides: {} }`. - - Settings with only OpenAI key (no `activeModels`) → one provider, no registry entries. - - Settings with `activeModels` containing only built-in entries and **no** provider keys → `providers = {}`, `registry = []` (every built-in dropped because no key existed). - - Settings with OpenAI key configured + built-in entries from Anthropic, Google, OpenAI → only the OpenAI built-ins migrate; Anthropic + Google built-ins dropped (no keys); breadcrumb lists the dropped models. - - Settings with `activeModels` mix of built-in + custom + Ollama → providers + registry entries split correctly; embedding models stay in `activeEmbeddingModels`. - - Settings with per-model overrides → overrides dropped, breadcrumbs populated. + - Fresh install (empty settings) → `settingsVersion = 2`, `providers = {}`, `enrollments = []`, `consumers` populated with empty `enabledModels: []` for each LangChain consumer. + - Settings with only the OpenAI key (no `activeModels`) → one `ProviderInstance`, no enrollments. + - Settings with `activeModels` containing only built-in entries and **no** provider keys → `providers = {}`, `enrollments = []` (every built-in dropped; breadcrumb logged). + - Settings with OpenAI key + built-in entries from Anthropic, Google, OpenAI → only the OpenAI built-ins migrate; Anthropic + Google built-ins dropped; breadcrumb lists them. + - Settings with a mix of built-in + custom + Ollama → providers + enrollments split correctly; Ollama-side enrollments carry `declaredContextLimit` / `declaredCapabilities`; embedding models stay in `activeEmbeddingModels`. + - Settings with per-model chat-tuning overrides → dropped, breadcrumbs populated. + - Settings with adapter knobs (`numCtx`, `enablePromptCaching`, `enableCors`) → migrated to `ByokEnrollment.overrides`. - Settings with `activeModels[*].enabled = false` → entry dropped, breadcrumb logged. - - Settings with `agentMode.backends..modelEnabledOverrides` keyed by old `|` → normalized per §2.2 (opencode → wire form `/`; claude/codex → bare ``; quickChat → `:`); orphans dropped. - - Settings with `defaultModelKey = "claude-sonnet-4-5|anthropic"` → `agentMode.backends.quickChat.defaultModel.baseModelId = "anthropic:claude-sonnet-4-5"`. - - Settings with no fields at all (corrupt) → migration falls back gracefully. + - Settings with `agentMode.backends..modelEnabledOverrides` → translated into `consumers["agent:"].enabledModels` with `ConsumerModelRef` per source; orphan keys dropped to breadcrumbs. + - Settings with `defaultModelKey = "claude-sonnet-4-5|anthropic"` → resolves to an enrollment and seeds `consumers["chat"].defaultModel` (plus the other LangChain consumers for continuity). + - Settings with `activeModels` containing OpenCode-bundled / Plus models → never enter `enrollments`; reach `consumers["agent:opencode"].enabledModels` as `{ source: "backend-bundled" }` / `{ source: "copilot-plus" }`. + - Corrupt / empty input → migration falls back gracefully (see §4.3). - Idempotency: running migration twice produces the same output. - - OpenCode-bundled / Plus models in `activeModels`: skipped from `registry`; forwarded into `agentMode.backends.opencode.modelEnabledOverrides` with bare wire-form `baseModelId` keys (bundled stays as-is; Plus prepends `copilot-plus/`) per migration step 4. **Agent verification checklist:** 1. `npm run test -- migrations` — fixtures green. -2. Run `npm run build` then load plugin in test vault with a pre-existing `data.json` from `git stash` (or seed one); verify: +2. Run `npm run build` then load plugin in test vault with a pre-existing `data.json` (seed via `git stash` or fixture); verify: - Notice toast appears once. - - Opening Settings → existing Basic + Models tabs still render (UI hasn't migrated yet) and show the migrated data correctly. + - Existing v2 Chat tab still renders, showing the chat-input model picker resolved through the new shape. - Chat works — pick a model in chat input, send a message, get a response. - Restart plugin → toast does NOT reappear. 3. Run `Copilot: Show settings migration status` command → modal shows breadcrumb with `from: 0, to: 2`. -4. Garbage `data.json` → safety net catches it; toast says "Couldn't upgrade", plugin still works in degraded mode. -5. Manual screenshot before/after — settings model picker still shows correct selection. +4. Garbage `data.json` → safety net catches it (§4.3); toast says "Couldn't upgrade", plugin still works in degraded mode. +5. Manual screenshot before/after — chat-input model picker still shows correct selection. **Risk:** Any consumer outside `ChatModelManager` that reads `settings.openAIApiKey` etc. directly will break. **Pre-flight grep** (see Appendix A) and update each call site to read from `ProviderRegistry`. --- -### M3 — Move embedding models into the QA tab (and rename to "Embedding") +### M3 — Embedding tab carries the embedding-model section -**Goal:** Pull the embedding model section out of the old `ModelSettings.tsx` and drop it into the existing QA tab at the bottom. Rename the tab from "QA" to "Embedding". All other QA settings stay exactly where they are. +**Goal:** Move the embedding-model section into the Embedding tab at the bottom, alongside the existing semantic-search settings. **Deliverables:** -- `src/settings/v2/components/QASettings.tsx` (the existing QA tab file) — extended: - - Existing QA settings (semantic-search settings, indexing settings, exclusions, etc.) stay in place at the top. - - **New section at the bottom**: heading "Embedding models" + the embedding-model table moved verbatim from `ModelSettings.tsx`. Same add/edit/delete flows, same `activeEmbeddingModels` field, same existing dialog components. Code is _moved_ (not copied) — the embedding table component lives in its own file extracted from `ModelSettings.tsx`. - - Tab label updated to **"Embedding"**. -- `src/settings/v2/components/ModelSettings.tsx` — embedding portion removed; only chat table remains (chat table itself goes away in M4–M9 as BYOK takes over). -- `src/settings/v2/SettingsMainV2.tsx` — tab label changed from "QA" to "Embedding"; tab order unchanged. -- Extracted embedding table component (e.g. `src/settings/v2/components/EmbeddingModelsSection.tsx`) so M3 is mostly a move-rename refactor with no behavior change. -- Snapshot tests for the renamed tab. +- `src/settings/v2/components/QASettings.tsx` — semantic-search settings up top; **new section at the bottom**: heading "Embedding models" + the embedding-model table (extracted into `EmbeddingModelsSection.tsx`). Same add/edit/delete flows, same `activeEmbeddingModels` field, same existing dialog components. Tab label is **"Embedding"**. +- `src/settings/v2/components/EmbeddingModelsSection.tsx` — extracted table component. +- `src/settings/v2/components/ModelSettings.tsx` — embedding portion removed. +- `src/settings/v2/SettingsMainV2.tsx` — tab label "Embedding"; tab order unchanged. +- Snapshot tests for the Embedding tab. **Agent verification checklist:** -1. Open settings → tab strip shows "Embedding" where "QA" was. Same position in the strip. -2. Open Embedding tab → existing QA settings visible at the top (semantic search, indexing, exclusions, etc.); embedding models table at the bottom. -3. Embedding model add / edit / delete / toggle flows all work; vector store rebuild still works (trigger via existing command). -4. Old "Models" tab → only chat models visible; embedding table gone. -5. No other settings tabs moved or renamed. +1. Open settings → tab strip shows "Embedding". +2. Open Embedding tab → semantic-search settings at the top, embedding-models table at the bottom. +3. Embedding-model add / edit / delete / toggle flows all work; vector-store rebuild still works (trigger via existing command). +4. No other settings tabs moved or renamed. --- -### M4 — BYOK panel (global table + provider sections, no dialogs yet) +### M4 — BYOK panel (global table + provider sections) -**Goal:** Implement the new BYOK tab as one global table with provider section rows. Existing add/edit flows still go through the old `ModelAddDialog` for this milestone (we wire the new dialogs in M5). The BYOK tab and old Models tab **both** exist after this milestone — but BYOK becomes the new primary. +**Goal:** Implement the BYOK tab as one global table with provider section rows. **Deliverables:** - `src/modelManagement/ui/tabs/ByokPanel.tsx`: - **On mount**: `await ModelCatalogService.getInstance().ensureLoaded()` (skeleton during load); then if `getMeta().fetchedAt < Date.now() - 24h`, fire-and-forget `refresh()`. - - Empty state with `[+ Add provider]` (button opens a placeholder modal until M5). + - Empty state with `[+ Add provider]`. - Populated state with one global table per §5.1. - - Header: title + the user-bring description copy per §5.1 + `[↻ Refresh catalog]` (with last-fetched timestamp tooltip) + `[Manage providers]` + `[+ Add provider]`. + - Header: title + description copy per §5.1 + `[↻ Refresh catalog]` (with last-fetched timestamp tooltip) + `[Manage providers]` + `[+ Add provider]`. - Filter bar: search input + `All` / `local` / `≥ 200k ctx` chips (no capability chips). - - Footer: ` enabled across providers · available in catalog`. + - Footer: ` enrolled across providers · available in catalog`. - `src/modelManagement/ui/components/ByokGlobalTable.tsx` — one global table component handling provider section rows + indented model rows: - - Provider section row: chevron · glyph · name · count · badge (`custom endpoint` if `kind === "custom"`) · `[⚙ Configure]` (ghost) · `⋯` (kebab → single "Remove provider" item). + - Provider section row: chevron · glyph · `ProviderInstance.displayName` · enrollment count · badge (`custom endpoint` if the provider type has no catalog) · `[⚙ Configure]` (ghost) · `⋯` (kebab → single "Remove provider" item). - Model rows: `Model name` + `Meta` only. No checkbox. No kebab. No badges. - - Foldable per provider (default: open; remembers state per provider). -- `src/settings/v2/SettingsMainV2.tsx` — register "BYOK" tab; rename old "Models" tab to "Models (legacy)" with a strikethrough style — to be removed in M9. -- Mobile rendering: `useIsMobile()` hook adapts header copy and stacks controls; provider sections look the same (since OpenCode/Plus rows are gone anyway). -- Tests: snapshot for populated/empty states; interaction tests for fold/unfold, Remove provider confirm; verify `ensureLoaded` runs on mount and `refresh()` fires only when stale. + - Foldable per provider instance (default: open; remembers state per `instanceId`). +- `src/settings/v2/SettingsMainV2.tsx` — register the BYOK tab. +- Mobile rendering: `useIsMobile()` hook adapts header copy and stacks controls. +- Tests: snapshot for populated/empty states; interaction tests for fold/unfold and Remove provider confirm; verify `ensureLoaded` runs on mount and `refresh()` fires only when stale. **Agent verification checklist:** 1. Open settings → BYOK tab visible. Plugin reload + open Settings (without visiting BYOK) makes zero models.dev requests. -2. **First-time BYOK tab open**: skeleton shows briefly → `ensureLoaded()` resolves → table populates. Second open in the same session is instant (memory cache). -3. With migrated data: table populated with provider section rows; counts match `ModelRegistry.list().length`; model rows are display-only. +2. **First-time BYOK tab open**: skeleton briefly visible → `ensureLoaded()` resolves → table populates. Second open in the same session is instant (memory cache). +3. With migrated data: table populated with provider section rows; counts match `EnrollmentRegistry.list({ instanceId }).length`; model rows are display-only. 4. **No OpenCode or Copilot Plus rows in BYOK**, ever. Even when OpenCode is running, only BYOK providers (Anthropic / OpenAI / Ollama / etc.) appear. -5. Click chevron → section folds; click again → unfolds. State persists across modal open/close. -6. Provider section kebab → Remove provider → confirm → all that provider's rows disappear; restart plugin → still gone. -7. Click `[⚙ Configure]` on a provider section → opens legacy edit modal (placeholder until M5). -8. Click `[↻ Refresh catalog]` → spinner → timestamp updates; rows show any newly-released models. -9. Filter bar: type "claude" → only Anthropic section + Claude rows show; click `local` chip → filters to Ollama; clear → all sections back. -10. Mobile build: layout adapts; no OpenCode/Plus sections (they were never there). +5. With two `ProviderInstance` rows of the same `providerTypeId`, both render as distinct sections (display names disambiguate). +6. Click chevron → section folds; click again → unfolds. State persists across modal open/close. +7. Provider section kebab → Remove provider → confirm → all that instance's enrollments disappear; restart plugin → still gone. +8. Click `[⚙ Configure]` on a provider section → opens Configure Provider dialog targeting that `instanceId` (full flow lands in M5). +9. Click `[↻ Refresh catalog]` → spinner → timestamp updates; rows show any newly-released models. +10. Filter bar: type "claude" → only Anthropic section + Claude rows show; click `local` chip → filters to Ollama; clear → all sections back. --- ### M5 — Configure Provider + Add Provider + Add Custom Model dialogs -**Goal:** All three new dialogs from §5.2 / §5.3, wired into the BYOK tab. After this milestone, users can complete the full BYOK flow without ever touching legacy UI. +**Goal:** All three dialogs from §5.2 / §5.3, wired into the BYOK tab. **Deliverables:** -- `src/settings/v3/dialogs/AddProviderDialog.tsx`: - - Provider picker with `Recommended` (Anthropic / OpenAI / Google) + `More providers` (alphabetical). +- `src/modelManagement/ui/dialogs/AddProviderDialog.tsx`: + - Provider type picker with `Recommended` (Anthropic / OpenAI / Google) + `More providers` (alphabetical). - "Add a custom provider" CTA card at the bottom (dashed border, accent tint per the design). - - Already-added providers filtered out. -- `src/settings/v3/dialogs/ConfigureProviderDialog.tsx` — single component supporting `state: "new-byok" | "new-custom" | "edit"`: - - Header adapts (✓ Verified badge in edit state; no badge in new states per the design). + - **No filter on already-added types.** Multi-instance is a first-class flow. +- `src/modelManagement/ui/dialogs/ConfigureProviderDialog.tsx` — single component supporting `state: "new-byok" | "new-custom" | "edit"`. Always targets a single `instanceId` (or synthesizes one in the `new-*` states): + - Header adapts (✓ Verified badge in edit state). - Connection fields with 120px label gutter. **No Availability row.** - - API key field directly editable in all states (no "Replace" button); test button present. + - Display name field — the UI suggests numbered/qualified defaults (e.g. `"Anthropic (prod)"`) when a sibling instance with the same `providerTypeId` already exists. + - API key field directly editable in all states (no "Replace" button); `[Test]` button present. - Models section header: just "Models" subtitle; right side has `[+ Add from catalog]` (edit only) + `[+ Add custom model]`. - Filter bar: search + `All` / `≥ 200k ctx` / `≤ $1/M` / `Released ≤ 6mo` chips. **No Vision / Reasoning / Tool use chips.** - - Model picker rows: checkbox + name + context + release date column. Edit state adds `⋯` kebab on registered rows (View docs / Remove from registry). + - Model picker rows: checkbox + name + context + release date column. Edit state adds `⋯` kebab on enrolled rows (View docs / Remove enrollment). - Sticky upstream-provider headers for OpenRouter. - Footer adapts (edit: `[Remove provider]` left, `[Save changes]` right; new: `[Verify & save]` right with selection count left). - - On save: writes to `ProviderRegistry` + `ModelRegistry`; verification calls dispatched async, errors decorate rows with ⚠. -- `src/settings/v3/dialogs/AddCustomModelDialog.tsx`: - - Three fields only: Display name, Model ID, Context window. - - **No Capabilities checkboxes. No Availability row.** Capabilities default to `["chat", "agent"]`. + - On save: writes to `ProviderRegistry` + `EnrollmentRegistry` scoped to the target `instanceId`; verification calls dispatched async, errors decorate rows with ⚠. +- `src/modelManagement/ui/dialogs/AddCustomModelDialog.tsx`: + - Three fields: Display name, Model ID, Context window. **No Capabilities checkboxes. No Availability row.** - `[Test]` button next to Model ID. -- `src/settings/v3/components/ProviderCatalogList.tsx` — checklist used inside Configure Provider. -- BYOK provider section `[⚙ Configure]` button → opens **new** dialog in edit state. -- BYOK `[+ Add provider]` → opens **new** dialog flow. -- Tests: each dialog's states; verification happy + error paths; OpenRouter sticky-header rendering. + - On save: creates a `ByokEnrollment` whose `modelId` isn't in the instance's `ProviderType` catalog (so `isCustomModel` returns true). `Context window` writes to `declaredContextLimit`. +- `src/modelManagement/ui/components/ProviderCatalogList.tsx` — checklist used inside Configure Provider. +- BYOK `[⚙ Configure]` on a provider section → opens ConfigureProviderDialog in edit state for that `instanceId`. +- BYOK `[+ Add provider]` → AddProviderDialog → ConfigureProviderDialog in `new-byok` (or `new-custom`) state. +- Tests: each dialog's states; verification happy + error paths; OpenRouter sticky-header rendering; second-instance disambiguation. **Agent verification checklist:** @@ -975,9 +906,9 @@ Each milestone is independently shippable and verifiable. Verification = unit te --- -### M6 — Agent tab redesign (with Quick Chat sub-tab skeleton) +### M6 — Agent tab (with Quick Chat sub-tab skeleton) -**Goal:** Replace the old Agent settings UI with the new Agent tab per §5.4. **Includes** the Quick Chat sub-tab as a UI skeleton (persistence + curation list working; actual chat-input → backend wiring is in the follow-up doc). +**Goal:** The Agent tab per §5.4, including the Quick Chat sub-tab as a UI skeleton (persistence wired; chat-input → backend routing lives in the follow-up doc). **Deliverables:** @@ -986,17 +917,15 @@ Each milestone is independently shippable and verifiable. Verification = unit te - `src/settings/v3/components/BackendStatusCard.tsx` — shared status card with three states (`✓ Active backend` / `○ Configured, not active` / `⚠ Not installed`) and `[Use this backend]` / `[Reinstall]` / `[Browse…]` actions. - `src/settings/v3/components/BackendModelPicker.tsx` — shared "Models in this backend's picker" component used by all four sub-tabs: - Header: title + sub-text ("tick which models show up when you switch model mid-session") + `Manage in BYOK →` link. - - Rows: checkbox + name + provider (muted) + meta. No ★ default badge column. - - Persists to `agentMode.backends..modelEnabledOverrides`. + - Rows: checkbox + name + provider instance display name (muted) + meta. No ★ default badge column. + - Persists each tick/untick through `ConsumerRegistry.setEnabledModels("agent:", refs)` (or `"quick-chat"` for Quick Chat). - Per-backend sub-panels in `src/settings/v3/components/backends/`: - - **OpencodePanel.tsx:** Status + BackendModelPicker sourced from `ModelRegistry.list({ capability: "agent" })`. - - **ClaudeCodePanel.tsx / CodexPanel.tsx:** Status + Subscription card (re-auth) + BackendModelPicker sourced from the backend's bundled model list. - - **QuickChatPanel.tsx (SKELETON):** Status (always "Active — runs in the plugin"; no install needed) + BackendModelPicker sourced from `ModelRegistry.list({ capability: "chat" })`. The picker writes to `agentMode.backends.quickChat.modelEnabledOverrides`. **No runtime routing wiring yet** — clicking around saves settings but the chat input still routes through the legacy ChatModelManager path. The follow-up doc connects the wires. - -Per-backend "Default model" + "Default reasoning effort" controls were dropped in the model-settings redesign. New sessions inherit (model, effort) from the previous active session on the same backend via `AgentSessionManager.getLastSelection`; on a fresh plugin load the manager falls back to the backend's catalog default. Picker selections feed `AgentSessionManager.rememberLastSelection` rather than `setSettings`. - -- `src/settings/v2/SettingsMainV2.tsx` — replace old Agent tab registration with new one. -- Tests: tab switch preserves state; `[Use this backend]` updates `agentMode.activeBackend`; picker persistence per backend. + - **OpencodePanel.tsx:** Status + BackendModelPicker sourced from `ConsumerRegistry.visibleEntries("agent:opencode")`. + - **ClaudeCodePanel.tsx / CodexPanel.tsx:** Status + Subscription card (re-auth) + BackendModelPicker sourced from the backend's bundled model list, persisted through `consumers["agent:claude-code"]` / `consumers["agent:codex"]`. + - **QuickChatPanel.tsx (skeleton):** Status (always "Active — runs in the plugin"; no install needed) + BackendModelPicker sourced from `EnrollmentRegistry.list()` filtered through `ProviderType.pipelines.langchain`. Persists through `consumers["quick-chat"]`. The chat-input → Quick Chat runtime routing is the follow-up doc's job. +- New sessions inherit (model, effort) from the previous active session on the same backend via `AgentSessionManager.getLastSelection`; on a fresh plugin load the manager falls back to the backend's catalog default. Picker selections feed `AgentSessionManager.rememberLastSelection` (in-memory, lost on reload), not `setSettings`. +- `src/settings/v2/SettingsMainV2.tsx` — Agent tab registration points at `AgentPanel`. +- Tests: tab switch preserves state; `[Use this backend]` updates `agentMode.activeBackend`; picker persistence writes through `ConsumerRegistry`. **Agent verification checklist:** @@ -1004,59 +933,57 @@ Per-backend "Default model" + "Default reasoning effort" controls were dropped i 2. Status card shows correct state per backend. 3. Switch sub-tabs → panel changes; each preserves its own state. 4. Click `[Use this backend]` in Claude Code's status card → it becomes active; OpenCode flips to `○ Configured`. -5. OpenCode picker: tick/untick a model → reload plugin → state persists; chat input agent picker reflects. -6. Quick chat sub-tab: status card says active; picker lists all chat-capable models. Tick some models → save → restart → state persists. **Chat still routes through legacy path** — this is expected. +5. OpenCode picker: tick/untick a model → reload plugin → state persists in `consumers["agent:opencode"].enabledModels`; chat input agent picker reflects. +6. Quick chat sub-tab: status card says active; picker lists all `pipelines.langchain` enrollments. Tick some models → save → restart → `consumers["quick-chat"].enabledModels` persists. Chat input routing is the follow-up doc's territory. --- -### M7 — _(skipped — Welcome modal is out of scope for this plan)_ +### M7 — _(reserved — Welcome modal lives in a separate workstream)_ -The standalone Welcome modal designed in `final.jsx` is deferred to a separate workstream. The BYOK tab's existing empty state (one big `[+ Add provider]` CTA per §5.1) is the only first-run surface this plan ships. Milestone numbers M8/M9 are kept as-is to preserve cross-references; M7 is intentionally a no-op slot. +The BYOK tab's empty state (one big `[+ Add provider]` CTA per §5.1) is the only first-run surface this plan ships. Milestone numbers M8/M9 are unchanged. --- -### M8 — BYOK→OpenCode agent bridge + OpenCode panel model sources +### M8 — BYOK → OpenCode bridge + OpenCode panel model sources -**Goal:** Make BYOK custom providers usable in agent mode (JTBD-17), and complete the OpenCode sub-tab's three-source picker (OpenCode-bundled ⊕ Copilot Plus ⊕ BYOK agent-capable). **OpenCode-bundled and Copilot Plus models stay out of the BYOK registry entirely.** +**Goal:** BYOK providers usable in agent mode, and the OpenCode sub-tab's three-source picker (OpenCode-bundled ⊕ Copilot Plus ⊕ BYOK). **OpenCode-bundled and Copilot Plus models stay out of the BYOK panel entirely.** **Deliverables:** -- `src/agentMode/backends/opencode/byokBridge.ts` — on OpenCode startup (and on `ProviderRegistry` changes), register every BYOK provider into OpenCode's config. For built-in providers, just register the API key; for custom providers, register the full endpoint config. -- `src/agentMode/backends/opencode/bundledModels.ts` — sync wrapper that exposes OpenCode's enumeration of bundled models (Big Pickle, etc.) via a `listBundledModels(): Promise` function. Reads from the running OpenCode binary's JSON-RPC or config; isolated here so the OpenCode panel doesn't have to know the wire format. +- `src/agentMode/backends/opencode/byokBridge.ts` — on OpenCode startup (and on `ProviderRegistry` changes), register every `ProviderInstance` whose `ProviderType.pipelines.opencode === true` into OpenCode's config. Bridged-provider id is derived from `instanceId`, so two `ProviderInstance` rows of the same `providerTypeId` register distinctly. Instances whose `pipelines.opencode` is false are skipped (surfaced in BYOK panel as ineligible-for-OpenCode). +- `src/agentMode/backends/opencode/bundledModels.ts` — sync wrapper exposing OpenCode's bundled-model enumeration via `listBundledModels(): Promise`. Reads from the running OpenCode binary's JSON-RPC or config. - `src/agentMode/backends/opencode/plusModels.ts` — same shape for Copilot Plus hosted models; gated by `isPlusUser`. -- `src/settings/v3/components/backends/OpencodePanel.tsx` (extended from M6 skeleton): - - `BackendModelPicker` is replaced/wrapped to display three sources unioned: - 1. `listBundledModels()` rows (header: "OpenCode-bundled"). - 2. `listPlusModels()` rows (header: "Copilot Plus", only when Plus active). - 3. `ModelRegistry.list({ capability: "agent" })` rows (header: "From BYOK"). - - Each row has a checkbox writing to `agentMode.backends.opencode.modelEnabledOverrides[]`. - - `` format: the bare wire-form `baseModelId` the running OpenCode binary reports (e.g. `bigpickle/big-pickle`, `copilot-plus/copilot-plus-flash`, `anthropic/claude-sonnet-4-5`). The provider segment is intrinsic to the wire form, so two providers offering the same `modelId` never collide. - - "OpenCode not installed" empty-state in the panel when source #1 is unavailable; the BYOK row sources still render so users can preview them. -- **No changes to `ByokGlobalTable.tsx`.** OpenCode-bundled and Plus models intentionally never appear in BYOK. -- Tests: bridge round-trip (BYOK custom provider → OpenCode config file → readable back); OpenCode panel renders all three sources correctly when present; missing sources hide their section header. +- `src/settings/v3/components/backends/OpencodePanel.tsx` — `BackendModelPicker` displays three sources unioned: + 1. `listBundledModels()` rows (header: "OpenCode-bundled") — refs of `source: "backend-bundled", backendId: "opencode"`. + 2. `listPlusModels()` rows (header: "Copilot Plus", only when Plus active) — refs of `source: "copilot-plus"`. + 3. `EnrollmentRegistry.list()` rows filtered by `ProviderType.pipelines.opencode` (header: "From BYOK") — refs of `source: "byok"`. + Each row's checkbox flips inclusion in `consumers["agent:opencode"].enabledModels`. The wire form OpenCode speaks at runtime is derived per source — bundled keeps its `bigpickle/...` form, Plus uses `copilot-plus/`, BYOK uses `/`. The runtime picker reconciles each ref against `BackendInventory["opencode"]` at render time. + "OpenCode not installed" empty-state replaces source #1 when unavailable; sources #2 / #3 still render. +- Tests: bridge round-trip (BYOK custom provider → OpenCode config file → readable back); OpenCode panel renders all three sources correctly when present; missing sources hide their section header; `pipelines.opencode === false` enrollments don't reach the panel. **Agent verification checklist:** -1. Add a local Ollama provider via BYOK → check OpenCode's config dir contains an entry for it. Ollama row appears in the OpenCode panel's "From BYOK" section. +1. Add a local Ollama provider via BYOK → check OpenCode's config dir contains an entry for it keyed by `instanceId`. Ollama row appears in the OpenCode panel's "From BYOK" section. 2. Start an agent session in OpenCode → Ollama model appears in the in-session model picker → can execute a task using it. 3. With OpenCode running: OpenCode panel shows three sections (Bundled / Plus if applicable / From BYOK); BYOK tab shows **no** OpenCode rows. -4. Stop OpenCode → "OpenCode not installed" empty-state replaces the Bundled section; BYOK section in the panel still works; the BYOK tab is unaffected. -5. Re-enable OpenCode → all three sources back. -6. With Plus license: Plus section appears in OpenCode panel; BYOK tab is unaffected. +4. Add two Anthropic `ProviderInstance` rows ("prod" and "staging") → both surface as separate rows in the OpenCode panel's "From BYOK" section, disambiguated by display name. +5. Stop OpenCode → "OpenCode not installed" empty-state replaces the Bundled section; BYOK section still renders. +6. With Plus license active: Plus section appears. --- ### M9 — Cleanup + final removals -**Goal:** Delete legacy code paths, remove "Models (legacy)" tab, finalize tab label renames, update docs. +**Goal:** Delete legacy code paths, finalize tab labels, update docs. **Deliverables:** -- Delete `src/settings/v2/components/ModelSettings.tsx`, `ModelAddDialog.tsx`, `ModelEditDialog.tsx`, the model-picker portion of `BasicSettings.tsx`. -- Delete legacy provider-key field references throughout the codebase (run grep from Appendix A; nothing should match). -- `src/settings/v2/SettingsMainV2.tsx` — remove "Models (legacy)" tab; rename "Basic" → "Chat", "Chat & Commands" → "Commands". (The QA → Embedding rename already shipped in M3.) +- Delete `src/settings/v2/components/ModelSettings.tsx`, `ModelAddDialog.tsx`, `ModelEditDialog.tsx`, `ModelParametersEditor.tsx`, and the model-picker portion of `BasicSettings.tsx`. +- Delete the legacy provider-key field references throughout the codebase (run grep from Appendix A; nothing should match). +- `src/settings/v2/SettingsMainV2.tsx` — tab labels: Chat · BYOK · Agent · Commands · Embedding · Advanced. - `src/constants.ts` — `BUILTIN_CHAT_MODELS` removed (catalog replaces it). `BUILTIN_EMBEDDING_MODELS` kept (embedding side is unchanged by this redesign). -- Delete `src/LLMProviders/` (its contents moved into `src/modelManagement/` in M2). +- Delete `src/LLMProviders/` (its contents are inside `src/modelManagement/` now). +- Remove the `ChatDefaults` type and the `temperature` / `maxTokens` / `reasoningEffort` / `verbosity` / `topP` / `frequencyPenalty` fields throughout the codebase. - Update user-facing docs (`docs/llm-providers.md`, `docs/agent-mode-and-tools.md`) per the new UI. - Update `AGENTS.md` migration notes section. - Final `npm run lint && npm run format && npm run test && npm run build` pass clean. @@ -1066,15 +993,15 @@ The standalone Welcome modal designed in `final.jsx` is deferred to a separate w 1. `git grep openAIApiKey src/` returns nothing. 2. `git grep activeModels src/` only returns `activeEmbeddingModels` references. 3. `git grep BUILTIN_CHAT_MODELS src/` returns nothing. -4. `src/LLMProviders/` no longer exists. -5. Tab strip shows: Chat · BYOK · Agent · Commands · Embedding · Advanced. (No "Models", no "QA".) -6. `npm run lint && npm run test && npm run build` all green. -7. Manual smoke test: +4. `git grep ChatDefaults src/` returns nothing. +5. `src/LLMProviders/` no longer exists. +6. Tab strip shows: Chat · BYOK · Agent · Commands · Embedding · Advanced. +7. `npm run lint && npm run test && npm run build` all green. +8. Manual smoke test: - Fresh install → BYOK empty state → `[+ Add provider]` → add provider → chat works. - Add custom Ollama → agent mode works in OpenCode. - Switch agent backend to Claude Code → agent session works. - Embedding tab → rebuild vector index → semantic search works. -8. Take "before" screenshots from M2 and "after" screenshots — UI complete per the design. --- @@ -1083,14 +1010,14 @@ The standalone Welcome modal designed in `final.jsx` is deferred to a separate w The implementing agent should maintain a `TODO.md` per `AGENTS.md` guidance for session-level tracking, plus produce these artifacts as deliverables of the whole series: - `designdocs/MODEL_MANAGEMENT_IMPLEMENTATION_PROGRESS.md` — checked off as each milestone completes. -- Screenshots before/after each milestone (saved to `.context/screenshots/M/`) — proves the UI works. -- Migration test fixtures under `src/settings/migrations/__tests__/fixtures/`: +- Screenshots before/after each milestone (saved to `.context/screenshots/M/`). +- Migration test fixtures under `src/modelManagement/migrations/__tests__/fixtures/`: - `fixture-keys-only.json` — only provider keys. - `fixture-custom-provider-ollama.json` — local Ollama with two models. - `fixture-agent-overrides.json` — `modelEnabledOverrides` populated with old-format keys. - `fixture-overrides-everywhere.json` — every per-model override field set, plus `enabled: false`. - `fixture-azure-bedrock.json` — Azure + Bedrock with their extras. - - `fixture-default-model-key.json` — `defaultModelKey` populated; verifies Quick Chat seeding. + - `fixture-default-model-key.json` — `defaultModelKey` populated; verifies seeding of LangChain `consumers[*].defaultModel`. --- @@ -1100,7 +1027,7 @@ The implementing agent should maintain a `TODO.md` per `AGENTS.md` guidance for | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `models.dev/api.json` schema drift between runtime fetches | Hand-rolled `.d.ts` + runtime Zod validation in `ModelCatalogService.refresh()`. Bad payload → log + keep last good source. | | `models.dev` outage or CORS issue from Obsidian's environment | Disk cache covers outage once we've fetched at least once; lazy fetch means most sessions never call out at all. CORS not a concern (Obsidian uses Node `fetch`). | -| Boundary eslint rule false positives during migration | Add explicit `import/no-restricted-paths` allowances for transition files (`src/main.ts`, the legacy `chatModelManager` shim) until M9 removes them. | +| Boundary eslint rule false positives | Add narrow `import/no-restricted-paths` allowances for files that legitimately bridge boundaries (`src/main.ts`); review allowances during M9. | | OpenCode bundled / Plus model enumeration API changes | `bundledModels.ts` + `plusModels.ts` are the only consumers; isolated in the OpenCode backend folder. | | Migration on corrupt data.json | Safety net in `runMigrations` — degrade gracefully. | | Keychain unavailability on older Obsidian | Existing `_keychainOnly` flag respected; falls back to inline keys. | @@ -1124,24 +1051,6 @@ The Quick Chat agent backend, end-to-end: The Quick Chat sub-tab in M6 is a **skeleton** — UI shell and persistence only. The follow-up doc owns runtime routing and the new chat view. -### Future migration: CustomModel capability removal - -`RegistryEntry.capabilities` has been removed. However, the legacy `CustomModel` type in `src/aiParams.ts` still carries its own pre-v2 `ModelCapability` enum (from `src/constants.ts`, values `REASONING` / `VISION` / `WEB_SEARCH`). The chat path still flows through `CustomModel`, and several consumers read `customModel.capabilities` to gate behavior. - -These consumers stay as-is in this redesign and are scheduled for removal alongside a broader "chains stop supporting thinking blocks + vision gating moves to attach-time" cleanup: - -| Site | What it does today | Future action | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `src/LLMProviders/chainRunner/LLMChainRunner.ts:92` | Excludes thinking blocks for non-reasoning models (`excludeThinking`) | Drop the gate. Chains will not support thinking blocks; rely on output-side `` stripping for open-weight reasoning models. | -| `src/LLMProviders/chainRunner/VaultQAChainRunner.ts:52` | Same | Same. | -| `src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts` (vision gate around line 570, reasoning gate around line 755) | `hasCapability(model, VISION)` strips images when model is text-only; reasoning gate mirrors LLMChainRunner | Replace vision gate with attach-time check in `ChatInput.tsx` driven by `ModelCatalogService.getModel(...)?.modalities?.input?.includes("image")`. Drop the reasoning gate with the rest of the thinking-block work. For custom (non-catalog) models, add an opt-in `supportsVision` flag on the custom provider/model config. | -| `src/modelManagement/chatModel/ChatModelManager.ts:352-396, 593` | Passes `enableReasoning` / `think` / `reasoning_effort` to OpenRouter / Ollama / LM Studio SDKs based on `customModel.capabilities` | Drop. We stop requesting thinking from any provider; default `reasoning_effort` is fine for the reasoning models that always reason internally (OpenAI o-series). | -| `src/components/ui/ModelParametersEditor.tsx:73-76` | Renders the reasoning-effort slider when the model has `REASONING` capability | Remove the slider with the rest of the thinking-block removal. | -| `src/components/ui/model-display.tsx` (`ModelCapabilityIcons`), `src/settings/v2/components/ModelTable.tsx`, `src/settings/v2/components/ModelEditDialog.tsx`, `src/settings/v2/components/ModelAddDialog.tsx` | Render capability icons / checkboxes in the legacy CustomModel-based UI | Delete with `CustomModel.capabilities` and the legacy `ModelCapability` enum. | -| `src/constants.ts` (`ModelCapability` enum + `MODEL_CAPABILITIES` record) and `src/aiParams.ts` (`CustomModel.capabilities`) | Type-system home for the legacy enum | Delete once all consumers above are gone. | - -These are deliberately separated from the registry-side cleanup: `RegistryEntry.capabilities` and `CustomModel.capabilities` are different fields on different types — removing the former does not constrain or require touching the latter. - --- ## Appendix A — Pre-flight grep targets @@ -1158,34 +1067,35 @@ Every match outside `src/settings/migrations/`, `src/settings/model.ts` (the typ ## Appendix B — Final file inventory -| New files (M1–M9) | Purpose | -| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | -| `src/modelManagement/index.ts` | M1 public API barrel (single entry point) | -| `src/modelManagement/types.ts` | M1 shared types (ProviderConfig, RegistryEntry, …) | -| `src/modelManagement/providers/supportedProviders.ts` | M1 `SUPPORTED_PROVIDER_IDS` | -| `src/modelManagement/catalog/modelsCatalog.types.ts` | M1 catalog types | -| `src/modelManagement/catalog/ModelCatalogService.ts` | M1 (lazy + 2-tier: memory → disk → live) | -| ESLint config additions (`import/no-restricted-paths`) | M1 boundary enforcement | -| `src/modelManagement/migrations/runMigrations.ts` | M2 | -| `src/modelManagement/migrations/v0-to-v2.ts` | M2 | -| `src/modelManagement/migrations/__tests__/v0-to-v2.test.ts` + fixtures | M2 | -| `src/modelManagement/providers/ProviderRegistry.ts` | M2 | -| `src/modelManagement/providers/adapters/*` (relocated from `src/LLMProviders/`) | M2 | -| `src/modelManagement/registry/ModelRegistry.ts` | M2 | -| `src/modelManagement/chatModel/ChatModelManager.ts` | M2 (refactored from src/LLMProviders) | -| `src/modelManagement/chatModel/buildLangChainConfig.ts` | M2 | -| `src/settings/v2/components/EmbeddingModelsSection.tsx` (extracted from ModelSettings) | M3 | -| `src/modelManagement/ui/tabs/ByokPanel.tsx` | M4 | -| `src/modelManagement/ui/components/ByokGlobalTable.tsx` | M4 | -| `src/modelManagement/ui/dialogs/{AddProviderDialog,ConfigureProviderDialog,AddCustomModelDialog}.tsx` | M5 | -| `src/modelManagement/ui/components/ProviderCatalogList.tsx` | M5 | -| `src/settings/v3/tabs/AgentPanel.tsx` | M6 | -| `src/settings/v3/components/{BackendSubtabs,BackendStatusCard,BackendModelPicker}.tsx` | M6 | -| `src/settings/v3/components/backends/{Opencode,ClaudeCode,Codex,QuickChat}Panel.tsx` | M6 | -| `src/agentMode/backends/opencode/byokBridge.ts` | M8 | -| `src/agentMode/backends/opencode/bundledModels.ts` | M8 | -| `src/agentMode/backends/opencode/plusModels.ts` | M8 | -| `designdocs/MODEL_MANAGEMENT_IMPLEMENTATION_PROGRESS.md` | Tracking artifact | +| New files (M1–M9) | Purpose | +| ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `src/modelManagement/index.ts` | M1 public API barrel (single entry point) | +| `src/modelManagement/types.ts` | M1 shared types (ProviderInstance, ByokEnrollment, …) | +| `src/modelManagement/providers/supportedProviders.ts` | M1 `SUPPORTED_PROVIDER_IDS` | +| `src/modelManagement/catalog/modelsCatalog.types.ts` | M1 catalog types | +| `src/modelManagement/catalog/ModelCatalogService.ts` | M1 (lazy + 2-tier: memory → disk → live) | +| ESLint config additions (`import/no-restricted-paths`) | M1 boundary enforcement | +| `src/modelManagement/migrations/runMigrations.ts` | M2 | +| `src/modelManagement/migrations/v0-to-v2.ts` | M2 | +| `src/modelManagement/migrations/__tests__/v0-to-v2.test.ts` + fixtures | M2 | +| `src/modelManagement/providers/ProviderRegistry.ts` | M2 | +| `src/modelManagement/providers/adapters/*` (relocated from `src/LLMProviders/`) | M2 | +| `src/modelManagement/registry/EnrollmentRegistry.ts` | M2 | +| `src/modelManagement/consumers/ConsumerRegistry.ts` | M2 | +| `src/modelManagement/chatModel/ChatModelManager.ts` | M2 (refactored from src/LLMProviders) | +| `src/modelManagement/chatModel/buildLangChainConfig.ts` | M2 | +| `src/settings/v2/components/EmbeddingModelsSection.tsx` (extracted from ModelSettings) | M3 | +| `src/modelManagement/ui/tabs/ByokPanel.tsx` | M4 | +| `src/modelManagement/ui/components/ByokGlobalTable.tsx` | M4 | +| `src/modelManagement/ui/dialogs/{AddProviderDialog,ConfigureProviderDialog,AddCustomModelDialog}.tsx` | M5 | +| `src/modelManagement/ui/components/ProviderCatalogList.tsx` | M5 | +| `src/settings/v3/tabs/AgentPanel.tsx` | M6 | +| `src/settings/v3/components/{BackendSubtabs,BackendStatusCard,BackendModelPicker}.tsx` | M6 | +| `src/settings/v3/components/backends/{Opencode,ClaudeCode,Codex,QuickChat}Panel.tsx` | M6 | +| `src/agentMode/backends/opencode/byokBridge.ts` | M8 | +| `src/agentMode/backends/opencode/bundledModels.ts` | M8 | +| `src/agentMode/backends/opencode/plusModels.ts` | M8 | +| `designdocs/MODEL_MANAGEMENT_IMPLEMENTATION_PROGRESS.md` | Tracking artifact | | Deleted files (M9) | Reason | | ---------------------------------------------------------------------- | --------------------------------------------------------------------------- | @@ -1198,10 +1108,8 @@ Every match outside `src/settings/migrations/`, `src/settings/model.ts` (the typ --- -## Appendix C — Where this doc gets stored +## Appendix C — Related docs -This is the working spec the implementing agent should read. - -- **`.context/plans/model-management-redesign-technical-design-impleme.md`** (this file) — canonical, shared with teammates. -- **On approval, copy to `designdocs/MODEL_MANAGEMENT_IMPLEMENTATION.md`** so it lives with the codebase. The two should be kept in sync; if either diverges, this `.context` file is the working draft. -- **`designdocs/QUICK_CHAT_AGENT_INTEGRATION.md`** — the follow-up doc; owns the Quick Chat agent runtime. +- **`designdocs/MODEL_DATA_MODEL_SPEC.md`** — canonical data model (entities, settings shape, invariants, resolution traces). +- **`designdocs/MODEL_MANAGEMENT_REDESIGN.md`** — product / UX spec. +- **`designdocs/QUICK_CHAT_AGENT_INTEGRATION.md`** — Quick Chat agent backend runtime + chat-input routing. diff --git a/eslint.config.mjs b/eslint.config.mjs index 53032372..e6d85885 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -256,6 +256,7 @@ export default [ { type: "backend", pattern: "src/agentMode/backends/*", capture: ["name"] }, { type: "ui", pattern: "src/agentMode/ui" }, { type: "skills", pattern: "src/agentMode/skills" }, + { type: "modelmgmt", pattern: "src/modelManagement" }, { type: "host", pattern: "src/**" }, ], }, @@ -295,7 +296,16 @@ export default [ }, }, }, - { from: { type: "host" }, allow: { to: { type: ["host", "barrel"] } } }, + // modelManagement: self-contained module. Host code may + // freely reach into the module at the boundary layer; the + // barrel-only entry rule (no deep imports of + // `@/modelManagement/types/*`) is enforced by + // `no-restricted-imports` patterns further down. + { from: { type: "modelmgmt" }, allow: { to: { type: "modelmgmt" } } }, + { + from: { type: "host" }, + allow: { to: { type: ["host", "barrel", "modelmgmt"] } }, + }, ], }, ], @@ -311,8 +321,19 @@ export default [ }, }, - // Only acp/ may import `@agentclientprotocol/sdk`. Tests are exempted via - // the test block; acp/ itself is exempted below. + // Two path-based import fences, combined in one block (flat config + // replaces — does not merge — rule values when the same rule key + // appears across matching blocks, so both fences MUST live here): + // + // 1. `@agentclientprotocol/sdk` — confined to src/agentMode/acp/. + // Other agent-mode layers depend on the session-domain types + // in @/agentMode/session/types instead. + // 2. `@/modelManagement/*` deep imports — host code must enter the + // modelManagement module via its barrel (`@/modelManagement`). + // This replaces a `modelmgmt-barrel` boundary element with the + // lighter no-restricted-imports mechanism already used for (1). + // + // Module-internal files are exempted in the override blocks below. { files: ["src/**/*.{ts,tsx}"], rules: { @@ -326,6 +347,13 @@ export default [ "ACP wire types are confined to src/agentMode/acp/. session/, sdk/, ui/, backends/, and skills/ should depend on the session-domain types in @/agentMode/session/types instead. See src/agentMode/AGENTS.md.", }, ], + patterns: [ + { + group: ["@/modelManagement/*"], + message: + "Import from @/modelManagement (the barrel) only. Deep imports of @/modelManagement/types/* are not allowed from outside the module. See src/modelManagement/AGENTS.md.", + }, + ], }, ], }, @@ -336,6 +364,12 @@ export default [ "no-restricted-imports": "off", }, }, + { + files: ["src/modelManagement/**/*.{ts,tsx}"], + rules: { + "no-restricted-imports": "off", + }, + }, // TypeScript-specific overrides (the @typescript-eslint plugin is registered // by obsidianmd's recommended config only for .ts/.tsx files). diff --git a/src/modelManagement/AGENTS.md b/src/modelManagement/AGENTS.md new file mode 100644 index 00000000..66a3fd9a --- /dev/null +++ b/src/modelManagement/AGENTS.md @@ -0,0 +1,21 @@ +# Model Management — layer rules + +The chat-model data model: providers, configured models, per-backend +model selection, and the transient catalog types used during setup. + +## Architecture in one paragraph + +`models.dev` (the catalog) is consumed **only during setup**. When the +user adds a provider via BYOK, when an agent is configured, a `Provider` row +is created with an +`origin` discriminator (`byok` / `agent` / `copilot-plus`). Each +provider has zero or more `ConfiguredModel` rows under it, each +embedding a `ModelInfo` snapshot copied from the catalog (or +hand-typed for self-hosted endpoints). The plugin keeps working +when the catalog is unreachable because every runtime-relevant field +lives in `ConfiguredModel.info`. The four backends that curate model +selection — `chat`, `opencode`, `claude-code`, `codex` — each persist a +`BackendConfig` listing `configuredModelId`s they expose in their +picker. The BYOK settings tab filters `providers` by +`origin.kind === "byok"`; the chat-model factory dispatches purely on +`Provider.providerType`. diff --git a/src/modelManagement/CLAUDE.md b/src/modelManagement/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/src/modelManagement/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/modelManagement/index.ts b/src/modelManagement/index.ts new file mode 100644 index 00000000..e213a650 --- /dev/null +++ b/src/modelManagement/index.ts @@ -0,0 +1,13 @@ +// 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. + +export type { CatalogProvider, ModelInfo, ProviderType } from "./types/catalog"; +export type { + AgentType, + BackendConfig, + BackendType, + ConfiguredModel, + Provider, + ProviderOrigin, +} from "./types/persisted"; diff --git a/src/modelManagement/types/catalog.ts b/src/modelManagement/types/catalog.ts new file mode 100644 index 00000000..3a038401 --- /dev/null +++ b/src/modelManagement/types/catalog.ts @@ -0,0 +1,73 @@ +/** + * Catalog types — transient, setup-time only. + * + * The catalog (e.g. `models.dev`) is consumed during the BYOK / agent / + * Plus setup flows to scaffold defaults. Once a model is configured, its + * metadata is snapshotted onto the persisted `ConfiguredModel.info` row + * and the catalog is never consulted again. Nothing in this file is + * persisted to disk. + */ + +/** + * Closed dispatch set the chat-model factory switches on. + * + * The user never types this — the BYOK / agent / Plus setup wizard + * assigns it from the catalog (via the `npm` field on `models.dev` + * entries) or from a built-in template (Ollama, LMStudio, Custom + * OpenAI-compatible, Azure OpenAI, AWS Bedrock). + * + * "anthropic" → @langchain/anthropic + * "openai-compatible" → @langchain/openai with custom baseUrl + * (OpenAI, Mistral, Groq, OpenRouter, Together, + * DeepSeek, Ollama, LMStudio, custom proxies) + * "google" → @langchain/google-genai + * "azure" → @langchain/openai (Azure path) + * "bedrock" → @langchain/aws + */ +export type ProviderType = "anthropic" | "openai-compatible" | "google" | "azure" | "bedrock"; + +/** + * Description of a single model. Used both as the catalog's per-model + * record and embedded into `ConfiguredModel.info`. One shape — the + * downstream code can iterate either catalog-side or persisted-side + * models with the same fields. + * + * The catalog fetcher populates whatever `models.dev` exposes. Self- + * hosted custom models (Ollama hand-typed) populate `id` + `displayName` + * and leave the metadata fields empty. + */ +export interface ModelInfo { + /** Wire-form id passed to the SDK ("claude-sonnet-4-5", "gpt-5", …). */ + id: string; + displayName: string; + modalities?: { input: string[]; output: string[] }; + limits?: { context: number; output: number; input?: number }; + reasoning?: boolean; + toolCall?: boolean; + cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number }; + releaseDate?: string; +} + +/** + * A provider as listed by the catalog. Lives in memory during a setup + * wizard pass; never persisted. + */ +export interface CatalogProvider { + /** Catalog provider id ("anthropic", "openai", "opencode-zen", …). */ + id: string; + /** From the catalog's `name` field. */ + displayName: string; + /** From the catalog's `api` field. Pre-fills `Provider.baseUrl`. */ + defaultBaseUrl: string; + /** + * Derived from the catalog's `npm` field by the catalog fetcher. + * "@ai-sdk/anthropic" → "anthropic" + * "@ai-sdk/google" → "google" + * "@ai-sdk/azure" → "azure" + * "@ai-sdk/bedrock" → "bedrock" + * anything else → "openai-compatible" + */ + providerType: ProviderType; + /** Keyed by `ModelInfo.id` (the wire-form id). */ + models: Record; +} diff --git a/src/modelManagement/types/persisted.ts b/src/modelManagement/types/persisted.ts new file mode 100644 index 00000000..86770605 --- /dev/null +++ b/src/modelManagement/types/persisted.ts @@ -0,0 +1,133 @@ +/** + * Persisted types — written to `CopilotSettings` (settings wiring lands + * in a follow-up PR). Once a row exists, the catalog is no longer + * consulted at runtime. + */ + +import type { ModelInfo, ProviderType } from "./catalog"; + +/** + * The three agent backends. Each can own its own `Provider`(s) and + * reports a runtime model inventory. + */ +export type AgentType = "opencode" | "claude-code" | "codex"; + +/** + * The broader set used for per-backend model curation: the three agents + * plus `"chat"` (Simple Chat). Used as the map key for `BackendConfig` + * in `settings.backends: Record`. + * + * "chat" → Simple Chat picker + * "opencode" → OpenCode agent picker + * "claude-code" → Claude Code agent picker + * "codex" → Codex agent picker + * + * Everything else either has no curated selection (vault-qa / project / + * quick-chat use their own per-feature model id; custom commands store + * a model id directly) or is handled by another module. + */ +export type BackendType = AgentType | "chat"; + +/** + * Where a `Provider` came from. Drives which settings tab shows it and + * which code path created it. Downstream consumption is uniform — the + * chat-model factory dispatches purely on `Provider.providerType`. + * + * "byok" → user added via the BYOK settings tab + * "agent" → auto-created when an agent was set up; the agent + * owns credentials and routing. Chat doesn't appear + * here because chat doesn't own `Provider`s — its + * models come from BYOK + Plus + provider-sharing + * with backed agents. + * "copilot-plus" → auto-created when the user signed into Plus + */ +export type ProviderOrigin = + | { kind: "byok" } + | { kind: "agent"; agentType: AgentType } + | { kind: "copilot-plus" }; + +/** + * A configured connection to a model provider. + * + * Multi-instance is supported within `byok`: two BYOK `Provider` rows + * can share the same `providerType` (e.g. two Anthropic accounts) — + * distinguished by `providerId` and `displayName`. Agent and Plus + * origins typically have exactly one row each, but the data model + * doesn't enforce singleton. + */ +export interface Provider { + /** UUID; primary key. Also the keychain namespace (BYOK only). */ + providerId: string; + /** Single dispatch field. See `ProviderType` in catalog.ts. */ + providerType: ProviderType; + /** User-editable label (BYOK) or auto-assigned (agent / Plus). */ + displayName: string; + /** Overrides what the wizard pre-filled from catalog / template. */ + baseUrl?: string; + /** + * Obsidian keychain entry id. `null` for providers that don't take an + * API key (Ollama, LMStudio, some agent-owned providers). + */ + apiKeyKeychainId?: string | null; + /** + * Opaque per-`providerType` payload. + * azure: { azureDeploymentName, azureApiVersion, azureInstanceName } + * bedrock: { bedrockRegion } + * openai: { openAIOrgId } + * Kept because those adapters can't function without it. + */ + extras?: Record; + origin: ProviderOrigin; + addedAt: number; +} + +/** + * A model the plugin knows about. Self-sufficient at runtime — once + * this row exists, the catalog is no longer consulted. + * + * "Configured" means "set up in the plugin, ready to use." Applies to + * BYOK (user-added), agent-owned (auto-added at agent setup), and Plus + * (auto-added at Plus sign-in) models alike — the difference is which + * `Provider` this row belongs to and that provider's `origin`. + * + * "Configured" is distinct from "enrolled": a `ConfiguredModel` row + * says the model exists on a provider; a backend separately enrolls + * some subset of configured models for its picker via + * `BackendConfig.enabledModels`. Auto-enrollment is the default UX, but + * the two layers stay separate so per-backend pruning is expressible. + */ +export interface ConfiguredModel { + /** UUID; primary key. */ + configuredModelId: string; + /** FK to `Provider.providerId`. */ + providerId: string; + /** + * Embedded model description (wire-form id, display name, optional + * metadata). The setup flow populates `info` from a `ModelInfo` + * pulled from the catalog. Self-hosted custom models populate + * `info.id` + `info.displayName` only. + * + * Uniqueness constraint: `(providerId, info.id)`. + */ + info: ModelInfo; + configuredAt: number; +} + +/** + * Per-backend model selection. Persisted. + * + * Backend identity is the map key in the future settings shape + * (`settings.backends: Record`), not a + * field on this row. + * + * `enabledModels` and `defaultModel` reference `ConfiguredModel` rows + * by `configuredModelId`. The picker shows every entry regardless of + * any runtime ACP inventory; unreachable models surface at request + * time, not as silent filters. + */ +export interface BackendConfig { + /** Each entry is a `ConfiguredModel.configuredModelId`. */ + enabledModels: string[]; + /** A `ConfiguredModel.configuredModelId` or `null`. */ + defaultModel?: string | null; +}