diff --git a/AGENTS.md b/AGENTS.md index e7e2c005..9761d2f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,6 +208,7 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE - After editing CSS, always run `npm run build` to regenerate `styles.css` - **No arbitrary font-size values**: Never use Tailwind's arbitrary-value syntax for typography (e.g. `tw-text-[10.5px]`, `tw-text-[13px]`). Stick to the configured `fontSize` tokens (`tw-text-ui-smaller`, `tw-text-ui-small`, `tw-text-xs`, `tw-text-smallest`, etc.) so type stays consistent with Obsidian's CSS variables. If none of the existing tokens fit, extend the `fontSize` scale in `tailwind.config.js` rather than hard-coding a pixel value at the call site. - **No inline `style={{ ... }}`**: Reserve the `style` prop for values that must change dynamically at runtime (computed positions, animated transforms). Static visual changes belong in Tailwind classes or the shared component (e.g. `Button` variants/sizes) — do not zero out component chrome with inline `border: 0`, `background: "transparent"`, `padding: 0`, etc. +- **Borders need style + color, not just width (preflight is off)**: Tailwind's preflight is disabled, so the default `border-style: solid` / `border-width: 0` reset is not injected. A visible full-perimeter border is three classes: `tw-border tw-border-solid tw-border-border` — `tw-border` alone renders nothing. For one-sided dividers, do **not** use `tw-border-b`/`-t`/`-l`/`-r` with `tw-border-solid` — `border-style` applies to all four sides and the other sides fall back to the browser default `medium` width, so you get a full perimeter instead of a single divider. Use the shared helper `.copilot-divider-b` in `src/styles/tailwind.css` (and add `.copilot-divider-t`/`-l`/`-r` siblings there if you need other sides). Use the configured color tokens (`tw-border-border`, `tw-border-border-hover`, `tw-border-border-focus`, `tw-border-interactive-accent`) — no arbitrary colors. Width is `DEFAULT` only; do not introduce `tw-border-2` or `tw-border-[Npx]`, and do not extend the `borderWidth` scale. Prefer the UI primitives in `src/components/ui/` (`Card`, `Input`, `Popover`, etc.) which already encode borders correctly. - **Always wrap Tailwind class strings with `cn()`** (from `@/lib/utils`) whenever the classes live anywhere other than a literal `className=` attribute on a JSX element — variable assignments, ternaries, function returns, props passed to other components, etc. `eslint-plugin-tailwindcss` only lints classes it can statically see inside JSX `className` literals or inside calls to its registered callees (`cn`, `clsx`, `classnames`, `ctl`, `cva`), so a bare string assigned to a variable silently bypasses class-order, shorthand, and contradicting-class checks. Use `cn()` for composition too — instead of a ternary between two whole class strings, merge a shared base with conditional fragments: `cn("tw-flex tw-text-sm", expandable && "tw-cursor-pointer")`. ## Testing Guidelines diff --git a/designdocs/MODEL_DATA_MODEL_REVIEW.html b/designdocs/MODEL_DATA_MODEL_REVIEW.html new file mode 100644 index 00000000..8cb890b6 --- /dev/null +++ b/designdocs/MODEL_DATA_MODEL_REVIEW.html @@ -0,0 +1,777 @@ + + +
+ + +Designed from first principles against described behavior. Single-page reference. The implementation on zero/model-settings-redesign does not fully match this design — the companion Markdown spec at designdocs/MODEL_DATA_MODEL_SPEC.md includes a reconciliation appendix.
(instanceId, modelId). Everything downstream holds enrollmentRef = ${instanceId}::${modelId}.pipelines: {langchain, opencode}). Single-pipeline-only providers are acceptable; the consumer picker filters by pipeline.openai-compatible with a custom baseUrl.For context. The master branch (pre-zero/model-settings-redesign) had a much flatter shape. Everything was a CustomModel; credentials lived as a flat bag of fields on CopilotSettings.
CustomModel mega-typeQuoted verbatim from git show master:src/aiParams.ts:
export interface CustomModel {
+ name: string;
+ provider: string;
+ baseUrl?: string;
+ apiKey?: string;
+ enabled: boolean;
+ isEmbeddingModel?: boolean;
+ isBuiltIn?: boolean;
+ enableCors?: boolean;
+ core?: boolean;
+ stream?: boolean;
+ streamUsage?: boolean;
+ temperature?: number;
+ maxTokens?: number;
+ topP?: number;
+ frequencyPenalty?: number;
+
+ // Ollama specific fields
+ numCtx?: number;
+
+ // LM Studio specific fields
+ useResponsesApi?: boolean;
+
+ // OpenRouter specific fields
+ enablePromptCaching?: boolean;
+
+ projectEnabled?: boolean;
+ plusExclusive?: boolean;
+ believerExclusive?: boolean;
+ capabilities?: ModelCapability[];
+ displayName?: string;
+
+ // Embedding models only (Jina at the moment)
+ dimensions?: number;
+ // OpenAI specific fields
+ openAIOrgId?: string;
+
+ // Azure OpenAI specific fields
+ azureOpenAIApiInstanceName?: string;
+ azureOpenAIApiDeploymentName?: string;
+ azureOpenAIApiVersion?: string;
+ azureOpenAIApiEmbeddingDeploymentName?: string;
+
+ // Amazon Bedrock specific fields
+ bedrockRegion?: string;
+
+ // OpenAI GPT-5 and O-series specific fields
+ reasoningEffort?: ReasoningEffort;
+ verbosity?: Verbosity;
+}
+
+Excerpted from git show master:src/settings/model.ts (CopilotSettings):
openAIApiKey: string;
+ openAIOrgId: string;
+ huggingfaceApiKey: string;
+ cohereApiKey: string;
+ anthropicApiKey: string;
+ azureOpenAIApiKey: string;
+ azureOpenAIApiInstanceName: string;
+ azureOpenAIApiDeploymentName: string;
+ azureOpenAIApiVersion: string;
+ azureOpenAIApiEmbeddingDeploymentName: string;
+ googleApiKey: string;
+ openRouterAiApiKey: string;
+ xaiApiKey: string;
+ mistralApiKey: string;
+ deepseekApiKey: string;
+ amazonBedrockApiKey: string;
+ amazonBedrockRegion: string;
+ siliconflowApiKey: string;
+ githubCopilotAccessToken: string;
+ githubCopilotToken: string;
+ githubCopilotTokenExpiresAt: number;
+ ...
+ defaultChainType: ChainType;
+ defaultModelKey: string; // "<name>|<provider>"
+ embeddingModelKey: string;
+ temperature: number;
+ maxTokens: number;
+ ...
+ activeModels: Array<CustomModel>;
+ activeEmbeddingModels: Array<CustomModel>;
+
+From git show master:src/constants.ts:
export enum ChatModelProviders {
+ OPENROUTERAI = "openrouterai",
+ OPENAI = "openai",
+ OPENAI_FORMAT = "3rd party (openai-format)",
+ ANTHROPIC = "anthropic",
+ GOOGLE = "google",
+ XAI = "xai",
+ AMAZON_BEDROCK = "amazon-bedrock",
+ AZURE_OPENAI = "azure openai",
+ GROQ = "groq",
+ OLLAMA = "ollama",
+ LM_STUDIO = "lm-studio",
+ COPILOT_PLUS = "copilot-plus",
+ MISTRAL = "mistralai",
+ DEEPSEEK = "deepseek",
+ COHEREAI = "cohereai",
+ SILICONFLOW = "siliconflow",
+ GITHUB_COPILOT = "github-copilot",
+}
+
+"<name>|<provider>"; one default per app; one credential per provider type. Multi-instance was structurally impossible.models.dev catalog (dozens of entries).Describes a kind of provider. Sourced from models.dev plus our corrections layer. Not persisted in user settings.
interface ProviderType {
+ id: string; // "anthropic", "openai", "google", "mistral", "groq", "deepseek", "openrouter", …
+ displayName: string;
+ adapter: AdapterKind; // closed at six values (see §5)
+ defaultBaseUrl?: string; // catalog-supplied
+ auth: "api-key" | "oauth" | "none";
+ envVars?: string[];
+ pipelines: { langchain: boolean; opencode: boolean }; // see §6
+ models: Record<string, CatalogModel>;
+}
+
+type AdapterKind =
+ | "anthropic"
+ | "openai-compatible"
+ | "google"
+ | "azure"
+ | "bedrock"
+ | "github-copilot";
+
+A user-configured credential set for a ProviderType. Multiple instances per type are allowed.
interface ProviderInstance {
+ instanceId: string; // UUID — primary key; keychain namespace
+ providerTypeId: string; // FK to ProviderType.id (non-unique)
+ displayName: string; // "Anthropic (prod)" / "Anthropic (staging)"
+ baseUrl?: string; // overrides ProviderType.defaultBaseUrl
+ apiKey?: KeychainRef | null;
+ extras?: Record<string, unknown>; // azure deployment, bedrock region, openai org id, …
+ addedAt: number;
+ lastVerifiedAt?: number;
+ lastVerificationError?: string;
+}
+
+type KeychainRef =
+ | { kind: "keychain"; id: string } // OS keychain entry, vault-scoped
+ | { kind: "inline"; value: string }; // plaintext fallback
+
+Example:
+{
+ "instanceId": "anthropic-prod-uuid",
+ "providerTypeId": "anthropic",
+ "displayName": "Anthropic (prod)",
+ "apiKey": { "kind": "keychain", "id": "provider-anthropic-prod-uuid-apiKey" },
+ "addedAt": 1716000000000
+}
+
+interface CatalogModel {
+ id: string; // wire form, e.g. "claude-sonnet-4-5"
+ displayName: string;
+ family?: string; // "Claude 4"
+ modalities: { input: string[]; output: string[] };
+ limits: { context: number; output: number };
+ capabilities: {
+ reasoning?: boolean;
+ toolCall?: boolean;
+ attachment?: boolean;
+ temperature?: boolean;
+ };
+ cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
+ knowledge?: string;
+ releaseDate?: string;
+ lastUpdated?: string;
+ openWeights?: boolean;
+}
+
+For self-hosted endpoints with no catalog. Attached to a ProviderInstance, not a ProviderType.
+interface CustomModel {
+ instanceId: string; // FK to ProviderInstance (must be synthetic-typed)
+ modelId: string; // "llama3.3:70b"
+ displayName: string;
+ declaredCapabilities?: { reasoning?: boolean; toolCall?: boolean; attachment?: boolean };
+ contextLimit?: number;
+ extras?: Record<string, unknown>; // ollama numCtx, etc.
+ addedAt: number;
+}
+
+interface ByokEnrollment {
+ instanceId: string;
+ modelId: string; // catalog model OR custom model (never both)
+ displayName: string;
+ enrolledAt: number;
+ overrides?: Record<string, unknown>; // per-model knobs — NOT temperature/maxTokens
+ lastVerifiedAt?: number;
+ lastVerificationError?: string;
+}
+enrollmentRef = ${instanceId}::${modelId}. Used everywhere downstream as a single string handle. Double colon avoids collisions with ids that contain /.Multi-instance example — same model enrolled under two Anthropic credentials:
+[
+ { "instanceId": "anthropic-prod-uuid", "modelId": "claude-sonnet-4-5", ... },
+ { "instanceId": "anthropic-staging-uuid", "modelId": "claude-sonnet-4-5", ... }
+]
+
+type ConsumerId =
+ | "chat" | "vault-qa" | "project" | "copilot-plus" | "quick-chat"
+ | "agent:opencode" | "agent:claude-code" | "agent:codex"
+ | `command:${string}`;
+
+interface ConsumerConfig {
+ consumerId: ConsumerId;
+ enabledModels: ConsumerModelRef[]; // empty = "use default heuristic"
+ defaultModel?: ConsumerModelRef | null;
+}
+
+type ConsumerModelRef =
+ | { source: "byok"; enrollmentRef: string }
+ | { source: "backend-bundled"; backendId: BackendId; backendModelId: string }
+ | { source: "copilot-plus"; modelId: string };
+
+type BackendId = "opencode" | "claude-code" | "codex";
+Examples:
+// Simple Chat enables GPT-5 via the user's only OpenAI instance
+{ "source": "byok", "enrollmentRef": "openai-uuid-1::gpt-5" }
+
+// OpenCode agent enables an OpenCode-bundled model
+{ "source": "backend-bundled", "backendId": "opencode", "backendModelId": "bigpickle/big-pickle" }
+
+// Quick Chat enables a Copilot Plus hosted model
+{ "source": "copilot-plus", "modelId": "copilot-plus-flash" }
+
+// OpenCode agent enables Sonnet via the prod Anthropic instance (NOT staging)
+{ "source": "byok", "enrollmentRef": "anthropic-prod-uuid::claude-sonnet-4-5" }
+
+Held in memory; never persisted.
+interface BackendInventory {
+ backendId: BackendId;
+ models: BackendModelEntry[]; // bundled + BYOK-bridged + hosted
+}
+
+interface BackendModelEntry {
+ backendModelId: string;
+ origin:
+ | { kind: "bundled" }
+ | { kind: "byok"; enrollmentRef: string }
+ | { kind: "copilot-plus" };
+ displayName?: string;
+ capabilities?: CatalogModel["capabilities"];
+}
+
+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.
| AdapterKind | LangChain path | OpenCode path |
|---|---|---|
anthropic | @langchain/anthropic (ChatAnthropic) | provider.set type=anthropic |
openai-compatible | @langchain/openai + 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 |
Why six and not more. Each one is real wiring in one or both pipelines. Adding an AdapterKind requires shipping LangChain integration code AND ensuring (or explicitly disclaiming via pipelines) OpenCode support. We don't multiply adapter kinds just because a new provider exists.
Why we still surface dozens of providers. The catalog (e.g. Mistral, Groq, DeepSeek, Together, Perplexity, xAI, OpenRouter, SiliconFlow, …) maps most providers onto openai-compatible with their own defaultBaseUrl. So the Add Provider list mirrors the full models.dev catalog; what differs is the adapter the runtime uses behind the scenes.
ProviderType.pipelines = { langchain, opencode } is load-bearing. It gates which consumer pickers a provider's enrollments can appear in.
| 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 don't accept BYOK at all; their enabledModels are all backend-bundled refs.
@langchain/openai.provider.set config doesn't recognize the protocol — LangChain works; OpenCode upstream hasn't wired it up.| ProviderType.id | adapter | langchain | opencode | notes |
|---|---|---|---|---|
anthropic | anthropic | ✓ | ✓ | |
openai | openai-compatible | ✓ | ✓ | |
google | google | ✓ | ✓ | |
mistral | openai-compatible | ✓ | ✓ | |
groq | openai-compatible | ✓ | ✓ | |
openrouter | openai-compatible | ✓ | ✓ | |
bedrock | bedrock | ✓ | ✓ | AWS SDK auth in both |
azure | azure | ✓ | ✓ | deployment name in extras |
github-copilot | github-copilot | ✓ | ✓ | OAuth refresh handled in both |
ollama | openai-compatible | ✓ | ✓ | synthetic; CustomModels per instance |
| hypothetical | openai-compatible | ✓ | ✗ | LangChain works; OpenCode unaware |
| hypothetical | (custom OAuth) | ✗ | ✓ | OpenCode CLI manages OAuth; can't replicate in-process |
+ ┌───────────────────────────────┐
+ │ ProviderType (read-only) │
+ │ id (PK) │
+ │ adapter │
+ │ pipelines {langchain,opencode}│
+ │ defaultBaseUrl │
+ │ models: {id → CatalogModel} │
+ └───────────┬───────────────────┘
+ │ 1
+ │
+ │ 1..n
+ ┌───────────▼───────────┐
+ │ ProviderInstance │
+ │ instanceId (PK) │
+ │ providerTypeId (FK) │
+ │ displayName │
+ │ baseUrl, apiKey,extras│
+ └───┬────────────┬──────┘
+ │ │
+ 1 │ 1 │
+ │ │
+ 0..n │ 0..n │
+ ┌─────────────────▼──┐ ┌────▼──────────────────┐
+ │ CustomModel │ │ ByokEnrollment │
+ │ instanceId (FK) │ │ instanceId (FK) │
+ │ modelId (PK part) │ │ modelId (PK part) │
+ │ declaredCaps │ │ displayName │
+ └────────┬────────────┘ │ overrides │
+ │ │ │
+ └─── resolves ──►│ (modelId points │
+ │ at catalog or │
+ │ customModel) │
+ └───────────┬───────────┘
+ │ 1
+ │
+ 0..n │
+ ┌────────────────────────────▼───────────┐
+ │ ConsumerConfig │
+ │ consumerId (PK) │
+ │ enabledModels[]: ConsumerModelRef │
+ │ defaultModel?: ConsumerModelRef │
+ └────────────────────────────────────────┘
+ ▲
+ │ runtime-only intersection
+ ┌────────┴────────────────────────────────┐
+ │ BackendInventory (in-memory) │
+ │ per BackendId: bundled + bridged BYOK │
+ │ + Copilot Plus models │
+ └─────────────────────────────────────────┘
+
+
+enrollment.instanceId ∈ keys(providers). (write path, migration)(instanceId, modelId) pair resolves either via the instance's ProviderType catalog OR via a CustomModel row — never both. (write path)ByokEnrollment per (instanceId, modelId). (write path)CustomModel per (instanceId, modelId). (write path)ProviderType.auth = "api-key", ProviderInstance.apiKey is non-null; otherwise the provider is "incomplete" and its enrollments are greyed in pickers. (runtime)ConsumerModelRef with source: "byok" points at a present enrollment. Broken refs surface in the UI — never silently pruned. (runtime)defaultModel is removed from enabledModels, defaultModel is set to null. (write path)ProviderInstance cascades to its enrollments and custom models. Consumer refs pointing at those enrollments become broken refs (surfaced, not pruned). (write path)langchain only renders enrollments whose ProviderType.pipelines.langchain = true; same for opencode. Ineligible enrollments are shown as ineligible in the BYOK panel with a reason — not hidden. (runtime)providerTypeId uniqueness constraint across providers. Multi-instance is the whole point. (explicit non-constraint)
+1. User opens BYOK panel
+ ── catalog shows OpenAI ProviderType.models
+
+2. User adds OpenAI provider
+ ── providers["openai-uuid-1"] = {
+ instanceId: "openai-uuid-1",
+ providerTypeId: "openai",
+ apiKey: { kind: "keychain", id: "provider-openai-uuid-1-apiKey" }
+ }
+
+3. User enrolls "gpt-5"
+ ── enrollments += { instanceId: "openai-uuid-1", modelId: "gpt-5" }
+
+4. User enables it in Simple Chat
+ ── consumers["chat"] = {
+ consumerId: "chat",
+ enabledModels: [{ source: "byok", enrollmentRef: "openai-uuid-1::gpt-5" }],
+ defaultModel: { source: "byok", enrollmentRef: "openai-uuid-1::gpt-5" }
+ }
+
+5. Picker renders
+ ── iterate enabledModels → resolve enrollmentRef
+ ── join with catalog → render "GPT-5"
+
+
+
+1. Plugin starts → OpenCode CLI launched
+ ── BackendInventory["opencode"].models includes
+ { backendModelId: "bigpickle/big-pickle", origin: { kind: "bundled" }, ... }
+
+2. User enables it in OpenCode consumer
+ ── consumers["agent:opencode"].enabledModels += {
+ source: "backend-bundled",
+ backendId: "opencode",
+ backendModelId: "bigpickle/big-pickle"
+ }
+
+3. Picker renders
+ ── resolve against live BackendInventory["opencode"] by backendModelId
+
+
+
+Pre-existing:
+ providers["anthropic-prod-uuid"] = { providerTypeId: "anthropic", displayName: "Anthropic (prod)", apiKey: K1 }
+ enrollments += { instanceId: "anthropic-prod-uuid", modelId: "claude-sonnet-4-5" }
+
+1. User enables it in OpenCode consumer
+ ── consumers["agent:opencode"].enabledModels += {
+ source: "byok",
+ enrollmentRef: "anthropic-prod-uuid::claude-sonnet-4-5"
+ }
+
+2. byokBridge runs at session start
+ ── registers prod Anthropic creds with OpenCode (one bridged provider per ProviderInstance, keyed by instanceId)
+ ── OpenCode now reports a model with origin: { kind: "byok", enrollmentRef: "anthropic-prod-uuid::claude-sonnet-4-5" }
+
+3. Picker reconciles
+ ── matches consumer's "byok" ref to inventory entry by enrollmentRef
+ ── if user also enabled staging-instance enrollment, both render as separate rows
+ disambiguated by parent ProviderInstance.displayName
+
+
+
+1. providers["anthropic-prod-uuid"] = { ..., displayName: "Anthropic (prod)", apiKey: K1 }
+
+2. User clicks "Add Provider → Anthropic" AGAIN
+ ── BYOK UI does NOT filter Anthropic out (multi-instance is allowed)
+ ── providers["anthropic-staging-uuid"] = { ..., displayName: "Anthropic (staging)", apiKey: K2 }
+
+3. User enrolls Sonnet 4.5 on both instances
+ ── enrollments += { instanceId: "anthropic-prod-uuid", modelId: "claude-sonnet-4-5" }
+ ── enrollments += { instanceId: "anthropic-staging-uuid", modelId: "claude-sonnet-4-5" }
+
+4. Simple Chat picker
+ ── shows TWO rows: "Claude Sonnet 4.5 (prod)" and "Claude Sonnet 4.5 (staging)"
+
+5. Delete staging instance
+ ── cascade: enrollment removed
+ ── any consumer ref pointing at it becomes a broken ref (surfaced in UI)
+ ── prod enrollment untouched
+
+
+
+1. providers["ollama-uuid-1"] = {
+ providerTypeId: "ollama",
+ displayName: "Ollama (laptop)",
+ baseUrl: "http://localhost:11434",
+ apiKey: null
+ }
+
+2. providers["ollama-uuid-2"] = {
+ providerTypeId: "ollama",
+ displayName: "Ollama (workstation)",
+ baseUrl: "http://192.168.1.50:11434",
+ apiKey: null
+ }
+
+3. customModels += { instanceId: "ollama-uuid-1", modelId: "llama3.3:70b" }
+ customModels += { instanceId: "ollama-uuid-2", modelId: "mistral:7b" }
+ (each instance owns its own model list)
+
+4. enrollments += two rows, one per instance
+ (from here, same as Trace A)
+
+
+visibleEntries(consumerId) =
+ let pipeline = pipelineOf(consumerId)
+ let inventory = (pipeline === "opencode")
+ ? BackendInventory[backendIdOf(consumerId)]
+ : null
+
+ enabled = consumer.enabledModels // empty ⇒ 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 support tool_call so hide it" check — runtime errors surface incompatibility via the normal error path.
+ +temperature / maxTokens / reasoningEffort / verbosity / topP / frequencyPenalty in the data model. Adapter SDK defaults are used. If user-tunable knobs are ever needed, they belong elsewhere (per-invocation, per-skill, per-command).temperature / maxTokens / topP. Same reason.enabled flag on ByokEnrollment. "Enabled for which consumer?" is ambiguous; per-consumer enabledModels carries the meaning.capabilities array on ByokEnrollment. Read from catalog or CustomModel.declaredCapabilities at query time.enrollments at first — embeddings can fold in once the carve-out resolves.ProjectConfig.projectModelKey is a wire-form string. Becomes ProjectConfig.defaultModelRef: ConsumerModelRef | null? Or does each project override consumers["project"] wholesale?ConsumerConfig per command? Or fold into a single record keyed by command id?For implementation handoff: see designdocs/MODEL_DATA_MODEL_SPEC.md — same data model in canonical form, plus a reconciliation appendix diffing this design against the current src/modelManagement/ branch.
{hintA} or {hintB}.
+ {hintA} or {hintB}.
+