Model Management — Data-Model Review

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.

Contents
  1. TL;DR
  2. Old world (master)
  3. Behaviors driving the new design
  4. Entity catalog
  5. AdapterKind rationale
  6. Pipeline compatibility
  7. ER diagram
  8. Invariants
  9. Resolution traces
  10. Per-consumer picker formulas
  11. Explicit non-goals
  12. Open questions

1. TL;DR

2. Old world (master, pre-refactor)

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.

2.1 The CustomModel mega-type

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

2.2 Settings — credentials as a flat bag

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

2.3 Provider enum

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",
}
Takeaway. Every concept that's now an entity (catalog metadata, credential set, enrollment, consumer selection) was a field — or worse, a string — in one big record. Identity was "<name>|<provider>"; one default per app; one credential per provider type. Multi-instance was structurally impossible.

3. Behaviors driving the new design

  1. Provider configuration. Pick a type, supply name / URL / key, plus adapter-specific extras (Azure deployment name, Bedrock region, OpenAI org id).
  2. Multiple instances of the same provider type. Two Anthropic credential sets ("prod" and "staging"), two Ollama endpoints, etc. Each independently configured and enrolled.
  3. Catalog drives the provider list. Add Provider iterates the full models.dev catalog (dozens of entries).
  4. Catalog drives the model list per provider. Once a provider is configured, the catalog says what models it serves.
  5. BYOK enrollment. The user picks specific models to bring into the pool — separate from "do I have a key for this provider."
  6. Custom models on custom providers. Self-hosted endpoints have no catalog; the user declares models by hand.
  7. Per-use-case (consumer) model selection. Simple Chat, Vault QA, OpenCode agent, Quick Chat, custom commands, etc. — each holds its own allow-list and its own default. Same enrollment can be selected by several consumers.
  8. Backend-supplied models. Agent backends report models at runtime that aren't BYOK (OpenCode bundled, Claude Code subscription, Codex subscription, Copilot Plus hosted). Pickers union BYOK + backend-reported, intersected with the user's per-consumer selection.
  9. Per-pipeline reachability is declared, not assumed. Each provider is reachable from (a) the LangChain direct path or (b) the OpenCode byokBridge path, or both. Single-pipeline-only is acceptable. Subscription backends sit outside this axis.
  10. Embeddings parity (deferred). The same five-layer pipeline applies; rollout sequenced separately.

4. Entity catalog

4.1 ProviderType read-only

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

4.2 ProviderInstance persisted

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
}

4.3 CatalogModel read-only

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

4.4 CustomModel persisted

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

4.5 ByokEnrollment persisted unit of identity

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;
}
Stable enrollment key: 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", ... }
]

4.6 ConsumerConfig persisted

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

4.7 ConsumerModelRef

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

4.8 BackendInventory runtime-only

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

5. AdapterKind 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.

AdapterKindLangChain pathOpenCode path
anthropic@langchain/anthropic (ChatAnthropic)provider.set type=anthropic
openai-compatible@langchain/openai + custom baseUrlprovider.set type=openai-compatible
google@langchain/google-genaiprovider.set type=google
azure@langchain/openai (Azure path) + deployment knobsprovider.set type=azure
bedrock@langchain/awsprovider.set type=bedrock
github-copilotCustom 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.

Surfacing, not silencing. A provider whose adapter we don't support yet should appear in the Add Provider UI with a "not yet supported in this plugin" affordance — not be silently filtered out. The user should know the gap exists.

6. Pipeline compatibility

ProviderType.pipelines = { langchain, opencode } is load-bearing. It gates which consumer pickers a provider's enrollments can appear in.

6.1 Consumer ↔ pipeline mapping

ConsumerIdPipeline
chatlangchain
vault-qalangchain
projectlangchain
copilot-pluslangchain
quick-chatlangchain
command:*langchain
agent:opencodeopencode
agent:claude-code(subscription)
agent:codex(subscription)

Subscription consumers don't accept BYOK at all; their enabledModels are all backend-bundled refs.

6.2 What causes a flag to flip false

pipelines.langchain = false

pipelines.opencode = false

6.3 Worked examples

ProviderType.idadapterlangchainopencodenotes
anthropicanthropic
openaiopenai-compatible
googlegoogle
mistralopenai-compatible
groqopenai-compatible
openrouteropenai-compatible
bedrockbedrockAWS SDK auth in both
azureazuredeployment name in extras
github-copilotgithub-copilotOAuth refresh handled in both
ollamaopenai-compatiblesynthetic; CustomModels per instance
hypotheticalopenai-compatibleLangChain works; OpenCode unaware
hypothetical(custom OAuth)OpenCode CLI manages OAuth; can't replicate in-process

7. ER diagram

                            ┌───────────────────────────────┐
                            │ 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  │
                       └─────────────────────────────────────────┘

8. Invariants

  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 enrollments and custom models. 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 the BYOK panel with a reason — not hidden. (runtime)
  10. NO providerTypeId uniqueness constraint across providers. Multi-instance is the whole point. (explicit non-constraint)

9. Resolution traces

Trace A — Simple Chat, single OpenAI instance

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"

Trace B — OpenCode-bundled (not BYOK)

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

Trace C — BYOK shared with OpenCode (byokBridge)

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

Trace D — Two Anthropic keys (multi-instance, catalog-backed)

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

Trace E — Custom providers, multi-instance, custom models

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)

10. Per-consumer picker formulas

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.

11. Explicit non-goals

12. Open questions

  1. Embeddings unification timing. Stay legacy now, fold in later?
  2. Project-level chain default storage. Currently ProjectConfig.projectModelKey is a wire-form string. Becomes ProjectConfig.defaultModelRef: ConsumerModelRef | null? Or does each project override consumers["project"] wholesale?
  3. Per-invocation knobs. If we ever surface a user-facing temperature slider, where? Per-message, per-skill, per-command? Spec says "not in the data model" — does that hold?
  4. "Not yet supported in this plugin" affordance for catalog providers we don't have an adapter for. Disabled list item with tooltip? Separate section?
  5. Per-pipeline-only badging. Do we put "works with chat only" / "works with OpenCode only" copy on the BYOK enrollment row? Or only at the provider level?
  6. backend-bundled ref staleness. OpenCode releases change bundled lists. Broken ref behavior or auto-prune?
  7. Custom command consumers. One 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.