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.