mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
wip: model-settings redesign prototype (full working tree snapshot)
Frozen snapshot of the prototype implementation that was deemed too far to maintain. Preserved here for reference; the design will be reimplemented from scratch using the spec docs on the zero/model-settings-redesign branch.
This commit is contained in:
parent
7d9336ac69
commit
7cc816bf1b
114 changed files with 14088 additions and 1946 deletions
|
|
@ -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
|
||||
|
|
|
|||
777
designdocs/MODEL_DATA_MODEL_REVIEW.html
Normal file
777
designdocs/MODEL_DATA_MODEL_REVIEW.html
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Model Management — Data-Model Review</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--fg: #1c1c1c;
|
||||
--muted: #5c5c5c;
|
||||
--rule: #d6d6d6;
|
||||
--code-bg: #f2f2f2;
|
||||
--code-fg: #1c1c1c;
|
||||
--link: #1a5fb4;
|
||||
--accent: #b35400;
|
||||
--callout-bg: #fff7e6;
|
||||
--callout-border: #e0b070;
|
||||
--good-bg: #ebf5eb;
|
||||
--good-border: #88c188;
|
||||
--table-stripe: #f4f4f4;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 36px 28px 96px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
margin: 48px 0 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--rule);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 17px;
|
||||
margin: 28px 0 6px;
|
||||
}
|
||||
h4 {
|
||||
font-size: 15px;
|
||||
margin: 18px 0 4px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
p, ul, ol { margin: 8px 0 12px; }
|
||||
ul, ol { padding-left: 22px; }
|
||||
li { margin: 3px 0; }
|
||||
code {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-fg);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre {
|
||||
background: var(--code-bg);
|
||||
color: var(--code-fg);
|
||||
padding: 14px 16px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 12px 0;
|
||||
}
|
||||
pre code { background: transparent; padding: 0; }
|
||||
a { color: var(--link); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid var(--rule);
|
||||
padding: 6px 9px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th { background: var(--code-bg); font-weight: 600; }
|
||||
tbody tr:nth-child(even) { background: var(--table-stripe); }
|
||||
.callout {
|
||||
background: var(--callout-bg);
|
||||
border: 1px solid var(--callout-border);
|
||||
border-left-width: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 4px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.callout strong { color: var(--accent); }
|
||||
.good {
|
||||
background: var(--good-bg);
|
||||
border: 1px solid var(--good-border);
|
||||
border-left-width: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 4px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
.toc {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--rule);
|
||||
padding: 12px 18px;
|
||||
border-radius: 6px;
|
||||
margin: 18px 0 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.toc ol { margin: 0; padding-left: 20px; }
|
||||
.toc li { margin: 2px 0; }
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--rule);
|
||||
margin: 32px 0;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9px;
|
||||
background: var(--code-bg);
|
||||
color: var(--muted);
|
||||
margin-left: 6px;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ascii {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<h1>Model Management — Data-Model Review</h1>
|
||||
<p class="subtitle">Designed from first principles against described behavior. Single-page reference. The implementation on <code>zero/model-settings-redesign</code> does <strong>not</strong> fully match this design — the companion Markdown spec at <code>designdocs/MODEL_DATA_MODEL_SPEC.md</code> includes a reconciliation appendix.</p>
|
||||
|
||||
<div class="toc">
|
||||
<strong>Contents</strong>
|
||||
<ol>
|
||||
<li><a href="#tldr">TL;DR</a></li>
|
||||
<li><a href="#old">Old world (master)</a></li>
|
||||
<li><a href="#behaviors">Behaviors driving the new design</a></li>
|
||||
<li><a href="#entities">Entity catalog</a></li>
|
||||
<li><a href="#adapter">AdapterKind rationale</a></li>
|
||||
<li><a href="#pipeline">Pipeline compatibility</a></li>
|
||||
<li><a href="#er">ER diagram</a></li>
|
||||
<li><a href="#invariants">Invariants</a></li>
|
||||
<li><a href="#traces">Resolution traces</a></li>
|
||||
<li><a href="#picker">Per-consumer picker formulas</a></li>
|
||||
<li><a href="#nongoals">Explicit non-goals</a></li>
|
||||
<li><a href="#questions">Open questions</a></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2 id="tldr">1. TL;DR</h2>
|
||||
<ul>
|
||||
<li><strong>Two layers of "provider":</strong> a read-only <em>ProviderType</em> (catalog metadata from models.dev) and a persisted <em>ProviderInstance</em> (the user's credential set). Multiple instances of the same type are allowed — two Anthropic keys live as two ProviderInstance rows.</li>
|
||||
<li><strong>BYOK enrollment is the unit of identity</strong>: a <em>ByokEnrollment</em> row points at <code>(instanceId, modelId)</code>. Everything downstream holds <code>enrollmentRef = ${instanceId}::${modelId}</code>.</li>
|
||||
<li><strong>Custom models attach to instances, not types</strong> — two Ollama machines can declare different model lineups.</li>
|
||||
<li><strong>Per-use-case (consumer) selection</strong>: each consumer (Simple Chat, Vault QA, OpenCode agent, …) holds its own allow-list of model refs. A consumer ref is polymorphic: BYOK / backend-bundled / Copilot Plus.</li>
|
||||
<li><strong>Pipeline reachability is declared per ProviderType</strong> (<code>pipelines: {langchain, opencode}</code>). Single-pipeline-only providers are acceptable; the consumer picker filters by pipeline.</li>
|
||||
<li><strong>Six AdapterKinds, not 30</strong>. The catalog still surfaces dozens of providers; most route through <code>openai-compatible</code> with a custom <code>baseUrl</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="old">2. Old world (master, pre-refactor)</h2>
|
||||
<p>For context. The master branch (pre-<code>zero/model-settings-redesign</code>) had a much flatter shape. Everything was a <code>CustomModel</code>; credentials lived as a flat bag of fields on <code>CopilotSettings</code>.</p>
|
||||
|
||||
<h3>2.1 The <code>CustomModel</code> mega-type</h3>
|
||||
<p>Quoted verbatim from <code>git show master:src/aiParams.ts</code>:</p>
|
||||
<pre><code>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;
|
||||
}</code></pre>
|
||||
|
||||
<h3>2.2 Settings — credentials as a flat bag</h3>
|
||||
<p>Excerpted from <code>git show master:src/settings/model.ts</code> (<code>CopilotSettings</code>):</p>
|
||||
<pre><code> 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>;</code></pre>
|
||||
|
||||
<h3>2.3 Provider enum</h3>
|
||||
<p>From <code>git show master:src/constants.ts</code>:</p>
|
||||
<pre><code>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",
|
||||
}</code></pre>
|
||||
|
||||
<div class="callout"><strong>Takeaway.</strong> 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 <code>"<name>|<provider>"</code>; one default per app; one credential per provider type. Multi-instance was structurally impossible.</div>
|
||||
|
||||
<h2 id="behaviors">3. Behaviors driving the new design</h2>
|
||||
<ol>
|
||||
<li><strong>Provider configuration.</strong> Pick a type, supply name / URL / key, plus adapter-specific extras (Azure deployment name, Bedrock region, OpenAI org id).</li>
|
||||
<li><strong>Multiple instances of the same provider type.</strong> Two Anthropic credential sets ("prod" and "staging"), two Ollama endpoints, etc. Each independently configured and enrolled.</li>
|
||||
<li><strong>Catalog drives the provider list.</strong> Add Provider iterates the full <code>models.dev</code> catalog (dozens of entries).</li>
|
||||
<li><strong>Catalog drives the model list per provider.</strong> Once a provider is configured, the catalog says what models it serves.</li>
|
||||
<li><strong>BYOK enrollment.</strong> The user picks specific models to bring into the pool — separate from "do I have a key for this provider."</li>
|
||||
<li><strong>Custom models on custom providers.</strong> Self-hosted endpoints have no catalog; the user declares models by hand.</li>
|
||||
<li><strong>Per-use-case (consumer) model selection.</strong> 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.</li>
|
||||
<li><strong>Backend-supplied models.</strong> 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.</li>
|
||||
<li><strong>Per-pipeline reachability is declared, not assumed.</strong> 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.</li>
|
||||
<li><strong>Embeddings parity (deferred).</strong> The same five-layer pipeline applies; rollout sequenced separately.</li>
|
||||
</ol>
|
||||
|
||||
<h2 id="entities">4. Entity catalog</h2>
|
||||
|
||||
<h3>4.1 ProviderType <span class="badge">read-only</span></h3>
|
||||
<p>Describes a <em>kind</em> of provider. Sourced from <code>models.dev</code> plus our corrections layer. Not persisted in user settings.</p>
|
||||
<pre><code>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";</code></pre>
|
||||
|
||||
<h3>4.2 ProviderInstance <span class="badge">persisted</span></h3>
|
||||
<p>A user-configured credential set for a <code>ProviderType</code>. <strong>Multiple instances per type are allowed.</strong></p>
|
||||
<pre><code>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</code></pre>
|
||||
|
||||
<p>Example:</p>
|
||||
<pre><code>{
|
||||
"instanceId": "anthropic-prod-uuid",
|
||||
"providerTypeId": "anthropic",
|
||||
"displayName": "Anthropic (prod)",
|
||||
"apiKey": { "kind": "keychain", "id": "provider-anthropic-prod-uuid-apiKey" },
|
||||
"addedAt": 1716000000000
|
||||
}</code></pre>
|
||||
|
||||
<h3>4.3 CatalogModel <span class="badge">read-only</span></h3>
|
||||
<pre><code>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;
|
||||
}</code></pre>
|
||||
|
||||
<h3>4.4 CustomModel <span class="badge">persisted</span></h3>
|
||||
<p>For self-hosted endpoints with no catalog. <strong>Attached to a ProviderInstance, not a ProviderType.</strong></p>
|
||||
<pre><code>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;
|
||||
}</code></pre>
|
||||
|
||||
<h3>4.5 ByokEnrollment <span class="badge">persisted</span> <span class="badge">unit of identity</span></h3>
|
||||
<pre><code>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;
|
||||
}</code></pre>
|
||||
<div class="callout"><strong>Stable enrollment key:</strong> <code>enrollmentRef = ${instanceId}::${modelId}</code>. Used everywhere downstream as a single string handle. Double colon avoids collisions with ids that contain <code>/</code>.</div>
|
||||
<p>Multi-instance example — same model enrolled under two Anthropic credentials:</p>
|
||||
<pre><code>[
|
||||
{ "instanceId": "anthropic-prod-uuid", "modelId": "claude-sonnet-4-5", ... },
|
||||
{ "instanceId": "anthropic-staging-uuid", "modelId": "claude-sonnet-4-5", ... }
|
||||
]</code></pre>
|
||||
|
||||
<h3>4.6 ConsumerConfig <span class="badge">persisted</span></h3>
|
||||
<pre><code>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;
|
||||
}</code></pre>
|
||||
|
||||
<h3>4.7 ConsumerModelRef</h3>
|
||||
<pre><code>type ConsumerModelRef =
|
||||
| { source: "byok"; enrollmentRef: string }
|
||||
| { source: "backend-bundled"; backendId: BackendId; backendModelId: string }
|
||||
| { source: "copilot-plus"; modelId: string };
|
||||
|
||||
type BackendId = "opencode" | "claude-code" | "codex";</code></pre>
|
||||
<p>Examples:</p>
|
||||
<pre><code>// 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" }</code></pre>
|
||||
|
||||
<h3>4.8 BackendInventory <span class="badge">runtime-only</span></h3>
|
||||
<p>Held in memory; never persisted.</p>
|
||||
<pre><code>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"];
|
||||
}</code></pre>
|
||||
|
||||
<h2 id="adapter">5. AdapterKind rationale</h2>
|
||||
<p><code>AdapterKind</code> is <strong>not</strong> the same thing as <code>ProviderType.id</code>. The id is the catalog handle (one per <code>models.dev</code> entry, potentially 30+). The adapter is the protocol / SDK we wire to internally — closed at six values.</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>AdapterKind</th><th>LangChain path</th><th>OpenCode path</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>anthropic</code></td><td><code>@langchain/anthropic</code> (<code>ChatAnthropic</code>)</td><td><code>provider.set type=anthropic</code></td></tr>
|
||||
<tr><td><code>openai-compatible</code></td><td><code>@langchain/openai</code> + custom <code>baseUrl</code></td><td><code>provider.set type=openai-compatible</code></td></tr>
|
||||
<tr><td><code>google</code></td><td><code>@langchain/google-genai</code></td><td><code>provider.set type=google</code></td></tr>
|
||||
<tr><td><code>azure</code></td><td><code>@langchain/openai</code> (Azure path) + deployment knobs</td><td><code>provider.set type=azure</code></td></tr>
|
||||
<tr><td><code>bedrock</code></td><td><code>@langchain/aws</code></td><td><code>provider.set type=bedrock</code></td></tr>
|
||||
<tr><td><code>github-copilot</code></td><td>Custom bridge (OAuth-based)</td><td><code>provider.set type=github-copilot</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><strong>Why six and not more.</strong> Each one is real wiring in one or both pipelines. Adding an AdapterKind requires shipping LangChain integration code AND ensuring (or explicitly disclaiming via <code>pipelines</code>) OpenCode support. We don't multiply adapter kinds just because a new provider exists.</p>
|
||||
|
||||
<p><strong>Why we still surface dozens of providers.</strong> The catalog (e.g. Mistral, Groq, DeepSeek, Together, Perplexity, xAI, OpenRouter, SiliconFlow, …) maps most providers onto <code>openai-compatible</code> with their own <code>defaultBaseUrl</code>. So the Add Provider list mirrors the full <code>models.dev</code> catalog; what differs is the adapter the runtime uses behind the scenes.</p>
|
||||
|
||||
<div class="callout"><strong>Surfacing, not silencing.</strong> 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.</div>
|
||||
|
||||
<h2 id="pipeline">6. Pipeline compatibility</h2>
|
||||
<p><code>ProviderType.pipelines = { langchain, opencode }</code> is load-bearing. It gates which consumer pickers a provider's enrollments can appear in.</p>
|
||||
|
||||
<h3>6.1 Consumer ↔ pipeline mapping</h3>
|
||||
<table>
|
||||
<thead><tr><th>ConsumerId</th><th>Pipeline</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>chat</code></td><td><code>langchain</code></td></tr>
|
||||
<tr><td><code>vault-qa</code></td><td><code>langchain</code></td></tr>
|
||||
<tr><td><code>project</code></td><td><code>langchain</code></td></tr>
|
||||
<tr><td><code>copilot-plus</code></td><td><code>langchain</code></td></tr>
|
||||
<tr><td><code>quick-chat</code></td><td><code>langchain</code></td></tr>
|
||||
<tr><td><code>command:*</code></td><td><code>langchain</code></td></tr>
|
||||
<tr><td><code>agent:opencode</code></td><td><code>opencode</code></td></tr>
|
||||
<tr><td><code>agent:claude-code</code></td><td>(subscription)</td></tr>
|
||||
<tr><td><code>agent:codex</code></td><td>(subscription)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Subscription consumers don't accept BYOK at all; their <code>enabledModels</code> are all <code>backend-bundled</code> refs.</p>
|
||||
|
||||
<h3>6.2 What causes a flag to flip false</h3>
|
||||
<h4>pipelines.langchain = false</h4>
|
||||
<ul>
|
||||
<li>No LangChain SDK package available; not OpenAI-compatible enough for <code>@langchain/openai</code>.</li>
|
||||
<li>CLI-bound session token or hardware-token auth that the in-process LangChain class can't bind to.</li>
|
||||
<li>Provider is bundled end-to-end by OpenCode (SDK + OAuth) and exposing it via LangChain would require shipping its SDK in the plugin bundle.</li>
|
||||
</ul>
|
||||
<h4>pipelines.opencode = false</h4>
|
||||
<ul>
|
||||
<li>OpenCode's <code>provider.set</code> config doesn't recognize the protocol — LangChain works; OpenCode upstream hasn't wired it up.</li>
|
||||
<li>Per-request state (custom headers, signed URLs) the byokBridge can't inject into OpenCode's HTTP client.</li>
|
||||
<li>Local endpoint OpenCode's subprocess can't reach (rare).</li>
|
||||
</ul>
|
||||
|
||||
<h3>6.3 Worked examples</h3>
|
||||
<table>
|
||||
<thead><tr><th>ProviderType.id</th><th>adapter</th><th>langchain</th><th>opencode</th><th>notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>anthropic</code></td><td><code>anthropic</code></td><td>✓</td><td>✓</td><td></td></tr>
|
||||
<tr><td><code>openai</code></td><td><code>openai-compatible</code></td><td>✓</td><td>✓</td><td></td></tr>
|
||||
<tr><td><code>google</code></td><td><code>google</code></td><td>✓</td><td>✓</td><td></td></tr>
|
||||
<tr><td><code>mistral</code></td><td><code>openai-compatible</code></td><td>✓</td><td>✓</td><td></td></tr>
|
||||
<tr><td><code>groq</code></td><td><code>openai-compatible</code></td><td>✓</td><td>✓</td><td></td></tr>
|
||||
<tr><td><code>openrouter</code></td><td><code>openai-compatible</code></td><td>✓</td><td>✓</td><td></td></tr>
|
||||
<tr><td><code>bedrock</code></td><td><code>bedrock</code></td><td>✓</td><td>✓</td><td>AWS SDK auth in both</td></tr>
|
||||
<tr><td><code>azure</code></td><td><code>azure</code></td><td>✓</td><td>✓</td><td>deployment name in <code>extras</code></td></tr>
|
||||
<tr><td><code>github-copilot</code></td><td><code>github-copilot</code></td><td>✓</td><td>✓</td><td>OAuth refresh handled in both</td></tr>
|
||||
<tr><td><code>ollama</code></td><td><code>openai-compatible</code></td><td>✓</td><td>✓</td><td>synthetic; CustomModels per instance</td></tr>
|
||||
<tr><td><em>hypothetical</em></td><td><code>openai-compatible</code></td><td>✓</td><td>✗</td><td>LangChain works; OpenCode unaware</td></tr>
|
||||
<tr><td><em>hypothetical</em></td><td>(custom OAuth)</td><td>✗</td><td>✓</td><td>OpenCode CLI manages OAuth; can't replicate in-process</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 id="er">7. ER diagram</h2>
|
||||
<pre class="ascii">
|
||||
┌───────────────────────────────┐
|
||||
│ 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 │
|
||||
└─────────────────────────────────────────┘
|
||||
</pre>
|
||||
|
||||
<h2 id="invariants">8. Invariants</h2>
|
||||
<ol>
|
||||
<li><code>enrollment.instanceId ∈ keys(providers)</code>. <em>(write path, migration)</em></li>
|
||||
<li><code>(instanceId, modelId)</code> pair resolves either via the instance's ProviderType catalog OR via a <code>CustomModel</code> row — never both. <em>(write path)</em></li>
|
||||
<li>At most one <code>ByokEnrollment</code> per <code>(instanceId, modelId)</code>. <em>(write path)</em></li>
|
||||
<li>At most one <code>CustomModel</code> per <code>(instanceId, modelId)</code>. <em>(write path)</em></li>
|
||||
<li>If <code>ProviderType.auth = "api-key"</code>, <code>ProviderInstance.apiKey</code> is non-null; otherwise the provider is "incomplete" and its enrollments are greyed in pickers. <em>(runtime)</em></li>
|
||||
<li>Every <code>ConsumerModelRef</code> with <code>source: "byok"</code> points at a present enrollment. Broken refs surface in the UI — never silently pruned. <em>(runtime)</em></li>
|
||||
<li>When a consumer's <code>defaultModel</code> is removed from <code>enabledModels</code>, <code>defaultModel</code> is set to <code>null</code>. <em>(write path)</em></li>
|
||||
<li>Deleting a <code>ProviderInstance</code> cascades to its enrollments and custom models. Consumer refs pointing at those enrollments become broken refs (surfaced, not pruned). <em>(write path)</em></li>
|
||||
<li>A consumer whose pipeline is <code>langchain</code> only renders enrollments whose <code>ProviderType.pipelines.langchain = true</code>; same for <code>opencode</code>. Ineligible enrollments are shown as ineligible in the BYOK panel with a reason — not hidden. <em>(runtime)</em></li>
|
||||
<li>NO <code>providerTypeId</code> uniqueness constraint across <code>providers</code>. Multi-instance is the whole point. <em>(explicit non-constraint)</em></li>
|
||||
</ol>
|
||||
|
||||
<h2 id="traces">9. Resolution traces</h2>
|
||||
|
||||
<h3>Trace A — Simple Chat, single OpenAI instance</h3>
|
||||
<pre class="ascii">
|
||||
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"
|
||||
</pre>
|
||||
|
||||
<h3>Trace B — OpenCode-bundled (not BYOK)</h3>
|
||||
<pre class="ascii">
|
||||
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>
|
||||
|
||||
<h3>Trace C — BYOK shared with OpenCode (byokBridge)</h3>
|
||||
<pre class="ascii">
|
||||
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
|
||||
</pre>
|
||||
|
||||
<h3>Trace D — Two Anthropic keys (multi-instance, catalog-backed)</h3>
|
||||
<pre class="ascii">
|
||||
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
|
||||
</pre>
|
||||
|
||||
<h3>Trace E — Custom providers, multi-instance, custom models</h3>
|
||||
<pre class="ascii">
|
||||
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)
|
||||
</pre>
|
||||
|
||||
<h2 id="picker">10. Per-consumer picker formulas</h2>
|
||||
<pre><code>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</code></pre>
|
||||
<p class="muted">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.</p>
|
||||
|
||||
<h2 id="nongoals">11. Explicit non-goals</h2>
|
||||
<ul>
|
||||
<li><strong>No global chat knobs.</strong> No <code>temperature</code> / <code>maxTokens</code> / <code>reasoningEffort</code> / <code>verbosity</code> / <code>topP</code> / <code>frequencyPenalty</code> 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).</li>
|
||||
<li><strong>No per-agent capability filtering</strong> at picker render.</li>
|
||||
<li><strong>No per-model <code>temperature</code> / <code>maxTokens</code> / <code>topP</code>.</strong> Same reason.</li>
|
||||
<li><strong>No <code>enabled</code> flag on <code>ByokEnrollment</code>.</strong> "Enabled for which consumer?" is ambiguous; per-consumer <code>enabledModels</code> carries the meaning.</li>
|
||||
<li><strong>No <code>capabilities</code> array on <code>ByokEnrollment</code>.</strong> Read from catalog or <code>CustomModel.declaredCapabilities</code> at query time.</li>
|
||||
<li><strong>No chat-embedding mixing in <code>enrollments</code></strong> at first — embeddings can fold in once the carve-out resolves.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="questions">12. Open questions</h2>
|
||||
<ol>
|
||||
<li><strong>Embeddings unification timing.</strong> Stay legacy now, fold in later?</li>
|
||||
<li><strong>Project-level chain default storage.</strong> Currently <code>ProjectConfig.projectModelKey</code> is a wire-form string. Becomes <code>ProjectConfig.defaultModelRef: ConsumerModelRef | null</code>? Or does each project override <code>consumers["project"]</code> wholesale?</li>
|
||||
<li><strong>Per-invocation knobs.</strong> 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?</li>
|
||||
<li><strong>"Not yet supported in this plugin" affordance</strong> for catalog providers we don't have an adapter for. Disabled list item with tooltip? Separate section?</li>
|
||||
<li><strong>Per-pipeline-only badging.</strong> Do we put "works with chat only" / "works with OpenCode only" copy on the BYOK enrollment row? Or only at the provider level?</li>
|
||||
<li><strong>backend-bundled ref staleness.</strong> OpenCode releases change bundled lists. Broken ref behavior or auto-prune?</li>
|
||||
<li><strong>Custom command consumers.</strong> One <code>ConsumerConfig</code> per command? Or fold into a single record keyed by command id?</li>
|
||||
</ol>
|
||||
|
||||
<hr />
|
||||
<p class="muted">For implementation handoff: see <code>designdocs/MODEL_DATA_MODEL_SPEC.md</code> — same data model in canonical form, plus a reconciliation appendix diffing this design against the current <code>src/modelManagement/</code> branch.</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
823
designdocs/MODEL_DATA_MODEL_SPEC.md
Normal file
823
designdocs/MODEL_DATA_MODEL_SPEC.md
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope & non-goals
|
||||
|
||||
### 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).
|
||||
|
||||
### Out of scope (deliberately)
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 3. Entities
|
||||
|
||||
Seven persisted entities + two read-only entities (catalog metadata + runtime
|
||||
inventory).
|
||||
|
||||
### 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.**
|
||||
|
||||
```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<string, CatalogModel>;
|
||||
}
|
||||
|
||||
type AdapterKind =
|
||||
| "anthropic"
|
||||
| "openai-compatible"
|
||||
| "google"
|
||||
| "azure"
|
||||
| "bedrock"
|
||||
| "github-copilot";
|
||||
```
|
||||
|
||||
Synthetic / non-BYOK ProviderTypes (no catalog model list):
|
||||
|
||||
| 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 ProviderInstance (persisted)
|
||||
|
||||
A user-configured credential set for a `ProviderType`. **Multiple instances of
|
||||
the same `ProviderType` are allowed.**
|
||||
|
||||
```ts
|
||||
interface ProviderInstance {
|
||||
/** UUID. Primary key. Also used as the keychain namespace
|
||||
* (`provider-<instanceId>-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<string, unknown>;
|
||||
|
||||
/** 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". */
|
||||
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;
|
||||
};
|
||||
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)
|
||||
|
||||
For self-hosted endpoints. **Attached to a `ProviderInstance`, not a
|
||||
`ProviderType`** — two Ollama instances on different machines can declare
|
||||
different model lineups.
|
||||
|
||||
```ts
|
||||
interface CustomModel {
|
||||
/** FK to ProviderInstance.instanceId. Must reference an instance whose
|
||||
* ProviderType has an empty `models` map (synthetic / custom-typed). */
|
||||
instanceId: string;
|
||||
|
||||
/** Wire form. "llama3.3:70b". */
|
||||
modelId: string;
|
||||
|
||||
displayName: string;
|
||||
|
||||
/** Declared by the user since no catalog metadata exists. */
|
||||
declaredCapabilities?: {
|
||||
reasoning?: boolean;
|
||||
toolCall?: boolean;
|
||||
attachment?: boolean;
|
||||
};
|
||||
contextLimit?: number;
|
||||
extras?: Record<string, unknown>; // ollama numCtx, etc.
|
||||
addedAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
Storage: `settings.customModels: CustomModel[]`.
|
||||
|
||||
### 3.5 ByokEnrollment (persisted) — the unit of identity
|
||||
|
||||
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.**
|
||||
|
||||
```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<string, unknown>;
|
||||
|
||||
lastVerifiedAt?: number;
|
||||
lastVerificationError?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**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 `/`.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
```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
|
||||
|
||||
interface ConsumerConfig {
|
||||
consumerId: ConsumerId;
|
||||
|
||||
/** Curated allow-list of model references this consumer may use. */
|
||||
enabledModels: ConsumerModelRef[];
|
||||
|
||||
/** Preferred default for this consumer, when applicable. */
|
||||
defaultModel?: ConsumerModelRef | null;
|
||||
}
|
||||
```
|
||||
|
||||
Storage: `settings.consumers: Record<ConsumerId, ConsumerConfig>`.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 4. 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.
|
||||
|
||||
| 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` |
|
||||
|
||||
Why exactly these six:
|
||||
|
||||
- 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.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pipeline compatibility
|
||||
|
||||
`ProviderType.pipelines: { langchain: boolean; opencode: boolean }` is a
|
||||
**load-bearing field**. It decides which `Consumer` pickers a provider's
|
||||
enrollments can appear in.
|
||||
|
||||
### 5.1 Consumer ↔ pipeline mapping
|
||||
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Top-level settings shape
|
||||
|
||||
```ts
|
||||
interface CopilotSettings {
|
||||
// … existing non-model fields …
|
||||
|
||||
settingsVersion: number;
|
||||
providers: Record<string /* instanceId */, ProviderInstance>;
|
||||
customModels: CustomModel[]; // keyed by (instanceId, modelId)
|
||||
enrollments: ByokEnrollment[]; // keyed by (instanceId, modelId)
|
||||
consumers: Record<ConsumerId, ConsumerConfig>;
|
||||
|
||||
// Out-of-scope but reserved:
|
||||
// embeddingConsumers: Record<EmbeddingConsumerId, EmbeddingConsumerConfig>;
|
||||
// (or keep legacy activeEmbeddingModels + embeddingModelKey until embeddings parity ships)
|
||||
}
|
||||
```
|
||||
|
||||
ProviderType / CatalogModel are catalog data, not settings.
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
|
||||
---
|
||||
|
||||
## 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 — Simple Chat, single OpenAI instance
|
||||
|
||||
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 — 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"]`
|
||||
and renders.
|
||||
|
||||
### Trace C — BYOK shared with OpenCode (byokBridge)
|
||||
|
||||
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: "<bridged-id>/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`.
|
||||
|
||||
### Trace D — Multi-instance catalog-backed (two Anthropic keys)
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 9. Per-consumer picker formulas
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions
|
||||
|
||||
These remain unresolved by this spec — to be answered before implementation
|
||||
starts.
|
||||
|
||||
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:<id>` consumers proliferate. Do
|
||||
we store one `ConsumerConfig` per command, or fold them into a single
|
||||
record keyed by command id internally?
|
||||
|
||||
---
|
||||
|
||||
## Reconciliation appendix
|
||||
|
||||
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.
|
||||
|
||||
### A.1 Provider records
|
||||
|
||||
- **Spec**: `providers: Record<instanceId, ProviderInstance>` where
|
||||
`providerTypeId` is a non-unique FK; multi-instance allowed.
|
||||
- **Branch**: `providers: Record<ProviderId, ProviderConfig>` keyed by the
|
||||
*provider type id* itself ("anthropic", "openai", "custom:<uuid>", …).
|
||||
**Singleton per type** — the BYOK Add Provider dialog filters out already-
|
||||
configured providers, so multiple Anthropic keys are impossible without
|
||||
routing through a `custom:<uuid>` 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"`.
|
||||
|
||||
### A.2 ProviderType / catalog
|
||||
|
||||
- **Spec**: explicit `ProviderType` entity with `adapter: AdapterKind`,
|
||||
`pipelines: {langchain, opencode}`, `auth`, `defaultBaseUrl`, `models:
|
||||
Record<id, CatalogModel>`.
|
||||
- **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:<uuid>` 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<ConsumerId, ConsumerConfig>` 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<string, boolean>`
|
||||
with **per-backend-different key formats** (OpenCode uses
|
||||
`<providerId>/<modelId>`, Claude/Codex use bare model ids, Quick Chat
|
||||
uses `<providerId>:<modelId>`). 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.<id>.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.
|
||||
|
|
@ -1,908 +0,0 @@
|
|||
# Model Management Redesign — Design Spec
|
||||
|
||||
**Status:** Aligned to Claude Design bundle (May 2026)
|
||||
**Source of truth:** `copilot-model-settings/project/index.html` exported from Claude Design — specifically the **★ Final flow** in `screens/final.jsx`, which consolidates the picked variants and fills two gap screens (unified Configure Provider, Add Custom Model).
|
||||
**Scope:** Provider keys, model catalog, model selection, model visibility, agent backend configuration. Everything a user does to answer the question "what LLM is this plugin going to call?"
|
||||
**Out of scope:** Embedding model configuration (separate panel, untouched), chat UI redesign, command palette redesign, Plus/license management.
|
||||
|
||||
---
|
||||
|
||||
## 0. What this product is, in one paragraph
|
||||
|
||||
Copilot for Obsidian is a plugin that adds AI capabilities (chat, autocomplete, semantic search, custom commands, agent workflows) to the Obsidian note-taking app. It supports many LLM providers — OpenAI, Anthropic, Google, Groq, Mistral, xAI, DeepSeek, OpenRouter, Azure, AWS Bedrock, GitHub Copilot, Ollama, plus arbitrary OpenAI-compatible endpoints. Users configure provider API keys and select which models they want the plugin to use. The plugin runs in both Obsidian desktop and Obsidian mobile, but the redesigned settings UI is desktop-shaped (mobile gets a stripped-down view of the same surfaces — see §10.1).
|
||||
|
||||
The plugin has **two execution modes** that share the same model pool:
|
||||
|
||||
- **Chain mode** (chat / commands) — single-turn LLM calls via LangChain. Used for chat, custom command execution, quick prompts. **Works on mobile.** Always available.
|
||||
- **Agent mode** — multi-turn agent sessions with tool use, file ops, sessions. Runs by spawning an external binary (OpenCode, Claude Code, or Codex). **Desktop-only.**
|
||||
|
||||
Today, model configuration is split across three duplicated UI surfaces (Basic Settings provider key panel, Models Settings table, Agent Mode model curation). This redesign consolidates them into a single **BYOK** tab plus a dedicated **Agent** tab.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals & Non-Goals
|
||||
|
||||
### Goals
|
||||
|
||||
1. **One canonical place to configure providers, keys, and models.** Currently three places; one BYOK tab now.
|
||||
2. **A user can complete first-run setup in under 60 seconds.** Welcome modal → pick provider → enter key + pick models in one screen → done.
|
||||
3. **OpenRouter and similar large-catalog providers don't drown the UI.** Hundreds of models compress into one searchable, filterable list with sticky group headers per upstream provider.
|
||||
4. **Visibility curation exists** — the row checkbox in the BYOK table hides a model from the chat input picker without removing it from the registry.
|
||||
5. **Local providers (Ollama, custom OpenAI-compatible endpoints) are first-class** — they appear as top-level providers in the same list, not nested under a "Custom" wrapper.
|
||||
6. **Non-BYOK models (OpenCode-provided free models like "Big Pickle", and Copilot Plus paid models like "Copilot Plus Flash") appear naturally in the same table** as ordinary rows; the row's "provider" column carries the OpenCode / Copilot Plus origin.
|
||||
7. **Offline-capable.** The plugin ships with a bundled model catalog so users without internet can still see providers and configure keys.
|
||||
8. **The Configure Provider modal is one component for three jobs** — adding a BYOK provider, adding a custom endpoint, and editing an existing provider. Same fields, same layout; only the footer + subhead adapt.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Embedding model management (separate, unchanged).
|
||||
- Per-model temperature / max_tokens / system prompt overrides. **Removed entirely.** All chains use sensible defaults.
|
||||
- A user-managed "Default chat model" pointer. The BYOK panel no longer surfaces this — chain-mode chat uses whatever the user last selected in the chat input picker.
|
||||
- Reordering models in a custom order (drag-to-reorder is out). Sort defaults are provider order then alphabetical.
|
||||
- Adding any new agent backends beyond OpenCode / Claude Code / Codex.
|
||||
- Visual restyling of the surrounding Obsidian settings shell.
|
||||
|
||||
---
|
||||
|
||||
## 2. Conceptual model
|
||||
|
||||
### Two execution modes, one model pool
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ONE BYOK registry │
|
||||
│ (the unified BYOK tab) │
|
||||
└──────────┬───────────────────┘
|
||||
│
|
||||
┌───────────────┴────────────────┐
|
||||
▼ ▼
|
||||
Chain mode Agent mode
|
||||
(LangChain) (External backend)
|
||||
│ │
|
||||
│ ┌─────┴─────┬─────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
Chat, Custom OpenCode Claude Code Codex
|
||||
Commands, Quick (BYOK) (sub-only) (sub-only)
|
||||
Prompts (mobile +
|
||||
desktop)
|
||||
```
|
||||
|
||||
### What lives in the BYOK registry
|
||||
|
||||
Models the user has explicitly enabled. Each model in the registry has one of four origins:
|
||||
|
||||
- **BYOK** — User entered a provider API key, then picked which of that provider's models they want enabled. Most common.
|
||||
- **Local/Custom** — User defined a custom OpenAI-compatible endpoint (e.g. Ollama at `http://localhost:11434`) and added one or more models running there. Appears as a top-level provider, **not** nested under a "Custom" wrapper.
|
||||
- **OpenCode-provided** — Free models bundled with OpenCode (e.g. **Big Pickle**). No key required. Only available when OpenCode is installed. Only usable in agent mode.
|
||||
- **Copilot-provided** — Paid models served by Copilot for **Plus subscribers** (e.g. **Copilot Plus Flash**). No BYOK key required — gated by the user's Copilot Plus license. Routed through OpenCode at runtime. Only usable in agent mode.
|
||||
|
||||
OpenCode-provided and Copilot-provided models appear as ordinary rows in the BYOK table (provider column reads "OpenCode" or "Copilot Plus"). They are **not** rendered as special pinned sections — the table treats them like any other row, just sorted to the top.
|
||||
|
||||
### Capability badges
|
||||
|
||||
Every model carries up to two capability badges:
|
||||
|
||||
- **💬 chat** — can be called via LangChain. Works in chat, custom commands, mobile.
|
||||
- **🤖 agent** — can be selected as the model an agent backend runs.
|
||||
|
||||
Examples:
|
||||
|
||||
| Model | Provider | 💬 chat | 🤖 agent |
|
||||
| ------------------ | -------------- | :-----: | :------: |
|
||||
| Claude Sonnet 4.5 | Anthropic | ✓ | ✓ |
|
||||
| GPT-5 | OpenAI | ✓ | ✓ |
|
||||
| llama3.2 | Ollama (local) | ✓ | ✓ |
|
||||
| Big Pickle | OpenCode | ✗ | ✓ |
|
||||
| Copilot Plus Flash | Copilot Plus | ✗ | ✓ |
|
||||
|
||||
In the chat input model picker, models are grouped by agent backend; selecting a model auto-selects the backend. This redesign does not change the chat input picker.
|
||||
|
||||
### Agent backend special case (Claude Code / Codex)
|
||||
|
||||
OpenCode is BYOK — it uses the models in the BYOK registry, augmented by the OpenCode-provided free models and (for Plus subscribers) the Copilot Plus paid models.
|
||||
|
||||
**Claude Code and Codex are different.** They are subscription-based binaries with their own bundled model lists, authenticated by the user's Anthropic/OpenAI subscription (not the BYOK keys). They cannot accept arbitrary models. So:
|
||||
|
||||
- The BYOK registry does **not** include Claude Code's or Codex's models.
|
||||
- Each of those backends has its own **visibility checklist** inside its Agent sub-tab to hide models from that backend's picker (e.g. "I never want Claude Haiku to appear").
|
||||
- Switching the active agent backend changes which catalog the agent model picker reads from.
|
||||
|
||||
### Catalog source priority (one source at a time, never merged)
|
||||
|
||||
When the BYOK panel loads, it picks ONE source for "available models per provider":
|
||||
|
||||
1. **OpenCode binary present and reachable** → ask OpenCode for its catalog. Includes OpenCode-provided free models (Big Pickle, etc.), Copilot Plus paid models when the user has a Plus license, any custom providers previously registered, and the bundled `models.dev` snapshot OpenCode ships with.
|
||||
2. **OpenCode not installed** → use the plugin-bundled `models.dev` snapshot. No 🤖 badges shown on cloud BYOK models (because no agent is available). OpenCode-provided and Copilot Plus rows are absent (they require OpenCode).
|
||||
|
||||
**No live refresh from `models.dev/api.json`.** The catalog is whatever ships with the plugin (bundled snapshot) or whatever OpenCode provides (when installed). New models become available on plugin update or OpenCode update. No "Refresh catalog" button, no daily background fetch, no network dependency for the catalog.
|
||||
|
||||
The bundled snapshot is tree-shaken to ~20 supported providers (Anthropic, OpenAI, Google, Groq, Mistral, xAI, DeepSeek, OpenRouter, Cohere, Azure, AWS Bedrock, GitHub Copilot, Together, Fireworks, Perplexity, plus a few more). For providers outside that set, the user can add them as a custom provider with manual model entry (via Add Custom Model).
|
||||
|
||||
---
|
||||
|
||||
## 3. User requirements (jobs to be done)
|
||||
|
||||
The redesigned UI must let a user accomplish every one of these:
|
||||
|
||||
### First-run setup
|
||||
|
||||
- **JTBD-1.** As a new user on desktop, get to a working agent in under 60 seconds via one of three paths: use an existing Claude Code / Codex subscription, bring my own provider key, or subscribe to Copilot Plus.
|
||||
- **JTBD-2.** As a new user, understand that Copilot is **agent-first** — the welcome modal surfaces three primary paths; there is no secondary "skip agent" link.
|
||||
|
||||
### Provider key management
|
||||
|
||||
- **JTBD-4.** Enter, edit, paste, and remove a provider API key.
|
||||
- **JTBD-5.** Verify that a key works (a `[Test]` affordance, or implicit on `[Verify & save]`).
|
||||
- **JTBD-6.** See which providers have keys configured at a glance (via the BYOK table's provider column, and via `[Manage providers]`).
|
||||
- **JTBD-7.** Get a clear error when a key is invalid, expired, or has insufficient permissions.
|
||||
|
||||
### Model selection (enabling models)
|
||||
|
||||
- **JTBD-8.** After entering a provider key, browse the models that provider offers and choose which to enable — **in the same screen as the key entry** (Configure Provider).
|
||||
- **JTBD-9.** Add a model from a provider that has many models (like OpenRouter, with hundreds) without the UI becoming unscannable.
|
||||
- **JTBD-10.** Quickly hide a model I no longer want, without removing my provider key (uncheck the row's visibility checkbox).
|
||||
- **JTBD-11.** Remove a model entirely from the registry (kebab → Remove from list).
|
||||
- **JTBD-12.** See, for each enabled model, where it works (chat, agent, both).
|
||||
- **JTBD-13.** Add a model that isn't in the catalog (preview, fine-tune, private deployment) via Add Custom Model — works for **any** provider.
|
||||
|
||||
### Custom / local providers
|
||||
|
||||
- **JTBD-14.** Add a local Ollama instance with one or more models, in a single flow.
|
||||
- **JTBD-15.** Add a custom OpenAI-compatible endpoint (e.g. self-hosted vLLM, a colleague's Tailscale-hosted server).
|
||||
- **JTBD-16.** Edit a custom provider's base URL after adding it (via the row kebab → Configure provider → edit state).
|
||||
- **JTBD-17.** Have local models work in both chat and (when desktop + OpenCode installed) agent.
|
||||
|
||||
### Defaults
|
||||
|
||||
- **JTBD-18.** Pick a **Default Agent Model per agent backend** — one each for OpenCode, Claude Code, Codex. Filtered to 🤖-capable models for that backend. Used when starting an agent session in that backend.
|
||||
- **JTBD-19.** Pick a **Default Reasoning Effort per agent backend** (Min / Low / Med / High) for reasoning-capable models.
|
||||
|
||||
(There is **no** "Default Chat Model" picker. Chain-mode chat uses the model the user last selected in the chat input.)
|
||||
|
||||
### Agent backend configuration
|
||||
|
||||
- **JTBD-20.** Pick which agent backend is **active**: OpenCode (recommended), Claude Code, or Codex. The active backend is set via `[Use this backend]` in the viewed sub-tab's Status card — not via sub-tab selection.
|
||||
- **JTBD-21.** Install the OpenCode binary from inside the plugin if I don't have it yet.
|
||||
- **JTBD-22.** Point the plugin at an existing binary location, or detect one automatically.
|
||||
- **JTBD-23.** When using Claude Code or Codex, curate which of that backend's bundled models show up in the agent model picker dropdown.
|
||||
- **JTBD-24.** Switch the viewed backend (sub-tab) without changing the active backend, so I can re-authenticate or pre-configure another backend without disruption.
|
||||
|
||||
### Visibility & cleanup
|
||||
|
||||
- **JTBD-25.** Hide models I don't want cluttering the chat input's model picker dropdown, without removing them from the registry (uncheck the row checkbox).
|
||||
- **JTBD-26.** Bulk-remove a provider's models by removing the provider from Configure Provider's edit footer.
|
||||
|
||||
### Offline
|
||||
|
||||
- **JTBD-27.** Configure providers and add models when my machine has no internet, using the bundled catalog that ships with the plugin.
|
||||
|
||||
### Mobile
|
||||
|
||||
- **JTBD-28.** On mobile, see the BYOK table without 🤖 badges and without OpenCode / Copilot Plus rows. Agent tab and Welcome modal are hidden. Mobile reuses the desktop BYOK panel with agent surfaces hidden.
|
||||
|
||||
---
|
||||
|
||||
## 4. Information architecture
|
||||
|
||||
Settings is rendered inside Copilot's settings modal — a horizontal tab strip at the top of the modal, with the content area scrolling beneath. Modal width is ~1320px (see §10.3).
|
||||
|
||||
The Model Management redesign occupies **two tabs** in the settings modal, plus **one standalone modal** for first-run welcome.
|
||||
|
||||
### Tab: **BYOK** (renamed from "Models")
|
||||
|
||||
The unified registry. A flat table of every model the user has enabled, sorted across all providers (OpenCode-provided, Copilot Plus, BYOK, custom). Provider configuration lives in a separate Configure Provider modal reached via the row kebab.
|
||||
|
||||
The label is **"BYOK"** (Bring Your Own Key) — chosen to make the user's mental model explicit ("these are my keys/models") and to distinguish from the per-backend bundled-model lists in the Agent tab.
|
||||
|
||||
### Tab: **Agent** (desktop only)
|
||||
|
||||
Backend selection (OpenCode / Claude Code / Codex) shown as **sub-tabs at the top of the panel**, not a radio. Sub-tab selection ≠ active backend (see §6.2). Per-backend configuration includes: binary path, authentication (CC/Codex only), default agent model, default reasoning effort, and for CC/Codex a visibility checklist of bundled models.
|
||||
|
||||
### Standalone modal: **Welcome**
|
||||
|
||||
First-run only. Lives outside the settings modal — overlays the entire Obsidian window with a dimmed app silhouette underneath. Three primary tiles: "Use my existing subscription", "Bring my own key", "Subscribe to Copilot Plus". No "skip agent" link.
|
||||
|
||||
The existing **Chat & Commands** tab is unchanged by this redesign. The chat input's model picker continues to read from the unified BYOK registry, grouped by agent backend.
|
||||
|
||||
---
|
||||
|
||||
## 5. The BYOK panel (detailed)
|
||||
|
||||
This is the centerpiece. Mockup priorities are highest for this screen.
|
||||
|
||||
The panel has **two visual states**: **empty** (no providers added yet) and **populated** (one or more providers added). Providers are added via the **[+ Add provider]** dialog (§7.1), then configured/edited via the **Configure Provider** modal (§7.2). OpenCode-provided and Copilot Plus-provided models auto-appear as rows when their preconditions are met.
|
||||
|
||||
### 5.1 Anatomy — empty state
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BYOK │
|
||||
│ Add providers and choose which models you want available │
|
||||
│ throughout Copilot. │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ No providers added yet. │ │
|
||||
│ │ │ │
|
||||
│ │ Add a provider to pick from its │ │
|
||||
│ │ models, or add a custom endpoint │ │
|
||||
│ │ (Ollama, self-hosted, etc.) │ │
|
||||
│ │ │ │
|
||||
│ │ [ + Add provider ] │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The only CTA is `[+ Add provider]`. No provider shortcut buttons — selection happens inside the Add Provider dialog (§7.1).
|
||||
|
||||
### 5.2 Anatomy — populated state (flat table)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BYOK [Manage providers] [+ Add prov] │
|
||||
│ All enabled models. Filter, sort, toggle visibility. │
|
||||
│ │
|
||||
│ [🔍 Filter models…] [All] [💬 chat] [🤖 agent] [local] │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ MODEL · PROVIDER CAPABILITY META │ │
|
||||
│ ├─────────────────────────────────────────────────────────────┤ │
|
||||
│ │ ☑ Big Pickle · OpenCode 🤖 Free │ │
|
||||
│ │ ☑ Copilot Plus Flash · … 🤖 Plus │ │
|
||||
│ │ ☑ Claude Sonnet 4.5 · Anthropic 💬 🤖 200k · Vision ⋯│ │
|
||||
│ │ ☑ Claude Opus 4.1 · Anthropic 💬 🤖 200k · Reason │ │
|
||||
│ │ ☑ Claude Haiku 4.5 · Anthropic 💬 🤖 200k · Fast │ │
|
||||
│ │ ☑ GPT-5 · OpenAI 💬 🤖 400k · Vision │ │
|
||||
│ │ ☑ llama3.2 · Ollama (local) 💬 🤖 local · 8B │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 7 enabled · 124 available in catalog │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Why a flat table instead of per-provider accordion sections?** Two reasons:
|
||||
|
||||
1. Power users with 30+ models can scan a single sorted list faster than 5+ collapsible cards.
|
||||
2. Provider configuration belongs in its own dedicated modal, not in the line-of-sight of users who just want to find a model. The Configure Provider modal (§7.2) lives one click away via the row kebab.
|
||||
|
||||
**Ordering rule.** OpenCode-provided rows appear first, then Copilot Plus rows, then BYOK and custom-provider rows grouped by provider in the order the user added them. The user cannot drag-to-reorder.
|
||||
|
||||
### 5.3 Header controls
|
||||
|
||||
Top-right of the populated table:
|
||||
|
||||
- **[+ Add provider]** — primary action. Opens Add Provider dialog (§7.1).
|
||||
- **[Manage providers]** — ghost button. Opens a small list of every configured provider with a "Configure →" link for each. Useful when you want to change a key but don't have a specific model row to kebab into.
|
||||
|
||||
Below the header, a filter bar:
|
||||
|
||||
- **Search box** — fuzzy match against model name and provider.
|
||||
- **Capability chips** — `All` / `💬 chat` / `🤖 agent` / `local`. Click to filter; multi-select.
|
||||
|
||||
Footer of the panel: a muted line showing `<N> enabled · <M> available in catalog`.
|
||||
|
||||
### 5.4 Model row anatomy
|
||||
|
||||
Each row in the flat table:
|
||||
|
||||
- **Checkbox** — visibility toggle. Checked = visible in the chat input's model picker dropdown. Unchecking **does not** remove the model from the registry — it just hides it from the picker.
|
||||
- **Model display name** (provider's preferred name, e.g. "Claude Sonnet 4.5", not the API id `claude-sonnet-4-5-20250929`) · **Provider** (muted, smaller, joined by `·`).
|
||||
- **Capability badges** — `💬 chat` and/or `🤖 agent` pills.
|
||||
- **Inline metadata** — context window, "Free", "Reasoning", "local", etc. — small, muted.
|
||||
- **Kebab `⋯`** — opens the row menu:
|
||||
- **⚙ Configure provider (<Provider>) →** — primary action. Opens Configure Provider modal (§7.2) in `edit` state, scoped to this row's provider.
|
||||
- **↗ View model docs** — opens the provider's model page in a browser.
|
||||
- **— Remove from list** — removes the model from the registry. Inline confirmation. (Different from unchecking the visibility checkbox.)
|
||||
|
||||
Capability badges have a tooltip on hover explaining what they mean.
|
||||
|
||||
There is **no ★ Default pill** on rows. The BYOK panel does not surface a "default chat model" — defaults are an Agent-tab concern (per backend), and chain-mode chat uses whatever the user last selected in the chat input.
|
||||
|
||||
### 5.5 OpenCode / Copilot Plus rows
|
||||
|
||||
When OpenCode is detected, OpenCode-provided models (e.g. Big Pickle) appear as flat-table rows with provider = "OpenCode", caps = `[🤖]`, meta = "Free". When the user has a Copilot Plus license, Copilot Plus-provided models (e.g. Copilot Plus Flash) appear as rows with provider = "Copilot Plus", caps = `[🤖]`, meta = "Plus".
|
||||
|
||||
Both kinds of rows behave like any other row (visibility checkbox, capability badges, meta). The kebab menu omits "Configure provider" (provider is system-managed — nothing to configure) but keeps "View model docs" and "Remove from list" (removing hides until the source re-adds it).
|
||||
|
||||
When OpenCode isn't installed, OpenCode-provided rows do not appear, and an inline banner at the top of the BYOK panel reads: _"OpenCode isn't installed yet. Models with the 🤖 badge will work once you install it. → Go to Agent tab."_ Copilot Plus rows are similarly absent when the license isn't active; the Welcome modal already pushes Plus to net-new users.
|
||||
|
||||
### 5.6 Mobile differences
|
||||
|
||||
- 🤖 badges hidden everywhere.
|
||||
- OpenCode and Copilot Plus rows hidden (no agent backend → no consumer).
|
||||
- Header text changes to "Choose which models you want available for chat."
|
||||
- Single-column layout, larger touch targets.
|
||||
|
||||
---
|
||||
|
||||
## 6. The Agent tab (detailed)
|
||||
|
||||
Desktop only. Hidden on mobile. There is **no "agent mode" on/off toggle** — desktop is always agent-capable.
|
||||
|
||||
### 6.1 Anatomy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Agent │
|
||||
│ Run multi-turn agent sessions with tool use and file edits. │
|
||||
│ │
|
||||
│ [ ▶ OpenCode ] [ Claude Code ] [ Codex ] │
|
||||
│ │
|
||||
│ ┌─ Status ─────────────────────────────────────────────────┐ │
|
||||
│ │ Status [✓ Active backend] │ │
|
||||
│ │ v0.4.2 at /usr/local/bin/opencode │ │
|
||||
│ │ [Use this backend] [Reinstall] [Browse…] │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Default agent model ─────┐ ┌─ Default reasoning effort ─┐ │
|
||||
│ │ [Claude Sonnet 4.5 ▾] │ │ [Min] [Low] [●Med] [High] │ │
|
||||
│ │ From Models registry, │ │ │ │
|
||||
│ │ 🤖-capable. │ │ │ │
|
||||
│ └───────────────────────────┘ └────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Models live in the BYOK tab. → Open Models │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 Sub-tabs vs active backend
|
||||
|
||||
Backend selection uses **sub-tabs at the top of the Agent panel**, not a radio list. Sub-tabs swap the whole configuration panel; switching sub-tabs does **not** change the active backend.
|
||||
|
||||
The **active backend** (the one chat sessions actually use) is separate from the sub-tab currently being viewed. Each backend's Status card shows whether it is active (`✓ Active backend`) or merely configured (`○ Configured, not active`) or unconfigured (`⚠ Not installed`). The **`[Use this backend]`** button in the Status card promotes the viewed backend to active.
|
||||
|
||||
This separation lets the user configure a non-active backend (re-authenticate, change visibility, install) without disrupting the currently-running active one.
|
||||
|
||||
Each backend remembers its own configuration (binary path, auth, default agent model, default reasoning effort, visibility) — switching sub-tabs preserves state.
|
||||
|
||||
### 6.3 OpenCode panel
|
||||
|
||||
The OpenCode sub-tab contains:
|
||||
|
||||
1. **Status card** — version + binary path; `[Use this backend]`, `[Reinstall]`, `[Browse…]` buttons. When the binary is missing, the card shows `⚠ Not installed` and offers `[Install OpenCode]` (primary) + `[I have it elsewhere…]`.
|
||||
2. **Default agent model** — dropdown filtered to every 🤖-capable model in the BYOK registry (BYOK + custom + Big Pickle + Copilot Plus Flash). The model OpenCode boots with for a new agent session.
|
||||
3. **Default reasoning effort** — **chip group** with four levels: Min / Low / Med / High. Applies to reasoning-capable models when run through OpenCode.
|
||||
4. **"Models live in the BYOK tab"** dashed banner — one-line explainer and deep link. No inline model curation on OpenCode (the BYOK registry is the single source of truth).
|
||||
|
||||
### 6.4 Claude Code / Codex panels
|
||||
|
||||
Five key elements:
|
||||
|
||||
1. **Status card** — same shape as OpenCode (binary path + version + buttons). For CC/Codex the install button reads `[Install Claude Code]` / `[Install Codex]`.
|
||||
2. **Subscription card** — "Authenticated as <email>", `[Re-authenticate]` button. Unauthenticated state: "Not signed in to <backend>. [Sign in via <backend>]".
|
||||
3. **Visible models in picker** — inline checklist of that backend's bundled models. The list is small (4–8 entries), maintained in plugin code, refreshed on plugin update. Each row: checkbox + model id (monospace). No 💬/🤖 badges (entire list is 🤖-only).
|
||||
4. **Default agent model** — dropdown filtered to the **visible** models above. Used as the seed model when starting a session in this backend.
|
||||
5. **Default reasoning effort** — chip group (Min / Low / Med / High).
|
||||
|
||||
If the binary isn't detected, the visible-models list still renders (pre-configure visibility before installing). The Default agent model picker is gated with "Install <backend> first."
|
||||
|
||||
### 6.5 Authentication state (Claude Code / Codex only)
|
||||
|
||||
Claude Code and Codex auth via the binary's own subscription flow (not BYOK). The Subscription card surfaces:
|
||||
|
||||
- Authenticated user (e.g. "Authenticated as zerolxy@gmail.com")
|
||||
- `[Re-authenticate]` button → triggers the binary's auth refresh
|
||||
- Unauthenticated state: `⚠ Not signed in` badge + `[Sign in via <backend>]` (opens binary auth flow in a child process). Bundled-models list rendered greyed until signed in.
|
||||
|
||||
---
|
||||
|
||||
## 7. Dialogs (detailed)
|
||||
|
||||
Three dialogs in this redesign:
|
||||
|
||||
- **Add Provider** (§7.1) — provider picker. Opens from `[+ Add provider]`.
|
||||
- **Configure Provider** (§7.2) — one shared modal with **three states**: `new-byok`, `new-custom`, `edit`. Replaces what would have been three separate dialogs (Enter API key / Add Models / Add Custom Endpoint). Opens after picking a provider, or from the row kebab on the BYOK table.
|
||||
- **Add Custom Model** (§7.3) — first-class modal opened from Configure Provider's Models header to add a model not in the catalog (preview models, fine-tunes, private deployments). Works for **any** provider — BYOK or custom.
|
||||
|
||||
### 7.1 Add Provider dialog
|
||||
|
||||
Modal opened by `[+ Add provider]`. Lists every supported provider plus a "Custom OpenAI-compatible endpoint" option.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Add a provider [X] │
|
||||
│ [🔍 Search providers… ] │
|
||||
│ │
|
||||
│ Recommended │
|
||||
│ [An] Anthropic — Claude family [Add →] │
|
||||
│ [Op] OpenAI — GPT family + │
|
||||
│ [Go] Google — Gemini family + │
|
||||
│ │
|
||||
│ More providers │
|
||||
│ [Gr] Groq + │
|
||||
│ [Mi] Mistral + │
|
||||
│ [xA] xAI + │
|
||||
│ [De] DeepSeek + │
|
||||
│ [OR] OpenRouter + │
|
||||
│ [Co] Cohere + │
|
||||
│ [Az] Azure OpenAI + │
|
||||
│ [AW] AWS Bedrock + │
|
||||
│ [Gi] GitHub Copilot + │
|
||||
│ [To] Together + │
|
||||
│ [Fi] Fireworks + │
|
||||
│ [Pe] Perplexity + │
|
||||
│ ──────────────────────────────────── │
|
||||
│ + Custom OpenAI-compatible endpoint │
|
||||
│ Ollama, vLLM, LM Studio, self-hosted, etc. [Add →] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Selecting a built-in provider closes this dialog and opens **Configure Provider** in `new-byok` state (§7.2). Selecting "+ Custom OpenAI-compatible endpoint" opens Configure Provider in `new-custom` state.
|
||||
|
||||
Recommended providers (Anthropic, OpenAI, Google) appear at the top; the rest follow alphabetically. Already-added providers are filtered out (so users can't accidentally duplicate). **OpenCode** and **Copilot Plus** never appear in this list — they're system-managed.
|
||||
|
||||
### 7.2 Configure Provider dialog — one screen, three states
|
||||
|
||||
The same screen renders three flavors based on entry point:
|
||||
|
||||
- **`new-byok`** — after picking Anthropic/OpenAI/etc. from Add Provider.
|
||||
- **`new-custom`** — after picking "Custom endpoint" from Add Provider.
|
||||
- **`edit`** — from the row kebab on the BYOK table (or from `[Manage providers]` → Configure).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [An] Anthropic ✓ Verified (edit state only) [X] │
|
||||
│ Added Mar 4 · 4 models registered · used by chat & agent │
|
||||
│ (or: "Paste your key and pick which models you want." new-byok)│
|
||||
│ (or: "OpenAI-, Anthropic-, or Google-compatible servers." cust)│
|
||||
│ │
|
||||
│ Display name Ollama (local) (new-custom only) │
|
||||
│ Type (●) OpenAI-comp ( ) Anthropic ( ) Google │
|
||||
│ (new-custom only) │
|
||||
│ │
|
||||
│ API key sk-ant-••••••••••••••• [Replace] [Test] │
|
||||
│ Base URL https://api.anthropic.com │
|
||||
│ Availability ☑ chat ☑ [OC] OpenCode ☐ mobile │
|
||||
│ │
|
||||
│ ── Models ─── (5 in catalog · pick which to register) │
|
||||
│ [+ Add from catalog] (edit) │
|
||||
│ [+ Add custom model] │
|
||||
│ │
|
||||
│ [🔍 Filter models…] [All][💬 chat][🤖 agent][Vision][Reasoning] │
|
||||
│ [Tool use][≥ 200k ctx][≤ $1/M] │
|
||||
│ │
|
||||
│ ☑ Claude Sonnet 4.5 200k · Vision · Reasoning ⋯ │
|
||||
│ ☑ Claude Opus 4.1 200k · Reasoning ⋯ │
|
||||
│ ☑ Claude Haiku 4.5 200k · Fast ⋯ │
|
||||
│ ☑ Claude Sonnet 3.7 200k ⋯ │
|
||||
│ ☐ Claude Haiku 3.5 200k │
|
||||
│ │
|
||||
│ catalog — known models · custom — preview/fine-tune/private │
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ [Remove provider] [Cancel] [Save changes] │
|
||||
│ (edit) │
|
||||
│ │
|
||||
│ "3 selected · key stored in OS keychain" │
|
||||
│ [Cancel] [Verify & save] │
|
||||
│ (new states) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 7.2.1 Header
|
||||
|
||||
- **Provider glyph + name** (provider's preferred name).
|
||||
- **Status badge** — `✓ Verified` only in `edit` state with a working key. Not shown in new states (no badge until the user clicks Test or Verify & save).
|
||||
- **Subhead line** varies by state:
|
||||
- `edit`: context — "Added Mar 4 · 4 models registered · used by chat & agent".
|
||||
- `new-byok`: "Paste your key and pick which models you want available."
|
||||
- `new-custom`: "OpenAI-, Anthropic-, or Google-compatible servers."
|
||||
|
||||
#### 7.2.2 Connection fields
|
||||
|
||||
All three states share a 120px label gutter. Fields:
|
||||
|
||||
- **Display name** — only in `new-custom`. The user-facing label for the custom provider.
|
||||
- **Type** — only in `new-custom`. Radio: OpenAI-compatible / Anthropic / Google. Drives request format.
|
||||
- **API key** — always shown. In `edit`: masked value with `[Replace]` button. In `new` states: empty field. `[Test]` button issues a no-cost verification call. In `new-custom`, the field label adds "(optional)" since many local servers don't require auth.
|
||||
- **Base URL** — always shown. Read-only in BYOK states (provider's known URL); editable in custom state (e.g. `http://localhost:11434/v1`).
|
||||
- **Availability** — three checkboxes: `chat`, `<OpenCode glyph> OpenCode`, `mobile`. Controls which consumers can see this provider's models. (Custom providers often default `mobile` off since custom endpoints typically live on localhost.)
|
||||
|
||||
#### 7.2.3 Models section
|
||||
|
||||
Header row: `Models <subtitle>` on the left, action buttons on the right.
|
||||
|
||||
- Subtitle in `new-byok`: "5 in catalog · pick which to register".
|
||||
- Subtitle in `edit`: "4 of 5 catalog registered".
|
||||
- Subtitle in `new-custom`: omitted (catalog is auto-discovered from the endpoint's `/models`).
|
||||
- Action buttons on the right:
|
||||
- `[+ Add from catalog]` — only in `edit` (re-opens the catalog picker inline).
|
||||
- `[+ Add custom model]` — in all three states (opens Add Custom Model dialog §7.3).
|
||||
|
||||
Below the header: a **filter bar** with search + chips for `All` / `💬 chat` / `🤖 agent` / `Vision` / `Reasoning` / `Tool use` / `≥ 200k ctx` / `≤ $1/M`. (The price chip is suppressed for custom providers where pricing isn't applicable.)
|
||||
|
||||
The model list is a tight checklist. Each row: checkbox + model display name + meta line. In `edit` state each registered row gets a `⋯` kebab with model-level actions (View docs, Remove from registry).
|
||||
|
||||
A helper note appears below the model list (edit state): _"catalog — known models · custom — preview, fine-tune, private deployment"_.
|
||||
|
||||
For `new-custom`, the model list is populated by calling the provider's `/models` endpoint. On failure, the list shows an empty state inviting the user to use `[+ Add custom model]`.
|
||||
|
||||
#### 7.2.4 Footer
|
||||
|
||||
- **New states (`new-byok`, `new-custom`):**
|
||||
- Left: muted info — "3 selected · key stored in OS keychain" (BYOK) or "2 selected · stored locally" (custom).
|
||||
- Right: `[Cancel]` `[Verify & save]`.
|
||||
- **Edit state:**
|
||||
- Left: `[Remove provider]` (ghost, danger). Removes the provider, its key, and all its registered models. Confirmation modal.
|
||||
- Right: `[Cancel]` `[Save changes]`.
|
||||
|
||||
### 7.3 Add Custom Model dialog (any provider)
|
||||
|
||||
Opens from the `[+ Add custom model]` button in any Configure Provider state. The provider's connection (API key, base URL) is reused — this dialog just adds a model entry that the catalog doesn't list (preview models, fine-tunes, private deployments, just-released models).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [An] Add custom model · under Anthropic [X] │
|
||||
│ Use this for preview models, fine-tunes, private deployments, │
|
||||
│ or anything not in the catalog. The provider's connection │
|
||||
│ (key, base URL) is reused. │
|
||||
│ │
|
||||
│ Display name Claude Sonnet 4.5 (preview) │
|
||||
│ Model ID claude-sonnet-4-5-20260601-preview [Test] │
|
||||
│ Context window 200000 tokens — optional, defaults to prov │
|
||||
│ │
|
||||
│ Capabilities ☑ 💬 chat ☑ 🤖 agent ☐ Vision │
|
||||
│ ☑ Reasoning ☑ Tool use ☐ JSON mode │
|
||||
│ │
|
||||
│ Availability ☑ chat ☑ [OC] OpenCode ☐ mobile │
|
||||
│ ────────────────────────────────────────────────────────────── │
|
||||
│ Test once before saving — we'll send a minimal "ping" request. │
|
||||
│ [Cancel] [Add model] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- **Display name** — what the user sees in pickers.
|
||||
- **Model ID** — the string the provider's API expects.
|
||||
- **Context window** — optional integer; defaults to the provider's general default.
|
||||
- **Capabilities** — six checkboxes: `💬 chat`, `🤖 agent`, `Vision`, `Reasoning`, `Tool use`, `JSON mode`. Explicit because Copilot can't infer capabilities from a model ID alone.
|
||||
- **Availability** — same three checkboxes as Configure Provider.
|
||||
|
||||
The `[Test]` button next to Model ID issues a minimal "ping" request to verify the model is reachable at the provider before saving. Save creates the entry in the registry under this provider.
|
||||
|
||||
### 7.4 Catalog source
|
||||
|
||||
Both Configure Provider's catalog list and Add Custom Model's provider context read from the same source that powered the BYOK panel (OpenCode if available, bundled snapshot otherwise — see §2). Already cached; no network call when the dialog opens.
|
||||
|
||||
### 7.5 Search and filters (in Configure Provider's model list)
|
||||
|
||||
- **Search box** — fuzzy match against model name, family, and description.
|
||||
- **Capability chips** — `All` / `💬 chat` / `🤖 agent` / `Vision` / `Reasoning` / `Tool use` / `≥ 200k ctx` / `≤ $1/M`. Multi-select. The `local` chip from the BYOK table doesn't appear here (scope is one provider).
|
||||
- **Sort** — default by recency (release date desc). Optionally by name or context window.
|
||||
|
||||
### 7.6 Already-registered models
|
||||
|
||||
In `edit` state, registered models are pre-checked. In `new-byok`, recommended models are pre-checked on open. A model already in the registry from a previous open of the dialog renders with its checkbox pre-checked.
|
||||
|
||||
### 7.7 OpenRouter special case
|
||||
|
||||
OpenRouter exposes hundreds of models from many upstream providers. Configure Provider's model list groups by upstream provider (Anthropic, OpenAI, Mistral, …) with **sticky section headers** within the scroll. Search still cross-cuts. Capability + price chip filters become especially important.
|
||||
|
||||
### 7.8 Verification on save
|
||||
|
||||
When Configure Provider closes via `[Verify & save]` or `[Save changes]`, the plugin verifies each newly-added model by issuing a minimal test call ("hello, reply ok"). On failure, the model appears in the BYOK table with a `⚠` icon and a tooltip ("Last verification failed — click to retry"). The user can still keep it (some endpoints don't support the test call but work fine for real prompts).
|
||||
|
||||
---
|
||||
|
||||
## 8. Critical user journeys (walk-throughs)
|
||||
|
||||
These are the journeys the designer mocked in the Final flow. Each is a multi-screen flow.
|
||||
|
||||
### 8.1 First-run desktop (agent-first, three onboarding paths)
|
||||
|
||||
**Goal:** New user gets to a working agent in under 60 seconds.
|
||||
|
||||
The Welcome modal is **standalone** — it overlays the entire Obsidian window with a dimmed app silhouette behind. It is **not** rendered inside the settings modal. There is **no** "Just want simple chat? Skip agent setup →" secondary link. The three primary paths are:
|
||||
|
||||
- **"Use my existing subscription"** — for users with Claude Code or Codex installed/subscribed. Opens Agent tab pre-selected to whichever backend the plugin can detect.
|
||||
- **"Bring my own key"** — for users who already have a provider API key. Opens Add Provider dialog over the BYOK tab.
|
||||
- **"Subscribe to Copilot Plus"** — for users who'd rather pay Copilot than juggle keys. Opens the Plus subscription flow; on success, OpenCode is auto-installed, Copilot Plus rows light up in BYOK, and the user lands in the Agent tab on the OpenCode sub-tab.
|
||||
|
||||
**Walkthrough — "Use my existing subscription" (e.g. Claude Code):**
|
||||
|
||||
1. User installs the plugin, opens Obsidian — Welcome modal appears over a dimmed Obsidian.
|
||||
2. User clicks **"Use my existing subscription."** Sub-picker shows Claude Code / Codex.
|
||||
3. User picks Claude Code. Welcome closes; Settings opens to the Agent tab, Claude Code sub-tab.
|
||||
4. Binary auto-detected at `/usr/local/bin/claude` → ✓ Detected.
|
||||
5. Subscription card: "Not signed in. [Sign in via Claude Code]." User clicks → Claude Code's auth flow runs in a child process.
|
||||
6. Status updates: "Authenticated as user@example.com." Bundled model list lights up. Default agent model auto-set to Sonnet. Default effort = Med.
|
||||
7. User clicks `[Use this backend]` to promote Claude Code to active. Done.
|
||||
|
||||
**Walkthrough — "Bring my own key":**
|
||||
|
||||
1. User clicks **"Bring my own key."** Welcome closes; Settings opens to BYOK tab with **Add Provider** dialog already showing.
|
||||
2. User picks Anthropic. Add Provider closes; **Configure Provider** opens in `new-byok` state with the API key field focused.
|
||||
3. User pastes the key, clicks `[Test]` → ✓ Verified.
|
||||
4. The Models section in the same screen pre-checks recommended Anthropic models (Sonnet 4.5, Opus 4.1).
|
||||
5. User leaves defaults, clicks `[Verify & save]`. Configure Provider closes; BYOK table now shows the registered Anthropic rows.
|
||||
6. A non-intrusive prompt: "Set up the agent so models work in agent sessions too." → Switches to Agent tab, OpenCode sub-tab, `[Install OpenCode]` prominent.
|
||||
7. User clicks Install. Progress, then ✓ Detected.
|
||||
8. OpenCode's Default agent model auto-populates with Sonnet 4.5 (from the BYOK registry). Default effort = Med. Done.
|
||||
|
||||
**Walkthrough — "Subscribe to Copilot Plus":**
|
||||
|
||||
1. User clicks **"Subscribe to Copilot Plus."**
|
||||
2. Plus subscription flow runs (out of scope — handled by license management).
|
||||
3. On success, the plugin installs OpenCode in the background.
|
||||
4. User lands in the Agent tab, OpenCode sub-tab. ✓ Detected. Copilot Plus rows now appear in the BYOK tab.
|
||||
5. Default agent model auto-set to Copilot Plus Flash. Default effort = Med. Done.
|
||||
|
||||
### 8.2 Add a model from OpenRouter (hundreds of models)
|
||||
|
||||
1. User has already added OpenRouter as a provider (Configure Provider, `edit` state).
|
||||
2. User opens the OpenRouter row's kebab on the BYOK table → "Configure provider (OpenRouter)".
|
||||
3. Configure Provider opens in `edit` state, scoped to OpenRouter, showing the catalog grouped by upstream provider with sticky section headers.
|
||||
4. User types "sonnet" in the search box → list filters to ~6 matches across upstreams.
|
||||
5. User applies the `Vision` chip → list filters further.
|
||||
6. User checks "anthropic/claude-sonnet-4-5" and "google/gemini-2.5-flash". Footer shows "5 of 327 selected".
|
||||
7. User clicks `[Save changes]`. Configure Provider closes. Both models appear in the BYOK table with verification spinners → ✓ within 2s each.
|
||||
|
||||
### 8.3 Add a local Ollama provider
|
||||
|
||||
1. User clicks `[+ Add provider]` at the top of the BYOK table.
|
||||
2. Add Provider dialog opens. User scrolls down and clicks "+ Custom OpenAI-compatible endpoint."
|
||||
3. **Configure Provider** opens in `new-custom` state.
|
||||
4. User fills: Display name = "Ollama (local)", Type = OpenAI-compatible, Base URL = `http://localhost:11434/v1`, API key empty.
|
||||
5. Plugin pings `http://localhost:11434/v1/models`; the Models section populates with locally-installed Ollama models.
|
||||
6. User checks `llama3.2:latest` and `qwen2.5-coder:7b`. Clicks `[Verify & save]`.
|
||||
7. Configure Provider closes. BYOK table now has two Ollama rows at the top-level (alongside Anthropic etc., not nested). Both show 💬 🤖 badges (Availability had OpenCode checked).
|
||||
8. If OpenCode is running, the plugin registers Ollama as a custom provider in OpenCode's config so the models are usable in agent mode.
|
||||
|
||||
### 8.4 Change Claude Code agent visibility
|
||||
|
||||
**Pre-condition:** User has Claude Code installed (sub-tab configured), but the active backend is OpenCode.
|
||||
|
||||
1. User opens Settings → Agent tab.
|
||||
2. User clicks the Claude Code sub-tab. The panel swaps. Status card shows `○ Configured, not active`.
|
||||
3. User scrolls to "Visible models in picker." Sees 4 checkboxes:
|
||||
- ☑ claude-sonnet-4-5
|
||||
- ☑ claude-opus-4-1
|
||||
- ☑ claude-haiku-4-5
|
||||
- ☑ claude-sonnet-3-7
|
||||
4. User unchecks `claude-haiku-4-5` and `claude-sonnet-3-7`.
|
||||
5. State persists immediately (no Save button — inline persistence). The change applies whether Claude Code is currently active or not.
|
||||
6. If the user wants Claude Code to be active too, they click `[Use this backend]` in the Status card. Otherwise OpenCode stays active.
|
||||
|
||||
### 8.5 Switch agent backends (OpenCode → Codex)
|
||||
|
||||
1. User opens Settings → Agent tab, clicks the Codex sub-tab.
|
||||
2. Codex panel: Status `⚠ Not installed`. `[Install Codex]` (primary) + `[I have it elsewhere…]`.
|
||||
3. User clicks Install. Progress, then ✓ Detected.
|
||||
4. Subscription card: "Sign in to Codex after install." User clicks `[Sign in]` → child process. Auth completes; status updates to "Authenticated as user@example.com."
|
||||
5. Bundled-models list lights up. User leaves defaults.
|
||||
6. Default agent model dropdown lists visible models. User picks GPT-5. Default effort = Med.
|
||||
7. User clicks `[Use this backend]` in the Status card. Codex is now active. Chat input picker now reads from Codex's visible models.
|
||||
|
||||
### 8.6 Offline (no network)
|
||||
|
||||
1. User opens Settings → BYOK on a laptop without internet.
|
||||
2. Panel renders normally — bundled catalog powers Configure Provider's Models section. No network call needed.
|
||||
3. User clicks `[+ Add provider]` → Anthropic → Configure Provider opens in `new-byok` state.
|
||||
4. User pastes a key, clicks `[Test]` — fails. The key is still saved but the field shows "⚠ Couldn't verify — offline. Will verify when online."
|
||||
5. User picks two models, clicks `[Verify & save]`. Verification calls fail. Models appear in the BYOK table with `⚠` icons and tooltip "Couldn't verify — offline. Will retry when used."
|
||||
6. Later, when online, the next real use of the models succeeds and the warning icons disappear automatically.
|
||||
|
||||
### 8.7 OpenCode not installed — graceful degradation
|
||||
|
||||
1. User has BYOK providers configured but no OpenCode binary.
|
||||
2. BYOK table renders normally. Catalog source falls back to the bundled snapshot. OpenCode-provided rows are absent; Copilot Plus rows are also absent (Plus requires OpenCode at runtime).
|
||||
3. 🤖 badges still appear on cloud BYOK models (they would work via OpenCode if it were installed).
|
||||
4. A banner at the top of the BYOK panel: "OpenCode isn't installed yet. Models with the 🤖 badge will work once you install it. → Go to Agent tab."
|
||||
5. User can configure chat models normally; chain-mode chat works.
|
||||
6. When user installs OpenCode (via Agent tab), the banner disappears, the catalog source switches to OpenCode, OpenCode-provided rows and (if Plus) Copilot Plus rows appear in the table.
|
||||
|
||||
### 8.8 Remove a model entirely
|
||||
|
||||
1. User in BYOK tab, opens a row's `⋯` kebab.
|
||||
2. Menu: ⚙ Configure provider (Anthropic) | ↗ View model docs | — Remove from list
|
||||
3. Clicks Remove. Inline confirmation: "Remove Claude Haiku 4.5? You can add it back from + Add from catalog." `[Cancel]` `[Remove]`.
|
||||
4. Row disappears. If it was a Default Agent Model for any backend, that backend's default reverts to its first visible model.
|
||||
|
||||
(There is no "Set as default chat" menu item — the Default Chat Model concept is gone. The visibility checkbox handles show/hide.)
|
||||
|
||||
### 8.9 Edit a custom provider's base URL
|
||||
|
||||
1. User in BYOK tab opens an Ollama row's `⋯` kebab → "Configure provider (Ollama)".
|
||||
2. Configure Provider opens in `edit` state with all fields pre-filled.
|
||||
3. User changes Base URL to `http://my-server.tailnet.ts.net:11434/v1`. Clicks `[Test]` → ✓.
|
||||
4. Clicks `[Save changes]`. Plugin pings the new URL. Existing models stay in the registry.
|
||||
5. If the new URL doesn't return the expected model IDs, the model rows show `⚠` warnings with "These models weren't found at the new URL. Remove them?" → `[Remove these models]`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Empty / loading / error states
|
||||
|
||||
### 9.1 Empty registry (no providers yet)
|
||||
|
||||
Default state on first launch of the BYOK tab. See §5.1. The single primary CTA is `[+ Add provider]`. Helper copy invites both BYOK and custom endpoints. No provider shortcut buttons.
|
||||
|
||||
### 9.2 Polling OpenCode for catalog
|
||||
|
||||
Brief skeleton state on BYOK tab open while the plugin queries OpenCode for its live catalog. Usually <500ms — shimmer skeletons on the table rows. When OpenCode isn't installed, this state is skipped (bundled catalog renders immediately).
|
||||
|
||||
### 9.3 Invalid API key
|
||||
|
||||
Inside Configure Provider, below the key field, red text:
|
||||
|
||||
```
|
||||
⚠ Couldn't verify your key. Check that it's correct and has access to chat models.
|
||||
[Re-enter key] [Get a new key →]
|
||||
```
|
||||
|
||||
The "Get a new key" link goes to the provider's key management page.
|
||||
|
||||
### 9.4 Model verification failed
|
||||
|
||||
On saving Configure Provider with a model that fails its test call, the row in the BYOK table shows `⚠` next to the model name. Tooltip: "Last verification failed: <error message>". Click `⚠` → popover with `[Retry]` and `[Remove from registry]`.
|
||||
|
||||
### 9.5 Two OpenCode binaries detected
|
||||
|
||||
Agent tab → OpenCode sub-tab. Status card shows a radio list:
|
||||
|
||||
```
|
||||
(●) /usr/local/bin/opencode v0.4.2 · system
|
||||
( ) ~/Library/.../opencode v0.4.0 · plugin-installed
|
||||
```
|
||||
|
||||
Helper text: "Two OpenCode installations detected. Choose which one to use."
|
||||
|
||||
### 9.6 No providers, no models
|
||||
|
||||
BYOK tab empty state. No "default chat model" picker exists (the concept is removed), so there's no empty-default-picker variant to design.
|
||||
|
||||
### 9.7 Subscription not authenticated (Claude Code / Codex)
|
||||
|
||||
Subscription card in the backend sub-tab:
|
||||
|
||||
```
|
||||
⚠ Not signed in
|
||||
Your subscription auth has expired or never completed.
|
||||
[Sign in via Claude Code] [Helper docs →]
|
||||
```
|
||||
|
||||
Bundled-models card greyed until signed in.
|
||||
|
||||
### 9.8 Offline notice
|
||||
|
||||
Top of BYOK tab when network is unreachable:
|
||||
|
||||
```
|
||||
[i] You're offline.
|
||||
Catalog still works (bundled). Keys & models will verify when you're online.
|
||||
```
|
||||
|
||||
### 9.9 OpenCode missing banner
|
||||
|
||||
Top of BYOK tab when OpenCode is selected as the active backend but the binary is missing:
|
||||
|
||||
```
|
||||
[i] OpenCode isn't installed yet.
|
||||
Models with the 🤖 badge will work once you install it. → Go to Agent tab
|
||||
```
|
||||
|
||||
### 9.10 Remove-model confirmation
|
||||
|
||||
Inline mini-modal triggered from the row kebab → Remove from list:
|
||||
|
||||
```
|
||||
Remove Claude Haiku 4.5?
|
||||
Remove from registry? You can add it back from + Add from catalog.
|
||||
(If it was a default agent model, the default will revert to next.)
|
||||
[Cancel] [Remove]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Platform considerations
|
||||
|
||||
### 10.1 Desktop vs mobile
|
||||
|
||||
The redesigned settings UI is **desktop-shaped** (90%+ of users). Mobile users get a stripped-down view of the same surfaces:
|
||||
|
||||
| Aspect | Desktop | Mobile |
|
||||
| ----------------------------------------------------------------- | ------------------------------ | ------------------------------------- |
|
||||
| Welcome modal | Standalone modal over Obsidian | Skipped — direct to BYOK tab |
|
||||
| Agent tab | Visible | Hidden entirely |
|
||||
| 🤖 badges | Visible | Hidden everywhere |
|
||||
| OpenCode rows (Big Pickle, etc.) | Visible | Hidden |
|
||||
| Copilot Plus rows (Plus Flash, etc.) | Visible | Hidden |
|
||||
| Configure Provider "Availability" — `OpenCode` checkbox | Visible | Hidden (forced off) |
|
||||
| Add Custom Model "Availability" — `OpenCode` + agent capabilities | Visible | `OpenCode` hidden, `agent` cap hidden |
|
||||
| Settings modal width | ~1320px | Full-screen overlay |
|
||||
| Touch targets | Default | Min 44pt |
|
||||
|
||||
### 10.2 Obsidian theme
|
||||
|
||||
The Obsidian shell exposes CSS variables for colors, spacing, radii, fonts. Mockups should look native to a default Obsidian dark theme but also work in light theme. Avoid full-bleed color blocks or unique typography that breaks theme expectations.
|
||||
|
||||
### 10.3 Settings modal constraints
|
||||
|
||||
Copilot's settings live inside the Obsidian modal. The tab list runs **horizontally across the top of the modal** (Copilot's existing pattern — not Obsidian's stock left-rail).
|
||||
|
||||
- Width: bounded by Obsidian modal (~1320px for the redesign).
|
||||
- Tabs: horizontal strip at the top (BYOK, Agent, Chat & Commands, …).
|
||||
- Content: scrolls vertically beneath the tabs.
|
||||
- Nested dialogs (Add Provider, Configure Provider, Add Custom Model) overlay the modal at ~80% of its area, max ~820px wide.
|
||||
- The Welcome modal is standalone — not nested inside Settings.
|
||||
|
||||
### 10.4 Persistence
|
||||
|
||||
All settings persist immediately on change (no Save button) — the existing Copilot pattern. Configure Provider and Add Custom Model are the exceptions: they use explicit `[Verify & save]` / `[Save changes]` / `[Add model]` buttons because the user is composing multiple fields at once.
|
||||
|
||||
---
|
||||
|
||||
## 11. Migration
|
||||
|
||||
Existing users on the current settings will, on first launch after this redesign ships:
|
||||
|
||||
1. Existing provider keys → preserved. Each provider with a configured key auto-appears in the BYOK registry (no need to re-add via Add Provider). The user can hit the row kebab → Configure provider to inspect.
|
||||
2. Existing `activeModels` array → preserved; each model lands as a row in the BYOK table under its provider.
|
||||
3. Existing per-model overrides (temperature, max_tokens, etc.) → **dropped silently.** All chains revert to defaults. No notice shown.
|
||||
4. Existing agent-mode model curation (`modelEnabledOverrides` for OpenCode) → merged into the BYOK registry's visibility state (the row checkbox).
|
||||
5. Existing "Default Chat Model" setting → **discarded.** The concept is removed; chat input remembers the last-selected model.
|
||||
6. Existing per-backend agent defaults (Default Agent Model, Default reasoning effort) → preserved per backend in the Agent tab. If any backend lacks a saved default, seed with the backend's first visible model and Med effort.
|
||||
7. Existing "agent mode enabled" boolean → **discarded.** Agent capabilities are always available on desktop; the per-user toggle no longer exists.
|
||||
8. Existing custom provider definitions → preserved as top-level providers in the BYOK registry. Each gets an `edit`-state row in Configure Provider.
|
||||
|
||||
There is no migration UI; the changes are silent on launch.
|
||||
|
||||
---
|
||||
|
||||
## 12. Out of scope (explicit)
|
||||
|
||||
- **Embedding models.** Separate "Embeddings" panel, not touched.
|
||||
- **Reranker models.** Same — separate, untouched.
|
||||
- **License / Plus / Believer tier management.** Separate panel, not part of this redesign.
|
||||
- **Chat UI.** The redesigned chat-input model picker is a consumer of the BYOK registry, but its design is owned by chat redesign work.
|
||||
- **Skills / Custom commands / MCP servers.** Separate panels.
|
||||
- **Per-model temperature / max_tokens / system prompt.** Removed.
|
||||
- **Default Chat Model picker.** Removed — chain-mode chat uses last-selected.
|
||||
- **Drag-to-reorder models.** Out. Sort defaults: provider order then alphabetical.
|
||||
- **Special "pinned section" styling for OpenCode / Copilot Plus.** OpenCode and Copilot Plus appear as ordinary table rows, sorted to the top, not as visually-distinct pinned sections.
|
||||
|
||||
---
|
||||
|
||||
## 13. Glossary
|
||||
|
||||
- **BYOK** — Bring Your Own Key. The user supplies an API key from their provider account. Also the name of the consolidated settings **tab** (renamed from "Models").
|
||||
- **Chain mode** — Single-turn LLM calls via LangChain. Powers chat, custom commands. Works on mobile.
|
||||
- **Agent mode** — Multi-turn agent sessions via an external binary backend. Desktop-only.
|
||||
- **OpenCode** — Copilot's recommended agent backend. Open source, BYOK, supports many providers. Ships with a bundled `models.dev` catalog and a set of free models (Big Pickle, etc.).
|
||||
- **Claude Code** — Anthropic's official agent CLI. Subscription-based, Claude-only.
|
||||
- **Codex** — OpenAI's official agent CLI. Subscription-based, GPT-only.
|
||||
- **Big Pickle** — A free model bundled with OpenCode. No BYOK or license required. Agent-only (requires OpenCode installed).
|
||||
- **Copilot Plus** — Copilot's paid subscription tier. Unlocks access to hosted models served by Copilot (e.g. Copilot Plus Flash).
|
||||
- **Copilot Plus Flash** — A paid hosted model served by Copilot, available to Copilot Plus subscribers. Routed through OpenCode at runtime. Agent-only.
|
||||
- **`models.dev`** — An open catalog of LLM models across providers with metadata (context, pricing, modalities, capabilities). The plugin ships a tree-shaken snapshot. There is **no live refresh** in this design.
|
||||
- **Capability badges** — `💬 chat` and `🤖 agent` pills shown next to each enabled model row.
|
||||
- **Default Agent Model** — One **per agent backend** (lives in the Agent tab, inside each backend's sub-tab). The model used when starting an agent session in that backend. Must be 🤖-capable and visible in that backend.
|
||||
- **Default Reasoning Effort** — One **per agent backend** (in the Agent tab). Four levels: Min / Low / Med / High. Applies to reasoning-capable models in that backend. Surfaced as a chip group, not a radio.
|
||||
- **Registry** — The user's enabled models. Curated, not the full catalog. The list users see in the BYOK panel.
|
||||
- **Catalog** — The full list of available models for a provider. Not all of them are in the user's registry.
|
||||
- **Active backend** — The agent backend that chat sessions actually use. Separate from the Agent tab's _viewed sub-tab_. Promoted via `[Use this backend]` on the sub-tab's Status card.
|
||||
- **Custom provider** — A user-defined OpenAI-compatible (or Anthropic / Google-compatible) endpoint, e.g. Ollama or a self-hosted server. Appears as a top-level provider in the BYOK registry, **not** nested under a "Custom" wrapper.
|
||||
- **Configure Provider** — A single dialog with three states (`new-byok`, `new-custom`, `edit`). Consolidates what would otherwise be three separate dialogs (Enter API key, Add Models, Add Custom Endpoint).
|
||||
- **Add Custom Model** — A first-class dialog opened from Configure Provider to register a model that isn't in the catalog. Works for any provider.
|
||||
- **LangChain** — The library Copilot uses to instantiate and call provider SDKs in chain mode.
|
||||
|
||||
---
|
||||
|
||||
## 14. Design source
|
||||
|
||||
The design that this doc reflects is exported from Claude Design as `copilot-model-settings/`. The canonical reference for visuals and interaction details:
|
||||
|
||||
- `project/index.html` — the wireframe app shell.
|
||||
- `project/screens/final.jsx` — the **★ Final flow** that consolidates picked variants and adds two gap-filling screens (unified Configure Provider, Add Custom Model). Read this first.
|
||||
- `project/screens/welcome.jsx` (V1) — Welcome modal as standalone three-tile picker.
|
||||
- `project/screens/models-populated.jsx` (V3) — populated BYOK tab as a flat table.
|
||||
- `project/screens/add-provider.jsx` (V1) — provider picker dialog.
|
||||
- `project/screens/add-models.jsx` (V2) — OpenRouter-scale picker with sticky upstream-provider headers (now folded into Configure Provider's catalog list).
|
||||
- `project/screens/agent-opencode.jsx` (V2) — Agent tab with sub-tabs per backend.
|
||||
- `project/screens/agent-cc.jsx` (V1) — Claude Code / Codex internals (subscription + bundled visibility checklist).
|
||||
- `project/screens/states.jsx` — all empty / loading / error / offline / auth states.
|
||||
- `project/styles.css` and `project/primitives.jsx` — the low-fi component primitives the wireframes use.
|
||||
- `chats/chat1.md` and `chats/chat2.md` — design conversation transcripts that document the iterations and final decisions.
|
||||
|
||||
When in doubt, the design files override anything in this doc. This doc exists to translate design intent into engineering-ready language and to track the constraints (catalog source, migration, mobile differences) that aren't visually obvious in the mocks.
|
||||
1207
designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md
Normal file
1207
designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md
Normal file
File diff suppressed because it is too large
Load diff
125
designdocs/todo/PORTAL_CONTAINER_CONTRACT.md
Normal file
125
designdocs/todo/PORTAL_CONTAINER_CONTRACT.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# TODO — Required `container` prop on portaled UI primitives
|
||||
|
||||
## Status
|
||||
|
||||
Not started. Scoped for a separate PR.
|
||||
|
||||
## Problem
|
||||
|
||||
Radix-based UI primitives in `src/components/ui/` portal their content out of
|
||||
the parent DOM subtree:
|
||||
|
||||
- `DropdownMenuContent`
|
||||
- `DialogContent`
|
||||
- `PopoverContent`
|
||||
- `TooltipContent`
|
||||
- `SelectContent`
|
||||
|
||||
Today these wrappers fall back to `activeDocument.body` (or hardcode it, in
|
||||
the case of `TooltipContent`) when no `container` prop is supplied. Two
|
||||
problems with that:
|
||||
|
||||
1. **Inside the settings modal**, `activeDocument.body` is outside the modal
|
||||
subtree. Portaled content renders, but pointer/hover events get swallowed
|
||||
by the modal's overlay — menus open with no highlighted item on hover,
|
||||
clicks may not register. The recent fix in `ByokGlobalTable` and
|
||||
`ProviderCatalogList` works around this by threading a local
|
||||
`containerRef` and passing `container={containerRef.current}` (matches
|
||||
`ModelTable`, `PatternListEditor`).
|
||||
2. **In popout windows**, `activeDocument.body` is wrong even outside a
|
||||
modal. `activeDocument` points at whichever window is _focused right
|
||||
now_, which may not be the window the component lives in. The correct
|
||||
target is `someElement.doc.body` where `someElement` is a DOM node owned
|
||||
by the relevant window — i.e. derived from a React ref local to the
|
||||
component tree.
|
||||
|
||||
The default is silently incorrect, and there's no type-system pressure on
|
||||
callers to do the right thing.
|
||||
|
||||
## Proposed contract
|
||||
|
||||
1. Make `container: HTMLElement | null` **required** on every portaled
|
||||
wrapper:
|
||||
- `DropdownMenuContent`
|
||||
- `DialogContent`
|
||||
- `PopoverContent`
|
||||
- `TooltipContent` (today has no `container` prop at all — add it)
|
||||
- `SelectContent`
|
||||
|
||||
Remove the `?? activeDocument.body` fallbacks. The caller must pass
|
||||
something. `null` is allowed for the "ref not populated yet on first
|
||||
render" case, but the wrapper does not inject a body default.
|
||||
|
||||
2. Add a `PortalContainerContext` + `usePortalContainer()` hook so the
|
||||
common case (everything below `CopilotView` / `SettingsMainV2` /
|
||||
`ConfirmModal` / dialogs) is one-liner:
|
||||
|
||||
```tsx
|
||||
<DropdownMenuContent container={usePortalContainer()}>…</DropdownMenuContent>
|
||||
```
|
||||
|
||||
The provider sets its value from a DOM ref local to the root component,
|
||||
so the resolved container is always in the same window as that root
|
||||
(popout-safe by construction).
|
||||
|
||||
3. Root providers to wire up:
|
||||
- `CopilotView` — main chat surface. Provider value = closest in-view DOM
|
||||
node, re-derived on `containerEl.onWindowMigrated(...)`.
|
||||
- `SettingsMainV2` — `TabProvider` already exposes a `modalContainer`;
|
||||
reuse or supersede it with `PortalContainerContext`.
|
||||
- Each `Dialog` in `modelManagement/ui/dialogs/` — provide a context
|
||||
scoped to the dialog body so descendant menus/popovers/tooltips inside
|
||||
the dialog portal back into the dialog (not the page below it).
|
||||
|
||||
## Scope of the migration
|
||||
|
||||
`grep -rE "(DropdownMenuContent|DialogContent|PopoverContent|TooltipContent|SelectContent)[^a-zA-Z]"`
|
||||
returns ~108 JSX call sites across ~28 files. `TooltipContent` alone is 44
|
||||
of those, and they live in leaf components (`ChatButtons`, `SuggestedPrompts`,
|
||||
`ChatSingleMessage`, etc.) that do not currently have access to any
|
||||
`containerRef`.
|
||||
|
||||
Plan:
|
||||
|
||||
1. Land the type change + context provider as one commit. CI breaks on
|
||||
every unmigrated callsite (expected).
|
||||
2. Migrate callsites in batches grouped by feature area:
|
||||
- chat-components/\*
|
||||
- agentMode/ui/\*
|
||||
- settings/v2/\*
|
||||
- modelManagement/ui/\* (already migrated for BYOK group / catalog list
|
||||
ahead of this work — those use a local ref, which can be swapped for
|
||||
the context once it exists).
|
||||
- system-prompts/\*
|
||||
3. Update jest mocks/tests where they assert on the absence of a `container`
|
||||
prop. The existing `dropdown-menu` mock in BYOK tests already passes
|
||||
children through transparently and won't need changes.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replacing Radix. The primitives stay; only the wrapper contract changes.
|
||||
- Reworking how `TabContext.modalContainer` is computed. The new context
|
||||
can wrap or replace it as a follow-up.
|
||||
- Migrating `obsidian-native-select.tsx` / `ModelSelector.tsx` /
|
||||
`help-tooltip.tsx` if they don't directly portal — audit during the
|
||||
migration PR.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should `container` accept `null` (current React-ref ergonomics) or be
|
||||
strictly `HTMLElement` (forces callers to gate rendering on ref-set)? The
|
||||
former is more practical; the latter is stricter. Recommendation: `null`
|
||||
allowed, with a JSDoc note that `null` falls back to Radix's own default
|
||||
(which is `document.body` — still wrong in popouts, but the type system
|
||||
has at least forced the caller to acknowledge the case).
|
||||
|
||||
## References
|
||||
|
||||
- `ByokGlobalTable.tsx`, `ProviderCatalogList.tsx` — recent local fix using
|
||||
the per-component `containerRef` pattern.
|
||||
- `ModelTable.tsx`, `PatternListEditor.tsx` — same pattern, predates this
|
||||
doc.
|
||||
- `ConfigureProviderDialog.tsx` — uses `useTabOptional()?.modalContainer`
|
||||
to portal the dialog itself into `.modal-container`.
|
||||
- AGENTS.md → "Picking the right `document` / `window` (popout-window
|
||||
safety)" — the broader principle this work enforces at the type system.
|
||||
|
|
@ -311,6 +311,77 @@ export default [
|
|||
},
|
||||
},
|
||||
|
||||
// Module boundary: everything in `src/modelManagement/` is private except
|
||||
// `index.ts`. Outside callers (settings UI, agent backends, chat view, …)
|
||||
// MUST import via `@/modelManagement`. Internal cross-references inside
|
||||
// the module use `@/modelManagement/<subpath>` and are NOT restricted by
|
||||
// this rule because the `target` patterns exclude the module itself.
|
||||
//
|
||||
// The `from` zone covers everything under `src/modelManagement/**`; the
|
||||
// `except` carve-out re-allows the single public entry point `index.ts`.
|
||||
// This is the pattern the rule's docs recommend for "one public file per
|
||||
// module" boundaries (the bare `!(index.ts)` extglob only matches direct
|
||||
// children, not nested files, so a glob+except combo is required for
|
||||
// multi-level modules).
|
||||
//
|
||||
// The `import` plugin is already registered globally by
|
||||
// eslint-plugin-obsidianmd's recommended config — we just enable the
|
||||
// specific rule here. Resolver settings are inherited from the boundaries
|
||||
// block above (typescript + node).
|
||||
//
|
||||
// See: designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §3.0.
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx,js,jsx}"],
|
||||
ignores: ["src/modelManagement/**"],
|
||||
rules: {
|
||||
"import/no-restricted-paths": [
|
||||
"error",
|
||||
{
|
||||
zones: [
|
||||
{
|
||||
target: "./src/**",
|
||||
from: "./src/modelManagement/**",
|
||||
// eslint-plugin-import resolves `from` against the project
|
||||
// basePath but treats glob `except` patterns as-is, against
|
||||
// the absolute import path. Use a `**` prefix so the rule's
|
||||
// minimatch picks up the resolved absolute file path.
|
||||
except: [
|
||||
"**/src/modelManagement/index.*",
|
||||
// Concrete LangChain `BaseChatModel` client classes (Bedrock,
|
||||
// LM Studio, OpenRouter) are imported directly by
|
||||
// `src/LLMProviders/ChatModelManager.ts`, which is itself
|
||||
// legacy and will be replaced by `ChatModelFactory` in M9.
|
||||
// Carving out the `clients/*` files keeps the boundary intact
|
||||
// for all new code while not blocking the legacy manager
|
||||
// during the transition. See:
|
||||
// designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §3.4.
|
||||
"**/src/modelManagement/providers/clients/*",
|
||||
// `ChatModelFactory` and the `AzureAdapter.normalizeAzureUrl`
|
||||
// helper are imported directly by
|
||||
// `src/LLMProviders/ChatModelManager.ts`. We deliberately
|
||||
// keep them OUT of the module barrel so test suites that
|
||||
// only need the catalog / registry / migration surface
|
||||
// aren't forced to mock every LangChain dependency. See M9
|
||||
// in designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §3.4.
|
||||
"**/src/modelManagement/chatModel/ChatModelFactory*",
|
||||
"**/src/modelManagement/providers/adapters/AzureAdapter*",
|
||||
// Provider-specific id helpers (`isOpenAIGPT5`, etc.) live
|
||||
// beside the adapters per the Task 8 refactor — they replaced
|
||||
// the legacy `getModelInfo` patterns in `src/utils.ts`. The
|
||||
// legacy `ChatModelManager.ts` / `chainManager.ts` still need
|
||||
// them until M9 deletes those files; the carve-out keeps the
|
||||
// boundary intact for everything else.
|
||||
"**/src/modelManagement/providers/adapters/adapterUtils*",
|
||||
],
|
||||
message:
|
||||
"Import model management code via @/modelManagement (the module's public API), not from internal files. See designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §3.0.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Only acp/ may import `@agentclientprotocol/sdk`. Tests are exempted via
|
||||
// the test block; acp/ itself is exempted below.
|
||||
{
|
||||
|
|
|
|||
133
src/LLMProviders/__tests__/ChatModelManager.instance.test.ts
Normal file
133
src/LLMProviders/__tests__/ChatModelManager.instance.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* Regression test for Task 7 of MODEL_MANAGEMENT_REVIEW.md.
|
||||
*
|
||||
* `ChatModelManager` previously held the active chat model in a process-wide
|
||||
* static field, which meant two scopes (e.g. project chat + Quick Chat agent)
|
||||
* picking different models would clobber each other. The manager is now
|
||||
* per-instance — these tests pin that contract.
|
||||
*/
|
||||
import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
||||
import type { CustomModel } from "@/aiParams";
|
||||
|
||||
// Settings used by the constructor's buildModelMap() — return a minimal
|
||||
// fixture so the manager constructs cleanly.
|
||||
jest.mock("@/settings/model", () => ({
|
||||
getSettings: () => ({
|
||||
activeModels: [],
|
||||
openAIApiKey: "",
|
||||
googleApiKey: "",
|
||||
azureOpenAIApiKey: "",
|
||||
anthropicApiKey: "",
|
||||
cohereApiKey: "",
|
||||
openRouterAiApiKey: "",
|
||||
groqApiKey: "",
|
||||
xaiApiKey: "",
|
||||
plusLicenseKey: "",
|
||||
mistralApiKey: "",
|
||||
deepseekApiKey: "",
|
||||
amazonBedrockApiKey: "",
|
||||
siliconflowApiKey: "",
|
||||
githubCopilotToken: "",
|
||||
githubCopilotAccessToken: "",
|
||||
temperature: 0.7,
|
||||
maxTokens: 1000,
|
||||
openAIOrgId: "",
|
||||
}),
|
||||
getModelKeyFromModel: (m: { name: string; provider: string }) => `${m.name}|${m.provider}`,
|
||||
subscribeToSettingsChange: jest.fn().mockReturnValue(() => {}),
|
||||
}));
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
// The factory pulls in every provider adapter (incl. @langchain/anthropic),
|
||||
// which transitively requires native SDKs that don't resolve under jsdom.
|
||||
// We stub the factory + Azure adapter helper since this test only exercises
|
||||
// instance state, not the model build path.
|
||||
jest.mock("@/modelManagement/chatModel/ChatModelFactory", () => ({
|
||||
ChatModelFactory: { create: jest.fn() },
|
||||
}));
|
||||
jest.mock("@/modelManagement/providers/adapters/AzureAdapter", () => ({
|
||||
normalizeAzureUrl: (u: string) => u,
|
||||
}));
|
||||
jest.mock("@/modelManagement", () => ({
|
||||
ModelCatalogService: { getInstance: () => ({}) },
|
||||
}));
|
||||
|
||||
import ChatModelManager from "@/LLMProviders/ChatModelManager";
|
||||
|
||||
/** Build a stub BaseChatModel that the manager can store + return. */
|
||||
function makeFakeChatModel(label: string): BaseChatModel {
|
||||
return {
|
||||
__label: label,
|
||||
getNumTokens: async (s: string) => s.length,
|
||||
} as unknown as BaseChatModel;
|
||||
}
|
||||
|
||||
/** Build a minimal CustomModel; we'll stub createModelInstance so its
|
||||
* contents don't actually drive a LangChain build. */
|
||||
function makeCustomModel(name: string, provider = "openai"): CustomModel {
|
||||
return {
|
||||
name,
|
||||
provider,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ChatModelManager — per-instance isolation", () => {
|
||||
it("two instances hold independent active models simultaneously", async () => {
|
||||
const managerA = new ChatModelManager();
|
||||
const managerB = new ChatModelManager();
|
||||
|
||||
const modelX = makeFakeChatModel("model-X");
|
||||
const modelY = makeFakeChatModel("model-Y");
|
||||
|
||||
// Bypass the factory dispatch — we only care about instance state here.
|
||||
jest.spyOn(managerA, "createModelInstance").mockResolvedValue(modelX);
|
||||
jest.spyOn(managerB, "createModelInstance").mockResolvedValue(modelY);
|
||||
|
||||
await managerA.setChatModel(makeCustomModel("gpt-4"));
|
||||
await managerB.setChatModel(makeCustomModel("claude-sonnet", "anthropic"));
|
||||
|
||||
// Both instances expose their own model — neither shadows the other.
|
||||
expect(managerA.getChatModel()).toBe(modelX);
|
||||
expect(managerB.getChatModel()).toBe(modelY);
|
||||
expect(managerA.getChatModel()).not.toBe(managerB.getChatModel());
|
||||
});
|
||||
|
||||
it("setChatModel on one instance does not mutate another instance", async () => {
|
||||
const managerA = new ChatModelManager();
|
||||
const managerB = new ChatModelManager();
|
||||
|
||||
const modelX = makeFakeChatModel("model-X");
|
||||
const modelY = makeFakeChatModel("model-Y");
|
||||
|
||||
jest.spyOn(managerA, "createModelInstance").mockResolvedValue(modelX);
|
||||
jest.spyOn(managerB, "createModelInstance").mockResolvedValue(modelY);
|
||||
|
||||
// A picks X first.
|
||||
await managerA.setChatModel(makeCustomModel("gpt-4"));
|
||||
expect(managerA.getChatModel()).toBe(modelX);
|
||||
|
||||
// B picks Y after — A must still report X (no shared static slot).
|
||||
await managerB.setChatModel(makeCustomModel("claude-sonnet", "anthropic"));
|
||||
expect(managerA.getChatModel()).toBe(modelX);
|
||||
expect(managerB.getChatModel()).toBe(modelY);
|
||||
|
||||
// A swaps to a fresh model — B remains untouched.
|
||||
const modelZ = makeFakeChatModel("model-Z");
|
||||
(managerA.createModelInstance as jest.Mock).mockResolvedValue(modelZ);
|
||||
await managerA.setChatModel(makeCustomModel("gpt-4o"));
|
||||
expect(managerA.getChatModel()).toBe(modelZ);
|
||||
expect(managerB.getChatModel()).toBe(modelY);
|
||||
});
|
||||
|
||||
it("static getInstance() returns a fresh instance each call (no shared singleton)", () => {
|
||||
const a = ChatModelManager.getInstance();
|
||||
const b = ChatModelManager.getInstance();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
|
@ -626,10 +626,12 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
// Extract user content (L3 smart references + L5) from base messages
|
||||
const userMessageContent = baseMessages.find((m) => m.role === "user");
|
||||
if (userMessageContent) {
|
||||
const isMultimodal = this.isMultimodalModel(chatModel);
|
||||
const content: string | MessageContent[] = isMultimodal
|
||||
? await this.buildMessageContent(userMessageContent.content, userMessage)
|
||||
: userMessageContent.content;
|
||||
// Always forward image content. If the model can't handle images, the
|
||||
// provider's error surfaces to the user via the standard error path.
|
||||
const content: string | MessageContent[] = await this.buildMessageContent(
|
||||
userMessageContent.content,
|
||||
userMessage
|
||||
);
|
||||
messages.push(new HumanMessage(content));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
COMPOSER_OUTPUT_INSTRUCTIONS,
|
||||
LOADING_MESSAGES,
|
||||
MAX_CHARS_FOR_LOCAL_SEARCH_CONTEXT,
|
||||
ModelCapability,
|
||||
} from "@/constants";
|
||||
import { LayerToMessagesConverter } from "@/context/LayerToMessagesConverter";
|
||||
import {
|
||||
|
|
@ -559,17 +558,6 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
return messageContent;
|
||||
}
|
||||
|
||||
protected hasCapability(model: BaseChatModel, capability: ModelCapability): boolean {
|
||||
const modelWithName = model as BaseChatModel & { modelName?: string; model?: string };
|
||||
const modelName: string = modelWithName.modelName || modelWithName.model || "";
|
||||
const customModel = this.chainManager.chatModelManager.findModelByName(modelName);
|
||||
return customModel?.capabilities?.includes(capability) ?? false;
|
||||
}
|
||||
|
||||
protected isMultimodalModel(model: BaseChatModel): boolean {
|
||||
return this.hasCapability(model, ModelCapability.VISION);
|
||||
}
|
||||
|
||||
/**
|
||||
* If userMessage.message contains '@composer', append COMPOSER_OUTPUT_INSTRUCTIONS to the text content.
|
||||
* Handles both string and MessageContent[] types.
|
||||
|
|
@ -596,7 +584,6 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
|
||||
// Get chat model
|
||||
const chatModel = this.chainManager.chatModelManager.getChatModel();
|
||||
const isMultimodalCurrent = this.isMultimodalModel(chatModel);
|
||||
|
||||
// Create messages array
|
||||
const messages: { role: string; content: string | MessageContent[] }[] = [];
|
||||
|
|
@ -691,10 +678,12 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
}
|
||||
}
|
||||
|
||||
// Build message content with text and images for multimodal models
|
||||
const content: string | MessageContent[] = isMultimodalCurrent
|
||||
? await this.buildMessageContent(finalUserContent, userMessage)
|
||||
: finalUserContent;
|
||||
// Always forward image content. If the model can't handle images, the
|
||||
// provider's error surfaces to the user via the standard error path.
|
||||
const content: string | MessageContent[] = await this.buildMessageContent(
|
||||
finalUserContent,
|
||||
userMessage
|
||||
);
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
|
|
@ -750,12 +739,9 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
): Promise<string> {
|
||||
const { updateLoadingMessage } = options;
|
||||
|
||||
// Check if the current model has reasoning capability
|
||||
const chatModel = this.chainManager.chatModelManager.getChatModel();
|
||||
const hasReasoning = this.hasCapability(chatModel, ModelCapability.REASONING);
|
||||
const excludeThinking = !hasReasoning;
|
||||
|
||||
const thinkStreamer = new ThinkBlockStreamer(updateCurrentAiMessage, excludeThinking);
|
||||
// Always render thinking content if the model returns it. Non-thinking models
|
||||
// simply don't produce thinking sections, so this is a no-op for them.
|
||||
const thinkStreamer = new ThinkBlockStreamer(updateCurrentAiMessage);
|
||||
let sources: { title: string; path: string; score: number; explanation?: unknown }[] = [];
|
||||
|
||||
const isPlusUser = await checkIsPlusUser({
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { ABORT_REASON, ModelCapability } from "@/constants";
|
||||
import { ABORT_REASON } from "@/constants";
|
||||
import { LayerToMessagesConverter } from "@/context/LayerToMessagesConverter";
|
||||
import { logInfo } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { ChatMessage } from "@/types/message";
|
||||
import { findCustomModel, withSuppressedTokenWarnings } from "@/utils";
|
||||
import { withSuppressedTokenWarnings } from "@/utils";
|
||||
import { BaseChainRunner } from "./BaseChainRunner";
|
||||
import { loadAndAddChatHistory } from "./utils/chatHistoryUtils";
|
||||
import { recordPromptPayload } from "./utils/promptPayloadRecorder";
|
||||
import { ThinkBlockStreamer } from "./utils/ThinkBlockStreamer";
|
||||
import { getModelKey } from "@/aiParams";
|
||||
|
||||
export class LLMChainRunner extends BaseChainRunner {
|
||||
/**
|
||||
|
|
@ -81,24 +79,9 @@ export class LLMChainRunner extends BaseChainRunner {
|
|||
updateLoading?: (loading: boolean) => void;
|
||||
}
|
||||
): Promise<string> {
|
||||
// Check if the current model has reasoning capability
|
||||
const settings = getSettings();
|
||||
const modelKey = getModelKey();
|
||||
let excludeThinking = false;
|
||||
|
||||
try {
|
||||
const currentModel = findCustomModel(modelKey, settings.activeModels);
|
||||
// Exclude thinking blocks if model doesn't have REASONING capability
|
||||
excludeThinking = !currentModel.capabilities?.includes(ModelCapability.REASONING);
|
||||
} catch (error) {
|
||||
// If we can't find the model, default to including thinking blocks
|
||||
logInfo(
|
||||
"Could not determine model capabilities, defaulting to include thinking blocks",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage, excludeThinking);
|
||||
// Always render thinking content if the model returns it. Non-thinking models
|
||||
// simply don't produce thinking sections, so this is a no-op for them.
|
||||
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage);
|
||||
|
||||
try {
|
||||
// Construct messages using envelope or legacy approach
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ABORT_REASON, ModelCapability, RETRIEVED_DOCUMENT_TAG } from "@/constants";
|
||||
import { ABORT_REASON, RETRIEVED_DOCUMENT_TAG } from "@/constants";
|
||||
import { getStandaloneQuestion } from "@/chainUtils";
|
||||
import { LayerToMessagesConverter } from "@/context/LayerToMessagesConverter";
|
||||
import { logInfo } from "@/logger";
|
||||
|
|
@ -11,7 +11,6 @@ import { ChatMessage } from "@/types/message";
|
|||
import {
|
||||
extractChatHistory,
|
||||
extractUniqueTitlesFromDocs,
|
||||
findCustomModel,
|
||||
getMessageRole,
|
||||
withSuppressedTokenWarnings,
|
||||
} from "@/utils";
|
||||
|
|
@ -27,7 +26,6 @@ import {
|
|||
} from "./utils/citationUtils";
|
||||
import { recordPromptPayload } from "./utils/promptPayloadRecorder";
|
||||
import { ThinkBlockStreamer } from "./utils/ThinkBlockStreamer";
|
||||
import { getModelKey } from "@/aiParams";
|
||||
|
||||
export class VaultQAChainRunner extends BaseChainRunner {
|
||||
async run(
|
||||
|
|
@ -41,24 +39,9 @@ export class VaultQAChainRunner extends BaseChainRunner {
|
|||
updateLoading?: (loading: boolean) => void;
|
||||
}
|
||||
): Promise<string> {
|
||||
// Check if the current model has reasoning capability
|
||||
const settings = getSettings();
|
||||
const modelKey = getModelKey();
|
||||
let excludeThinking = false;
|
||||
|
||||
try {
|
||||
const currentModel = findCustomModel(modelKey, settings.activeModels);
|
||||
// Exclude thinking blocks if model doesn't have REASONING capability
|
||||
excludeThinking = !currentModel.capabilities?.includes(ModelCapability.REASONING);
|
||||
} catch (error) {
|
||||
// If we can't find the model, default to including thinking blocks
|
||||
logInfo(
|
||||
"Could not determine model capabilities, defaulting to include thinking blocks",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage, excludeThinking);
|
||||
// Always render thinking content if the model returns it. Non-thinking models
|
||||
// simply don't produce thinking sections, so this is a no-op for them.
|
||||
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage);
|
||||
|
||||
try {
|
||||
// Tiered lexical retriever doesn't need index check - it builds indexes on demand
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { BREVILABS_MODELS_BASE_URL, EmbeddingModelProviders, ProviderInfo } from
|
|||
import { getDecryptedKey } from "@/encryptionService";
|
||||
import { CustomError } from "@/error";
|
||||
import { logInfo } from "@/logger";
|
||||
import { getProviderApiKeySync, ProviderRegistry } from "@/modelManagement";
|
||||
import { getModelKeyFromModel, getSettings, subscribeToSettingsChange } from "@/settings/model";
|
||||
import { err2String, safeFetch } from "@/utils";
|
||||
import { Embeddings } from "@langchain/core/embeddings";
|
||||
|
|
@ -14,6 +15,47 @@ import { BrevilabsClient } from "./brevilabsClient";
|
|||
import { CustomJinaEmbeddings } from "./CustomJinaEmbeddings";
|
||||
import { CustomOpenAIEmbeddings } from "./CustomOpenAIEmbeddings";
|
||||
|
||||
/**
|
||||
* Maps `EmbeddingModelProviders` enum values to canonical `ProviderRegistry`
|
||||
* provider ids. Local providers and Copilot-Plus pseudo-providers fall back
|
||||
* to their respective dedicated credentials in `resolveDefaultApiKey`.
|
||||
*/
|
||||
const EMBEDDING_PROVIDER_TO_REGISTRY_ID: Partial<Record<EmbeddingModelProviders, string>> = {
|
||||
[EmbeddingModelProviders.OPENAI]: "openai",
|
||||
[EmbeddingModelProviders.COHEREAI]: "cohere",
|
||||
[EmbeddingModelProviders.GOOGLE]: "google",
|
||||
[EmbeddingModelProviders.AZURE_OPENAI]: "azure",
|
||||
[EmbeddingModelProviders.SILICONFLOW]: "siliconflow",
|
||||
[EmbeddingModelProviders.OPENROUTERAI]: "openrouter",
|
||||
};
|
||||
|
||||
/** Resolve a default API key for an embedding provider via `ProviderRegistry`. */
|
||||
function resolveDefaultEmbeddingApiKey(provider: EmbeddingModelProviders): string {
|
||||
switch (provider) {
|
||||
case EmbeddingModelProviders.COPILOT_PLUS:
|
||||
case EmbeddingModelProviders.COPILOT_PLUS_JINA:
|
||||
return getSettings().plusLicenseKey ?? "";
|
||||
case EmbeddingModelProviders.OLLAMA:
|
||||
case EmbeddingModelProviders.LM_STUDIO:
|
||||
case EmbeddingModelProviders.OPENAI_FORMAT:
|
||||
return "default-key";
|
||||
default: {
|
||||
const registryId = EMBEDDING_PROVIDER_TO_REGISTRY_ID[provider];
|
||||
if (!registryId) return "";
|
||||
return getProviderApiKeySync(registryId) ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a string extras field from the OpenAI / Azure provider registry entry. */
|
||||
function getAzureExtra(
|
||||
field: "azureInstanceName" | "azureDeploymentName" | "azureApiVersion"
|
||||
): string | undefined {
|
||||
const provider = ProviderRegistry.getInstance().get("azure");
|
||||
const value = provider?.extra?.[field];
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
type EmbeddingConstructorType = new (config: Record<string, unknown>) => Embeddings;
|
||||
|
||||
const EMBEDDING_PROVIDER_CONSTRUCTORS = {
|
||||
|
|
@ -46,17 +88,28 @@ export default class EmbeddingManager {
|
|||
>;
|
||||
|
||||
private readonly providerApiKeyMap: Record<EmbeddingModelProviders, () => string> = {
|
||||
[EmbeddingModelProviders.COPILOT_PLUS]: () => getSettings().plusLicenseKey,
|
||||
[EmbeddingModelProviders.COPILOT_PLUS_JINA]: () => getSettings().plusLicenseKey,
|
||||
[EmbeddingModelProviders.OPENAI]: () => getSettings().openAIApiKey,
|
||||
[EmbeddingModelProviders.COHEREAI]: () => getSettings().cohereApiKey,
|
||||
[EmbeddingModelProviders.GOOGLE]: () => getSettings().googleApiKey,
|
||||
[EmbeddingModelProviders.AZURE_OPENAI]: () => getSettings().azureOpenAIApiKey,
|
||||
[EmbeddingModelProviders.OLLAMA]: () => "default-key",
|
||||
[EmbeddingModelProviders.LM_STUDIO]: () => "default-key",
|
||||
[EmbeddingModelProviders.OPENAI_FORMAT]: () => "default-key",
|
||||
[EmbeddingModelProviders.SILICONFLOW]: () => getSettings().siliconflowApiKey,
|
||||
[EmbeddingModelProviders.OPENROUTERAI]: () => getSettings().openRouterAiApiKey,
|
||||
[EmbeddingModelProviders.COPILOT_PLUS]: () =>
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.COPILOT_PLUS),
|
||||
[EmbeddingModelProviders.COPILOT_PLUS_JINA]: () =>
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.COPILOT_PLUS_JINA),
|
||||
[EmbeddingModelProviders.OPENAI]: () =>
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.OPENAI),
|
||||
[EmbeddingModelProviders.COHEREAI]: () =>
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.COHEREAI),
|
||||
[EmbeddingModelProviders.GOOGLE]: () =>
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.GOOGLE),
|
||||
[EmbeddingModelProviders.AZURE_OPENAI]: () =>
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.AZURE_OPENAI),
|
||||
[EmbeddingModelProviders.OLLAMA]: () =>
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.OLLAMA),
|
||||
[EmbeddingModelProviders.LM_STUDIO]: () =>
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.LM_STUDIO),
|
||||
[EmbeddingModelProviders.OPENAI_FORMAT]: () =>
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.OPENAI_FORMAT),
|
||||
[EmbeddingModelProviders.SILICONFLOW]: () =>
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.SILICONFLOW),
|
||||
[EmbeddingModelProviders.OPENROUTERAI]: () =>
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.OPENROUTERAI),
|
||||
};
|
||||
|
||||
private constructor() {
|
||||
|
|
@ -230,7 +283,9 @@ export default class EmbeddingManager {
|
|||
},
|
||||
[EmbeddingModelProviders.OPENAI]: {
|
||||
modelName,
|
||||
apiKey: await getDecryptedKey(customModel.apiKey || settings.openAIApiKey),
|
||||
apiKey: await getDecryptedKey(
|
||||
customModel.apiKey || resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.OPENAI)
|
||||
),
|
||||
timeout: 10000,
|
||||
batchSize: getSettings().embeddingBatchSize,
|
||||
configuration: {
|
||||
|
|
@ -240,7 +295,9 @@ export default class EmbeddingManager {
|
|||
},
|
||||
[EmbeddingModelProviders.COHEREAI]: {
|
||||
modelName,
|
||||
apiKey: await getDecryptedKey(customModel.apiKey || settings.cohereApiKey),
|
||||
apiKey: await getDecryptedKey(
|
||||
customModel.apiKey || resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.COHEREAI)
|
||||
),
|
||||
timeout: 10000,
|
||||
batchSize: getSettings().embeddingBatchSize,
|
||||
configuration: {
|
||||
|
|
@ -250,17 +307,26 @@ export default class EmbeddingManager {
|
|||
},
|
||||
[EmbeddingModelProviders.GOOGLE]: {
|
||||
modelName: modelName,
|
||||
apiKey: await getDecryptedKey(settings.googleApiKey),
|
||||
apiKey: await getDecryptedKey(
|
||||
resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.GOOGLE)
|
||||
),
|
||||
},
|
||||
[EmbeddingModelProviders.AZURE_OPENAI]: {
|
||||
modelName,
|
||||
azureOpenAIApiKey: await getDecryptedKey(customModel.apiKey || settings.azureOpenAIApiKey),
|
||||
azureOpenAIApiKey: await getDecryptedKey(
|
||||
customModel.apiKey || resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.AZURE_OPENAI)
|
||||
),
|
||||
azureOpenAIApiInstanceName:
|
||||
customModel.azureOpenAIApiInstanceName || settings.azureOpenAIApiInstanceName,
|
||||
customModel.azureOpenAIApiInstanceName || getAzureExtra("azureInstanceName") || "",
|
||||
// Embedding-specific deployment override on the legacy CustomModel
|
||||
// wins; otherwise fall back to the provider-level deployment in
|
||||
// `extra.azureDeploymentName`.
|
||||
azureOpenAIApiDeploymentName:
|
||||
customModel.azureOpenAIApiEmbeddingDeploymentName ||
|
||||
settings.azureOpenAIApiEmbeddingDeploymentName,
|
||||
azureOpenAIApiVersion: customModel.azureOpenAIApiVersion || settings.azureOpenAIApiVersion,
|
||||
getAzureExtra("azureDeploymentName") ||
|
||||
"",
|
||||
azureOpenAIApiVersion:
|
||||
customModel.azureOpenAIApiVersion || getAzureExtra("azureApiVersion") || "",
|
||||
},
|
||||
[EmbeddingModelProviders.OLLAMA]: {
|
||||
baseUrl: customModel.baseUrl || "http://localhost:11434",
|
||||
|
|
@ -290,7 +356,9 @@ export default class EmbeddingManager {
|
|||
},
|
||||
[EmbeddingModelProviders.SILICONFLOW]: {
|
||||
modelName,
|
||||
apiKey: await getDecryptedKey(customModel.apiKey || settings.siliconflowApiKey),
|
||||
apiKey: await getDecryptedKey(
|
||||
customModel.apiKey || resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.SILICONFLOW)
|
||||
),
|
||||
batchSize: getSettings().embeddingBatchSize,
|
||||
configuration: {
|
||||
baseURL: customModel.baseUrl || ProviderInfo[EmbeddingModelProviders.SILICONFLOW].host,
|
||||
|
|
@ -299,7 +367,9 @@ export default class EmbeddingManager {
|
|||
},
|
||||
[EmbeddingModelProviders.OPENROUTERAI]: {
|
||||
modelName,
|
||||
apiKey: await getDecryptedKey(customModel.apiKey || settings.openRouterAiApiKey),
|
||||
apiKey: await getDecryptedKey(
|
||||
customModel.apiKey || resolveDefaultEmbeddingApiKey(EmbeddingModelProviders.OPENROUTERAI)
|
||||
),
|
||||
batchSize: getSettings().embeddingBatchSize,
|
||||
configuration: {
|
||||
baseURL: customModel.baseUrl || "https://openrouter.ai/api/v1",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type {
|
|||
ListSessionsOutput,
|
||||
LoadSessionInput,
|
||||
LoadSessionOutput,
|
||||
ModelSelection,
|
||||
OpenSessionInput,
|
||||
OpenSessionOutput,
|
||||
PermissionDecision,
|
||||
|
|
@ -124,7 +125,14 @@ export class AcpBackendProcess implements BackendProcess {
|
|||
private readonly app: App,
|
||||
private readonly backend: AcpBackend,
|
||||
private readonly clientVersion: string,
|
||||
private readonly descriptor: BackendDescriptor
|
||||
private readonly descriptor: BackendDescriptor,
|
||||
/**
|
||||
* Lazy accessor for the in-memory last-used (model, effort) on this
|
||||
* backend. Forwarded into `buildSpawnDescriptor` so suffix-style
|
||||
* backends can seed their spawn-time `config.model`. Optional — tests
|
||||
* and legacy callers may omit it.
|
||||
*/
|
||||
private readonly getSeedSelection?: () => ModelSelection | null
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -140,6 +148,7 @@ export class AcpBackendProcess implements BackendProcess {
|
|||
}
|
||||
const descriptor = await this.backend.buildSpawnDescriptor({
|
||||
vaultBasePath: adapter.getBasePath(),
|
||||
seedSelection: this.getSeedSelection?.() ?? null,
|
||||
});
|
||||
|
||||
const procOpts: AcpProcessManagerOptions = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { BackendId } from "@/agentMode/session/types";
|
||||
import type { BackendId, ModelSelection } from "@/agentMode/session/types";
|
||||
|
||||
/**
|
||||
* Spawn descriptor for an ACP-speaking agent backend. Backends produce these
|
||||
|
|
@ -11,6 +11,18 @@ export interface AcpSpawnDescriptor {
|
|||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to `AcpBackend.buildSpawnDescriptor`. `seedSelection`
|
||||
* carries the in-memory last-used (model, effort) for this backend so
|
||||
* suffix-style backends (e.g. opencode) can boot their first turn on
|
||||
* the right model before `unstable_setSessionModel` lands. `null` when
|
||||
* no session on this backend has chosen a model yet.
|
||||
*/
|
||||
export interface AcpSpawnContext {
|
||||
vaultBasePath: string;
|
||||
seedSelection?: ModelSelection | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One ACP-track agent backend. Implementers (OpencodeBackend, CodexBackend,
|
||||
* etc.) own the spawn-time contract. The rest of Agent Mode —
|
||||
|
|
@ -23,5 +35,5 @@ export interface AcpBackend {
|
|||
/** Human-readable name surfaced in the UI. */
|
||||
readonly displayName: string;
|
||||
/** Build the spawn descriptor (BYOK keys decrypted, env composed). */
|
||||
buildSpawnDescriptor(ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor>;
|
||||
buildSpawnDescriptor(ctx: AcpSpawnContext): Promise<AcpSpawnDescriptor>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ export const ClaudeBackendDescriptor: BackendDescriptor = {
|
|||
getEnableThinking: () => Boolean(getSettings().agentMode?.backends?.claude?.enableThinking),
|
||||
getEnvOverrides: () => getSettings().agentMode?.backends?.claude?.envOverrides,
|
||||
isPlanModePlanFilePath: isClaudePlanModePlanFilePath,
|
||||
getDefaultModelId: () => getSettings().agentMode?.backends?.claude?.defaultModel?.baseModelId,
|
||||
getDefaultModelId: () => args.getSeedSelection?.()?.baseModelId,
|
||||
// Spawn-time skill-creation directive: read the current
|
||||
// `agentMode.skills.folder` so the directive templates the live value
|
||||
// on every new session. See the Skills Management spec.
|
||||
|
|
@ -232,17 +232,20 @@ export const ClaudeBackendDescriptor: BackendDescriptor = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Replay the persisted effort on a freshly created session. The Claude
|
||||
* SDK adapter probes the model catalog asynchronously, so the effort
|
||||
* Replay the seed effort on a freshly created session. The Claude SDK
|
||||
* adapter probes the model catalog asynchronously, so the effort
|
||||
* `SessionConfigOption` may not be present yet when this runs;
|
||||
* `replayPersistedEffort` subscribes to the session and applies once the
|
||||
* option arrives (with a timeout guard to avoid leaking listeners on
|
||||
* agents that never report effort). Mode is never persisted — the
|
||||
* `replayPersistedEffort` subscribes to the session and applies once
|
||||
* the option arrives (with a timeout guard to avoid leaking listeners
|
||||
* on agents that never report effort). Mode is never persisted — the
|
||||
* Claude SDK's natural starting mode is already canonical `default`.
|
||||
*
|
||||
* The seed effort is read off `AgentSession.getDefaultEffort()`, populated
|
||||
* by the manager from its in-memory `lastSelectionByBackend` cache.
|
||||
*/
|
||||
async applyInitialSessionConfig(session: AgentSession, settings: CopilotSettings): Promise<void> {
|
||||
const persistedEffort = settings.agentMode?.backends?.claude?.defaultModel?.effort ?? null;
|
||||
await replayPersistedEffort(session, persistedEffort ?? undefined);
|
||||
async applyInitialSessionConfig(session: AgentSession): Promise<void> {
|
||||
const seedEffort = session.getDefaultEffort();
|
||||
await replayPersistedEffort(session, seedEffort ?? undefined);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { getSettings } from "@/settings/model";
|
||||
import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
|
||||
import { AcpBackend, AcpSpawnContext, AcpSpawnDescriptor } from "@/agentMode/acp/types";
|
||||
import { buildSimpleSpawnDescriptor } from "@/agentMode/backends/shared/simpleBinaryBackend";
|
||||
import {
|
||||
buildPillSyntaxDirective,
|
||||
|
|
@ -21,7 +21,7 @@ export class CodexBackend implements AcpBackend {
|
|||
readonly id = "codex" as const;
|
||||
readonly displayName = "Codex";
|
||||
|
||||
async buildSpawnDescriptor(_ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor> {
|
||||
async buildSpawnDescriptor(_ctx: AcpSpawnContext): Promise<AcpSpawnDescriptor> {
|
||||
const descriptor = buildSimpleSpawnDescriptor(
|
||||
getSettings().agentMode?.backends?.codex?.binaryPath,
|
||||
"Codex binary path not configured. Open Agent Mode settings and set the path to codex-acp.",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,47 @@ jest.mock("@/logger", () => ({
|
|||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
// In-memory BYOK fixture the mocked `@/modelManagement` reads from. Tests
|
||||
// mutate these directly to seed providers + registry entries.
|
||||
type ProviderConfigStub = {
|
||||
id: string;
|
||||
kind: "builtin" | "custom";
|
||||
displayName: string;
|
||||
type: string;
|
||||
baseUrl?: string;
|
||||
apiKeyRef?: { kind: "inline"; value: string } | null;
|
||||
addedAt: number;
|
||||
};
|
||||
type RegistryEntryStub = {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
addedAt: number;
|
||||
};
|
||||
let mockProviders: ProviderConfigStub[] = [];
|
||||
let mockRegistry: RegistryEntryStub[] = [];
|
||||
|
||||
jest.mock("@/modelManagement", () => ({
|
||||
ProviderRegistry: {
|
||||
getInstance: () => ({
|
||||
list: () => mockProviders.slice(),
|
||||
}),
|
||||
},
|
||||
ModelRegistry: {
|
||||
getInstance: () => ({
|
||||
list: (filter?: { providerId?: string }) =>
|
||||
filter?.providerId
|
||||
? mockRegistry.filter((e) => e.providerId === filter.providerId)
|
||||
: mockRegistry.slice(),
|
||||
}),
|
||||
},
|
||||
getProviderApiKeySync: (id: string) => {
|
||||
const p = mockProviders.find((x) => x.id === id);
|
||||
if (!p?.apiKeyRef) return null;
|
||||
return p.apiKeyRef.kind === "inline" ? p.apiKeyRef.value : null;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the skills package so we can drive the deny-list synthesis path
|
||||
// without booting the real jotai store / Obsidian App singleton.
|
||||
let mockSkills: Skill[] = [];
|
||||
|
|
@ -48,213 +89,148 @@ function seedSkills(skills: Skill[]): void {
|
|||
mockSkillManagerReady = skills.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Most tests below disable the built-in `activeModels` so injection is a
|
||||
* blank slate. The few that exercise injection set their own active models
|
||||
* explicitly.
|
||||
*/
|
||||
function clearActiveModels() {
|
||||
setSettings({ activeModels: [] });
|
||||
function seedByokProvider(p: ProviderConfigStub): void {
|
||||
mockProviders.push(p);
|
||||
}
|
||||
|
||||
function seedByokModel(entry: RegistryEntryStub): void {
|
||||
mockRegistry.push(entry);
|
||||
}
|
||||
|
||||
describe("buildOpencodeConfig", () => {
|
||||
beforeEach(() => {
|
||||
resetSettings();
|
||||
clearActiveModels();
|
||||
mockProviders = [];
|
||||
mockRegistry = [];
|
||||
seedSkills([]);
|
||||
});
|
||||
|
||||
it("emits provider entries only for non-empty keys", async () => {
|
||||
updateSetting("anthropicApiKey", "anth-123");
|
||||
updateSetting("openAIApiKey", "");
|
||||
updateSetting("googleApiKey", "g-456");
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(Object.keys(cfg.provider).sort()).toEqual(["anthropic", "google"]);
|
||||
expect(cfg.provider.anthropic).toEqual({ options: { apiKey: "anth-123" } });
|
||||
expect(cfg.provider.google).toEqual({ options: { apiKey: "g-456" } });
|
||||
});
|
||||
|
||||
it("returns empty provider map when no keys are set", async () => {
|
||||
it("returns an empty provider map when BYOK is empty and no Plus license is set", async () => {
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(cfg.provider).toEqual({});
|
||||
});
|
||||
|
||||
it("injects enabled active models under their provider's `models` map", async () => {
|
||||
updateSetting("anthropicApiKey", "anth-123");
|
||||
it("ignores any leftover legacy *ApiKey-shaped fields — BYOK is the only source of provider keys", async () => {
|
||||
// Reason: the legacy `anthropicApiKey` / `openAIApiKey` fields were
|
||||
// deleted by the M9 migration. If a downgraded device writes them back
|
||||
// through a synced data.json they must NOT influence the OpenCode config.
|
||||
setSettings({
|
||||
anthropicApiKey: "anth-legacy",
|
||||
openAIApiKey: "oai-legacy",
|
||||
} as unknown as Parameters<typeof setSettings>[0]);
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(cfg.provider).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores legacy activeModels — BYOK ModelRegistry is the only source of models", async () => {
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "claude-sonnet-4-6",
|
||||
name: "claude-sonnet-legacy",
|
||||
provider: ChatModelProviders.ANTHROPIC,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "claude-haiku",
|
||||
provider: ChatModelProviders.ANTHROPIC,
|
||||
enabled: false, // disabled — should NOT inject
|
||||
},
|
||||
],
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(cfg.provider).toEqual({});
|
||||
});
|
||||
|
||||
it("registers a BYOK built-in provider with its API key and registry models", async () => {
|
||||
seedByokProvider({
|
||||
id: "anthropic",
|
||||
kind: "builtin",
|
||||
displayName: "Anthropic",
|
||||
type: "anthropic",
|
||||
apiKeyRef: { kind: "inline", value: "sk-ant-xxx" },
|
||||
addedAt: 1,
|
||||
});
|
||||
seedByokModel({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
addedAt: 2,
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
provider: Record<string, { options?: unknown; models?: Record<string, unknown> }>;
|
||||
};
|
||||
expect(cfg.provider.anthropic.models).toEqual({ "claude-sonnet-4-6": {} });
|
||||
});
|
||||
|
||||
it("does not inject a model when neither top-level nor per-model key is available", async () => {
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "gpt-5",
|
||||
provider: ChatModelProviders.OPENAI,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
expect(cfg.provider.anthropic).toEqual({
|
||||
options: { apiKey: "sk-ant-xxx" },
|
||||
models: { "claude-sonnet-4-5": {} },
|
||||
});
|
||||
// Neither openAIApiKey nor model.apiKey configured
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(cfg.provider).toEqual({});
|
||||
});
|
||||
|
||||
it("falls back to per-model apiKey when top-level provider key is missing", async () => {
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "gpt-5",
|
||||
provider: ChatModelProviders.OPENAI,
|
||||
enabled: true,
|
||||
apiKey: "per-model-key",
|
||||
},
|
||||
],
|
||||
it("registers a BYOK custom provider with baseURL + key and its registry models", async () => {
|
||||
seedByokProvider({
|
||||
id: "custom:abc",
|
||||
kind: "custom",
|
||||
displayName: "Local Ollama",
|
||||
type: "openai-compatible",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
apiKeyRef: { kind: "inline", value: "ollama-key" },
|
||||
addedAt: 1,
|
||||
});
|
||||
// No openAIApiKey configured globally, but the model carries its own key.
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
provider: Record<string, { options?: { apiKey?: string }; models?: Record<string, unknown> }>;
|
||||
};
|
||||
expect(cfg.provider.openai.options).toEqual({ apiKey: "per-model-key" });
|
||||
expect(cfg.provider.openai.models).toEqual({ "gpt-5": {} });
|
||||
});
|
||||
|
||||
it("prefers the top-level provider key when both are present", async () => {
|
||||
updateSetting("openAIApiKey", "global-key");
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "gpt-5",
|
||||
provider: ChatModelProviders.OPENAI,
|
||||
enabled: true,
|
||||
apiKey: "per-model-key",
|
||||
},
|
||||
],
|
||||
seedByokModel({
|
||||
providerId: "custom:abc",
|
||||
modelId: "llama3:8b",
|
||||
displayName: "Llama 3 8B",
|
||||
addedAt: 2,
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
provider: Record<string, { options?: { apiKey?: string } }>;
|
||||
provider: Record<
|
||||
string,
|
||||
{
|
||||
npm?: string;
|
||||
name?: string;
|
||||
options?: { apiKey?: string; baseURL?: string };
|
||||
models?: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
// Top-level wins because the provider entry is built before the
|
||||
// per-model fallback runs — keeps the historical behaviour.
|
||||
expect(cfg.provider.openai.options).toEqual({ apiKey: "global-key" });
|
||||
expect(cfg.provider["custom:abc"]).toEqual({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Local Ollama",
|
||||
options: { baseURL: "http://localhost:11434/v1", apiKey: "ollama-key" },
|
||||
models: { "llama3:8b": {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inject models for providers OpenCode cannot route", async () => {
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "claude-via-bedrock",
|
||||
provider: ChatModelProviders.AMAZON_BEDROCK,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "llama",
|
||||
provider: ChatModelProviders.OLLAMA,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
it("skips a BYOK built-in provider that has no API key", async () => {
|
||||
seedByokProvider({
|
||||
id: "groq",
|
||||
kind: "builtin",
|
||||
displayName: "Groq",
|
||||
type: "openai-compatible",
|
||||
apiKeyRef: null,
|
||||
addedAt: 1,
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(cfg.provider).toEqual({});
|
||||
});
|
||||
|
||||
it("skips embedding models", async () => {
|
||||
updateSetting("openAIApiKey", "key");
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "text-embedding-3-large",
|
||||
provider: ChatModelProviders.OPENAI,
|
||||
enabled: true,
|
||||
isEmbeddingModel: true,
|
||||
},
|
||||
{
|
||||
name: "gpt-test",
|
||||
provider: ChatModelProviders.OPENAI,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
provider: Record<string, { models?: Record<string, unknown> }>;
|
||||
};
|
||||
// gpt-test is injected; the embedding model is not, even though both have
|
||||
// the same provider and enabled flag.
|
||||
expect(cfg.provider.openai.models).toHaveProperty("gpt-test");
|
||||
expect(cfg.provider.openai.models).not.toHaveProperty("text-embedding-3-large");
|
||||
});
|
||||
|
||||
it("sets top-level model from the persisted defaultModel.baseModelId", async () => {
|
||||
updateSetting("anthropicApiKey", "anth-123");
|
||||
setSettings({
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: {
|
||||
opencode: {
|
||||
binaryPath: "/x",
|
||||
defaultModel: { baseModelId: "anthropic/claude-sonnet-4-6", effort: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { model?: string };
|
||||
it("sets top-level model from the seed selection baseModelId", async () => {
|
||||
const cfg = (await buildOpencodeConfig({
|
||||
baseModelId: "anthropic/claude-sonnet-4-6",
|
||||
effort: null,
|
||||
})) as { model?: string };
|
||||
expect(cfg.model).toBe("anthropic/claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("appends effort suffix when defaultModel.effort is set", async () => {
|
||||
updateSetting("anthropicApiKey", "anth-123");
|
||||
setSettings({
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: {
|
||||
opencode: {
|
||||
binaryPath: "/x",
|
||||
defaultModel: { baseModelId: "anthropic/claude-sonnet-4-6", effort: "high" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { model?: string };
|
||||
it("appends effort suffix when the seed selection includes effort", async () => {
|
||||
const cfg = (await buildOpencodeConfig({
|
||||
baseModelId: "anthropic/claude-sonnet-4-6",
|
||||
effort: "high",
|
||||
})) as { model?: string };
|
||||
expect(cfg.model).toBe("anthropic/claude-sonnet-4-6/high");
|
||||
});
|
||||
|
||||
it("registers a custom copilot-plus provider when plusLicenseKey is set", async () => {
|
||||
it("leaves config.model unset when no seed selection is provided", async () => {
|
||||
const cfg = (await buildOpencodeConfig()) as { model?: string };
|
||||
expect(cfg.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers a custom copilot-plus provider with PLUS_MODELS when plusLicenseKey is set", async () => {
|
||||
updateSetting("plusLicenseKey", "plus-token-123");
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "copilot-plus-flash",
|
||||
provider: ChatModelProviders.COPILOT_PLUS,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
provider: Record<
|
||||
string,
|
||||
|
|
@ -271,49 +247,20 @@ describe("buildOpencodeConfig", () => {
|
|||
expect(cp.name).toBe("Copilot Plus");
|
||||
expect(cp.options?.baseURL).toBe("https://models.brevilabs.com/v1");
|
||||
expect(cp.options?.apiKey).toBe("plus-token-123");
|
||||
expect(cp.models).toEqual({ "copilot-plus-flash": {} });
|
||||
expect(cp.models).toHaveProperty("copilot-plus-flash");
|
||||
});
|
||||
|
||||
it("does not register copilot-plus provider when plusLicenseKey is empty", async () => {
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "copilot-plus-flash",
|
||||
provider: ChatModelProviders.COPILOT_PLUS,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(cfg.provider["copilot-plus"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses a Copilot-Plus-shaped defaultModel.baseModelId verbatim", async () => {
|
||||
it("uses a Copilot-Plus-shaped seed baseModelId verbatim", async () => {
|
||||
updateSetting("plusLicenseKey", "plus-token-123");
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "copilot-plus-flash",
|
||||
provider: ChatModelProviders.COPILOT_PLUS,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: {
|
||||
opencode: {
|
||||
binaryPath: "/x",
|
||||
defaultModel: { baseModelId: "copilot-plus/copilot-plus-flash", effort: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { model?: string };
|
||||
const cfg = (await buildOpencodeConfig({
|
||||
baseModelId: "copilot-plus/copilot-plus-flash",
|
||||
effort: null,
|
||||
})) as { model?: string };
|
||||
expect(cfg.model).toBe("copilot-plus/copilot-plus-flash");
|
||||
});
|
||||
|
||||
|
|
@ -335,9 +282,6 @@ describe("buildOpencodeConfig", () => {
|
|||
'metadata.copilot-enabled-agents: "opencode"'
|
||||
);
|
||||
expect(cfg.agent.build.prompt).toContain('metadata.copilot-enabled-agents: "opencode"');
|
||||
// Regression guard: the copilot-build permission block must survive
|
||||
// alongside the new prompt field — opencode's field-wise merge depends
|
||||
// on us not stomping native fields.
|
||||
expect(cfg.agent["copilot-build"].permission).toEqual({ bash: "ask", edit: "ask" });
|
||||
expect(cfg.agent["copilot-build"].mode).toBe("primary");
|
||||
});
|
||||
|
|
@ -402,13 +346,10 @@ describe("buildOpencodeConfig", () => {
|
|||
const cfg = (await buildOpencodeConfig()) as {
|
||||
permission?: { skill?: Record<string, string> };
|
||||
};
|
||||
// a is claude-only → denied. e is codex-only → denied (codex also
|
||||
// populates the cross-discovered `.agents/skills/` path). b/c/d not denied.
|
||||
expect(cfg.permission?.skill).toEqual({ a: "deny", e: "deny" });
|
||||
});
|
||||
|
||||
it("skips deny synthesis when SkillManager has not initialised yet", async () => {
|
||||
// Place a skill in the snapshot, but mark the singleton as not ready.
|
||||
mockSkills = [makeSkill("foo", ["claude"])];
|
||||
mockSkillManagerReady = false;
|
||||
const cfg = (await buildOpencodeConfig()) as { permission?: unknown };
|
||||
|
|
@ -416,9 +357,7 @@ describe("buildOpencodeConfig", () => {
|
|||
});
|
||||
|
||||
it("omits cfg.model when no defaultModel is set", async () => {
|
||||
updateSetting("anthropicApiKey", "anth-123");
|
||||
setSettings({
|
||||
activeModels: [],
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
|
|
@ -439,7 +378,8 @@ describe("buildOpencodeConfig", () => {
|
|||
describe("OpencodeBackend.buildSpawnDescriptor", () => {
|
||||
beforeEach(() => {
|
||||
resetSettings();
|
||||
clearActiveModels();
|
||||
mockProviders = [];
|
||||
mockRegistry = [];
|
||||
});
|
||||
|
||||
it("throws if no binary is installed", async () => {
|
||||
|
|
@ -465,14 +405,21 @@ describe("OpencodeBackend.buildSpawnDescriptor", () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
updateSetting("anthropicApiKey", "anth-xyz");
|
||||
seedByokProvider({
|
||||
id: "anthropic",
|
||||
kind: "builtin",
|
||||
displayName: "Anthropic",
|
||||
type: "anthropic",
|
||||
apiKeyRef: { kind: "inline", value: "sk-ant-xyz" },
|
||||
addedAt: 1,
|
||||
});
|
||||
const backend = new OpencodeBackend();
|
||||
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault/abs" });
|
||||
expect(desc.command).toBe("/path/to/opencode");
|
||||
expect(desc.args).toEqual(["acp", "--cwd", "/vault/abs"]);
|
||||
expect(desc.env.OPENCODE_CONFIG_CONTENT).toBeDefined();
|
||||
const cfg = JSON.parse(desc.env.OPENCODE_CONFIG_CONTENT as string);
|
||||
expect(cfg.provider.anthropic.options).toEqual({ apiKey: "anth-xyz" });
|
||||
expect(cfg.provider.anthropic.options).toEqual({ apiKey: "sk-ant-xyz" });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -493,8 +440,6 @@ describe("COPILOT_PROMPT_BASE", () => {
|
|||
});
|
||||
|
||||
it("does not carry chat-mode-only baggage that misfires in tool-driven agents", () => {
|
||||
// @vault and getCurrentTime/getTimeRangeMs are chat-mode injections that
|
||||
// do not exist in opencode. YouTube auto-transcription is also chat-only.
|
||||
expect(COPILOT_PROMPT_BASE).not.toMatch(/@vault/);
|
||||
expect(COPILOT_PROMPT_BASE).not.toMatch(/getCurrentTime/);
|
||||
expect(COPILOT_PROMPT_BASE).not.toMatch(/getTimeRangeMs/);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { BREVILABS_MODELS_BASE_URL, ChatModelProviders } from "@/constants";
|
||||
import { getDecryptedKey } from "@/encryptionService";
|
||||
import { logInfo, logWarn } from "@/logger";
|
||||
import { logInfo } from "@/logger";
|
||||
import { ModelRegistry, ProviderRegistry } from "@/modelManagement";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
|
||||
import type { CopilotMode } from "@/agentMode/session/types";
|
||||
import { AcpBackend, AcpSpawnContext, AcpSpawnDescriptor } from "@/agentMode/acp/types";
|
||||
import type { CopilotMode, ModelSelection } from "@/agentMode/session/types";
|
||||
import {
|
||||
buildPillSyntaxDirective,
|
||||
buildSkillCreationDirective,
|
||||
|
|
@ -12,9 +13,13 @@ import {
|
|||
getManagedSkills,
|
||||
SkillManager,
|
||||
} from "@/agentMode/skills";
|
||||
import { buildByokOpencodeProviderConfig } from "./byokBridge";
|
||||
import { OpencodeBackendDescriptor } from "./descriptor";
|
||||
import { PLUS_MODELS } from "./plusModels";
|
||||
import { selectCopilotPrompt } from "./prompts";
|
||||
|
||||
const BYOK_DIAG = true;
|
||||
|
||||
/**
|
||||
* Map from Copilot's `ChatModelProviders` enum value (as stored in
|
||||
* `CustomModel.provider`) to OpenCode's provider id (as it appears in
|
||||
|
|
@ -75,7 +80,7 @@ export class OpencodeBackend implements AcpBackend {
|
|||
readonly id = "opencode" as const;
|
||||
readonly displayName = "opencode";
|
||||
|
||||
async buildSpawnDescriptor(ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor> {
|
||||
async buildSpawnDescriptor(ctx: AcpSpawnContext): Promise<AcpSpawnDescriptor> {
|
||||
const binaryPath = getSettings().agentMode?.backends?.opencode?.binaryPath;
|
||||
if (!binaryPath) {
|
||||
throw new Error(
|
||||
|
|
@ -83,7 +88,7 @@ export class OpencodeBackend implements AcpBackend {
|
|||
);
|
||||
}
|
||||
|
||||
const config = await buildOpencodeConfig();
|
||||
const config = await buildOpencodeConfig(ctx.seedSelection ?? null);
|
||||
const envOverrides = getSettings().agentMode?.backends?.opencode?.envOverrides ?? {};
|
||||
|
||||
return {
|
||||
|
|
@ -103,111 +108,80 @@ export class OpencodeBackend implements AcpBackend {
|
|||
/**
|
||||
* Build the `OPENCODE_CONFIG_CONTENT` payload from current Copilot settings.
|
||||
*
|
||||
* - Per-provider `options.apiKey` is set for any BYOK key configured in
|
||||
* Copilot, decrypted in-process.
|
||||
* - Each enabled `activeModel` whose provider is in `OPENCODE_PROVIDER_MAP`
|
||||
* is registered under `provider.<id>.models.<modelName>` so OpenCode
|
||||
* reports it in `NewSessionResponse.models.availableModels`. Built-in
|
||||
* providers (anthropic, openai, …) carry their own models.dev snapshot
|
||||
* so this is largely additive there; for the custom Copilot Plus
|
||||
* provider — and for OpenRouter models the snapshot doesn't cover —
|
||||
* the registration is what makes the model visible at all. The
|
||||
* Agents-tab catalog modal then curates from opencode's reported
|
||||
* `availableModels`.
|
||||
* - BYOK providers (`settings.providers`) and their registry models
|
||||
* (`settings.registry`) are the sole source of `provider.<id>` entries
|
||||
* — assembled by `buildByokOpencodeProviderConfig` from `byokBridge.ts`.
|
||||
* - Copilot Plus is registered separately as the system-managed
|
||||
* `copilot-plus` pseudo-provider: a custom `@ai-sdk/openai-compatible`
|
||||
* entry pointing at brevilabs and authed via `plusLicenseKey`. It never
|
||||
* lives in BYOK. The hard-coded `PLUS_MODELS` list is registered under
|
||||
* its `models` map so opencode lists them in `availableModels`.
|
||||
* - The top-level `model` field carries the user's sticky preference so
|
||||
* a fresh session boots with the right default, even before
|
||||
* `unstable_setSessionModel` is called.
|
||||
*
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export async function buildOpencodeConfig(): Promise<Record<string, unknown>> {
|
||||
export async function buildOpencodeConfig(
|
||||
seedSelection: ModelSelection | null = null
|
||||
): Promise<Record<string, unknown>> {
|
||||
const s = getSettings();
|
||||
|
||||
type Mapping = { providerId: string; settingsKey: keyof typeof s };
|
||||
const mappings: Mapping[] = [
|
||||
{ providerId: "anthropic", settingsKey: "anthropicApiKey" },
|
||||
{ providerId: "openai", settingsKey: "openAIApiKey" },
|
||||
{ providerId: "google", settingsKey: "googleApiKey" },
|
||||
{ providerId: "groq", settingsKey: "groqApiKey" },
|
||||
{ providerId: "mistral", settingsKey: "mistralApiKey" },
|
||||
{ providerId: "deepseek", settingsKey: "deepseekApiKey" },
|
||||
{ providerId: "openrouter", settingsKey: "openRouterAiApiKey" },
|
||||
{ providerId: "xai", settingsKey: "xaiApiKey" },
|
||||
];
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
mappings.map(async (m) => {
|
||||
const raw = s[m.settingsKey];
|
||||
if (typeof raw !== "string" || !raw) return null;
|
||||
const apiKey = await getDecryptedKey(raw);
|
||||
if (!apiKey) return null;
|
||||
return { providerId: m.providerId, apiKey };
|
||||
})
|
||||
);
|
||||
|
||||
type ProviderConfig = {
|
||||
npm?: string;
|
||||
name?: string;
|
||||
options?: { apiKey?: string; baseURL?: string; headers?: Record<string, string> };
|
||||
models?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const provider: Record<string, ProviderConfig> = {};
|
||||
for (const entry of decrypted) {
|
||||
if (entry) provider[entry.providerId] = { options: { apiKey: entry.apiKey } };
|
||||
}
|
||||
const provider: Record<string, ProviderConfig> = {
|
||||
...buildByokOpencodeProviderConfig(ProviderRegistry.getInstance(), ModelRegistry.getInstance()),
|
||||
};
|
||||
|
||||
// Copilot Plus speaks OpenAI's wire format but isn't a built-in OpenCode
|
||||
// provider. Register it as a custom `@ai-sdk/openai-compatible` entry
|
||||
// pointing at brevilabs and authed via the user's `plusLicenseKey`.
|
||||
// provider, and it never lives in BYOK (system-managed pseudo-provider per
|
||||
// §2.1 of the redesign spec). Register it here when `plusLicenseKey` is
|
||||
// configured, including the hard-coded `PLUS_MODELS` list so opencode
|
||||
// surfaces them in `availableModels` without depending on legacy
|
||||
// `activeModels`.
|
||||
if (typeof s.plusLicenseKey === "string" && s.plusLicenseKey) {
|
||||
const licenseKey = await getDecryptedKey(s.plusLicenseKey);
|
||||
if (licenseKey) {
|
||||
provider[COPILOT_PLUS_PROVIDER_ID] = {
|
||||
const plusEntry: ProviderConfig = {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Copilot Plus",
|
||||
options: { baseURL: BREVILABS_MODELS_BASE_URL, apiKey: licenseKey },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Register Copilot-configured models under their respective providers so
|
||||
// OpenCode treats them as known when reporting `availableModels`. When the
|
||||
// top-level provider key is absent, fall back to the per-model `apiKey` so
|
||||
// models the user configured with a model-specific key still reach the
|
||||
// agent. Without this fallback any such model would be silently dropped.
|
||||
const injected: string[] = [];
|
||||
for (const model of s.activeModels ?? []) {
|
||||
if (!model.enabled) continue;
|
||||
if (model.isEmbeddingModel) continue;
|
||||
const providerId = OPENCODE_PROVIDER_MAP[model.provider as ChatModelProviders];
|
||||
if (!providerId) continue;
|
||||
|
||||
if (!provider[providerId]) {
|
||||
const perModel = model.apiKey ? await getDecryptedKey(model.apiKey) : null;
|
||||
if (!perModel) {
|
||||
logWarn(
|
||||
`[AgentMode] skipping ${model.provider}/${model.name}: no API key (set the provider key in Copilot settings or on the model itself)`
|
||||
);
|
||||
continue;
|
||||
if (PLUS_MODELS.length > 0) {
|
||||
plusEntry.models = {};
|
||||
for (const m of PLUS_MODELS) plusEntry.models[m.id] = {};
|
||||
}
|
||||
provider[providerId] = { options: { apiKey: perModel } };
|
||||
provider[COPILOT_PLUS_PROVIDER_ID] = plusEntry;
|
||||
}
|
||||
|
||||
if (!provider[providerId].models) provider[providerId].models = {};
|
||||
provider[providerId].models[model.name] = {};
|
||||
injected.push(`${providerId}/${model.name}`);
|
||||
}
|
||||
|
||||
if (injected.length > 0) {
|
||||
if (Object.keys(provider).length === 0) {
|
||||
logInfo(
|
||||
`[AgentMode] injected ${injected.length} model(s) into opencode config: ${injected.join(", ")}`
|
||||
);
|
||||
} else if (Object.keys(provider).length === 0) {
|
||||
logInfo(
|
||||
"[AgentMode] no BYOK keys found; opencode will rely on its own auth. Set provider keys in Copilot settings to use Agent Mode end-to-end."
|
||||
"[AgentMode] no providers configured for opencode (BYOK empty, no Copilot Plus license). Add a provider in the BYOK tab to use Agent Mode end-to-end."
|
||||
);
|
||||
}
|
||||
|
||||
if (BYOK_DIAG) {
|
||||
logInfo("[BYOK-DIAG] buildOpencodeConfig merged provider slice", {
|
||||
providerIds: Object.keys(provider),
|
||||
perProvider: Object.fromEntries(
|
||||
Object.entries(provider).map(([id, cfg]) => [
|
||||
id,
|
||||
{
|
||||
npm: cfg.npm,
|
||||
hasApiKey: !!cfg.options?.apiKey,
|
||||
baseURL: cfg.options?.baseURL,
|
||||
modelIds: Object.keys(cfg.models ?? {}),
|
||||
},
|
||||
])
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const config: Record<string, unknown> = { provider };
|
||||
|
||||
// Inject a managed `copilot-build` agent so the mode picker can offer the
|
||||
|
|
@ -222,9 +196,7 @@ export async function buildOpencodeConfig(): Promise<Record<string, unknown>> {
|
|||
// CLI-coding-agent prompt — wrong domain for an Obsidian vault assistant.
|
||||
// opencode's `cfg.agent.<id>` merge is field-wise, so adding `prompt` to
|
||||
// the built-in `build` agent leaves its native permissions intact.
|
||||
const basePrompt = selectCopilotPrompt(
|
||||
s.agentMode?.backends?.opencode?.defaultModel?.baseModelId
|
||||
);
|
||||
const basePrompt = selectCopilotPrompt(seedSelection?.baseModelId);
|
||||
// Append the spawn-time skill-creation directive so agent-authored skills
|
||||
// land in the canonical managed folder instead of `.opencode/skills/`.
|
||||
// Folder is read live from settings on each spawn — see the Skills
|
||||
|
|
@ -246,15 +218,16 @@ export async function buildOpencodeConfig(): Promise<Record<string, unknown>> {
|
|||
},
|
||||
};
|
||||
|
||||
// Apply sticky model preference at spawn so the very first turn (before
|
||||
// `unstable_setSessionModel` lands) uses the user's pick. The persisted
|
||||
// shape is `{ baseModelId, effort }` where `baseModelId` is opencode's
|
||||
// `<provider>/<model>` form — append the effort suffix when present.
|
||||
const defaultModel = s.agentMode?.backends?.opencode?.defaultModel;
|
||||
if (defaultModel?.baseModelId) {
|
||||
config.model = defaultModel.effort
|
||||
? `${defaultModel.baseModelId}/${defaultModel.effort}`
|
||||
: defaultModel.baseModelId;
|
||||
// Apply the seed (model, effort) at spawn so the very first turn (before
|
||||
// `unstable_setSessionModel` lands) uses the user's pick. The seed comes
|
||||
// from `AgentSessionManager.getLastSelection(backendId)` via the spawn
|
||||
// ctx; `null` here means the user hasn't picked a model on this backend
|
||||
// yet, so we leave `config.model` unset and let opencode fall back to
|
||||
// its own catalog default.
|
||||
if (seedSelection?.baseModelId) {
|
||||
config.model = seedSelection.effort
|
||||
? `${seedSelection.baseModelId}/${seedSelection.effort}`
|
||||
: seedSelection.baseModelId;
|
||||
}
|
||||
|
||||
// Always spawn in canonical `default` (ask-before-write `copilot-build`).
|
||||
|
|
|
|||
273
src/agentMode/backends/opencode/bundledModels.test.ts
Normal file
273
src/agentMode/backends/opencode/bundledModels.test.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
/**
|
||||
* `bundledModels` tests.
|
||||
*
|
||||
* Verifies that `listBundledModels` correctly:
|
||||
* - Returns `null` when the session manager / cached state is missing
|
||||
* (so the panel can render the "OpenCode not installed" empty-state).
|
||||
* - Surfaces OpenCode-native model rows (non-BYOK provider prefixes).
|
||||
* - Filters out rows whose provider prefix maps onto a BYOK / Copilot Plus
|
||||
* provider (those belong to the other sections).
|
||||
*/
|
||||
import type { BackendState } from "@/agentMode/session/types";
|
||||
import {
|
||||
classifyOpencodeModels,
|
||||
filterBundledEntries,
|
||||
listBundledModels,
|
||||
type ModelRegistryLike,
|
||||
type ProviderRegistryLike,
|
||||
} from "./bundledModels";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
/** Fake provider registry. Any id passed in via `byokIds` is treated as a
|
||||
* registered BYOK provider; everything else returns `undefined`. */
|
||||
function makeRegistry(byokIds: string[] = []): ProviderRegistryLike {
|
||||
const set = new Set(byokIds);
|
||||
return { get: (id: string) => (set.has(id) ? { id } : undefined) };
|
||||
}
|
||||
|
||||
/** Fake model registry. `picks` is a list of `<providerId>|<modelId>` keys
|
||||
* the user has picked; lookups outside that set return `undefined`. */
|
||||
function makeModelRegistry(picks: string[] = []): ModelRegistryLike {
|
||||
const set = new Set(picks);
|
||||
return {
|
||||
get: (providerId: string, modelId: string) =>
|
||||
set.has(`${providerId}|${modelId}`) ? { providerId, modelId } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const allModelsRegistered: ModelRegistryLike = {
|
||||
get: (providerId: string, modelId: string) => ({ providerId, modelId }),
|
||||
};
|
||||
|
||||
const noModelsRegistered: ModelRegistryLike = {
|
||||
get: () => undefined,
|
||||
};
|
||||
|
||||
/** Build a minimal `BackendState` carrying the given availableModels. */
|
||||
function makeState(
|
||||
availableModels: Array<{ baseModelId: string; name: string; provider?: string | null }>
|
||||
): BackendState {
|
||||
return {
|
||||
model: {
|
||||
current: { baseModelId: availableModels[0]?.baseModelId ?? "", effort: null },
|
||||
availableModels: availableModels.map((m) => ({
|
||||
baseModelId: m.baseModelId,
|
||||
name: m.name,
|
||||
provider: m.provider ?? null,
|
||||
effortOptions: [],
|
||||
})),
|
||||
},
|
||||
mode: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("classifyOpencodeModels", () => {
|
||||
const entries = [
|
||||
{ baseModelId: "bigpickle/big-pickle", name: "Big Pickle", provider: null, effortOptions: [] },
|
||||
{
|
||||
baseModelId: "anthropic/claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
effortOptions: [],
|
||||
},
|
||||
{ baseModelId: "openai/gpt-5", name: "GPT-5", provider: "openai", effortOptions: [] },
|
||||
{
|
||||
baseModelId: "copilot-plus/copilot-plus-flash",
|
||||
name: "Copilot Plus Flash",
|
||||
provider: "copilot-plus",
|
||||
effortOptions: [],
|
||||
},
|
||||
{
|
||||
baseModelId: "custom:abc-uuid/llama-3.3",
|
||||
name: "Llama 3.3",
|
||||
provider: null,
|
||||
effortOptions: [],
|
||||
},
|
||||
];
|
||||
|
||||
it("routes models whose (providerId, modelId) is in ModelRegistry into the byok bucket", () => {
|
||||
const result = classifyOpencodeModels(
|
||||
entries,
|
||||
makeRegistry(["anthropic", "openai", "custom:abc-uuid"]),
|
||||
allModelsRegistered
|
||||
);
|
||||
expect(result.byok.map((m) => m.baseModelId)).toEqual([
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"openai/gpt-5",
|
||||
"custom:abc-uuid/llama-3.3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes copilot-plus models into the plus bucket regardless of registry", () => {
|
||||
const result = classifyOpencodeModels(entries, makeRegistry([]), noModelsRegistered);
|
||||
expect(result.plus.map((m) => m.baseModelId)).toEqual(["copilot-plus/copilot-plus-flash"]);
|
||||
});
|
||||
|
||||
it("treats unregistered leading segments as bundled", () => {
|
||||
const result = classifyOpencodeModels(
|
||||
entries,
|
||||
makeRegistry(["anthropic"]),
|
||||
allModelsRegistered
|
||||
);
|
||||
// openai not registered → falls into bundled; custom:abc-uuid not registered → also bundled.
|
||||
expect(result.bundled.map((m) => m.baseModelId)).toEqual([
|
||||
"bigpickle/big-pickle",
|
||||
"openai/gpt-5",
|
||||
"custom:abc-uuid/llama-3.3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops models whose provider is registered but whose modelId is NOT in ModelRegistry", () => {
|
||||
// Mimics the real scenario: user registered OpenRouter and picked 2
|
||||
// specific models. OpenCode reports its full ~50-row bundled snapshot
|
||||
// for openrouter. Only the 2 picked rows should appear; the rest
|
||||
// should be dropped (not appear in byok, bundled, or plus).
|
||||
const orEntries = [
|
||||
{
|
||||
baseModelId: "openrouter/moonshotai/kimi-k2-thinking",
|
||||
name: "Kimi K2",
|
||||
provider: "openrouter",
|
||||
effortOptions: [],
|
||||
},
|
||||
{
|
||||
baseModelId: "openrouter/openai/gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openrouter",
|
||||
effortOptions: [],
|
||||
},
|
||||
{
|
||||
baseModelId: "openrouter/google/gemini-2.5-pro",
|
||||
name: "Gemini 2.5 Pro",
|
||||
provider: "openrouter",
|
||||
effortOptions: [],
|
||||
},
|
||||
{
|
||||
baseModelId: "openrouter/meta-llama/llama-3.3-70b",
|
||||
name: "Llama 3.3 70B",
|
||||
provider: "openrouter",
|
||||
effortOptions: [],
|
||||
},
|
||||
];
|
||||
const result = classifyOpencodeModels(
|
||||
orEntries,
|
||||
makeRegistry(["openrouter"]),
|
||||
makeModelRegistry(["openrouter|moonshotai/kimi-k2-thinking", "openrouter|openai/gpt-5.5"])
|
||||
);
|
||||
expect(result.byok.map((m) => m.baseModelId)).toEqual([
|
||||
"openrouter/moonshotai/kimi-k2-thinking",
|
||||
"openrouter/openai/gpt-5.5",
|
||||
]);
|
||||
expect(result.bundled).toEqual([]);
|
||||
expect(result.plus).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterBundledEntries", () => {
|
||||
const registry = makeRegistry(["anthropic", "openai", "google", "openrouter"]);
|
||||
|
||||
it("keeps OpenCode-native rows that have no BYOK provider prefix", () => {
|
||||
const result = filterBundledEntries(
|
||||
[
|
||||
{
|
||||
baseModelId: "bigpickle/big-pickle",
|
||||
name: "Big Pickle",
|
||||
provider: null,
|
||||
effortOptions: [],
|
||||
},
|
||||
{
|
||||
baseModelId: "bigpickle/turbo",
|
||||
name: "Big Pickle Turbo",
|
||||
provider: null,
|
||||
effortOptions: [],
|
||||
},
|
||||
],
|
||||
registry,
|
||||
noModelsRegistered
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ id: "bigpickle/big-pickle", displayName: "Big Pickle", provider: "bigpickle" },
|
||||
{ id: "bigpickle/turbo", displayName: "Big Pickle Turbo", provider: "bigpickle" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out rows whose provider prefix is a BYOK / Plus provider", () => {
|
||||
const result = filterBundledEntries(
|
||||
[
|
||||
{
|
||||
baseModelId: "anthropic/claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
effortOptions: [],
|
||||
},
|
||||
{
|
||||
baseModelId: "openai/gpt-5",
|
||||
name: "GPT-5",
|
||||
provider: "openai",
|
||||
effortOptions: [],
|
||||
},
|
||||
{
|
||||
baseModelId: "copilot-plus/copilot-plus-flash",
|
||||
name: "Copilot Plus Flash",
|
||||
provider: "copilot-plus",
|
||||
effortOptions: [],
|
||||
},
|
||||
],
|
||||
registry,
|
||||
allModelsRegistered
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array when there are no available models", () => {
|
||||
expect(filterBundledEntries([], registry, noModelsRegistered)).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops rows without a provider segment (malformed ids)", () => {
|
||||
const result = filterBundledEntries(
|
||||
[{ baseModelId: "big-pickle", name: "Big Pickle", provider: null, effortOptions: [] }],
|
||||
registry,
|
||||
noModelsRegistered
|
||||
);
|
||||
// No slash → leadingSegment returns null → row is treated as "no provider
|
||||
// prefix" → it _is_ kept (we only filter when the prefix matches a BYOK
|
||||
// provider). This documents the intentional behavior so contributors don't
|
||||
// accidentally over-tighten the filter.
|
||||
expect(result).toEqual([{ id: "big-pickle", displayName: "Big Pickle", provider: undefined }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listBundledModels", () => {
|
||||
const registry = makeRegistry(["anthropic"]);
|
||||
|
||||
it("returns null when the session manager is missing", async () => {
|
||||
const result = await listBundledModels(null, registry, noModelsRegistered);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the cached state has no model slice", async () => {
|
||||
const sessionManager = {
|
||||
getCachedBackendState: () => ({ model: null, mode: null }) as BackendState,
|
||||
};
|
||||
const result = await listBundledModels(sessionManager as never, registry, noModelsRegistered);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the filtered bundled row list when the cache is populated", async () => {
|
||||
const sessionManager = {
|
||||
getCachedBackendState: () =>
|
||||
makeState([
|
||||
{ baseModelId: "bigpickle/big-pickle", name: "Big Pickle" },
|
||||
{ baseModelId: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
]),
|
||||
};
|
||||
const result = await listBundledModels(sessionManager as never, registry, allModelsRegistered);
|
||||
expect(result).toEqual([
|
||||
{ id: "bigpickle/big-pickle", displayName: "Big Pickle", provider: "bigpickle" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
227
src/agentMode/backends/opencode/bundledModels.ts
Normal file
227
src/agentMode/backends/opencode/bundledModels.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* bundledModels — surface OpenCode's enumeration of its own bundled models.
|
||||
*
|
||||
* Per M8 of the Model Management redesign (designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §5.4.1):
|
||||
* the OpenCode panel's picker is a UNION of three sources, and source #1 —
|
||||
* "OpenCode-bundled (Big Pickle, etc.)" — comes from whatever OpenCode itself
|
||||
* reports via its session catalog. We don't query the binary directly; instead
|
||||
* we lean on `AgentSessionManager.getCachedBackendState("opencode")` which
|
||||
* `AgentModelPreloader` has already populated by booting a probe session at
|
||||
* plugin load. That probe is the only ACP-supported way to ask OpenCode "what
|
||||
* models do you know about"; running another query at panel-render time would
|
||||
* spin up an extra subprocess for no gain.
|
||||
*
|
||||
* What counts as "bundled" vs "BYOK" vs "Plus"?
|
||||
* - Each `availableModels` entry's leading wire-form segment names its
|
||||
* OpenCode-side provider. We bucket as follows:
|
||||
* · `copilot-plus/...` → Plus (our brevilabs-routed pseudo-provider).
|
||||
* · leading segment matches a registered BYOK provider id
|
||||
* (`ProviderRegistry.get(segment)`) → BYOK row. This catches built-ins
|
||||
* (`anthropic`, `openai`, `groq`, …) AND custom providers
|
||||
* (`custom:<uuid>`), since both register under the same id on both
|
||||
* sides per `byokBridge.ts:resolveOpencodeProviderId`.
|
||||
* · anything else → bundled (OpenCode-native, e.g. `bigpickle/big-pickle`).
|
||||
*
|
||||
* Fallback: if the cached state is unavailable (OpenCode not installed, probe
|
||||
* still running, mobile platform), the function returns `null` so the panel
|
||||
* can render its "OpenCode not installed" empty-state. The bridge / panel
|
||||
* never blocks on a live JSON-RPC call here.
|
||||
*/
|
||||
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
|
||||
import type { BackendState, ModelEntry } from "@/agentMode/session/types";
|
||||
import { logInfo } from "@/logger";
|
||||
|
||||
const BYOK_DIAG = true;
|
||||
|
||||
/**
|
||||
* One bundled-model row, shaped for the panel's `BackendModelPickerRow`
|
||||
* consumers. `id` is the modelId-form key (matches OpenCode's
|
||||
* `baseModelId`); `displayName` is the user-visible label.
|
||||
*/
|
||||
export interface BundledModel {
|
||||
/** Stable model id used as the row's persistence key (without provider prefix). */
|
||||
id: string;
|
||||
/** Human-readable label (e.g. "Big Pickle"). */
|
||||
displayName: string;
|
||||
/** OpenCode provider segment (e.g. "bigpickle"). Useful for grouping. */
|
||||
provider?: string;
|
||||
/** Catalog-declared context window when known. Currently unset. */
|
||||
contextWindow?: number;
|
||||
}
|
||||
|
||||
/** OpenCode provider segment reserved for our brevilabs-routed Plus models. */
|
||||
const COPILOT_PLUS_SEGMENT = "copilot-plus";
|
||||
|
||||
/**
|
||||
* The three buckets the OpenCode panel renders, populated from a single
|
||||
* `availableModels` pass so the classification is consistent across sources.
|
||||
*/
|
||||
export interface OpencodeModelBuckets {
|
||||
bundled: ModelEntry[];
|
||||
byok: ModelEntry[];
|
||||
plus: ModelEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal `ProviderRegistry` shape the classifier needs. Threaded in as a
|
||||
* parameter rather than imported as a singleton so the helper stays
|
||||
* test-able without pulling the whole settings layer (see `AGENTS.md`,
|
||||
* "Avoiding Deep Dependency Chains in Tests").
|
||||
*/
|
||||
export interface ProviderRegistryLike {
|
||||
get(id: string): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal `ModelRegistry` shape the classifier needs. Same rationale as
|
||||
* `ProviderRegistryLike` — passed in so the helper doesn't have to pull
|
||||
* the whole settings layer.
|
||||
*/
|
||||
export interface ModelRegistryLike {
|
||||
get(providerId: string, modelId: string): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an OpenCode `availableModels` list into the three picker buckets.
|
||||
*
|
||||
* The leading wire-form segment names the OpenCode-side provider id and the
|
||||
* remainder is the BYOK registry's `modelId` (per `byokBridge.ts:142`, which
|
||||
* writes entries verbatim into `provider.<id>.models.<modelId>`). To
|
||||
* distinguish a model the user actually picked from OpenCode's bundled
|
||||
* `models.dev` snapshot for the same provider, we require **both**:
|
||||
*
|
||||
* 1. the leading segment matches a registered BYOK provider, AND
|
||||
* 2. the `(providerId, suffix)` pair exists in `ModelRegistry`.
|
||||
*
|
||||
* Rows whose leading segment matches a BYOK provider but whose suffix is
|
||||
* NOT in `ModelRegistry` are OpenCode's bundled snapshot for that provider
|
||||
* (e.g. all ~50 `openrouter/*` rows when the user only picked 2). They're
|
||||
* dropped — neither BYOK nor bundled — since the user didn't opt into them
|
||||
* and they'd be unusable without an explicit pick.
|
||||
*/
|
||||
export function classifyOpencodeModels(
|
||||
entries: ModelEntry[],
|
||||
providerRegistry: ProviderRegistryLike,
|
||||
modelRegistry: ModelRegistryLike
|
||||
): OpencodeModelBuckets {
|
||||
const bundled: ModelEntry[] = [];
|
||||
const byok: ModelEntry[] = [];
|
||||
const plus: ModelEntry[] = [];
|
||||
const diagRows: Array<{
|
||||
baseModelId: string;
|
||||
leadingSegment: string | null;
|
||||
providerHit: boolean;
|
||||
modelHit: boolean;
|
||||
bucket: "bundled" | "byok" | "plus" | "dropped";
|
||||
}> = [];
|
||||
for (const entry of entries) {
|
||||
const seg = leadingSegment(entry.baseModelId);
|
||||
const providerHit = !!(seg && providerRegistry.get(seg));
|
||||
const suffix = seg ? entry.baseModelId.slice(seg.length + 1) : "";
|
||||
const modelHit = !!(seg && providerHit && suffix && modelRegistry.get(seg, suffix));
|
||||
let bucket: "bundled" | "byok" | "plus" | "dropped";
|
||||
if (seg === COPILOT_PLUS_SEGMENT) {
|
||||
plus.push(entry);
|
||||
bucket = "plus";
|
||||
} else if (providerHit) {
|
||||
if (modelHit) {
|
||||
byok.push(entry);
|
||||
bucket = "byok";
|
||||
} else {
|
||||
// BYOK provider, but not a model the user picked → drop entirely
|
||||
// (OpenCode's bundled snapshot for this provider; unusable without
|
||||
// an explicit BYOK pick).
|
||||
bucket = "dropped";
|
||||
}
|
||||
} else {
|
||||
bundled.push(entry);
|
||||
bucket = "bundled";
|
||||
}
|
||||
if (BYOK_DIAG) {
|
||||
diagRows.push({
|
||||
baseModelId: entry.baseModelId,
|
||||
leadingSegment: seg,
|
||||
providerHit,
|
||||
modelHit,
|
||||
bucket,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (BYOK_DIAG) {
|
||||
logInfo("[BYOK-DIAG] classifyOpencodeModels", {
|
||||
total: entries.length,
|
||||
counts: {
|
||||
bundled: bundled.length,
|
||||
byok: byok.length,
|
||||
plus: plus.length,
|
||||
dropped: diagRows.filter((r) => r.bucket === "dropped").length,
|
||||
},
|
||||
rows: diagRows,
|
||||
});
|
||||
}
|
||||
return { bundled, byok, plus };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an OpenCode `ModelEntry` list down to the rows we consider
|
||||
* "bundled". Thin wrapper over `classifyOpencodeModels` kept for the
|
||||
* existing `listBundledModels` consumer + test harness.
|
||||
*/
|
||||
export function filterBundledEntries(
|
||||
entries: ModelEntry[],
|
||||
providerRegistry: ProviderRegistryLike,
|
||||
modelRegistry: ModelRegistryLike
|
||||
): BundledModel[] {
|
||||
return classifyOpencodeModels(entries, providerRegistry, modelRegistry).bundled.map((entry) => ({
|
||||
id: entry.baseModelId,
|
||||
displayName: entry.name,
|
||||
provider: leadingSegment(entry.baseModelId) ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the OpenCode-bundled models the running binary has advertised.
|
||||
*
|
||||
* Returns `null` (rather than an empty array) when the cached state is
|
||||
* missing — that signals "OpenCode not installed / not running" to the
|
||||
* panel, which then renders the empty-state copy. An empty array means
|
||||
* "OpenCode probed successfully but reports zero non-BYOK rows" — distinct
|
||||
* from the not-installed case.
|
||||
*
|
||||
* Async only to match the spec's signature; in practice this is a sync
|
||||
* read of the cached state.
|
||||
*/
|
||||
export async function listBundledModels(
|
||||
sessionManager: AgentSessionManager | null | undefined,
|
||||
providerRegistry: ProviderRegistryLike,
|
||||
modelRegistry: ModelRegistryLike
|
||||
): Promise<BundledModel[] | null> {
|
||||
if (!sessionManager) return null;
|
||||
const state: BackendState | null = sessionManager.getCachedBackendState("opencode");
|
||||
if (!state?.model) return null;
|
||||
return filterBundledEntries(state.model.availableModels, providerRegistry, modelRegistry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the full bucket breakdown the OpenCode panel renders. Same fallback
|
||||
* semantics as `listBundledModels`: `null` means "not probed yet / not
|
||||
* installed"; a fully-populated buckets value means the probe responded.
|
||||
*/
|
||||
export async function listOpencodeBuckets(
|
||||
sessionManager: AgentSessionManager | null | undefined,
|
||||
providerRegistry: ProviderRegistryLike,
|
||||
modelRegistry: ModelRegistryLike
|
||||
): Promise<OpencodeModelBuckets | null> {
|
||||
if (!sessionManager) return null;
|
||||
const state: BackendState | null = sessionManager.getCachedBackendState("opencode");
|
||||
if (!state?.model) return null;
|
||||
return classifyOpencodeModels(state.model.availableModels, providerRegistry, modelRegistry);
|
||||
}
|
||||
|
||||
/** Extract the leading `<provider>` segment of a wire-form model id. */
|
||||
function leadingSegment(modelId: string): string | null {
|
||||
if (!modelId) return null;
|
||||
const slash = modelId.indexOf("/");
|
||||
if (slash <= 0) return null;
|
||||
return modelId.slice(0, slash);
|
||||
}
|
||||
286
src/agentMode/backends/opencode/byokBridge.test.ts
Normal file
286
src/agentMode/backends/opencode/byokBridge.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* `byokBridge` unit tests.
|
||||
*
|
||||
* `buildByokOpencodeProviderConfig` shapes the BYOK provider + model
|
||||
* registries into the OpenCode `provider.<id>` slice. Covers built-in and
|
||||
* custom providers, the no-key skip rule for built-ins, the keep-anyway
|
||||
* rule for custom providers, and `models` registration from `ModelRegistry`.
|
||||
*/
|
||||
import { buildByokOpencodeProviderConfig } from "./byokBridge";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
type ProviderConfigStub = {
|
||||
id: string;
|
||||
kind: "builtin" | "custom";
|
||||
displayName: string;
|
||||
type: string;
|
||||
baseUrl?: string;
|
||||
apiKeyRef?: { kind: "inline"; value: string } | null;
|
||||
addedAt: number;
|
||||
};
|
||||
|
||||
type RegistryEntryStub = {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
addedAt: number;
|
||||
};
|
||||
|
||||
let mockProviders: ProviderConfigStub[] = [];
|
||||
let mockRegistry: RegistryEntryStub[] = [];
|
||||
|
||||
jest.mock("@/modelManagement", () => ({
|
||||
ProviderRegistry: {
|
||||
getInstance: () => ({
|
||||
list: () => mockProviders.slice(),
|
||||
}),
|
||||
},
|
||||
ModelRegistry: {
|
||||
getInstance: () => ({
|
||||
list: (filter?: { providerId?: string }) =>
|
||||
filter?.providerId
|
||||
? mockRegistry.filter((e) => e.providerId === filter.providerId)
|
||||
: mockRegistry.slice(),
|
||||
}),
|
||||
},
|
||||
getProviderApiKeySync: (id: string) => {
|
||||
const p = mockProviders.find((x) => x.id === id);
|
||||
if (!p?.apiKeyRef) return null;
|
||||
return p.apiKeyRef.kind === "inline" ? p.apiKeyRef.value : null;
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockProviders = [];
|
||||
mockRegistry = [];
|
||||
});
|
||||
|
||||
async function loadRegistries() {
|
||||
const mm = await import("@/modelManagement");
|
||||
return {
|
||||
providerRegistry: mm.ProviderRegistry.getInstance(),
|
||||
modelRegistry: mm.ModelRegistry.getInstance(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildByokOpencodeProviderConfig", () => {
|
||||
it("registers a built-in provider as { options: { apiKey } } under its canonical id", async () => {
|
||||
mockProviders = [
|
||||
{
|
||||
id: "anthropic",
|
||||
kind: "builtin",
|
||||
displayName: "Anthropic",
|
||||
type: "anthropic",
|
||||
apiKeyRef: { kind: "inline", value: "sk-ant-xxx" },
|
||||
addedAt: 1,
|
||||
},
|
||||
];
|
||||
const { providerRegistry, modelRegistry } = await loadRegistries();
|
||||
const result = buildByokOpencodeProviderConfig(providerRegistry, modelRegistry);
|
||||
expect(result).toEqual({
|
||||
anthropic: { options: { apiKey: "sk-ant-xxx" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("registers `openai-compatible` built-ins under their own canonical id (not collapsed onto `openai`)", async () => {
|
||||
mockProviders = [
|
||||
{
|
||||
id: "openai",
|
||||
kind: "builtin",
|
||||
displayName: "OpenAI",
|
||||
type: "openai-compatible",
|
||||
apiKeyRef: { kind: "inline", value: "sk-oai" },
|
||||
addedAt: 1,
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
kind: "builtin",
|
||||
displayName: "OpenRouter",
|
||||
type: "openai-compatible",
|
||||
apiKeyRef: { kind: "inline", value: "sk-or" },
|
||||
addedAt: 2,
|
||||
},
|
||||
{
|
||||
id: "groq",
|
||||
kind: "builtin",
|
||||
displayName: "Groq",
|
||||
type: "openai-compatible",
|
||||
apiKeyRef: { kind: "inline", value: "gsk-x" },
|
||||
addedAt: 3,
|
||||
},
|
||||
];
|
||||
const { providerRegistry, modelRegistry } = await loadRegistries();
|
||||
const result = buildByokOpencodeProviderConfig(providerRegistry, modelRegistry);
|
||||
expect(result).toEqual({
|
||||
openai: { options: { apiKey: "sk-oai" } },
|
||||
openrouter: { options: { apiKey: "sk-or" } },
|
||||
groq: { options: { apiKey: "gsk-x" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("registers a custom provider with npm + baseURL + apiKey under its custom: id", async () => {
|
||||
mockProviders = [
|
||||
{
|
||||
id: "custom:abc",
|
||||
kind: "custom",
|
||||
displayName: "Local Ollama",
|
||||
type: "openai-compatible",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
apiKeyRef: { kind: "inline", value: "ollama-key" },
|
||||
addedAt: 1,
|
||||
},
|
||||
];
|
||||
const { providerRegistry, modelRegistry } = await loadRegistries();
|
||||
const result = buildByokOpencodeProviderConfig(providerRegistry, modelRegistry);
|
||||
expect(result).toEqual({
|
||||
"custom:abc": {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Local Ollama",
|
||||
options: {
|
||||
baseURL: "http://localhost:11434/v1",
|
||||
apiKey: "ollama-key",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps custom providers without an API key (local endpoints)", async () => {
|
||||
mockProviders = [
|
||||
{
|
||||
id: "custom:local",
|
||||
kind: "custom",
|
||||
displayName: "LM Studio",
|
||||
type: "openai-compatible",
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKeyRef: null,
|
||||
addedAt: 1,
|
||||
},
|
||||
];
|
||||
const { providerRegistry, modelRegistry } = await loadRegistries();
|
||||
const result = buildByokOpencodeProviderConfig(providerRegistry, modelRegistry);
|
||||
expect(result["custom:local"]).toEqual({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "LM Studio",
|
||||
options: { baseURL: "http://localhost:1234/v1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops built-in providers with no API key (would be useless)", async () => {
|
||||
mockProviders = [
|
||||
{
|
||||
id: "groq",
|
||||
kind: "builtin",
|
||||
displayName: "Groq",
|
||||
type: "openai-compatible",
|
||||
apiKeyRef: null,
|
||||
addedAt: 1,
|
||||
},
|
||||
];
|
||||
const { providerRegistry, modelRegistry } = await loadRegistries();
|
||||
const result = buildByokOpencodeProviderConfig(providerRegistry, modelRegistry);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("registers BYOK registry models under the resolved provider's `models` map", async () => {
|
||||
mockProviders = [
|
||||
{
|
||||
id: "anthropic",
|
||||
kind: "builtin",
|
||||
displayName: "Anthropic",
|
||||
type: "anthropic",
|
||||
apiKeyRef: { kind: "inline", value: "sk-ant" },
|
||||
addedAt: 1,
|
||||
},
|
||||
];
|
||||
mockRegistry = [
|
||||
{
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
addedAt: 2,
|
||||
},
|
||||
{
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-haiku",
|
||||
displayName: "Claude Haiku",
|
||||
addedAt: 3,
|
||||
},
|
||||
];
|
||||
const { providerRegistry, modelRegistry } = await loadRegistries();
|
||||
const result = buildByokOpencodeProviderConfig(providerRegistry, modelRegistry);
|
||||
expect(result.anthropic).toEqual({
|
||||
options: { apiKey: "sk-ant" },
|
||||
models: { "claude-sonnet-4-5": {}, "claude-haiku": {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("registers models for custom providers under the `custom:` id", async () => {
|
||||
mockProviders = [
|
||||
{
|
||||
id: "custom:abc",
|
||||
kind: "custom",
|
||||
displayName: "Local Ollama",
|
||||
type: "openai-compatible",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
apiKeyRef: null,
|
||||
addedAt: 1,
|
||||
},
|
||||
];
|
||||
mockRegistry = [
|
||||
{ providerId: "custom:abc", modelId: "llama3:8b", displayName: "Llama 3 8B", addedAt: 2 },
|
||||
];
|
||||
const { providerRegistry, modelRegistry } = await loadRegistries();
|
||||
const result = buildByokOpencodeProviderConfig(providerRegistry, modelRegistry);
|
||||
expect(result["custom:abc"]).toEqual({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Local Ollama",
|
||||
options: { baseURL: "http://localhost:11434/v1" },
|
||||
models: { "llama3:8b": {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the `models` field when no registry entries exist for the provider", async () => {
|
||||
mockProviders = [
|
||||
{
|
||||
id: "anthropic",
|
||||
kind: "builtin",
|
||||
displayName: "Anthropic",
|
||||
type: "anthropic",
|
||||
apiKeyRef: { kind: "inline", value: "sk-ant" },
|
||||
addedAt: 1,
|
||||
},
|
||||
];
|
||||
const { providerRegistry, modelRegistry } = await loadRegistries();
|
||||
const result = buildByokOpencodeProviderConfig(providerRegistry, modelRegistry);
|
||||
expect(result.anthropic).not.toHaveProperty("models");
|
||||
});
|
||||
|
||||
it("does not leak registry entries that belong to a different provider", async () => {
|
||||
mockProviders = [
|
||||
{
|
||||
id: "anthropic",
|
||||
kind: "builtin",
|
||||
displayName: "Anthropic",
|
||||
type: "anthropic",
|
||||
apiKeyRef: { kind: "inline", value: "sk-ant" },
|
||||
addedAt: 1,
|
||||
},
|
||||
];
|
||||
mockRegistry = [
|
||||
{
|
||||
providerId: "openai",
|
||||
modelId: "gpt-4o",
|
||||
displayName: "GPT-4o",
|
||||
addedAt: 2,
|
||||
},
|
||||
];
|
||||
const { providerRegistry, modelRegistry } = await loadRegistries();
|
||||
const result = buildByokOpencodeProviderConfig(providerRegistry, modelRegistry);
|
||||
expect(result.anthropic).not.toHaveProperty("models");
|
||||
});
|
||||
});
|
||||
153
src/agentMode/backends/opencode/byokBridge.ts
Normal file
153
src/agentMode/backends/opencode/byokBridge.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Pure helper that shapes the BYOK provider + model registry into OpenCode's
|
||||
* spawn-time `provider.<id>` slice.
|
||||
*
|
||||
* Per M8 of the Model Management redesign (designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §6):
|
||||
* BYOK is the single source of truth for user-brought providers and models.
|
||||
* OpenCode consumes those at spawn time via `OPENCODE_CONFIG_CONTENT`, built
|
||||
* by `OpencodeBackend.buildOpencodeConfig` — which calls this helper.
|
||||
*
|
||||
* - Built-in providers (`kind: "builtin"`): register `{ options: { apiKey } }`
|
||||
* under the canonical provider id (e.g. `anthropic`, `openai`). OpenCode
|
||||
* already knows about these via its bundled models.dev snapshot, so the
|
||||
* auth slice is all we add. Built-ins without a key are skipped — OpenCode
|
||||
* would reject them anyway.
|
||||
* - Custom providers (`kind: "custom"`): register the full endpoint config
|
||||
* under the BYOK provider id (e.g. `custom:abc-…`). For these we pick
|
||||
* the `@ai-sdk/openai-compatible` npm package — the only openai-format
|
||||
* glue OpenCode ships with that supports an arbitrary base URL.
|
||||
* - Each provider's BYOK registry entries are emitted as
|
||||
* `provider.<id>.models.<modelId> = {}` so OpenCode lists them in
|
||||
* `availableModels`. For custom providers this is the *only* way the
|
||||
* models become visible (no models.dev snapshot for `custom:<uuid>`);
|
||||
* for built-ins it's additive on top of the snapshot.
|
||||
*/
|
||||
import { ModelRegistry, ProviderRegistry, getProviderApiKeySync } from "@/modelManagement";
|
||||
import { logInfo } from "@/logger";
|
||||
|
||||
const BYOK_DIAG = true;
|
||||
|
||||
/**
|
||||
* One OpenCode provider config entry as it appears under
|
||||
* `OPENCODE_CONFIG_CONTENT.provider.<id>`. Mirrors the shape `OpencodeBackend`
|
||||
* already uses for built-in providers + the Copilot Plus glue entry.
|
||||
*/
|
||||
export interface OpencodeProviderEntry {
|
||||
/** npm module OpenCode should load — only present for custom providers. */
|
||||
npm?: string;
|
||||
/** Display label OpenCode uses in its picker. */
|
||||
name?: string;
|
||||
/** OpenCode-native options bag: API key + base URL + extra headers. */
|
||||
options?: {
|
||||
apiKey?: string;
|
||||
baseURL?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
/**
|
||||
* Models OpenCode should surface under this provider in `availableModels`.
|
||||
* Empty object per model — opencode merges field-wise with its bundled
|
||||
* snapshot for built-ins, and treats this as the canonical list for
|
||||
* custom providers.
|
||||
*/
|
||||
models?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Map keyed by provider id, written under `OPENCODE_CONFIG_CONTENT.provider`. */
|
||||
export type OpencodeProviderMap = Record<string, OpencodeProviderEntry>;
|
||||
|
||||
/**
|
||||
* Resolve the OpenCode provider id for a BYOK provider config.
|
||||
*
|
||||
* Both built-ins and customs already carry their canonical id on the
|
||||
* BYOK side (`openai`, `anthropic`, `openrouter`, `groq`, `xai`,
|
||||
* `custom:<uuid>`, …) — the same id OpenCode uses for its bundled
|
||||
* `models.dev` snapshot and for the wire-form leading segment in
|
||||
* `availableModels`. Translating on `provider.type` is wrong: it
|
||||
* collapses every `openai-compatible` built-in (openrouter, groq,
|
||||
* xai, mistral, deepseek, cohere, siliconflow, …) onto a single
|
||||
* `openai` slot, which clobbers the user's choices and breaks the
|
||||
* BYOK ↔ picker classifier in `bundledModels.ts:classifyOpencodeModels`.
|
||||
*/
|
||||
function resolveOpencodeProviderId(provider: { id: string }): string {
|
||||
return provider.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `OpencodeProviderMap` for every BYOK provider currently in the
|
||||
* registry, including the `models` sub-map sourced from `ModelRegistry`.
|
||||
* Pure — does no I/O beyond reading the two registries. Providers with no
|
||||
* API key (built-in only) are skipped (OpenCode would reject them anyway);
|
||||
* custom providers without a key are kept so local endpoints like Ollama
|
||||
* still wire through.
|
||||
*/
|
||||
export function buildByokOpencodeProviderConfig(
|
||||
providerRegistry: ProviderRegistry,
|
||||
modelRegistry: ModelRegistry
|
||||
): OpencodeProviderMap {
|
||||
const map: OpencodeProviderMap = {};
|
||||
for (const provider of providerRegistry.list()) {
|
||||
// System providers (e.g. `opencode`, `copilot-plus`) are credentialed by
|
||||
// the agent backend itself, not by BYOK. The OpenCode backend writes
|
||||
// their config slice directly in `buildOpencodeConfig` (see
|
||||
// `OpencodeBackend.ts`), so we skip them here to avoid double-registering
|
||||
// and to keep the bridge's BYOK invariant (apiKey-or-skip) intact.
|
||||
if (provider.kind === "system") {
|
||||
if (BYOK_DIAG) {
|
||||
logInfo("[BYOK-DIAG] byokBridge skip system provider", {
|
||||
providerRegistryId: provider.id,
|
||||
kind: provider.kind,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const apiKey = getProviderApiKeySync(provider.id);
|
||||
if (provider.kind === "builtin" && !apiKey) {
|
||||
if (BYOK_DIAG) {
|
||||
logInfo("[BYOK-DIAG] byokBridge skip builtin without apiKey", {
|
||||
providerRegistryId: provider.id,
|
||||
kind: provider.kind,
|
||||
type: provider.type,
|
||||
modelIds: modelRegistry.list({ providerId: provider.id }).map((m) => m.modelId),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const opencodeId = resolveOpencodeProviderId(provider);
|
||||
const entry: OpencodeProviderEntry = { options: {} };
|
||||
if (provider.kind === "custom") {
|
||||
entry.npm = "@ai-sdk/openai-compatible";
|
||||
entry.name = provider.displayName;
|
||||
if (provider.baseUrl) entry.options!.baseURL = provider.baseUrl;
|
||||
}
|
||||
if (apiKey) entry.options!.apiKey = apiKey;
|
||||
if (!entry.options || Object.keys(entry.options).length === 0) delete entry.options;
|
||||
|
||||
const registryModels = modelRegistry.list({ providerId: provider.id });
|
||||
if (registryModels.length > 0) {
|
||||
const models: Record<string, Record<string, unknown>> = {};
|
||||
for (const reg of registryModels) models[reg.modelId] = {};
|
||||
entry.models = models;
|
||||
}
|
||||
|
||||
map[opencodeId] = entry;
|
||||
if (BYOK_DIAG) {
|
||||
logInfo("[BYOK-DIAG] byokBridge wire", {
|
||||
providerRegistryId: provider.id,
|
||||
kind: provider.kind,
|
||||
type: provider.type,
|
||||
opencodeId,
|
||||
hasApiKey: !!apiKey,
|
||||
modelIds: registryModels.map((m) => m.modelId),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (BYOK_DIAG) {
|
||||
logInfo("[BYOK-DIAG] byokBridge final map", {
|
||||
opencodeProviderIds: Object.keys(map),
|
||||
perProviderModelCount: Object.fromEntries(
|
||||
Object.entries(map).map(([k, v]) => [k, Object.keys(v.models ?? {}).length])
|
||||
),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
|
@ -6,6 +6,28 @@ jest.mock("@/logger", () => ({
|
|||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
const registeredProviderIds = new Set<string>();
|
||||
const registeredModelKeys = new Set<string>();
|
||||
|
||||
jest.mock("@/modelManagement", () => ({
|
||||
ProviderRegistry: {
|
||||
getInstance: () => ({
|
||||
get: (id: string) => (registeredProviderIds.has(id) ? { id } : undefined),
|
||||
}),
|
||||
},
|
||||
ModelRegistry: {
|
||||
getInstance: () => ({
|
||||
get: (providerId: string, modelId: string) =>
|
||||
registeredModelKeys.has(`${providerId}|${modelId}`) ? { providerId, modelId } : undefined,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
registeredProviderIds.clear();
|
||||
registeredModelKeys.clear();
|
||||
});
|
||||
|
||||
describe("OpencodeBackendDescriptor.wire.decode", () => {
|
||||
const decode = OpencodeBackendDescriptor.wire.decode;
|
||||
|
||||
|
|
@ -134,18 +156,59 @@ describe("OpencodeBackendDescriptor.isModelEnabledByDefault", () => {
|
|||
const fn = OpencodeBackendDescriptor.isModelEnabledByDefault!;
|
||||
|
||||
it("matches 'Big Pickle' in name", () => {
|
||||
expect(fn({ modelId: "anthropic/foo", name: "Big Pickle" })).toBe(true);
|
||||
expect(fn({ modelId: "anthropic/foo", name: "BIG PICKLE" })).toBe(true);
|
||||
expect(fn({ modelId: "anthropic/foo", name: "big-pickle" })).toBe(true);
|
||||
expect(fn({ modelId: "bigpickle/foo", name: "Big Pickle" })).toBe(true);
|
||||
expect(fn({ modelId: "bigpickle/foo", name: "BIG PICKLE" })).toBe(true);
|
||||
expect(fn({ modelId: "bigpickle/foo", name: "big-pickle" })).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'big-pickle' in modelId", () => {
|
||||
expect(fn({ modelId: "openai/big-pickle", name: "Some Display" })).toBe(true);
|
||||
expect(fn({ modelId: "openai/big_pickle", name: "Some Display" })).toBe(true);
|
||||
expect(fn({ modelId: "bigpickle/big-pickle", name: "Some Display" })).toBe(true);
|
||||
expect(fn({ modelId: "bigpickle/big_pickle", name: "Some Display" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated models", () => {
|
||||
it("returns false for non-BYOK, non-Big-Pickle models", () => {
|
||||
// No providers registered in BYOK → unrelated models stay opt-in.
|
||||
expect(fn({ modelId: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" })).toBe(false);
|
||||
expect(fn({ modelId: "openai/gpt-5", name: "GPT-5" })).toBe(false);
|
||||
});
|
||||
|
||||
it("default-enables models whose (providerId, modelId) is in ModelRegistry", () => {
|
||||
registeredProviderIds.add("openrouter");
|
||||
registeredProviderIds.add("custom:abc-uuid");
|
||||
registeredModelKeys.add("openrouter|anthropic/claude-sonnet-4.5");
|
||||
registeredModelKeys.add("custom:abc-uuid|llama-3.3");
|
||||
expect(
|
||||
fn({ modelId: "openrouter/anthropic/claude-sonnet-4.5", name: "Claude Sonnet 4.5" })
|
||||
).toBe(true);
|
||||
expect(fn({ modelId: "custom:abc-uuid/llama-3.3", name: "Llama 3.3" })).toBe(true);
|
||||
// Other providers without a BYOK entry remain opt-in.
|
||||
expect(fn({ modelId: "groq/llama-3.1-70b", name: "Llama 3.1 70B" })).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT default-enable models from a BYOK provider when the modelId isn't in ModelRegistry", () => {
|
||||
// Real scenario: user registered OpenRouter but only picked 2 models.
|
||||
// OpenCode's bundled snapshot reports the other ~50 openrouter rows.
|
||||
// Those must stay opt-in even though the provider is registered.
|
||||
registeredProviderIds.add("openrouter");
|
||||
registeredModelKeys.add("openrouter|moonshotai/kimi-k2-thinking");
|
||||
expect(
|
||||
fn({
|
||||
modelId: "openrouter/moonshotai/kimi-k2-thinking",
|
||||
name: "Kimi K2 Thinking",
|
||||
})
|
||||
).toBe(true);
|
||||
expect(fn({ modelId: "openrouter/google/gemini-2.5-pro", name: "Gemini 2.5 Pro" })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not default-enable copilot-plus rows (system provider, not BYOK)", () => {
|
||||
// Plus rows flow through the OpenCode picker but are NOT BYOK; they
|
||||
// should stay subject to the Big Pickle regex / explicit user toggle.
|
||||
expect(fn({ modelId: "copilot-plus/copilot-plus-flash", name: "Plus Flash" })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not default-enable single-segment modelIds (no provider segment to look up)", () => {
|
||||
registeredProviderIds.add("anthropic");
|
||||
registeredModelKeys.add("anthropic|"); // even if registry happened to contain it, no slash → no match
|
||||
expect(fn({ modelId: "anthropic", name: "Anthropic" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { OpencodeInstallModal } from "@/agentMode/backends/opencode/OpencodeInstallModal";
|
||||
import OpencodeLogo from "@/agentMode/backends/opencode/logo.svg";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { ModelRegistry, ProviderRegistry } from "@/modelManagement";
|
||||
import {
|
||||
subscribeToSettingsChange,
|
||||
updateAgentModeBackendFields,
|
||||
|
|
@ -131,8 +132,25 @@ export const OpencodeBackendDescriptor: BackendDescriptor = {
|
|||
},
|
||||
|
||||
isModelEnabledByDefault(model) {
|
||||
// Default-enable only "Big Pickle"; users widen the catalog via the
|
||||
// per-model toggles in the Agents tab.
|
||||
// BYOK-picked models default on: their wire form `<providerId>/<rest>`
|
||||
// must match an entry the user actually picked in `ModelRegistry`.
|
||||
// Matching only on `ProviderRegistry` is too broad — OpenCode's
|
||||
// bundled `models.dev` snapshot reports every model under that
|
||||
// provider (e.g. ~50 `openrouter/*` rows) even when the user picked
|
||||
// just two. The classifier in `bundledModels.ts:classifyOpencodeModels`
|
||||
// uses the same rule.
|
||||
const slash = model.modelId.indexOf("/");
|
||||
if (slash > 0) {
|
||||
const seg = model.modelId.slice(0, slash);
|
||||
const rest = model.modelId.slice(slash + 1);
|
||||
if (
|
||||
rest &&
|
||||
ProviderRegistry.getInstance().get(seg) &&
|
||||
ModelRegistry.getInstance().get(seg, rest)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const re = /big[\s_-]*pickle/i;
|
||||
return re.test(model.name) || re.test(model.modelId);
|
||||
},
|
||||
|
|
|
|||
49
src/agentMode/backends/opencode/plusModels.test.ts
Normal file
49
src/agentMode/backends/opencode/plusModels.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* `plusModels` tests.
|
||||
*
|
||||
* Verifies that `listPlusModels` gates on `isPlusEnabled()` and otherwise
|
||||
* returns the hard-coded Plus model catalog.
|
||||
*/
|
||||
let plusEnabled = false;
|
||||
jest.mock("@/plusUtils", () => ({
|
||||
isPlusEnabled: () => plusEnabled,
|
||||
}));
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
import { ChatModels } from "@/constants";
|
||||
import { listPlusModels } from "./plusModels";
|
||||
|
||||
beforeEach(() => {
|
||||
plusEnabled = false;
|
||||
});
|
||||
|
||||
describe("listPlusModels", () => {
|
||||
it("returns an empty list when the user is not on Plus", async () => {
|
||||
plusEnabled = false;
|
||||
expect(await listPlusModels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the hard-coded Plus catalog when the user is on Plus", async () => {
|
||||
plusEnabled = true;
|
||||
const result = await listPlusModels();
|
||||
expect(result).toEqual([
|
||||
{ id: ChatModels.COPILOT_PLUS_FLASH, displayName: "Copilot Plus Flash" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a fresh copy each call (callers can't mutate the canonical list)", async () => {
|
||||
plusEnabled = true;
|
||||
const a = await listPlusModels();
|
||||
const b = await listPlusModels();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual(b);
|
||||
a.push({ id: "x", displayName: "X" });
|
||||
const c = await listPlusModels();
|
||||
expect(c).toEqual([{ id: ChatModels.COPILOT_PLUS_FLASH, displayName: "Copilot Plus Flash" }]);
|
||||
});
|
||||
});
|
||||
66
src/agentMode/backends/opencode/plusModels.ts
Normal file
66
src/agentMode/backends/opencode/plusModels.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* plusModels — surface Copilot Plus hosted models for the OpenCode picker.
|
||||
*
|
||||
* Per M8 of the Model Management redesign (designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §5.4.1):
|
||||
* source #2 of the OpenCode panel's three-source picker is the list of
|
||||
* Plus-hosted models (e.g. Copilot Plus Flash). These flow through OpenCode
|
||||
* via the brevilabs glue provider that `OpencodeBackend` registers when the
|
||||
* user is on a Plus plan; the panel just needs the labels to render.
|
||||
*
|
||||
* Current implementation: hard-coded stub list, gated by `isPlusEnabled()`.
|
||||
*
|
||||
* TODO(M9+): once brevilabs ships a `/plus/models` endpoint, replace the
|
||||
* stub with a `BrevilabsClient.listPlusModels()` call (cached for the
|
||||
* lifetime of a session). Until then, hard-coding the known Plus model
|
||||
* keeps the bridge testable without requiring network access.
|
||||
*
|
||||
* No live JSON-RPC; this is a pure read of plugin state.
|
||||
*/
|
||||
import { ChatModels } from "@/constants";
|
||||
import { isPlusEnabled } from "@/plusUtils";
|
||||
|
||||
/**
|
||||
* One Plus-hosted model row, shaped for the panel's `BackendModelPickerRow`
|
||||
* consumers. `id` is the modelId-form key (without the `copilot-plus:`
|
||||
* prefix); the panel composes the full key when writing overrides.
|
||||
*/
|
||||
export interface PlusModel {
|
||||
/** Stable model id used as the row's persistence key. */
|
||||
id: string;
|
||||
/** Human-readable label (e.g. "Copilot Plus Flash"). */
|
||||
displayName: string;
|
||||
/** Catalog-declared context window when known. */
|
||||
contextWindow?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-coded Plus model catalog. Mirrors the `COPILOT_PLUS_FLASH` constant
|
||||
* already wired into `OpencodeBackend.buildOpencodeConfig` — both reach the
|
||||
* brevilabs proxy under the same model id.
|
||||
*
|
||||
* Exported so the OpenCode spawn-time config can register these models under
|
||||
* `provider["copilot-plus"].models.<id> = {}`, making them visible in
|
||||
* `availableModels` without depending on the legacy `activeModels` pathway
|
||||
* (which BYOK has retired). Once brevilabs ships a `/plus/models` endpoint,
|
||||
* this constant becomes the offline fallback.
|
||||
*/
|
||||
export const PLUS_MODELS: PlusModel[] = [
|
||||
{ id: ChatModels.COPILOT_PLUS_FLASH, displayName: "Copilot Plus Flash" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Read the Plus-hosted models available to the current user.
|
||||
*
|
||||
* - Returns `[]` when the user is not on a Plus plan (the panel hides the
|
||||
* Plus section in that case).
|
||||
* - Returns the hard-coded list otherwise. Same shape as
|
||||
* `listBundledModels`, so the panel can render the two side-by-side
|
||||
* with identical row components.
|
||||
*
|
||||
* Async only to match the spec's signature; this is a pure read today.
|
||||
*/
|
||||
export async function listPlusModels(): Promise<PlusModel[]> {
|
||||
if (!isPlusEnabled()) return [];
|
||||
// Slice so callers can't mutate the canonical list.
|
||||
return PLUS_MODELS.slice();
|
||||
}
|
||||
|
|
@ -115,11 +115,17 @@ export const EnvOverridesSetting: React.FC<Props> = ({
|
|||
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-2 tw-py-4">
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
<div className="tw-text-sm tw-font-medium tw-leading-none">Environment variables</div>
|
||||
<div className="tw-text-xs tw-text-muted">
|
||||
Set values for {backendDisplayName}, like <code>{hintA}</code> or <code>{hintB}</code>.
|
||||
<div className="tw-flex tw-items-center tw-justify-between tw-gap-2">
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
<div className="tw-text-sm tw-font-medium tw-leading-none">Environment variables</div>
|
||||
<div className="tw-text-xs tw-text-muted">
|
||||
Set values for {backendDisplayName}, like <code>{hintA}</code> or <code>{hintB}</code>.
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={addRow}>
|
||||
<Plus className="tw-size-icon-xs" />
|
||||
Add variable
|
||||
</Button>
|
||||
</div>
|
||||
<div className="tw-flex tw-w-full tw-flex-col tw-gap-2">
|
||||
{rows.map((row) => {
|
||||
|
|
@ -167,12 +173,6 @@ export const EnvOverridesSetting: React.FC<Props> = ({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="tw-flex tw-justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={addRow}>
|
||||
<Plus className="tw-size-icon-xs" />
|
||||
Add variable
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import type CopilotPlugin from "@/main";
|
|||
import { AcpBackendProcess } from "@/agentMode/acp/AcpBackendProcess";
|
||||
import type { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
|
||||
import { augmentPathForNodeShebang } from "@/agentMode/acp/nodeShebangPath";
|
||||
import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types";
|
||||
import type {
|
||||
BackendDescriptor,
|
||||
BackendProcess,
|
||||
InstallState,
|
||||
ModelSelection,
|
||||
} from "@/agentMode/session/types";
|
||||
|
||||
/**
|
||||
* Build a spawn descriptor for a backend whose only configuration is a
|
||||
|
|
@ -48,8 +53,15 @@ export function simpleBinaryBackendProcess(
|
|||
app: App;
|
||||
clientVersion: string;
|
||||
descriptor: BackendDescriptor;
|
||||
getSeedSelection?: () => ModelSelection | null;
|
||||
},
|
||||
backend: AcpBackend
|
||||
): BackendProcess {
|
||||
return new AcpBackendProcess(args.app, backend, args.clientVersion, args.descriptor);
|
||||
return new AcpBackendProcess(
|
||||
args.app,
|
||||
backend,
|
||||
args.clientVersion,
|
||||
args.descriptor,
|
||||
args.getSeedSelection
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,20 @@ export { frameSink as acpFrameSink } from "./session/debugSink";
|
|||
export { SkillManager, SkillsSettings, useManagedSkills } from "./skills";
|
||||
export type { Skill } from "./skills";
|
||||
|
||||
// OpenCode picker data sources — exposed through the agentMode barrel so the
|
||||
// settings panel doesn't import directly across the host/backend boundary.
|
||||
export {
|
||||
listBundledModels as listOpencodeBundledModels,
|
||||
listOpencodeBuckets,
|
||||
type BundledModel as OpencodeBundledModel,
|
||||
type OpencodeModelBuckets,
|
||||
type ProviderRegistryLike as OpencodeProviderRegistryLike,
|
||||
} from "./backends/opencode/bundledModels";
|
||||
export {
|
||||
listPlusModels as listOpencodePlusModels,
|
||||
type PlusModel as OpencodePlusModel,
|
||||
} from "./backends/opencode/plusModels";
|
||||
|
||||
/**
|
||||
* True when Agent Mode is enabled and the platform supports it. Agent Mode
|
||||
* requires subprocess support, so this is always false on mobile regardless
|
||||
|
|
|
|||
|
|
@ -141,6 +141,13 @@ export interface AgentSessionStartOptions {
|
|||
* agent's fresh `newSession` response inside `initialize()`.
|
||||
*/
|
||||
initialCachedState?: BackendState | null;
|
||||
/**
|
||||
* Effort half of the seed (model, effort) selection. Stored on the
|
||||
* session so `descriptor.applyInitialSessionConfig` can read it back
|
||||
* without reaching into settings (which no longer carry per-backend
|
||||
* defaults).
|
||||
*/
|
||||
defaultEffort?: string | null;
|
||||
/**
|
||||
* Optional descriptor accessor. The session uses it to resolve mode mappings
|
||||
* without coupling to specific backends. Manager-supplied; tests omit it.
|
||||
|
|
@ -165,6 +172,8 @@ export interface AgentSessionStateOptions {
|
|||
* seed on failure.
|
||||
*/
|
||||
defaultModelSelection?: ModelSelection;
|
||||
/** Effort half of the seed selection. See `AgentSessionStartOptions.defaultEffort`. */
|
||||
defaultEffort?: string | null;
|
||||
cwd?: string | null;
|
||||
getDescriptor?: () => BackendDescriptor | undefined;
|
||||
}
|
||||
|
|
@ -215,6 +224,13 @@ export class AgentSession {
|
|||
* `setSession*` responses.
|
||||
*/
|
||||
private currentState: BackendState | null = null;
|
||||
/**
|
||||
* Effort the session was seeded with at construction. Replayed once by
|
||||
* `descriptor.applyInitialSessionConfig` after the backend reports a
|
||||
* model catalog. Null when no seed was supplied (fresh runtime, first
|
||||
* session on this backend).
|
||||
*/
|
||||
private defaultEffort: string | null = null;
|
||||
private label: string | null = null;
|
||||
// Tracks who set the current label so an agent-pushed `session_info_update`
|
||||
// can't clobber a label the user explicitly chose via Rename.
|
||||
|
|
@ -274,6 +290,7 @@ export class AgentSession {
|
|||
this.backendSessionId = opts.backendSessionId;
|
||||
const originalState = opts.initialState ?? null;
|
||||
this.currentState = seedSelectionIntoState(originalState, opts.defaultModelSelection);
|
||||
this.defaultEffort = opts.defaultEffort ?? null;
|
||||
this.unregisterSessionHandler = this.backend.registerSessionHandler(
|
||||
opts.backendSessionId,
|
||||
(event) => this.handleSessionEvent(event)
|
||||
|
|
@ -296,6 +313,7 @@ export class AgentSession {
|
|||
opts.initialCachedState ?? null,
|
||||
opts.defaultModelSelection
|
||||
);
|
||||
this.defaultEffort = opts.defaultEffort ?? null;
|
||||
this.ready = this.initialize(opts);
|
||||
}
|
||||
}
|
||||
|
|
@ -315,6 +333,16 @@ export class AgentSession {
|
|||
return this.backendSessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effort the session was seeded with. Consumed by
|
||||
* `descriptor.applyInitialSessionConfig` so descriptor-style backends can
|
||||
* replay it once their model catalog reports an effort dimension. Null
|
||||
* when the session was seeded without an effort preference.
|
||||
*/
|
||||
getDefaultEffort(): string | null {
|
||||
return this.defaultEffort;
|
||||
}
|
||||
|
||||
private async initialize(opts: AgentSessionStartOptions): Promise<void> {
|
||||
const { backend, cwd, defaultModelSelection } = opts;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
import { FileSystemAdapter, App } from "obsidian";
|
||||
import { AgentSession } from "./AgentSession";
|
||||
import { AgentSessionManager } from "./AgentSessionManager";
|
||||
import { setSettings as mockedSetSettings } from "@/settings/model";
|
||||
import type { BackendDescriptor } from "./types";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
|
|
@ -824,29 +823,26 @@ describe("AgentSessionManager.applySelection", () => {
|
|||
const session = await mgr.createSession();
|
||||
|
||||
// Effort-only patch: baseModelId resolves from current state.
|
||||
(mockedSetSettings as jest.Mock).mockClear();
|
||||
await mgr.applySelection({ effort: "high" }, { expectBackendId: "opencode" });
|
||||
expect(applySelectionMock).toHaveBeenCalledWith(session, {
|
||||
baseModelId: "anthropic/sonnet",
|
||||
effort: "high",
|
||||
});
|
||||
// Resolved selection is also persisted to settings.
|
||||
const persistedAfterEffort = readPersistedDefault(mockedSetSettings as jest.Mock, "opencode");
|
||||
expect(persistedAfterEffort).toEqual({
|
||||
// Resolved selection is remembered in memory so the next new session
|
||||
// on this backend inherits it.
|
||||
expect(mgr.getLastSelection("opencode")).toEqual({
|
||||
baseModelId: "anthropic/sonnet",
|
||||
effort: "high",
|
||||
});
|
||||
|
||||
// Full patch: both fields land verbatim.
|
||||
applySelectionMock.mockClear();
|
||||
(mockedSetSettings as jest.Mock).mockClear();
|
||||
await mgr.applySelection({ baseModelId: "anthropic/opus", effort: null });
|
||||
expect(applySelectionMock).toHaveBeenCalledWith(session, {
|
||||
baseModelId: "anthropic/opus",
|
||||
effort: null,
|
||||
});
|
||||
const persistedAfterFull = readPersistedDefault(mockedSetSettings as jest.Mock, "opencode");
|
||||
expect(persistedAfterFull).toEqual({
|
||||
expect(mgr.getLastSelection("opencode")).toEqual({
|
||||
baseModelId: "anthropic/opus",
|
||||
effort: null,
|
||||
});
|
||||
|
|
@ -906,32 +902,25 @@ describe("AgentSessionManager.applySelection", () => {
|
|||
return s;
|
||||
});
|
||||
await mgr.createSession();
|
||||
(mockedSetSettings as jest.Mock).mockClear();
|
||||
await expect(
|
||||
mgr.applySelection({ baseModelId: "anthropic/opus", effort: "high" })
|
||||
).rejects.toThrow("nope");
|
||||
// No persistence after a failed apply.
|
||||
expect(readPersistedDefault(mockedSetSettings as jest.Mock, "opencode")).toBeUndefined();
|
||||
// No in-memory remembered selection after a failed apply.
|
||||
expect(mgr.getLastSelection("opencode")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Walk through `setSettings` calls (each carries an updater function) and
|
||||
* return the most recent `defaultModel` written for `backendId`.
|
||||
*/
|
||||
function readPersistedDefault(
|
||||
setSettings: jest.Mock,
|
||||
backendId: string
|
||||
): { baseModelId: string; effort: string | null } | undefined {
|
||||
let backends: Record<string, { defaultModel?: { baseModelId: string; effort: string | null } }> =
|
||||
{};
|
||||
for (const call of setSettings.mock.calls) {
|
||||
const updater = call[0];
|
||||
if (typeof updater !== "function") continue;
|
||||
const patch = updater({ agentMode: { backends } });
|
||||
if (patch?.agentMode?.backends) {
|
||||
backends = { ...backends, ...patch.agentMode.backends };
|
||||
}
|
||||
}
|
||||
return backends[backendId]?.defaultModel;
|
||||
}
|
||||
describe("AgentSessionManager.userEffortPreference", () => {
|
||||
it("starts unset", () => {
|
||||
const mgr = buildManager();
|
||||
expect(mgr.getUserEffortPreference()).toBeNull();
|
||||
});
|
||||
|
||||
it("setUserEffortPreference / getUserEffortPreference round-trip", () => {
|
||||
const mgr = buildManager();
|
||||
mgr.setUserEffortPreference("high", 2);
|
||||
expect(mgr.getUserEffortPreference()).toEqual({ value: "high", index: 2 });
|
||||
mgr.setUserEffortPreference(null, 0);
|
||||
expect(mgr.getUserEffortPreference()).toEqual({ value: null, index: 0 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,16 @@ const AUTOSAVE_DEBOUNCE_MS = 500;
|
|||
|
||||
export type PermissionPrompter = (req: PermissionPrompt) => Promise<PermissionDecision>;
|
||||
|
||||
/**
|
||||
* A user's last explicit effort pick, recorded as both the canonical value
|
||||
* and the index it occupied in the source model's `effortOptions`. The
|
||||
* picker resolver re-maps this onto any new model's options.
|
||||
*/
|
||||
export interface EffortPreference {
|
||||
value: string | null;
|
||||
index: number;
|
||||
}
|
||||
|
||||
// Injected by the barrel so `session/` doesn't have to import
|
||||
// `backends/registry` directly (would breach the layer boundary).
|
||||
export type DescriptorResolver = (id: BackendId) => BackendDescriptor | undefined;
|
||||
|
|
@ -73,6 +83,24 @@ export class AgentSessionManager {
|
|||
private readonly pendingBackendRestarts = new Map<BackendId, string>();
|
||||
private readonly restartingBackends = new Set<BackendId>();
|
||||
private readonly preloader: AgentModelPreloader;
|
||||
/**
|
||||
* Per-backend most-recent (model, effort) used in this runtime. Seeds new
|
||||
* sessions on the same backend so the user's last pick carries forward
|
||||
* without persistence — wiped on plugin reload, at which point we fall
|
||||
* back to the backend's catalog-declared default.
|
||||
*/
|
||||
private readonly lastSelectionByBackend = new Map<BackendId, ModelSelection>();
|
||||
/**
|
||||
* Most recent effort the user *explicitly* picked, alongside the index it
|
||||
* occupied in that model's `effortOptions`. Survives model swaps —
|
||||
* including swaps through models that don't expose effort at all — so the
|
||||
* intent can be re-resolved against any new model's options (value match,
|
||||
* then same index, clamped to the available range). In-memory only,
|
||||
* wiped on plugin reload to mirror `lastSelectionByBackend`. Set only by
|
||||
* explicit user picks (effort sibling or atomic model+effort commit) —
|
||||
* never by the implicit resolver that runs during a model-only swap.
|
||||
*/
|
||||
private userEffortPreference: EffortPreference | null = null;
|
||||
/**
|
||||
* Per-backend preload status. The chat UI gates its first render on the
|
||||
* *active* backend's status; the model picker shows a "Loading…" or
|
||||
|
|
@ -178,10 +206,10 @@ export class AgentSessionManager {
|
|||
* to `settings.agentMode.activeBackend` (the model-picker keeps that in
|
||||
* sync with the user's most recently selected default model).
|
||||
*
|
||||
* The new session's initial (model, effort) is read from the persisted
|
||||
* default for `backendId` via `getDefaultSelection`. Picker call sites that
|
||||
* want a specific selection on a new backend should call
|
||||
* `persistDefaultSelection` first.
|
||||
* The new session's initial (model, effort) is read from the in-memory
|
||||
* last-used selection for `backendId` via `getLastSelection`. Picker call
|
||||
* sites that want a specific selection on a new backend should call
|
||||
* `rememberLastSelection` first.
|
||||
*/
|
||||
async createSession(backendId?: BackendId): Promise<AgentSession> {
|
||||
if (this.disposed) {
|
||||
|
|
@ -219,7 +247,7 @@ export class AgentSessionManager {
|
|||
throw new Error("AgentSessionManager was shut down during session creation");
|
||||
}
|
||||
|
||||
const seedSelection = this.getDefaultSelection(resolvedId) ?? undefined;
|
||||
const seedSelection = this.getLastSelection(resolvedId) ?? undefined;
|
||||
|
||||
let session: AgentSession;
|
||||
if (warm) {
|
||||
|
|
@ -235,6 +263,7 @@ export class AgentSessionManager {
|
|||
backendId: resolvedId,
|
||||
initialState: warm.state,
|
||||
defaultModelSelection: seedSelection,
|
||||
defaultEffort: seedSelection?.effort ?? null,
|
||||
cwd: vaultBasePath,
|
||||
getDescriptor: () => this.opts.resolveDescriptor(resolvedId),
|
||||
});
|
||||
|
|
@ -248,6 +277,7 @@ export class AgentSessionManager {
|
|||
internalId: uuidv4(),
|
||||
backendId: resolvedId,
|
||||
defaultModelSelection: seedSelection,
|
||||
defaultEffort: seedSelection?.effort ?? null,
|
||||
initialCachedState: this.preloader.getCachedBackendState(resolvedId),
|
||||
getDescriptor: () => this.opts.resolveDescriptor(resolvedId),
|
||||
});
|
||||
|
|
@ -270,7 +300,7 @@ export class AgentSessionManager {
|
|||
.then(async () => {
|
||||
if (descriptor.applyInitialSessionConfig) {
|
||||
try {
|
||||
await descriptor.applyInitialSessionConfig(session, getSettings());
|
||||
await descriptor.applyInitialSessionConfig(session);
|
||||
} catch (e) {
|
||||
logWarn(
|
||||
`[AgentMode] applyInitialSessionConfig failed for ${resolvedId}; continuing`,
|
||||
|
|
@ -313,33 +343,41 @@ export class AgentSessionManager {
|
|||
this.notify();
|
||||
}
|
||||
|
||||
/** Read the user's sticky model preference for `backendId`, or `null` if none. */
|
||||
getDefaultSelection(backendId: BackendId): ModelSelection | null {
|
||||
const backends = getSettings().agentMode?.backends as
|
||||
| Record<string, { defaultModel?: ModelSelection | null } | undefined>
|
||||
| undefined;
|
||||
return backends?.[backendId]?.defaultModel ?? null;
|
||||
/**
|
||||
* Most recent (model, effort) used on `backendId` in this runtime, or `null`
|
||||
* if no session on that backend has chosen one yet. In-memory only — the
|
||||
* map is wiped on plugin reload, at which point new sessions fall back to
|
||||
* the backend's catalog-declared default.
|
||||
*/
|
||||
getLastSelection(backendId: BackendId): ModelSelection | null {
|
||||
return this.lastSelectionByBackend.get(backendId) ?? null;
|
||||
}
|
||||
|
||||
/** Persist a sticky model preference for `backendId`. Pass `null` to clear. */
|
||||
async persistDefaultSelection(
|
||||
backendId: BackendId,
|
||||
selection: ModelSelection | null
|
||||
): Promise<void> {
|
||||
setSettings((cur) => {
|
||||
const existing = (cur.agentMode.backends as Record<string, unknown> | undefined)?.[
|
||||
backendId
|
||||
] as Record<string, unknown> | undefined;
|
||||
return {
|
||||
agentMode: {
|
||||
...cur.agentMode,
|
||||
backends: {
|
||||
...cur.agentMode.backends,
|
||||
[backendId]: { ...(existing ?? {}), defaultModel: selection },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
/**
|
||||
* Record `selection` as the most recent pick for `backendId`. Called after
|
||||
* every user-driven model/effort change so the next new session on the
|
||||
* same backend inherits it.
|
||||
*/
|
||||
rememberLastSelection(backendId: BackendId, selection: ModelSelection): void {
|
||||
this.lastSelectionByBackend.set(backendId, selection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the user's explicit-effort preference, if any. Returned by reference
|
||||
* — callers must not mutate the object.
|
||||
*/
|
||||
getUserEffortPreference(): EffortPreference | null {
|
||||
return this.userEffortPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the user's explicit effort pick + its index in the source model's
|
||||
* `effortOptions`. The picker resolver consults this on model swaps so the
|
||||
* intent carries across models that don't share an effort vocabulary (or
|
||||
* don't expose effort at all).
|
||||
*/
|
||||
setUserEffortPreference(value: string | null, index: number): void {
|
||||
this.userEffortPreference = { value, index };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -356,8 +394,9 @@ export class AgentSessionManager {
|
|||
* might fire after a session swap.
|
||||
*
|
||||
* After a successful descriptor apply, the resolved selection is also
|
||||
* written to the persisted default for the active backend — symmetric
|
||||
* with `applyMode`. If the descriptor throws, no persistence occurs.
|
||||
* remembered in memory as the latest pick for the active backend, so the
|
||||
* next new session on the same backend inherits it. If the descriptor
|
||||
* throws, nothing is remembered.
|
||||
*/
|
||||
async applySelection(
|
||||
patch: { baseModelId?: string; effort?: string | null },
|
||||
|
|
@ -374,7 +413,7 @@ export class AgentSessionManager {
|
|||
effort: patch.effort !== undefined ? patch.effort : current.effort,
|
||||
};
|
||||
await descriptor.applySelection(session, resolved);
|
||||
await this.persistDefaultSelection(session.backendId, resolved);
|
||||
this.rememberLastSelection(session.backendId, resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1050,6 +1089,7 @@ export class AgentSessionManager {
|
|||
app: this.app,
|
||||
clientVersion: this.plugin.manifest.version,
|
||||
descriptor,
|
||||
getSeedSelection: () => this.getLastSelection(backendId),
|
||||
});
|
||||
const startPromise = (async () => {
|
||||
// ACP backends declare `start()` to spawn the subprocess and run the
|
||||
|
|
|
|||
|
|
@ -92,6 +92,15 @@ export interface BackendDescriptor {
|
|||
app: App;
|
||||
clientVersion: string;
|
||||
descriptor: BackendDescriptor;
|
||||
/**
|
||||
* Latest in-memory (model, effort) selection for this backend, computed
|
||||
* lazily so spawn-time consumers (e.g. OpenCode's `config.model`) and
|
||||
* per-session seeders (Claude SDK's `newSession`) read it on demand
|
||||
* rather than at registration time. Returns `null` when no session on
|
||||
* this backend has chosen a model yet — backends should fall back to
|
||||
* their catalog-declared default.
|
||||
*/
|
||||
getSeedSelection?: () => ModelSelection | null;
|
||||
}): BackendProcess;
|
||||
|
||||
/** Optional: backend-specific settings panel. Rendered inside the Agent Mode tab. */
|
||||
|
|
@ -139,10 +148,12 @@ export interface BackendDescriptor {
|
|||
): ModeMapping | null;
|
||||
|
||||
/**
|
||||
* Optional: replay persisted state on a freshly created session. Runs
|
||||
* once after `createSession` resolves.
|
||||
* Optional: replay seed state on a freshly created session. Runs once
|
||||
* after `createSession` resolves. The seed (model, effort) was passed to
|
||||
* `AgentSession.start` via `defaultModelId` / `defaultEffort`; descriptors
|
||||
* read it back off the session instead of looking up settings here.
|
||||
*/
|
||||
applyInitialSessionConfig?(session: AgentSession, settings: CopilotSettings): Promise<void>;
|
||||
applyInitialSessionConfig?(session: AgentSession): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional: identify the backend's own plan-mode plan files. Used by the
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ import { getSettings, updateAgentModeBackendFields, type CopilotSettings } from
|
|||
/**
|
||||
* User overrides win; absent overrides fall through to the descriptor's
|
||||
* default policy; absent policy = default-enabled.
|
||||
*
|
||||
* Override keys are the bare `baseModelId` (e.g. `anthropic/claude-sonnet-4-5`,
|
||||
* `bigpickle/big-pickle`, `claude-3-5-sonnet-20241022`). The per-backend
|
||||
* scoping comes from the storage path
|
||||
* (`agentMode.backends.<id>.modelEnabledOverrides`); the key never repeats
|
||||
* the backend id or any other prefix. Settings panels MUST write the same
|
||||
* shape or their toggles won't take effect.
|
||||
*/
|
||||
export function isAgentModelEnabled(
|
||||
descriptor: BackendDescriptor,
|
||||
|
|
|
|||
|
|
@ -526,9 +526,7 @@ export class SkillManager {
|
|||
const patchResult = await this.runInternalMutation(
|
||||
() => runUpdateProperties({ skill: activeSkill, patch: req.patch, fs }),
|
||||
(r) =>
|
||||
r.ok && vaultRoot !== null
|
||||
? buildUpdatePropertiesExpectations(activeSkill, vaultRoot)
|
||||
: []
|
||||
r.ok && vaultRoot !== null ? buildUpdatePropertiesExpectations(activeSkill, vaultRoot) : []
|
||||
);
|
||||
if (!patchResult.ok) {
|
||||
if (newName !== undefined) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import {
|
||||
buildCommitSelection,
|
||||
buildEffortSibling,
|
||||
buildModelOnChange,
|
||||
buildPickerEntries,
|
||||
resolveActiveDisplayState,
|
||||
resolveEffortForOptions,
|
||||
} from "./agentModelPickerHelpers";
|
||||
import type { EffortPreference } from "@/agentMode/session/AgentSessionManager";
|
||||
import type {
|
||||
BackendDescriptor,
|
||||
BackendState,
|
||||
|
|
@ -132,19 +135,23 @@ function makeUIState(opts: {
|
|||
|
||||
function makeManager(opts: {
|
||||
cachedStateById?: Record<string, BackendState | null>;
|
||||
defaultSelectionById?: Record<string, { baseModelId: string; effort: string | null } | null>;
|
||||
lastSelectionById?: Record<string, { baseModelId: string; effort: string | null } | null>;
|
||||
userEffortPreference?: EffortPreference | null;
|
||||
setDefaultBackend?: jest.Mock;
|
||||
applySelection?: jest.Mock;
|
||||
persistDefaultSelection?: jest.Mock;
|
||||
rememberLastSelection?: jest.Mock;
|
||||
createSession?: jest.Mock;
|
||||
closeSession?: jest.Mock;
|
||||
setUserEffortPreference?: jest.Mock;
|
||||
}): AgentSessionManager {
|
||||
return {
|
||||
getCachedBackendState: (id: string) => opts.cachedStateById?.[id] ?? null,
|
||||
getDefaultSelection: (id: string) => opts.defaultSelectionById?.[id] ?? null,
|
||||
getLastSelection: (id: string) => opts.lastSelectionById?.[id] ?? null,
|
||||
getUserEffortPreference: () => opts.userEffortPreference ?? null,
|
||||
setUserEffortPreference: opts.setUserEffortPreference ?? jest.fn(),
|
||||
setDefaultBackend: opts.setDefaultBackend ?? jest.fn(),
|
||||
applySelection: opts.applySelection ?? jest.fn().mockResolvedValue(undefined),
|
||||
persistDefaultSelection: opts.persistDefaultSelection ?? jest.fn().mockResolvedValue(undefined),
|
||||
rememberLastSelection: opts.rememberLastSelection ?? jest.fn(),
|
||||
createSession: opts.createSession ?? jest.fn().mockResolvedValue(undefined),
|
||||
closeSession: opts.closeSession ?? jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentSessionManager;
|
||||
|
|
@ -323,7 +330,10 @@ describe("buildModelOnChange", () => {
|
|||
const onChange = buildModelOnChange(manager, ctxFor("codex"), entries);
|
||||
onChange("codex:gpt-5|agent");
|
||||
expect(setDefaultBackend).toHaveBeenCalledWith("codex");
|
||||
expect(applySelection).toHaveBeenCalledWith({ baseModelId: "gpt-5" });
|
||||
// No effort preference and no effort options on the target → effort is
|
||||
// resolved to null and passed explicitly so the descriptor's
|
||||
// applySelection sees the canonical "no effort" pick.
|
||||
expect(applySelection).toHaveBeenCalledWith({ baseModelId: "gpt-5", effort: null });
|
||||
});
|
||||
|
||||
it("same-backend pick with canSwitchModel === false does not call applySelection", () => {
|
||||
|
|
@ -337,36 +347,36 @@ describe("buildModelOnChange", () => {
|
|||
expect(applySelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cross-backend pick persists the new (model, effort) on the target backend before creating the session", async () => {
|
||||
const persistDefaultSelection = jest.fn().mockResolvedValue(undefined);
|
||||
it("cross-backend pick remembers the new (model, effort) on the target backend before creating the session", async () => {
|
||||
const rememberLastSelection = jest.fn();
|
||||
const createSession = jest.fn().mockResolvedValue(undefined);
|
||||
const setDefaultBackend = jest.fn();
|
||||
const closeSession = jest.fn().mockResolvedValue(undefined);
|
||||
const callOrder: string[] = [];
|
||||
persistDefaultSelection.mockImplementation(async () => {
|
||||
callOrder.push("persist");
|
||||
rememberLastSelection.mockImplementation(() => {
|
||||
callOrder.push("remember");
|
||||
});
|
||||
createSession.mockImplementation(async () => {
|
||||
callOrder.push("create");
|
||||
});
|
||||
const manager = makeManager({
|
||||
persistDefaultSelection,
|
||||
rememberLastSelection,
|
||||
createSession,
|
||||
setDefaultBackend,
|
||||
closeSession,
|
||||
defaultSelectionById: { claude: { baseModelId: "old", effort: "low" } },
|
||||
lastSelectionById: { claude: { baseModelId: "old", effort: "low" } },
|
||||
});
|
||||
const entries = [pickerEntry("claude", "opus")];
|
||||
const onChange = buildModelOnChange(manager, ctxFor("codex"), entries);
|
||||
onChange("claude:opus|agent");
|
||||
// Allow the IIFE to run.
|
||||
await new Promise((r) => window.setTimeout(r, 0));
|
||||
expect(persistDefaultSelection).toHaveBeenCalledWith("claude", {
|
||||
expect(rememberLastSelection).toHaveBeenCalledWith("claude", {
|
||||
baseModelId: "opus",
|
||||
effort: "low",
|
||||
});
|
||||
expect(createSession).toHaveBeenCalledWith("claude");
|
||||
expect(callOrder).toEqual(["persist", "create"]);
|
||||
expect(callOrder).toEqual(["remember", "create"]);
|
||||
expect(setDefaultBackend).toHaveBeenCalledWith("claude");
|
||||
expect(closeSession).toHaveBeenCalledWith("tab-1");
|
||||
});
|
||||
|
|
@ -384,4 +394,179 @@ describe("buildModelOnChange", () => {
|
|||
expect(setDefaultBackend).not.toHaveBeenCalled();
|
||||
expect(applySelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("same-backend pick re-resolves the effort intent against the target model's options (label match)", () => {
|
||||
const applySelection = jest.fn().mockResolvedValue(undefined);
|
||||
const opus = makeModelEntry("opus");
|
||||
opus.effortOptions = [
|
||||
{ value: "low", label: "low" },
|
||||
{ value: "medium", label: "medium" },
|
||||
{ value: "high", label: "high" },
|
||||
];
|
||||
const sonnet = makeModelEntry("sonnet");
|
||||
sonnet.effortOptions = [
|
||||
{ value: "low", label: "low" },
|
||||
{ value: "medium", label: "medium" },
|
||||
];
|
||||
const ctx = ctxFor("claude");
|
||||
ctx.activeModelState = makeModelState("opus", [opus, sonnet]);
|
||||
const manager = makeManager({
|
||||
applySelection,
|
||||
userEffortPreference: { value: "high", index: 2 },
|
||||
});
|
||||
const entries = [pickerEntry("claude", "sonnet")];
|
||||
const onChange = buildModelOnChange(manager, ctx, entries);
|
||||
onChange("claude:sonnet|agent");
|
||||
// Sonnet has no "high" — fall back to index 2 clamped to last
|
||||
// available, i.e. "medium".
|
||||
expect(applySelection).toHaveBeenCalledWith({ baseModelId: "sonnet", effort: "medium" });
|
||||
});
|
||||
|
||||
it("same-backend pick to a no-effort model passes effort: null without clobbering the preference", () => {
|
||||
const applySelection = jest.fn().mockResolvedValue(undefined);
|
||||
const setUserEffortPreference = jest.fn();
|
||||
const opus = makeModelEntry("opus");
|
||||
opus.effortOptions = [
|
||||
{ value: "low", label: "low" },
|
||||
{ value: "medium", label: "medium" },
|
||||
{ value: "high", label: "high" },
|
||||
];
|
||||
const haiku = makeModelEntry("haiku");
|
||||
haiku.effortOptions = [];
|
||||
const ctx = ctxFor("claude");
|
||||
ctx.activeModelState = makeModelState("opus", [opus, haiku]);
|
||||
const manager = makeManager({
|
||||
applySelection,
|
||||
setUserEffortPreference,
|
||||
userEffortPreference: { value: "high", index: 2 },
|
||||
});
|
||||
const entries = [pickerEntry("claude", "haiku")];
|
||||
const onChange = buildModelOnChange(manager, ctx, entries);
|
||||
onChange("claude:haiku|agent");
|
||||
expect(applySelection).toHaveBeenCalledWith({ baseModelId: "haiku", effort: null });
|
||||
// Crucial: a model-only swap must not update the preference, so that
|
||||
// swapping back to a model with effort still restores "high".
|
||||
expect(setUserEffortPreference).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildEffortSibling.onChange ----
|
||||
|
||||
describe("buildEffortSibling.onChange", () => {
|
||||
it("records the picked effort + its index in the active model's options", () => {
|
||||
const applySelection = jest.fn().mockResolvedValue(undefined);
|
||||
const setUserEffortPreference = jest.fn();
|
||||
const opus: ModelEntry = {
|
||||
baseModelId: "opus",
|
||||
name: "opus",
|
||||
provider: null,
|
||||
effortOptions: [
|
||||
{ value: "low", label: "low" },
|
||||
{ value: "medium", label: "medium" },
|
||||
{ value: "high", label: "high" },
|
||||
],
|
||||
};
|
||||
const ctx: ModelActiveContext = {
|
||||
activeSession: { backendId: "claude" } as unknown as AgentSession,
|
||||
activeChatUIState: makeUIState({ canSwitchEffort: true }),
|
||||
activeBackendId: "claude",
|
||||
activeDescriptor: makeDescriptor("claude"),
|
||||
activeSessionHasHistory: false,
|
||||
activeModelState: makeModelState("opus", [opus]),
|
||||
activeCurrentEntry: opus,
|
||||
};
|
||||
const manager = makeManager({ applySelection, setUserEffortPreference });
|
||||
const sibling = buildEffortSibling(manager, ctx);
|
||||
sibling?.onChange("high");
|
||||
expect(setUserEffortPreference).toHaveBeenCalledWith("high", 2);
|
||||
expect(applySelection).toHaveBeenCalledWith({ effort: "high" }, { expectBackendId: "claude" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildCommitSelection ----
|
||||
|
||||
describe("buildCommitSelection", () => {
|
||||
function pickerEntry(backendId: string, baseModelId: string) {
|
||||
return {
|
||||
name: baseModelId,
|
||||
provider: "agent",
|
||||
enabled: true,
|
||||
isBuiltIn: false,
|
||||
displayName: baseModelId,
|
||||
_group: backendId,
|
||||
_backendId: backendId,
|
||||
};
|
||||
}
|
||||
|
||||
function ctxFor(activeBackendId: string | null): ModelActiveContext {
|
||||
const session = activeBackendId
|
||||
? ({ backendId: activeBackendId, internalId: "tab-1" } as unknown as AgentSession)
|
||||
: null;
|
||||
return {
|
||||
activeSession: session,
|
||||
activeChatUIState: makeUIState({ canSwitchModel: true }),
|
||||
activeBackendId,
|
||||
activeDescriptor: activeBackendId ? makeDescriptor("claude") : undefined,
|
||||
activeSessionHasHistory: false,
|
||||
activeModelState: null,
|
||||
activeCurrentEntry: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
it("records the committed effort + its index before applying the same-backend pick", () => {
|
||||
const applySelection = jest.fn().mockResolvedValue(undefined);
|
||||
const setUserEffortPreference = jest.fn();
|
||||
const opus = makeModelEntry("opus");
|
||||
opus.effortOptions = [
|
||||
{ value: "low", label: "low" },
|
||||
{ value: "medium", label: "medium" },
|
||||
{ value: "high", label: "high" },
|
||||
];
|
||||
const ctx = ctxFor("claude");
|
||||
ctx.activeModelState = makeModelState("opus", [opus]);
|
||||
const manager = makeManager({ applySelection, setUserEffortPreference });
|
||||
const entries = [pickerEntry("claude", "opus")];
|
||||
const commit = buildCommitSelection(manager, ctx, entries, () => {});
|
||||
commit("claude:opus|agent", "medium");
|
||||
expect(setUserEffortPreference).toHaveBeenCalledWith("medium", 1);
|
||||
expect(applySelection).toHaveBeenCalledWith({ baseModelId: "opus", effort: "medium" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- resolveEffortForOptions ----
|
||||
|
||||
describe("resolveEffortForOptions", () => {
|
||||
const options = [
|
||||
{ value: "low", label: "low" },
|
||||
{ value: "medium", label: "medium" },
|
||||
{ value: "high", label: "high" },
|
||||
];
|
||||
|
||||
it("returns null when the target has no effort options", () => {
|
||||
expect(resolveEffortForOptions({ value: "high", index: 2 }, [])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the first option when there is no preference", () => {
|
||||
expect(resolveEffortForOptions(null, options)).toBe("low");
|
||||
});
|
||||
|
||||
it("returns the exact value when present in the target options", () => {
|
||||
expect(resolveEffortForOptions({ value: "high", index: 2 }, options)).toBe("high");
|
||||
});
|
||||
|
||||
it("falls back to the same index when the value is missing", () => {
|
||||
expect(resolveEffortForOptions({ value: "xhigh", index: 1 }, options)).toBe("medium");
|
||||
});
|
||||
|
||||
it("clamps an out-of-bounds index to the last available option", () => {
|
||||
const shorter = [
|
||||
{ value: "low", label: "low" },
|
||||
{ value: "medium", label: "medium" },
|
||||
];
|
||||
expect(resolveEffortForOptions({ value: "xhigh", index: 4 }, shorter)).toBe("medium");
|
||||
});
|
||||
|
||||
it("clamps a negative index to zero", () => {
|
||||
expect(resolveEffortForOptions({ value: "ghost", index: -3 }, options)).toBe("low");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { Notice } from "obsidian";
|
||||
import { logError } from "@/logger";
|
||||
import { logError, logInfo } from "@/logger";
|
||||
|
||||
const BYOK_DIAG = true;
|
||||
import type { ModelSelectorEntry } from "@/components/ui/ModelSelector";
|
||||
import { isAgentModelEnabledOrKept } from "@/agentMode/session/modelEnable";
|
||||
import type { AgentSession } from "@/agentMode/session/AgentSession";
|
||||
import type { AgentChatUIState } from "@/agentMode/session/AgentChatUIState";
|
||||
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
|
||||
import type {
|
||||
AgentSessionManager,
|
||||
EffortPreference,
|
||||
} from "@/agentMode/session/AgentSessionManager";
|
||||
import { MethodUnsupportedError } from "@/agentMode/session/errors";
|
||||
import { backendRegistry } from "@/agentMode/backends/registry";
|
||||
import { getBackendModelOverrides } from "@/agentMode/session/backendSettingsAccess";
|
||||
|
|
@ -80,6 +85,28 @@ export function appendBackendSection(
|
|||
ctx.keepBaseModelId
|
||||
)
|
||||
);
|
||||
if (BYOK_DIAG && descriptor.id === "opencode") {
|
||||
const filteredIds = new Set(filtered.map((m) => m.baseModelId));
|
||||
const drops = ctx.backendModels
|
||||
.filter((m) => !filteredIds.has(m.baseModelId))
|
||||
.map((entry) => ({
|
||||
baseModelId: entry.baseModelId,
|
||||
name: entry.name,
|
||||
override: ctx.overrides?.[entry.baseModelId],
|
||||
defaultEnabled:
|
||||
descriptor.isModelEnabledByDefault?.({
|
||||
modelId: entry.baseModelId,
|
||||
name: entry.name,
|
||||
}) ?? null,
|
||||
keepBaseModelId: ctx.keepBaseModelId,
|
||||
}));
|
||||
logInfo("[BYOK-DIAG] appendBackendSection opencode", {
|
||||
total: ctx.backendModels.length,
|
||||
kept: filtered.length,
|
||||
droppedCount: drops.length,
|
||||
drops,
|
||||
});
|
||||
}
|
||||
for (const m of filtered) {
|
||||
entries.push(synthesizeAgentEntry(m.baseModelId, m.name, descriptor));
|
||||
}
|
||||
|
|
@ -130,6 +157,30 @@ export function resolveBaseModelId(entry: ModelSelectorEntry): string | undefine
|
|||
return entry.provider === AGENT_PROVIDER ? entry.name : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the effort value to apply to a model swap, given the user's last
|
||||
* explicit effort intent and the target model's effort options. Fallback
|
||||
* cascade: (1) exact value match, (2) same index, (3) clamp to last
|
||||
* available index. Returns `null` when the target has no effort options
|
||||
* (the model doesn't expose effort at all) or when there is no recorded
|
||||
* preference and the target has no options.
|
||||
*
|
||||
* Match on `value` rather than `label` because `value` is the stable
|
||||
* identifier the backend dispatches against; today's labels happen to be
|
||||
* the lowercased values but that's a translation-layer detail.
|
||||
*/
|
||||
export function resolveEffortForOptions(
|
||||
preference: EffortPreference | null,
|
||||
options: ReadonlyArray<{ value: string | null; label: string }>
|
||||
): string | null {
|
||||
if (options.length === 0) return null;
|
||||
if (!preference) return options[0].value;
|
||||
const exact = options.find((o) => o.value === preference.value);
|
||||
if (exact) return exact.value;
|
||||
const idx = Math.min(Math.max(preference.index, 0), options.length - 1);
|
||||
return options[idx].value;
|
||||
}
|
||||
|
||||
/** Bundle of session-derived inputs every model+effort builder needs. */
|
||||
export interface ModelActiveContext {
|
||||
activeSession: AgentSession | null;
|
||||
|
|
@ -183,15 +234,34 @@ export function buildPickerEntries(
|
|||
const entries: ModelSelectorEntry[] = [];
|
||||
for (const descriptor of descriptors) {
|
||||
const isActiveBackend = descriptor.id === ctx.activeBackendId;
|
||||
if (!isActiveBackend && ctx.activeSessionHasHistory) continue;
|
||||
if (!isActiveBackend && ctx.activeSessionHasHistory) {
|
||||
if (BYOK_DIAG && descriptor.id === "opencode") {
|
||||
logInfo("[BYOK-DIAG] buildPickerEntries opencode skipped", {
|
||||
reason: "not active backend && active session has history",
|
||||
activeBackendId: ctx.activeBackendId,
|
||||
activeSessionHasHistory: ctx.activeSessionHasHistory,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const cached = manager.getCachedBackendState(descriptor.id);
|
||||
const keepBaseModelId = isActiveBackend
|
||||
? (ctx.activeModelState?.current.baseModelId ?? null)
|
||||
: (manager.getDefaultSelection(descriptor.id)?.baseModelId ?? null);
|
||||
: (manager.getLastSelection(descriptor.id)?.baseModelId ?? null);
|
||||
const backendModels = cached?.model?.availableModels ?? null;
|
||||
const overrides = getBackendModelOverrides(settings, descriptor.id);
|
||||
if (BYOK_DIAG && descriptor.id === "opencode") {
|
||||
logInfo("[BYOK-DIAG] buildPickerEntries opencode", {
|
||||
isActiveBackend,
|
||||
activeSessionHasHistory: ctx.activeSessionHasHistory,
|
||||
keepBaseModelId,
|
||||
backendModels: backendModels?.map((m) => m.baseModelId) ?? null,
|
||||
overrides: overrides ?? null,
|
||||
});
|
||||
}
|
||||
appendBackendSection(entries, descriptor, {
|
||||
backendModels,
|
||||
overrides: getBackendModelOverrides(settings, descriptor.id),
|
||||
overrides,
|
||||
keepBaseModelId,
|
||||
});
|
||||
// No catalog cached yet (and not because the agent intentionally
|
||||
|
|
@ -230,6 +300,27 @@ export function buildPickerEntries(
|
|||
return { entries, valueKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the `effortOptions` for a `(backendId, baseModelId)` pair.
|
||||
* Reads the active session's translated state for the active backend (more
|
||||
* authoritative than the shared per-backend cache, which can lag a
|
||||
* sibling-tab swap) and the cached `BackendState` for any other backend.
|
||||
* Returns `[]` when the model isn't in the catalog yet.
|
||||
*/
|
||||
function lookupEffortOptions(
|
||||
manager: AgentSessionManager,
|
||||
ctx: ModelActiveContext,
|
||||
backendId: BackendId,
|
||||
baseModelId: string
|
||||
): ReadonlyArray<{ value: string | null; label: string }> {
|
||||
const modelState =
|
||||
ctx.activeBackendId === backendId
|
||||
? ctx.activeModelState
|
||||
: (manager.getCachedBackendState(backendId)?.model ?? null);
|
||||
const entry = modelState?.availableModels.find((e) => e.baseModelId === baseModelId);
|
||||
return entry?.effortOptions ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the optional effort sibling. Returns `undefined` when the active
|
||||
* model has no effort options. `disabled` mirrors
|
||||
|
|
@ -244,11 +335,17 @@ export function buildEffortSibling(
|
|||
if (!activeBackendId || !activeCurrentEntry) return undefined;
|
||||
if (activeCurrentEntry.effortOptions.length === 0) return undefined;
|
||||
if (!activeModelState) return undefined;
|
||||
const effortOptions = activeCurrentEntry.effortOptions;
|
||||
return {
|
||||
options: activeCurrentEntry.effortOptions,
|
||||
options: effortOptions,
|
||||
value: activeModelState.current.effort,
|
||||
disabled: activeChatUIState?.canSwitchEffort() === false,
|
||||
onChange: (value) => {
|
||||
// Record the user's explicit intent before applying — the index here
|
||||
// anchors the same-index fallback used when this preference is later
|
||||
// re-resolved against a different model's effort options.
|
||||
const index = effortOptions.findIndex((o) => o.value === value);
|
||||
manager.setUserEffortPreference(value, index >= 0 ? index : 0);
|
||||
manager
|
||||
.applySelection({ effort: value }, { expectBackendId: activeBackendId })
|
||||
.catch((err) => {
|
||||
|
|
@ -259,8 +356,9 @@ export function buildEffortSibling(
|
|||
}
|
||||
|
||||
/**
|
||||
* Persist the picked (model, effort) as the target backend's default, then
|
||||
* spawn a fresh session on it
|
||||
* Remember the picked (model, effort) for the target backend, then spawn a
|
||||
* fresh session on it. `createSession` reads the remembered selection back
|
||||
* out via `getLastSelection` to seed the new session.
|
||||
*/
|
||||
function runCrossBackendPick(
|
||||
manager: AgentSessionManager,
|
||||
|
|
@ -272,7 +370,7 @@ function runCrossBackendPick(
|
|||
): void {
|
||||
void (async () => {
|
||||
try {
|
||||
await manager.persistDefaultSelection(targetBackendId, { baseModelId, effort });
|
||||
manager.rememberLastSelection(targetBackendId, { baseModelId, effort });
|
||||
await manager.createSession(targetBackendId);
|
||||
manager.setDefaultBackend(targetBackendId);
|
||||
if (oldSessionId) {
|
||||
|
|
@ -289,11 +387,14 @@ function runCrossBackendPick(
|
|||
|
||||
/**
|
||||
* Build the model picker's `onChange` callback. Cross-backend picks seed
|
||||
* a fresh session on the target backend with the chosen model + the
|
||||
* target backend's persisted effort (no effort plumbed through the
|
||||
* legacy single-arg signature) and close the old empty tab in the
|
||||
* background. Same-backend picks route through the running session via
|
||||
* `applySelection`. Neither path writes to the saved default selection.
|
||||
* a fresh session on the target backend with the chosen model + the best
|
||||
* effort the target supports (the most recently remembered selection on
|
||||
* that backend wins; otherwise the user's global effort intent is
|
||||
* re-resolved against the target model's options). The old empty tab is
|
||||
* closed in the background. Same-backend picks route through the running
|
||||
* session via `applySelection` with the effort re-resolved against the
|
||||
* new model's options so the user's intent carries across models with
|
||||
* differing effort vocabularies (or no effort at all).
|
||||
*/
|
||||
export function buildModelOnChange(
|
||||
manager: AgentSessionManager,
|
||||
|
|
@ -321,16 +422,22 @@ export function buildModelOnChange(
|
|||
}
|
||||
|
||||
if (!activeSession || activeSession.backendId !== targetBackendId) {
|
||||
// Legacy ModelSelector path: no effort plumbed through. Preserve any
|
||||
// existing persisted effort for this backend.
|
||||
const persistedEffort = manager.getDefaultSelection(targetBackendId)?.effort ?? null;
|
||||
const targetOptions = lookupEffortOptions(manager, ctx, targetBackendId, baseModelId);
|
||||
const rememberedEffort = manager.getLastSelection(targetBackendId)?.effort;
|
||||
// Prefer the user's most-recent explicit pick on that backend.
|
||||
// Fall back to re-resolving the global effort intent against the
|
||||
// target model's options (handles "first time on this backend").
|
||||
const seedEffort =
|
||||
rememberedEffort !== undefined
|
||||
? rememberedEffort
|
||||
: resolveEffortForOptions(manager.getUserEffortPreference(), targetOptions);
|
||||
runCrossBackendPick(
|
||||
manager,
|
||||
activeSession?.internalId,
|
||||
targetBackendId,
|
||||
targetDescriptor,
|
||||
baseModelId,
|
||||
persistedEffort
|
||||
seedEffort
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -341,7 +448,15 @@ export function buildModelOnChange(
|
|||
new Notice("This agent doesn't support runtime model switching.");
|
||||
return;
|
||||
}
|
||||
manager.applySelection({ baseModelId }).catch((err) => {
|
||||
// Re-resolve the user's effort intent against the new model's options
|
||||
// so a model swap doesn't silently snap to `low` when the previous
|
||||
// effort label isn't present on the target (or doesn't exist at all).
|
||||
const targetOptions = lookupEffortOptions(manager, ctx, targetBackendId, baseModelId);
|
||||
const resolvedEffort = resolveEffortForOptions(
|
||||
manager.getUserEffortPreference(),
|
||||
targetOptions
|
||||
);
|
||||
manager.applySelection({ baseModelId, effort: resolvedEffort }).catch((err) => {
|
||||
handlePickerSwitchError(err, "model");
|
||||
});
|
||||
};
|
||||
|
|
@ -381,9 +496,10 @@ export function buildEffortOptionsByModelKey(
|
|||
/**
|
||||
* Build the atomic `(model, effort)` commit callback used by the merged
|
||||
* picker. Same-backend picks push both fields through `applySelection` in
|
||||
* one call. Cross-backend picks seed a fresh session on the target with
|
||||
* the drafted `(baseModelId, effort)` — the user's effort choice survives
|
||||
* the backend swap, and the saved default for either backend is left alone.
|
||||
* one call (which also updates the in-memory last-selection cache).
|
||||
* Cross-backend picks remember the drafted `(baseModelId, effort)` on
|
||||
* the target backend and seed a fresh session on it — the user's effort
|
||||
* choice survives the backend swap.
|
||||
*/
|
||||
export function buildCommitSelection(
|
||||
manager: AgentSessionManager,
|
||||
|
|
@ -406,6 +522,15 @@ export function buildCommitSelection(
|
|||
logError("[AgentMode] commitSelection references unknown backend", targetBackendId);
|
||||
return;
|
||||
}
|
||||
// The atomic commit is the user explicitly picking an effort against a
|
||||
// specific model — record it as the new global intent so subsequent
|
||||
// model swaps re-resolve against this index, even if they pass through
|
||||
// a model whose effort vocabulary doesn't contain this value.
|
||||
const targetOptions = lookupEffortOptions(manager, ctx, targetBackendId, baseModelId);
|
||||
if (targetOptions.length > 0) {
|
||||
const index = targetOptions.findIndex((o) => o.value === effort);
|
||||
manager.setUserEffortPreference(effort, index >= 0 ? index : 0);
|
||||
}
|
||||
if (!activeSession || activeSession.backendId !== targetBackendId) {
|
||||
runCrossBackendPick(
|
||||
manager,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ export interface AgentModelPickerOverride {
|
|||
* Atomically commit both the model and its effort. Same-backend picks
|
||||
* route through `applySelection({ baseModelId, effort })`; cross-backend
|
||||
* picks seed a fresh session on the target with the drafted selection.
|
||||
* Neither path writes to the saved default.
|
||||
* Both paths update the manager's in-memory last-selection cache so the
|
||||
* next new session on the same backend inherits this pick.
|
||||
*/
|
||||
commitSelection?: (modelKey: string, effort: string | null) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
MessagesPlaceholder,
|
||||
SystemMessagePromptTemplate,
|
||||
} from "@langchain/core/prompts";
|
||||
import ChatModelManager from "@/LLMProviders/chatModelManager";
|
||||
import ChatModelManager from "@/LLMProviders/ChatModelManager";
|
||||
import { CustomModel } from "@/aiParams";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -30,11 +30,7 @@ export const TokenLimitWarning: React.FC<TokenLimitWarningProps> = ({ message, a
|
|||
}
|
||||
|
||||
// Create update handler
|
||||
const handleModelUpdate = (
|
||||
isEmbedding: boolean,
|
||||
original: CustomModel,
|
||||
updated: CustomModel
|
||||
) => {
|
||||
const handleModelUpdate = (original: CustomModel, updated: CustomModel) => {
|
||||
const updatedModels = settings.activeModels.map((m) => (m === original ? updated : m));
|
||||
updateSetting("activeModels", updatedModels);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ export interface TabItem {
|
|||
icon: React.ReactNode;
|
||||
label: string;
|
||||
id: string;
|
||||
/**
|
||||
* When true, render the label with muted text + line-through to signal a
|
||||
* deprecated tab. Used during the BYOK migration (M4) for the legacy
|
||||
* Models tab while both BYOK and Models coexist.
|
||||
*/
|
||||
isMuted?: boolean;
|
||||
}
|
||||
|
||||
interface TabItemProps {
|
||||
|
|
@ -66,7 +72,8 @@ export const TabItem: React.FC<TabItemProps> = ({ tab, isSelected, onClick, isFi
|
|||
"tw-font-medium",
|
||||
"tw-transition-all tw-duration-200 tw-ease-in-out",
|
||||
"tw-overflow-hidden tw-whitespace-nowrap",
|
||||
"tw-max-w-[100px] tw-translate-x-0 tw-opacity-100"
|
||||
"tw-max-w-[100px] tw-translate-x-0 tw-opacity-100",
|
||||
tab.isMuted && !isSelected && "tw-text-muted tw-line-through tw-opacity-70"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@ interface TabContextType {
|
|||
|
||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Optional accessor for the tab context — returns `undefined` rather than
|
||||
* throwing when there's no `TabProvider` in the tree. Use when a component
|
||||
* may be rendered in tests or contexts outside the settings shell (e.g.
|
||||
* model-management dialogs that should still mount under jsdom without a
|
||||
* provider wrapper).
|
||||
*/
|
||||
export const useTabOptional = (): TabContextType | undefined => {
|
||||
return useContext(TabContext);
|
||||
};
|
||||
|
||||
export const TabProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [selectedTab, setSelectedTab] = useState("basic");
|
||||
// Compute the modal container lazily once; activeDocument is stable at provider
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Mock dependencies first to avoid circular dependencies
|
||||
jest.mock("./MessageRepository");
|
||||
jest.mock("@/LLMProviders/chatModelManager");
|
||||
jest.mock("@/LLMProviders/ChatModelManager");
|
||||
jest.mock("./ContextCompactor");
|
||||
jest.mock("./ContextManager");
|
||||
jest.mock("@/logger", () => ({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { logInfo } from "@/logger";
|
||||
import ChatModelManager from "@/LLMProviders/chatModelManager";
|
||||
import ChatModelManager from "@/LLMProviders/ChatModelManager";
|
||||
import { CompactionResult, ParsedContextItem } from "@/types/compaction";
|
||||
import { HumanMessage } from "@langchain/core/messages";
|
||||
|
||||
|
|
|
|||
18
src/main.ts
18
src/main.ts
|
|
@ -60,6 +60,7 @@ import {
|
|||
} from "@/services/webViewerService/webViewerServiceSingleton";
|
||||
import { WebSelectionTracker } from "@/services/webViewerService/webViewerServiceSelection";
|
||||
import VectorStoreManager from "@/search/vectorStoreManager";
|
||||
import { maybeShowMigrationNotice, registerMigrationStatusCommand } from "@/modelManagement";
|
||||
import { CopilotSettingTab } from "@/settings/SettingsPage";
|
||||
import {
|
||||
getModelKeyFromModel,
|
||||
|
|
@ -244,6 +245,23 @@ export default class CopilotPlugin extends Plugin {
|
|||
|
||||
registerCommands(this, undefined, getSettings());
|
||||
|
||||
// Model-management migration UX: surface a one-time notice if a
|
||||
// migration has been recorded (per §4.4) and register the support-
|
||||
// staff dev command (per §4.5). Both are no-ops when there's nothing
|
||||
// to show.
|
||||
{
|
||||
const settings = getSettings();
|
||||
const breadcrumbs = settings._migrationBreadcrumbs ?? [];
|
||||
maybeShowMigrationNotice(
|
||||
breadcrumbs[breadcrumbs.length - 1],
|
||||
Boolean(settings._migrationNoticeDismissed),
|
||||
() => {
|
||||
setSettings({ _migrationNoticeDismissed: true });
|
||||
}
|
||||
);
|
||||
registerMigrationStatusCommand(this, () => getSettings()._migrationBreadcrumbs ?? []);
|
||||
}
|
||||
|
||||
// Tool initialization is now handled automatically in CopilotPlusChainRunner and AutonomousAgentChainRunner
|
||||
|
||||
this.registerEvent(
|
||||
|
|
|
|||
495
src/modelManagement/catalog/ModelCatalogService.ts
Normal file
495
src/modelManagement/catalog/ModelCatalogService.ts
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
import type {
|
||||
CatalogDiskCache,
|
||||
CatalogFilters,
|
||||
CatalogMeta,
|
||||
CatalogModel,
|
||||
CatalogProvider,
|
||||
CatalogSource,
|
||||
ModelsCatalog,
|
||||
RefreshResult,
|
||||
} from "@/modelManagement/catalog/modelsCatalog.types";
|
||||
import { SUPPORTED_PROVIDER_IDS } from "@/modelManagement/providers/supportedProviders";
|
||||
import type { ProviderId } from "@/modelManagement/types";
|
||||
import { logInfo, logWarn } from "@/logger";
|
||||
|
||||
/**
|
||||
* Where to fetch the live catalog from.
|
||||
*/
|
||||
const CATALOG_URL = "https://models.dev/api.json";
|
||||
|
||||
/**
|
||||
* 5-second cap on the live fetch per §1.4 — we never block the user
|
||||
* indefinitely on a remote call.
|
||||
*/
|
||||
const FETCH_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Recommended providers always appear first in `getAllProviders()`. The
|
||||
* remaining providers follow `SUPPORTED_PROVIDER_IDS` order.
|
||||
*/
|
||||
const RECOMMENDED_PROVIDER_IDS: readonly string[] = ["anthropic", "openai", "google"];
|
||||
|
||||
/**
|
||||
* Filesystem abstraction used by the service. Mirrors the subset of
|
||||
* Obsidian's `DataAdapter` we touch — pulled out so tests can plug in a
|
||||
* tiny fake without bringing in the full Obsidian mock surface.
|
||||
*/
|
||||
export interface CatalogStorageAdapter {
|
||||
exists(path: string): Promise<boolean>;
|
||||
read(path: string): Promise<string>;
|
||||
write(path: string, data: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options consumed by `init()`. Both fields are required before any of the
|
||||
* disk-touching paths can run, but accessors that only need in-memory state
|
||||
* (and the bundled fallback) work without `init()` having been called.
|
||||
*/
|
||||
export interface CatalogServiceInit {
|
||||
/**
|
||||
* Vault data adapter (typically `app.vault.adapter`). The service only
|
||||
* uses `exists` / `read` / `write` — see `CatalogStorageAdapter`.
|
||||
*/
|
||||
adapter: CatalogStorageAdapter;
|
||||
/**
|
||||
* Plugin manifest `dir` (e.g. `.obsidian/plugins/copilot`). The disk
|
||||
* cache file is written under this directory.
|
||||
*/
|
||||
manifestDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional injection hooks for tests. None of these are exposed via the
|
||||
* `@/modelManagement` public API — the service is constructed via
|
||||
* `getInstance()` everywhere except inside its own test file.
|
||||
*/
|
||||
export interface CatalogServiceOverrides {
|
||||
/** Replace `globalThis.fetch` for the duration of the instance. */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Override `Date.now()` for deterministic timestamps. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal name of the disk cache file (kept hidden via leading-dot
|
||||
* convention — same pattern Obsidian uses for other plugin caches).
|
||||
*/
|
||||
const DISK_CACHE_FILENAME = ".modelsCatalogCache.json";
|
||||
|
||||
/**
|
||||
* `ModelCatalogService` — lazy, three-tier read facade backing the BYOK
|
||||
* model picker.
|
||||
*
|
||||
* Read order: memory cache → disk cache → bundled fallback.
|
||||
* Writes: only `refresh()` mutates state (live fetch → memory + disk).
|
||||
*
|
||||
* The service is a singleton (`getInstance()`); production code never
|
||||
* instantiates it directly. Tests use the constructor with overrides.
|
||||
*
|
||||
* **Lazy contract**: nothing reads the disk or hits the network until a
|
||||
* caller actually invokes `ensureLoaded()` or `refresh()`. Plugin onload
|
||||
* MUST NOT call either — only BYOK-tab-side callers (M4+) do.
|
||||
*
|
||||
* See: designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §1.4.
|
||||
*/
|
||||
export class ModelCatalogService {
|
||||
private static instance: ModelCatalogService | undefined;
|
||||
|
||||
private adapter: CatalogStorageAdapter | undefined;
|
||||
private manifestDir: string | undefined;
|
||||
/**
|
||||
* Resolved lazily so test environments without a global `fetch` (e.g.
|
||||
* the default jsdom in `jest.config.js`) can still construct the
|
||||
* service. Production builds run in Electron renderer where
|
||||
* `globalThis.fetch` is always defined.
|
||||
*/
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly now: () => number;
|
||||
|
||||
private memoryCache: ModelsCatalog | undefined;
|
||||
private memorySource: CatalogSource = "bundled";
|
||||
private fetchedAt: number | null = null;
|
||||
|
||||
/** Tracks whether ensureLoaded has populated the memory cache. */
|
||||
private loaded = false;
|
||||
/** In-flight ensureLoaded promise for idempotency. */
|
||||
private loadInflight: Promise<void> | null = null;
|
||||
|
||||
/** Listeners notified when the memory cache changes. */
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Public constructor used only by tests (and the singleton bootstrap).
|
||||
* Application code should use `getInstance()` so all consumers share
|
||||
* one cache.
|
||||
*/
|
||||
constructor(overrides: CatalogServiceOverrides = {}) {
|
||||
this.fetchImpl =
|
||||
overrides.fetchImpl ??
|
||||
((input, init) => {
|
||||
// Resolve `fetch` lazily so the constructor works under jsdom (no
|
||||
// global fetch), Electron renderer (window.fetch), and any other
|
||||
// host. We avoid `globalThis` directly because the codebase's
|
||||
// popout-window lint flags it.
|
||||
if (typeof window === "undefined" || typeof window.fetch !== "function") {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"window.fetch is unavailable — ModelCatalogService.refresh() requires a fetch implementation."
|
||||
)
|
||||
);
|
||||
}
|
||||
return window.fetch(input, init);
|
||||
});
|
||||
this.now = overrides.now ?? (() => Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the process-wide singleton. Lazy — does not allocate until
|
||||
* first call.
|
||||
*/
|
||||
static getInstance(): ModelCatalogService {
|
||||
if (!ModelCatalogService.instance) {
|
||||
ModelCatalogService.instance = new ModelCatalogService();
|
||||
}
|
||||
return ModelCatalogService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* TEST-ONLY: clears the singleton so a fresh instance can be wired up.
|
||||
* Production code should never call this.
|
||||
*/
|
||||
static resetInstanceForTests(): void {
|
||||
ModelCatalogService.instance = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the service with a storage adapter + manifest dir. Safe to
|
||||
* call multiple times (later calls overwrite earlier config). Does NOT
|
||||
* trigger any I/O on its own.
|
||||
*/
|
||||
init(opts: CatalogServiceInit): void {
|
||||
this.adapter = opts.adapter;
|
||||
this.manifestDir = opts.manifestDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the memory cache. Idempotent — subsequent calls are no-ops.
|
||||
*
|
||||
* Tries the disk cache first; on any failure (file missing, parse error,
|
||||
* etc.) falls back to the bundled snapshot.
|
||||
*
|
||||
* **Never fetches the network.** Use `refresh()` for that.
|
||||
*/
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
if (this.loadInflight) {
|
||||
await this.loadInflight;
|
||||
return;
|
||||
}
|
||||
this.loadInflight = this.doInitialLoad();
|
||||
try {
|
||||
await this.loadInflight;
|
||||
} finally {
|
||||
this.loadInflight = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async doInitialLoad(): Promise<void> {
|
||||
const fromDisk = await this.tryReadDisk();
|
||||
if (fromDisk) {
|
||||
this.memoryCache = fromDisk.data;
|
||||
this.memorySource = "disk";
|
||||
this.fetchedAt = fromDisk.fetchedAt;
|
||||
this.loaded = true;
|
||||
logInfo("[ModelCatalogService] Loaded catalog from disk cache.");
|
||||
return;
|
||||
}
|
||||
// No disk cache yet — start empty; the first `refresh()` populates it.
|
||||
this.memoryCache = {};
|
||||
this.memorySource = "bundled";
|
||||
this.fetchedAt = null;
|
||||
this.loaded = true;
|
||||
logInfo("[ModelCatalogService] No disk cache; awaiting live refresh.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-fetch the catalog with a 5s timeout. On success: replace memory
|
||||
* cache, write disk, emit change. On failure: log + keep last loaded
|
||||
* source intact.
|
||||
*
|
||||
* Returns a structured result rather than throwing so callers (the BYOK
|
||||
* tab header button) can render a non-fatal error state.
|
||||
*/
|
||||
async refresh(): Promise<RefreshResult> {
|
||||
// Make sure something is loaded before we attempt the fetch so a
|
||||
// failure leaves us with at least the bundled fallback in memory.
|
||||
await this.ensureLoaded();
|
||||
|
||||
const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
||||
// `window.setTimeout` is used over the bare global per the popout-window
|
||||
// lint — both share the same handle type in Electron renderer + jsdom.
|
||||
const timer = controller ? window.setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) : null;
|
||||
|
||||
try {
|
||||
const response = await this.fetchImpl(CATALOG_URL, {
|
||||
signal: controller?.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
logWarn(
|
||||
`[ModelCatalogService] refresh() got non-200 (${response.status}); keeping ${this.memorySource} source.`
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: `HTTP ${response.status}`,
|
||||
source: this.memorySource,
|
||||
};
|
||||
}
|
||||
const payload: unknown = await response.json();
|
||||
const filtered = this.filterAndValidate(payload);
|
||||
const fetchedAt = this.now();
|
||||
|
||||
this.memoryCache = filtered;
|
||||
this.memorySource = "live";
|
||||
this.fetchedAt = fetchedAt;
|
||||
|
||||
// Best-effort disk write; failures are non-fatal.
|
||||
await this.tryWriteDisk({ fetchedAt, data: filtered });
|
||||
|
||||
this.emitChange();
|
||||
logInfo(
|
||||
`[ModelCatalogService] refresh() succeeded; ${Object.keys(filtered).length} providers cached.`
|
||||
);
|
||||
return { ok: true, source: "live" };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logWarn(
|
||||
`[ModelCatalogService] refresh() failed (${message}); keeping ${this.memorySource} source.`
|
||||
);
|
||||
return { ok: false, error: message, source: this.memorySource };
|
||||
} finally {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the catalog provider with the given id, or `undefined`.
|
||||
* Synchronous — caller must have awaited `ensureLoaded()` first.
|
||||
*/
|
||||
getProvider(id: ProviderId): CatalogProvider | undefined {
|
||||
return this.requireMemory()[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a specific model within a provider, or `undefined`.
|
||||
* Synchronous — caller must have awaited `ensureLoaded()` first.
|
||||
*/
|
||||
getModel(providerId: ProviderId, modelId: string): CatalogModel | undefined {
|
||||
return this.requireMemory()[providerId]?.models?.[modelId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all providers in `SUPPORTED_PROVIDER_IDS` order, with the
|
||||
* recommended ones (Anthropic / OpenAI / Google) bumped to the front.
|
||||
* Providers missing from the catalog (e.g. `ollama`, `openai-compatible`)
|
||||
* are skipped.
|
||||
*/
|
||||
getAllProviders(): CatalogProvider[] {
|
||||
const cache = this.requireMemory();
|
||||
const recommended: CatalogProvider[] = [];
|
||||
const rest: CatalogProvider[] = [];
|
||||
|
||||
for (const id of SUPPORTED_PROVIDER_IDS) {
|
||||
const provider = cache[id];
|
||||
if (!provider) continue;
|
||||
if (RECOMMENDED_PROVIDER_IDS.includes(id)) {
|
||||
recommended.push(provider);
|
||||
} else {
|
||||
rest.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve RECOMMENDED_PROVIDER_IDS ordering explicitly (anthropic →
|
||||
// openai → google) regardless of the order they appeared in
|
||||
// SUPPORTED_PROVIDER_IDS.
|
||||
recommended.sort(
|
||||
(a, b) => RECOMMENDED_PROVIDER_IDS.indexOf(a.id) - RECOMMENDED_PROVIDER_IDS.indexOf(b.id)
|
||||
);
|
||||
|
||||
return [...recommended, ...rest];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a provider's model list by query string + optional CatalogFilters.
|
||||
* Catalog-only — does not consult the registry. Synchronous after
|
||||
* `ensureLoaded()` resolves.
|
||||
*
|
||||
* Filter semantics:
|
||||
* - `query`: case-insensitive substring match against `name` or `id`.
|
||||
* - `contextAtLeast`: keep when `limit.context >= n`.
|
||||
* - `maxCostPerMillion`: keep when `(cost.input + cost.output) <= n`.
|
||||
* Models with no `cost` block are dropped when this filter is active
|
||||
* (we can't prove they're cheap).
|
||||
* - `releasedWithinMonths`: keep when `release_date >= now - months`.
|
||||
* Models with no `release_date` are dropped when this filter is active.
|
||||
*/
|
||||
searchModels(
|
||||
providerId: ProviderId,
|
||||
query: string,
|
||||
filters: CatalogFilters = {}
|
||||
): CatalogModel[] {
|
||||
const provider = this.getProvider(providerId);
|
||||
if (!provider) return [];
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const cutoffMs =
|
||||
filters.releasedWithinMonths !== undefined
|
||||
? this.now() - filters.releasedWithinMonths * 30 * 24 * 60 * 60 * 1000
|
||||
: null;
|
||||
|
||||
return Object.values(provider.models).filter((model) => {
|
||||
if (normalizedQuery) {
|
||||
const haystack = `${model.name} ${model.id}`.toLowerCase();
|
||||
if (!haystack.includes(normalizedQuery)) return false;
|
||||
}
|
||||
if (
|
||||
filters.contextAtLeast !== undefined &&
|
||||
(model.limit?.context ?? 0) < filters.contextAtLeast
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (filters.maxCostPerMillion !== undefined) {
|
||||
if (!model.cost) return false;
|
||||
const total = (model.cost.input ?? 0) + (model.cost.output ?? 0);
|
||||
if (total > filters.maxCostPerMillion) return false;
|
||||
}
|
||||
if (cutoffMs !== null) {
|
||||
if (!model.release_date) return false;
|
||||
const parsed = Date.parse(model.release_date);
|
||||
if (Number.isNaN(parsed) || parsed < cutoffMs) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns metadata about the active catalog cache. Safe to call before
|
||||
* `ensureLoaded()` — reports `source: "bundled"` and `fetchedAt: null`
|
||||
* (the eventual state if no disk cache exists).
|
||||
*/
|
||||
getMeta(): CatalogMeta {
|
||||
return { fetchedAt: this.fetchedAt, source: this.memorySource };
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to memory-cache changes (currently only fired on successful
|
||||
* `refresh()`). Returns an unsubscribe function.
|
||||
*/
|
||||
onChange(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
// ----- internal helpers -----
|
||||
|
||||
/**
|
||||
* Resolve the disk-cache path. Throws if `init()` hasn't been called —
|
||||
* callers should check `adapter` presence and skip the disk path entirely
|
||||
* when uninitialized (the bundled fallback handles that case).
|
||||
*/
|
||||
private getCachePath(): string | undefined {
|
||||
if (!this.manifestDir) return undefined;
|
||||
return `${this.manifestDir}/${DISK_CACHE_FILENAME}`;
|
||||
}
|
||||
|
||||
private async tryReadDisk(): Promise<CatalogDiskCache | null> {
|
||||
const path = this.getCachePath();
|
||||
if (!path || !this.adapter) return null;
|
||||
try {
|
||||
if (!(await this.adapter.exists(path))) return null;
|
||||
const raw = await this.adapter.read(path);
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== "object" ||
|
||||
typeof (parsed as CatalogDiskCache).fetchedAt !== "number" ||
|
||||
!(parsed as CatalogDiskCache).data ||
|
||||
typeof (parsed as CatalogDiskCache).data !== "object"
|
||||
) {
|
||||
logWarn("[ModelCatalogService] Disk cache present but malformed.");
|
||||
return null;
|
||||
}
|
||||
return parsed as CatalogDiskCache;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logWarn(`[ModelCatalogService] Disk cache read failed: ${message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async tryWriteDisk(cache: CatalogDiskCache): Promise<void> {
|
||||
const path = this.getCachePath();
|
||||
if (!path || !this.adapter) return;
|
||||
try {
|
||||
await this.adapter.write(path, JSON.stringify(cache));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logWarn(`[ModelCatalogService] Disk cache write failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive filter applied to live payloads — we drop unknown providers
|
||||
* and assert a minimal structural shape. A malformed entry on one
|
||||
* provider does NOT poison the whole refresh; we just skip it.
|
||||
*/
|
||||
private filterAndValidate(payload: unknown): ModelsCatalog {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("Catalog payload is not an object");
|
||||
}
|
||||
const source = payload as Record<string, unknown>;
|
||||
const out: ModelsCatalog = {};
|
||||
for (const id of SUPPORTED_PROVIDER_IDS) {
|
||||
const candidate = source[id];
|
||||
if (!candidate || typeof candidate !== "object") continue;
|
||||
const provider = candidate as Partial<CatalogProvider>;
|
||||
if (
|
||||
typeof provider.id !== "string" ||
|
||||
typeof provider.name !== "string" ||
|
||||
!provider.models ||
|
||||
typeof provider.models !== "object"
|
||||
) {
|
||||
logWarn(
|
||||
`[ModelCatalogService] Skipping provider '${id}' in live payload (missing required fields).`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
out[id] = provider as CatalogProvider;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private requireMemory(): ModelsCatalog {
|
||||
if (!this.memoryCache) {
|
||||
// The lazy contract says callers await `ensureLoaded()`; if they
|
||||
// haven't, return an empty catalog rather than crashing.
|
||||
return {};
|
||||
}
|
||||
return this.memoryCache;
|
||||
}
|
||||
|
||||
private emitChange(): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (err) {
|
||||
// A listener bug should not poison the change-fanout for everyone
|
||||
// else. Swallow + log; the listener's owner will see the issue
|
||||
// when their UI doesn't update.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logWarn(`[ModelCatalogService] onChange listener threw: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
import {
|
||||
ModelCatalogService,
|
||||
type CatalogStorageAdapter,
|
||||
} from "@/modelManagement/catalog/ModelCatalogService";
|
||||
import type {
|
||||
CatalogModel,
|
||||
CatalogProvider,
|
||||
ModelsCatalog,
|
||||
} from "@/modelManagement/catalog/modelsCatalog.types";
|
||||
|
||||
// Mock logger so calls go through without exercising the logFileManager
|
||||
// singleton (which itself reaches into the global Obsidian `app`).
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Build a minimal but valid catalog provider for fixture purposes.
|
||||
*/
|
||||
function makeProvider(
|
||||
id: string,
|
||||
models: Array<Partial<CatalogModel> & Pick<CatalogModel, "id" | "name">>
|
||||
): CatalogProvider {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
env: [],
|
||||
models: Object.fromEntries(
|
||||
models.map((m) => {
|
||||
const full: CatalogModel = {
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 0, output: 0 },
|
||||
...m,
|
||||
};
|
||||
return [m.id, full];
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fake of the Obsidian DataAdapter subset we use.
|
||||
*/
|
||||
class FakeAdapter implements CatalogStorageAdapter {
|
||||
files = new Map<string, string>();
|
||||
/** Counts of operations for read-once / write-once assertions. */
|
||||
ops = { exists: 0, read: 0, write: 0 };
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
this.ops.exists += 1;
|
||||
return this.files.has(path);
|
||||
}
|
||||
async read(path: string): Promise<string> {
|
||||
this.ops.read += 1;
|
||||
const v = this.files.get(path);
|
||||
if (v === undefined) throw new Error(`Not found: ${path}`);
|
||||
return v;
|
||||
}
|
||||
async write(path: string, data: string): Promise<void> {
|
||||
this.ops.write += 1;
|
||||
this.files.set(path, data);
|
||||
}
|
||||
}
|
||||
|
||||
// Path strings used only as opaque keys inside the FakeAdapter — the real
|
||||
// plugin resolves the config directory via Obsidian's API. The lint that
|
||||
// warns about hard-coded `.obsidian/` does not apply to test fixtures.
|
||||
// eslint-disable-next-line obsidianmd/hardcoded-config-path
|
||||
const MANIFEST_DIR = ".obsidian/plugins/copilot";
|
||||
const CACHE_PATH = `${MANIFEST_DIR}/.modelsCatalogCache.json`;
|
||||
|
||||
describe("ModelCatalogService", () => {
|
||||
beforeEach(() => {
|
||||
ModelCatalogService.resetInstanceForTests();
|
||||
});
|
||||
|
||||
describe("ensureLoaded()", () => {
|
||||
it("is idempotent — disk is read once across many calls", async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const catalog: ModelsCatalog = {
|
||||
anthropic: makeProvider("anthropic", [{ id: "claude", name: "Claude" }]),
|
||||
};
|
||||
adapter.files.set(CACHE_PATH, JSON.stringify({ fetchedAt: 1700000000000, data: catalog }));
|
||||
const fetchImpl = jest.fn();
|
||||
const svc = new ModelCatalogService({ fetchImpl });
|
||||
svc.init({ adapter, manifestDir: MANIFEST_DIR });
|
||||
|
||||
await svc.ensureLoaded();
|
||||
await svc.ensureLoaded();
|
||||
await svc.ensureLoaded();
|
||||
|
||||
// Disk read should happen exactly once.
|
||||
expect(adapter.ops.read).toBe(1);
|
||||
// ensureLoaded must never hit the network on its own.
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(svc.getMeta().source).toBe("disk");
|
||||
expect(svc.getMeta().fetchedAt).toBe(1700000000000);
|
||||
});
|
||||
|
||||
it("never fetches the network until refresh() is called", async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const fetchImpl = jest.fn();
|
||||
const svc = new ModelCatalogService({ fetchImpl });
|
||||
svc.init({ adapter, manifestDir: MANIFEST_DIR });
|
||||
|
||||
await svc.ensureLoaded();
|
||||
svc.getProvider("anthropic");
|
||||
svc.getAllProviders();
|
||||
svc.getMeta();
|
||||
svc.searchModels("anthropic", "");
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the catalog empty when disk read fails", async () => {
|
||||
const adapter: CatalogStorageAdapter = {
|
||||
exists: jest.fn().mockResolvedValue(true),
|
||||
// Throw on read to simulate I/O failure.
|
||||
read: jest.fn().mockRejectedValue(new Error("disk corrupt")),
|
||||
write: jest.fn(),
|
||||
};
|
||||
const svc = new ModelCatalogService();
|
||||
svc.init({ adapter, manifestDir: MANIFEST_DIR });
|
||||
|
||||
await svc.ensureLoaded();
|
||||
|
||||
expect(svc.getMeta().source).toBe("bundled");
|
||||
expect(svc.getMeta().fetchedAt).toBeNull();
|
||||
// Empty catalog — providers are populated only by a successful refresh().
|
||||
expect(svc.getProvider("anthropic")).toBeUndefined();
|
||||
expect(svc.getProvider("openai")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the catalog empty when disk cache file is absent", async () => {
|
||||
const adapter = new FakeAdapter(); // empty
|
||||
const svc = new ModelCatalogService();
|
||||
svc.init({ adapter, manifestDir: MANIFEST_DIR });
|
||||
|
||||
await svc.ensureLoaded();
|
||||
expect(svc.getMeta().source).toBe("bundled");
|
||||
expect(svc.getAllProviders()).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the catalog empty when disk cache JSON is malformed", async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
adapter.files.set(CACHE_PATH, "{not valid json");
|
||||
const svc = new ModelCatalogService();
|
||||
svc.init({ adapter, manifestDir: MANIFEST_DIR });
|
||||
|
||||
await svc.ensureLoaded();
|
||||
expect(svc.getMeta().source).toBe("bundled");
|
||||
expect(svc.getAllProviders()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh()", () => {
|
||||
it("success path: fetch → memory + disk write → change emitted, meta reports 'live'", async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const liveCatalog: ModelsCatalog = {
|
||||
anthropic: makeProvider("anthropic", [
|
||||
{ id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" },
|
||||
]),
|
||||
};
|
||||
const fetchImpl = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => liveCatalog,
|
||||
});
|
||||
const svc = new ModelCatalogService({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
now: () => 1737000000000,
|
||||
});
|
||||
svc.init({ adapter, manifestDir: MANIFEST_DIR });
|
||||
const listener = jest.fn();
|
||||
svc.onChange(listener);
|
||||
|
||||
const result = await svc.refresh();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.source).toBe("live");
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(svc.getMeta()).toEqual({ fetchedAt: 1737000000000, source: "live" });
|
||||
expect(svc.getProvider("anthropic")?.models["claude-sonnet-4-5-20250929"]).toBeDefined();
|
||||
// Disk write must have happened.
|
||||
expect(adapter.ops.write).toBe(1);
|
||||
const persisted: unknown = JSON.parse(adapter.files.get(CACHE_PATH) ?? "{}");
|
||||
expect((persisted as { fetchedAt: number }).fetchedAt).toBe(1737000000000);
|
||||
// Listener fired exactly once.
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("filters non-supported providers out of the live payload", async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const liveCatalog = {
|
||||
anthropic: makeProvider("anthropic", [{ id: "claude", name: "Claude" }]),
|
||||
togetherai: makeProvider("togetherai", [{ id: "llama", name: "Llama" }]),
|
||||
"fireworks-ai": makeProvider("fireworks-ai", [{ id: "x", name: "x" }]),
|
||||
};
|
||||
const fetchImpl = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => liveCatalog,
|
||||
});
|
||||
const svc = new ModelCatalogService({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
svc.init({ adapter, manifestDir: MANIFEST_DIR });
|
||||
|
||||
const result = await svc.refresh();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(svc.getProvider("anthropic")).toBeDefined();
|
||||
expect(svc.getProvider("togetherai")).toBeUndefined();
|
||||
expect(svc.getProvider("fireworks-ai")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves last loaded source on non-200 response", async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
// Seed disk cache so ensureLoaded picks it up first.
|
||||
const diskCatalog: ModelsCatalog = {
|
||||
anthropic: makeProvider("anthropic", [{ id: "claude-from-disk", name: "Claude" }]),
|
||||
};
|
||||
adapter.files.set(
|
||||
CACHE_PATH,
|
||||
JSON.stringify({ fetchedAt: 1700000000000, data: diskCatalog })
|
||||
);
|
||||
const fetchImpl = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
json: async () => ({}),
|
||||
});
|
||||
const svc = new ModelCatalogService({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
svc.init({ adapter, manifestDir: MANIFEST_DIR });
|
||||
const listener = jest.fn();
|
||||
svc.onChange(listener);
|
||||
|
||||
const result = await svc.refresh();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("503");
|
||||
expect(svc.getMeta().source).toBe("disk");
|
||||
expect(svc.getProvider("anthropic")?.models["claude-from-disk"]).toBeDefined();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
// Failed fetch never writes disk.
|
||||
expect(adapter.ops.write).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves last loaded source when fetch throws (network error / timeout)", async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const fetchImpl = jest.fn().mockRejectedValue(new Error("network down"));
|
||||
const svc = new ModelCatalogService({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
svc.init({ adapter, manifestDir: MANIFEST_DIR });
|
||||
|
||||
const result = await svc.refresh();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("network down");
|
||||
// ensureLoaded ran inside refresh() and seeded bundled.
|
||||
expect(svc.getMeta().source).toBe("bundled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllProviders()", () => {
|
||||
it("sorts recommended providers (anthropic, openai, google) to the front after refresh()", async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const liveCatalog: ModelsCatalog = {
|
||||
groq: makeProvider("groq", [{ id: "g1", name: "G1" }]),
|
||||
openrouter: makeProvider("openrouter", [{ id: "o1", name: "O1" }]),
|
||||
google: makeProvider("google", [{ id: "gem", name: "Gemini" }]),
|
||||
openai: makeProvider("openai", [{ id: "gpt", name: "GPT" }]),
|
||||
anthropic: makeProvider("anthropic", [{ id: "claude", name: "Claude" }]),
|
||||
};
|
||||
const fetchImpl = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => liveCatalog,
|
||||
});
|
||||
const svc = new ModelCatalogService({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
svc.init({ adapter, manifestDir: MANIFEST_DIR });
|
||||
await svc.refresh();
|
||||
|
||||
const ids = svc.getAllProviders().map((p) => p.id);
|
||||
// Recommended order is fixed regardless of SUPPORTED_PROVIDER_IDS order.
|
||||
expect(ids.slice(0, 3)).toEqual(["anthropic", "openai", "google"]);
|
||||
// The rest are still present.
|
||||
expect(ids).toContain("groq");
|
||||
expect(ids).toContain("openrouter");
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchModels filter behavior", () => {
|
||||
const PROVIDER_ID = "test-provider";
|
||||
|
||||
/**
|
||||
* Build a service whose memory cache holds exactly one provider with
|
||||
* the fixture models we care about for filter testing.
|
||||
*/
|
||||
function buildServiceWith(models: CatalogModel[]): ModelCatalogService {
|
||||
const adapter = new FakeAdapter();
|
||||
const catalog: ModelsCatalog = {
|
||||
[PROVIDER_ID]: {
|
||||
id: PROVIDER_ID,
|
||||
name: PROVIDER_ID,
|
||||
env: [],
|
||||
models: Object.fromEntries(models.map((m) => [m.id, m])),
|
||||
},
|
||||
};
|
||||
adapter.files.set(CACHE_PATH, JSON.stringify({ fetchedAt: 1, data: catalog }));
|
||||
const svc = new ModelCatalogService({ now: () => 1737000000000 }); // ~Jan 2025
|
||||
svc.init({ adapter, manifestDir: MANIFEST_DIR });
|
||||
return svc;
|
||||
}
|
||||
|
||||
it("contextAtLeast filters out models below threshold", async () => {
|
||||
const svc = buildServiceWith([
|
||||
{
|
||||
id: "small",
|
||||
name: "Small",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 100_000, output: 4096 },
|
||||
},
|
||||
{
|
||||
id: "big",
|
||||
name: "Big",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 200_000, output: 4096 },
|
||||
},
|
||||
]);
|
||||
await svc.ensureLoaded();
|
||||
|
||||
const ids = svc
|
||||
.searchModels(PROVIDER_ID, "", { contextAtLeast: 200_000 })
|
||||
.map((m) => m.id)
|
||||
.sort();
|
||||
expect(ids).toEqual(["big"]);
|
||||
});
|
||||
|
||||
it("maxCostPerMillion filters out expensive models; drops cost-less models when active", async () => {
|
||||
const svc = buildServiceWith([
|
||||
{
|
||||
id: "cheap",
|
||||
name: "Cheap",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 1, output: 1 },
|
||||
cost: { input: 0.5, output: 0.3 }, // 0.8/M
|
||||
},
|
||||
{
|
||||
id: "pricey",
|
||||
name: "Pricey",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 1, output: 1 },
|
||||
cost: { input: 3, output: 6 }, // 9/M
|
||||
},
|
||||
{
|
||||
id: "unknown-cost",
|
||||
name: "Unknown",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 1, output: 1 },
|
||||
},
|
||||
]);
|
||||
await svc.ensureLoaded();
|
||||
|
||||
const ids = svc
|
||||
.searchModels(PROVIDER_ID, "", { maxCostPerMillion: 1 })
|
||||
.map((m) => m.id)
|
||||
.sort();
|
||||
expect(ids).toEqual(["cheap"]);
|
||||
});
|
||||
|
||||
it("releasedWithinMonths filters by release date; drops date-less models when active", async () => {
|
||||
// `now` set to 2025-01-16 (≈1737000000000 ms). 6 months before that
|
||||
// is ~2024-07-20; older releases should be filtered out.
|
||||
const svc = buildServiceWith([
|
||||
{
|
||||
id: "old",
|
||||
name: "Old",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 1, output: 1 },
|
||||
release_date: "2023-01-15",
|
||||
},
|
||||
{
|
||||
id: "fresh",
|
||||
name: "Fresh",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 1, output: 1 },
|
||||
release_date: "2024-12-01",
|
||||
},
|
||||
{
|
||||
id: "undated",
|
||||
name: "Undated",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 1, output: 1 },
|
||||
},
|
||||
]);
|
||||
await svc.ensureLoaded();
|
||||
|
||||
const ids = svc
|
||||
.searchModels(PROVIDER_ID, "", { releasedWithinMonths: 6 })
|
||||
.map((m) => m.id)
|
||||
.sort();
|
||||
expect(ids).toEqual(["fresh"]);
|
||||
});
|
||||
|
||||
it("combines filters with AND semantics and applies the query string", async () => {
|
||||
const svc = buildServiceWith([
|
||||
{
|
||||
id: "claude-fresh",
|
||||
name: "Claude Fresh",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 200_000, output: 4096 },
|
||||
release_date: "2024-12-01",
|
||||
cost: { input: 0.1, output: 0.2 },
|
||||
},
|
||||
{
|
||||
id: "claude-old",
|
||||
name: "Claude Old",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 200_000, output: 4096 },
|
||||
release_date: "2023-01-01",
|
||||
cost: { input: 0.1, output: 0.2 },
|
||||
},
|
||||
{
|
||||
id: "gpt-fresh",
|
||||
name: "GPT Fresh",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 200_000, output: 4096 },
|
||||
release_date: "2024-12-01",
|
||||
cost: { input: 0.1, output: 0.2 },
|
||||
},
|
||||
]);
|
||||
await svc.ensureLoaded();
|
||||
|
||||
const ids = svc
|
||||
.searchModels(PROVIDER_ID, "claude", {
|
||||
contextAtLeast: 200_000,
|
||||
releasedWithinMonths: 6,
|
||||
})
|
||||
.map((m) => m.id)
|
||||
.sort();
|
||||
expect(ids).toEqual(["claude-fresh"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
133
src/modelManagement/catalog/modelsCatalog.types.ts
Normal file
133
src/modelManagement/catalog/modelsCatalog.types.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* Hand-rolled TypeScript types matching the `models.dev/api.json` shape
|
||||
* (and the disk-cached / bundled-fallback subsets thereof).
|
||||
*
|
||||
* Schema is verified against the live endpoint as of 2026-05. If `models.dev`
|
||||
* changes shape, `ModelCatalogService.refresh()` should reject the payload
|
||||
* and keep serving the last good source.
|
||||
*
|
||||
* See: designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §1.3.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A provider entry in the catalog. `models` is keyed by the model's id so
|
||||
* that callers can do `provider.models[modelId]` directly.
|
||||
*/
|
||||
export interface CatalogProvider {
|
||||
/** Canonical id, e.g. `"anthropic"`. */
|
||||
id: string;
|
||||
/** Display label, e.g. `"Anthropic"`. */
|
||||
name: string;
|
||||
/** Environment variables the upstream tooling looks at (informational). */
|
||||
env: string[];
|
||||
/** npm package the upstream SDK ships as (informational). */
|
||||
npm?: string;
|
||||
/** Default base URL for the provider's API. */
|
||||
api?: string;
|
||||
/** Models keyed by id. */
|
||||
models: Record<string, CatalogModel>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single model entry in the catalog.
|
||||
*
|
||||
* Capabilities (`reasoning`, `tool_call`, `attachment`, etc.) are present
|
||||
* for internal routing only — they are NOT surfaced as user-editable
|
||||
* settings or filter chips per the redesign.
|
||||
*/
|
||||
export interface CatalogModel {
|
||||
/** Model id as accepted by the provider's API. */
|
||||
id: string;
|
||||
/** Display label. */
|
||||
name: string;
|
||||
/** Family for grouping in the Configure Provider UI (e.g. "Claude 4"). */
|
||||
family?: string;
|
||||
/** Accepts file attachments. */
|
||||
attachment?: boolean;
|
||||
/** Supports reasoning / thinking traces. */
|
||||
reasoning?: boolean;
|
||||
/** Supports tool calling. */
|
||||
tool_call?: boolean;
|
||||
/** Supports the `temperature` parameter. */
|
||||
temperature?: boolean;
|
||||
/** Training data cutoff date (e.g. `"2024-04"`). */
|
||||
knowledge?: string;
|
||||
/** Release date (e.g. `"2025-09-29"`). Surfaced as "Sep 2025" in the UI. */
|
||||
release_date?: string;
|
||||
/** Last updated date in the upstream catalog. */
|
||||
last_updated?: string;
|
||||
/** Whether the model has open weights. */
|
||||
open_weights?: boolean;
|
||||
/** Input/output modalities (e.g. `input: ["text", "image"]`). */
|
||||
modalities: { input: string[]; output: string[] };
|
||||
/** Context and output token limits. */
|
||||
limit: { context: number; output: number };
|
||||
/** Per-million-token costs. Optional — some catalog entries omit it. */
|
||||
cost?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog as a whole: a map of provider id → provider entry.
|
||||
*
|
||||
* The disk cache and bundled fallback use this exact shape (with the
|
||||
* tree-shaken set of providers per `SUPPORTED_PROVIDER_IDS`).
|
||||
*/
|
||||
export type ModelsCatalog = Record<string, CatalogProvider>;
|
||||
|
||||
/**
|
||||
* Filters accepted by `ModelCatalogService.searchModels`. Each is optional;
|
||||
* multiple filters are AND-combined.
|
||||
*
|
||||
* No capability filter — Vision / Reasoning / Tool use chips were removed
|
||||
* from the UI per the redesign (§1.4).
|
||||
*/
|
||||
export interface CatalogFilters {
|
||||
/** Drop models whose `limit.context` is less than this. */
|
||||
contextAtLeast?: number;
|
||||
/** Drop models whose `cost.input + cost.output` exceeds this (per million). */
|
||||
maxCostPerMillion?: number;
|
||||
/** Drop models whose `release_date` is older than `now - months`. */
|
||||
releasedWithinMonths?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the in-memory cache was populated from.
|
||||
*
|
||||
* - `"live"`: most recent successful fetch from `models.dev/api.json`.
|
||||
* - `"disk"`: read from the on-disk cache file written by an earlier fetch.
|
||||
* - `"bundled"`: the committed offline snapshot (first launch, no internet).
|
||||
*/
|
||||
export type CatalogSource = "live" | "disk" | "bundled";
|
||||
|
||||
/**
|
||||
* Metadata about the active catalog cache. Surfaced in the BYOK header
|
||||
* (last-fetched timestamp tooltip).
|
||||
*/
|
||||
export interface CatalogMeta {
|
||||
/** Epoch millis of the last successful live fetch, or `null` if never. */
|
||||
fetchedAt: number | null;
|
||||
source: CatalogSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a `refresh()` call.
|
||||
*/
|
||||
export interface RefreshResult {
|
||||
ok: boolean;
|
||||
/** Human-readable error if `ok === false`. */
|
||||
error?: string;
|
||||
source: CatalogSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the JSON file written to disk after a successful live fetch.
|
||||
*/
|
||||
export interface CatalogDiskCache {
|
||||
fetchedAt: number;
|
||||
data: ModelsCatalog;
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"activeModels": [
|
||||
{
|
||||
"name": "llama3.2",
|
||||
"provider": "ollama",
|
||||
"isBuiltIn": false,
|
||||
"enabled": true,
|
||||
"baseUrl": "http://localhost:11434"
|
||||
},
|
||||
{
|
||||
"name": "qwen2.5-coder",
|
||||
"provider": "ollama",
|
||||
"isBuiltIn": false,
|
||||
"enabled": true,
|
||||
"baseUrl": "http://localhost:11434"
|
||||
}
|
||||
],
|
||||
"agentMode": { "enabled": true, "backends": {} }
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"anthropicApiKey": "sk-ant-test",
|
||||
"defaultModelKey": "claude-sonnet-4-5-20250929|anthropic",
|
||||
"activeModels": [
|
||||
{
|
||||
"name": "claude-sonnet-4-5-20250929",
|
||||
"provider": "anthropic",
|
||||
"isBuiltIn": true,
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"agentMode": { "enabled": true, "backends": {} }
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"openAIApiKey": "sk-test-keys-only",
|
||||
"anthropicApiKey": "sk-ant-test-keys-only",
|
||||
"googleApiKey": "google-test-keys-only",
|
||||
"activeModels": [],
|
||||
"agentMode": { "enabled": true, "backends": {} }
|
||||
}
|
||||
150
src/modelManagement/migrations/__tests__/fkInvariant.test.ts
Normal file
150
src/modelManagement/migrations/__tests__/fkInvariant.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Foreign-key invariant tests for the model management module.
|
||||
*
|
||||
* Asserts that for every `RegistryEntry` in `settings.registry`, the referenced
|
||||
* `settings.providers[entry.providerId]` exists. This is the contract
|
||||
* documented on `RegistryEntry.providerId` in `@/modelManagement/types`.
|
||||
*
|
||||
* Historically the v0→v2 migration violated this invariant by forwarding
|
||||
* `opencode:*` and `copilot-plus:*` registry entries to
|
||||
* `agentMode.backends.opencode.modelEnabledOverrides` instead of writing them
|
||||
* to `settings.providers` — those pseudo-providers had no home in the
|
||||
* providers map. They're now promoted to first-class `kind: "system"`
|
||||
* providers (see `ProviderConfig.kind` JSDoc).
|
||||
*/
|
||||
import { runModelManagementMigrations } from "@/modelManagement/migrations/runMigrations";
|
||||
import type { ProviderConfig, RegistryEntry } from "@/modelManagement/types";
|
||||
|
||||
/**
|
||||
* Assert the FK invariant: every registry entry references a provider that
|
||||
* exists in `settings.providers`.
|
||||
*/
|
||||
function assertProviderFkInvariant(settings: Record<string, unknown>): void {
|
||||
const providers = settings.providers as Record<string, ProviderConfig> | undefined;
|
||||
const registry = settings.registry as RegistryEntry[] | undefined;
|
||||
expect(providers).toBeDefined();
|
||||
expect(registry).toBeDefined();
|
||||
for (const entry of registry ?? []) {
|
||||
expect(providers).toHaveProperty(entry.providerId);
|
||||
expect(providers?.[entry.providerId]?.id).toBe(entry.providerId);
|
||||
}
|
||||
}
|
||||
|
||||
describe("ProviderId FK invariant: every RegistryEntry.providerId exists in settings.providers", () => {
|
||||
it("holds on a fresh empty install", () => {
|
||||
const { settings } = runModelManagementMigrations({
|
||||
agentMode: { enabled: true, backends: {} },
|
||||
});
|
||||
assertProviderFkInvariant(settings);
|
||||
});
|
||||
|
||||
it("holds for a single OpenAI BYOK provider with one model", () => {
|
||||
const { settings } = runModelManagementMigrations({
|
||||
openAIApiKey: "sk-test",
|
||||
activeModels: [{ name: "gpt-5", provider: "openai", isBuiltIn: true, enabled: true }],
|
||||
agentMode: { enabled: true, backends: {} },
|
||||
});
|
||||
assertProviderFkInvariant(settings);
|
||||
});
|
||||
|
||||
it("holds for OpenCode-bundled + Copilot-Plus pseudo-providers (system providers created)", () => {
|
||||
// The exact scenario from the task brief: v0 settings carry
|
||||
// `opencode:big-pickle` and `copilot-plus:plus-flash` activeModels. The
|
||||
// migration must create matching `kind: "system"` providers AND real
|
||||
// registry entries pointing at them.
|
||||
const { settings } = runModelManagementMigrations({
|
||||
activeModels: [
|
||||
{ name: "big-pickle", provider: "opencode", enabled: true },
|
||||
{ name: "plus-flash", provider: "copilot-plus", enabled: true },
|
||||
],
|
||||
agentMode: { enabled: true, backends: {} },
|
||||
});
|
||||
const s = settings as Record<string, unknown>;
|
||||
assertProviderFkInvariant(s);
|
||||
|
||||
// Spot-check the two specific system providers.
|
||||
const providers = s.providers as Record<string, ProviderConfig>;
|
||||
expect(providers.opencode).toMatchObject({
|
||||
id: "opencode",
|
||||
kind: "system",
|
||||
displayName: "OpenCode",
|
||||
});
|
||||
expect(providers["copilot-plus"]).toMatchObject({
|
||||
id: "copilot-plus",
|
||||
kind: "system",
|
||||
displayName: "Copilot Plus",
|
||||
});
|
||||
|
||||
// System providers carry no API key.
|
||||
expect(providers.opencode.apiKeyRef).toBeUndefined();
|
||||
expect(providers["copilot-plus"].apiKeyRef).toBeUndefined();
|
||||
expect(providers.opencode.extra).toBeUndefined();
|
||||
expect(providers["copilot-plus"].extra).toBeUndefined();
|
||||
|
||||
// Both system providers have at least one registry entry referencing them.
|
||||
const registry = s.registry as RegistryEntry[];
|
||||
expect(registry.some((e) => e.providerId === "opencode" && e.modelId === "big-pickle")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
registry.some((e) => e.providerId === "copilot-plus" && e.modelId === "plus-flash")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("idempotently creates ONE system provider for multiple OpenCode models", () => {
|
||||
// Two opencode activeModels should share the same `opencode` system
|
||||
// provider — not produce two duplicates.
|
||||
const { settings } = runModelManagementMigrations({
|
||||
activeModels: [
|
||||
{ name: "big-pickle", provider: "opencode", enabled: true },
|
||||
{ name: "small-pickle", provider: "opencode", enabled: true },
|
||||
],
|
||||
agentMode: { enabled: true, backends: {} },
|
||||
});
|
||||
const s = settings as Record<string, unknown>;
|
||||
assertProviderFkInvariant(s);
|
||||
const providers = s.providers as Record<string, ProviderConfig>;
|
||||
expect(Object.keys(providers)).toEqual(["opencode"]);
|
||||
const registry = s.registry as RegistryEntry[];
|
||||
expect(registry).toHaveLength(2);
|
||||
for (const entry of registry) expect(entry.providerId).toBe("opencode");
|
||||
});
|
||||
|
||||
it("holds for the mixed-everything fixture (built-ins + custom + system)", () => {
|
||||
const { settings } = runModelManagementMigrations({
|
||||
anthropicApiKey: "sk-ant",
|
||||
openAIApiKey: "sk-openai",
|
||||
activeModels: [
|
||||
{
|
||||
name: "claude-sonnet-4-5-20250929",
|
||||
provider: "anthropic",
|
||||
isBuiltIn: true,
|
||||
enabled: true,
|
||||
},
|
||||
{ name: "gpt-5", provider: "openai", isBuiltIn: true, enabled: true },
|
||||
{
|
||||
name: "qwen2.5-coder",
|
||||
provider: "ollama",
|
||||
isBuiltIn: false,
|
||||
enabled: true,
|
||||
baseUrl: "http://localhost:11434",
|
||||
},
|
||||
{ name: "big-pickle", provider: "opencode", enabled: true },
|
||||
{ name: "plus-flash", provider: "copilot-plus", enabled: true },
|
||||
],
|
||||
agentMode: { enabled: true, backends: {} },
|
||||
});
|
||||
const s = settings as Record<string, unknown>;
|
||||
assertProviderFkInvariant(s);
|
||||
const providers = s.providers as Record<string, ProviderConfig>;
|
||||
// Built-in + custom + 2 system = 4 distinct provider ids (plus the
|
||||
// custom: uuid for the ollama entry).
|
||||
expect(providers.anthropic.kind).toBe("builtin");
|
||||
expect(providers.openai.kind).toBe("builtin");
|
||||
expect(providers.opencode.kind).toBe("system");
|
||||
expect(providers["copilot-plus"].kind).toBe("system");
|
||||
const customIds = Object.keys(providers).filter((id) => id.startsWith("custom:"));
|
||||
expect(customIds).toHaveLength(1);
|
||||
expect(providers[customIds[0]].kind).toBe("custom");
|
||||
});
|
||||
});
|
||||
130
src/modelManagement/migrations/runMigrations.ts
Normal file
130
src/modelManagement/migrations/runMigrations.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Settings migration runner. Owns the chain of versioned migrations applied
|
||||
* inside `sanitizeSettings`.
|
||||
*
|
||||
* Synchronous by contract — `sanitizeSettings` is sync, and the catalog data
|
||||
* needed for capability inference is loaded from the bundled fallback JSON
|
||||
* imported at module load (no `await`).
|
||||
*
|
||||
* See: designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §2.4 and §4.
|
||||
*/
|
||||
import { migrateV0toV2 } from "@/modelManagement/migrations/v0-to-v2";
|
||||
|
||||
// Reason: the migration runner is called from `sanitizeSettings`, which
|
||||
// runs DURING settings load. `@/logger` imports `getSettings()` from
|
||||
// `@/settings/model` — pulling the logger in here creates a heavy cycle
|
||||
// (and load-time hazards). Use `console.*` directly for the same reason
|
||||
// `KeychainService` does (see its file header).
|
||||
function logInfo(msg: string): void {
|
||||
// eslint-disable-next-line obsidianmd/rule-custom-message
|
||||
console.info(msg);
|
||||
}
|
||||
function logError(msg: string): void {
|
||||
console.error(msg);
|
||||
}
|
||||
|
||||
/** Current schema version after this redesign. */
|
||||
export const CURRENT_SETTINGS_VERSION = 2;
|
||||
|
||||
type Migration = (raw: Record<string, unknown>) => {
|
||||
settings: Record<string, unknown>;
|
||||
droppedFields: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Migration chain keyed by the version they upgrade TO.
|
||||
*
|
||||
* For now there's only one entry (v0/undefined → v2). Future migrations
|
||||
* append more entries and `runMigrations` walks them in numerical order.
|
||||
*/
|
||||
const MIGRATIONS: Record<number, Migration> = {
|
||||
2: migrateV0toV2,
|
||||
};
|
||||
|
||||
/** Breadcrumb shape recorded in `_migrationBreadcrumbs`. */
|
||||
export interface MigrationBreadcrumb {
|
||||
from: number;
|
||||
to: number;
|
||||
appliedAt: number;
|
||||
droppedFields?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run any pending migrations against the raw settings object.
|
||||
*
|
||||
* Behavior:
|
||||
* - Operates on a deep clone of the input; the original is never mutated.
|
||||
* - Idempotent: if `settingsVersion >= CURRENT`, returns input unchanged.
|
||||
* - On any thrown error, logs via `logError`, returns the original input
|
||||
* untouched, and reports an empty `migrationsApplied` list so the caller
|
||||
* can decide whether to surface a one-time notice.
|
||||
* - Appends a breadcrumb entry per migration step.
|
||||
*/
|
||||
export function runModelManagementMigrations<TSettings extends object>(
|
||||
raw: TSettings | null | undefined
|
||||
): { settings: TSettings; migrationsApplied: number[] } {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return {
|
||||
settings: raw as unknown as TSettings,
|
||||
migrationsApplied: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Deep clone defensively — migration mutates its working copy.
|
||||
let working: Record<string, unknown>;
|
||||
try {
|
||||
working = JSON.parse(JSON.stringify(raw)) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
logError(`[runMigrations] Failed to deep-clone settings: ${String(err)}`);
|
||||
return { settings: raw, migrationsApplied: [] };
|
||||
}
|
||||
|
||||
const currentVersion = Number(working.settingsVersion ?? 0);
|
||||
if (currentVersion >= CURRENT_SETTINGS_VERSION) {
|
||||
return { settings: raw, migrationsApplied: [] };
|
||||
}
|
||||
|
||||
const applied: number[] = [];
|
||||
let fromVersion = currentVersion;
|
||||
|
||||
// Walk migrations in ascending version order, skipping already-applied ones.
|
||||
const pendingVersions = Object.keys(MIGRATIONS)
|
||||
.map((v) => Number(v))
|
||||
.sort((a, b) => a - b)
|
||||
.filter((v) => v > currentVersion);
|
||||
|
||||
for (const toVersion of pendingVersions) {
|
||||
const migrate = MIGRATIONS[toVersion];
|
||||
try {
|
||||
const { settings: migrated, droppedFields } = migrate(working);
|
||||
working = migrated;
|
||||
const breadcrumb: MigrationBreadcrumb = {
|
||||
from: fromVersion,
|
||||
to: toVersion,
|
||||
appliedAt: Date.now(),
|
||||
...(droppedFields.length > 0 ? { droppedFields } : {}),
|
||||
};
|
||||
const crumbs = Array.isArray(working._migrationBreadcrumbs)
|
||||
? (working._migrationBreadcrumbs as MigrationBreadcrumb[])
|
||||
: [];
|
||||
crumbs.push(breadcrumb);
|
||||
working._migrationBreadcrumbs = crumbs;
|
||||
working.settingsVersion = toVersion;
|
||||
applied.push(toVersion);
|
||||
fromVersion = toVersion;
|
||||
logInfo(
|
||||
`[runMigrations] Applied migration v${breadcrumb.from} → v${breadcrumb.to} (${droppedFields.length} dropped fields).`
|
||||
);
|
||||
} catch (err) {
|
||||
logError(
|
||||
`[runMigrations] Migration to v${toVersion} threw — restoring original settings. Error: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
// Restore the input untouched on any error.
|
||||
return { settings: raw, migrationsApplied: [] };
|
||||
}
|
||||
}
|
||||
|
||||
return { settings: working as unknown as TSettings, migrationsApplied: applied };
|
||||
}
|
||||
195
src/modelManagement/providers/ProviderRegistry.ts
Normal file
195
src/modelManagement/providers/ProviderRegistry.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* ProviderRegistry — source of truth for provider credentials.
|
||||
*
|
||||
* Wraps `settings.providers` and emits change notifications so the BYOK UI
|
||||
* can re-render when providers are added/edited/removed. Keychain support is
|
||||
* deferred — M2 stores keys inline; the M9-era keychain promotion path will
|
||||
* extend `getApiKey()` and the writers to lift secrets into the OS keychain.
|
||||
*
|
||||
* See: designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §3.1.
|
||||
*/
|
||||
import type {
|
||||
KeychainRef,
|
||||
ProviderConfig,
|
||||
ProviderId,
|
||||
VerificationResult,
|
||||
} from "@/modelManagement/types";
|
||||
import { getSettings, setSettings, subscribeToSettingsChange } from "@/settings/model";
|
||||
|
||||
/**
|
||||
* Singleton facade over `settings.providers`. All callers should use
|
||||
* `getInstance()` so listeners share one notification source.
|
||||
*/
|
||||
export class ProviderRegistry {
|
||||
private static instance: ProviderRegistry | undefined;
|
||||
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private unsubscribeSettings: (() => void) | null = null;
|
||||
|
||||
private constructor() {
|
||||
// Re-emit whenever settings change — UI may render off `list()` and
|
||||
// needs to refresh on programmatic mutations from elsewhere.
|
||||
this.unsubscribeSettings = subscribeToSettingsChange((prev, next) => {
|
||||
if (prev.providers !== next.providers) {
|
||||
this.emit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the process-wide singleton. */
|
||||
static getInstance(): ProviderRegistry {
|
||||
if (!ProviderRegistry.instance) {
|
||||
ProviderRegistry.instance = new ProviderRegistry();
|
||||
}
|
||||
return ProviderRegistry.instance;
|
||||
}
|
||||
|
||||
/** TEST-ONLY hook. */
|
||||
static resetInstanceForTests(): void {
|
||||
if (ProviderRegistry.instance?.unsubscribeSettings) {
|
||||
ProviderRegistry.instance.unsubscribeSettings();
|
||||
}
|
||||
ProviderRegistry.instance = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns registered providers in insertion order.
|
||||
*
|
||||
* Pass `filter.kind` to narrow to a single classification — useful for
|
||||
* the BYOK UI which displays only `"builtin"` and `"custom"` providers
|
||||
* (system providers are credentialed by their agent backend and have no
|
||||
* user-configurable surface).
|
||||
*
|
||||
* No filter → all kinds. Existing callers that just want everything keep
|
||||
* working unchanged.
|
||||
*/
|
||||
list(filter?: { kind?: ProviderConfig["kind"] }): ProviderConfig[] {
|
||||
const providers = getSettings().providers ?? {};
|
||||
const all = Object.values(providers);
|
||||
if (!filter?.kind) return all;
|
||||
return all.filter((p) => p.kind === filter.kind);
|
||||
}
|
||||
|
||||
/** Returns a single provider by id, or `undefined`. */
|
||||
get(id: ProviderId): ProviderConfig | undefined {
|
||||
return getSettings().providers?.[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new provider. `addedAt` is stamped server-side. Listeners
|
||||
* fire via the settings-change subscription wired in the constructor —
|
||||
* no explicit `emit()` needed.
|
||||
*/
|
||||
async add(config: Omit<ProviderConfig, "addedAt">): Promise<void> {
|
||||
const next: ProviderConfig = { ...config, addedAt: Date.now() };
|
||||
setSettings((cur) => ({
|
||||
providers: { ...(cur.providers ?? {}), [next.id]: next },
|
||||
}));
|
||||
}
|
||||
|
||||
/** Shallow-merge `patch` into the existing provider. No-op if missing. */
|
||||
async update(id: ProviderId, patch: Partial<ProviderConfig>): Promise<void> {
|
||||
setSettings((cur) => {
|
||||
const existing = cur.providers?.[id];
|
||||
if (!existing) return {};
|
||||
const updated: ProviderConfig = { ...existing, ...patch, id };
|
||||
return { providers: { ...(cur.providers ?? {}), [id]: updated } };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a provider AND cascade-delete all registry entries that
|
||||
* reference it. Mirrors the design's "Remove provider" kebab action.
|
||||
*/
|
||||
async remove(id: ProviderId): Promise<void> {
|
||||
setSettings((cur) => {
|
||||
const providers = { ...(cur.providers ?? {}) };
|
||||
delete providers[id];
|
||||
const registry = (cur.registry ?? []).filter((entry) => entry.providerId !== id);
|
||||
return { providers, registry };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the provider's API key. `kind: "inline"` returns the value
|
||||
* directly; `kind: "keychain"` will (in a future milestone) read from
|
||||
* `KeychainService`. M2 only supports inline.
|
||||
*
|
||||
* Returns `null` if the provider has no key set or doesn't exist.
|
||||
* Async-by-contract so the keychain milestone can swap in I/O without
|
||||
* changing call sites.
|
||||
*/
|
||||
async getApiKey(id: ProviderId): Promise<string | null> {
|
||||
const provider = this.get(id);
|
||||
if (!provider?.apiKeyRef) return null;
|
||||
return resolveApiKeySync(provider.apiKeyRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub verification. Real implementation (M5) will dispatch to the
|
||||
* provider's adapter — for now we just check that an API key exists.
|
||||
*/
|
||||
async verify(id: ProviderId): Promise<VerificationResult> {
|
||||
const key = await this.getApiKey(id);
|
||||
const provider = this.get(id);
|
||||
if (!provider) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unknown provider '${id}'`,
|
||||
verifiedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
if (provider.apiKeyRef !== null && !key) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "No API key configured",
|
||||
verifiedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
return { ok: true, verifiedAt: Date.now() };
|
||||
}
|
||||
|
||||
/** Subscribe to provider mutations. Returns an unsubscribe function. */
|
||||
onChange(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// Listener errors must not poison the fanout. The caller will
|
||||
// see their bug when their UI stops updating.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public helper for non-class consumers: read a provider's API key by id.
|
||||
* Equivalent to `ProviderRegistry.getInstance().getApiKey(id)`.
|
||||
*/
|
||||
export async function getProviderApiKey(id: ProviderId): Promise<string | null> {
|
||||
return ProviderRegistry.getInstance().getApiKey(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous accessor for an inline-stored API key. Returns `null` if the
|
||||
* key is in the keychain (force the caller to use the async variant) or if
|
||||
* the provider doesn't exist.
|
||||
*
|
||||
* Useful for hot-path code that can't await — e.g. inside a `setSettings`
|
||||
* updater. M9 will deprecate this once keychain promotion is universal.
|
||||
*/
|
||||
export function getProviderApiKeySync(id: ProviderId): string | null {
|
||||
const provider = getSettings().providers?.[id];
|
||||
if (!provider?.apiKeyRef) return null;
|
||||
return resolveApiKeySync(provider.apiKeyRef);
|
||||
}
|
||||
|
||||
/** Inline-only resolver. Keychain support lands in a follow-up. */
|
||||
function resolveApiKeySync(ref: KeychainRef): string | null {
|
||||
return ref.kind === "inline" ? ref.value : null;
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { ChatOpenAI } from "@langchain/openai";
|
||||
|
||||
/**
|
||||
* ChatLMStudio extends ChatOpenAI with the Responses API (/v1/responses)
|
||||
* LMStudioChatModel extends ChatOpenAI with the Responses API (/v1/responses)
|
||||
* for LM Studio local inference.
|
||||
*
|
||||
* Patches LangChain/OpenAI SDK compatibility issues with LM Studio:
|
||||
* - Ensures text.format is always set (LM Studio requires it)
|
||||
* - Removes strict:null from tool definitions (LM Studio rejects it)
|
||||
*/
|
||||
export interface ChatLMStudioInput {
|
||||
export interface LMStudioChatModelInput {
|
||||
modelName?: string;
|
||||
apiKey?: string;
|
||||
configuration?: Record<string, unknown>;
|
||||
|
|
@ -61,8 +61,8 @@ function createLMStudioFetch(baseFetch?: typeof window.fetch): typeof window.fet
|
|||
};
|
||||
}
|
||||
|
||||
export class ChatLMStudio extends ChatOpenAI {
|
||||
constructor(fields: ChatLMStudioInput) {
|
||||
export class LMStudioChatModel extends ChatOpenAI {
|
||||
constructor(fields: LMStudioChatModelInput) {
|
||||
const configuration = fields.configuration as { fetch?: typeof window.fetch } | undefined;
|
||||
const originalFetch = configuration?.fetch;
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ type OpenRouterUsage = NonNullable<OpenRouterChatChunk["usage"]>;
|
|||
type OpenRouterMessageParam = OpenAI.ChatCompletionMessageParam;
|
||||
|
||||
/**
|
||||
* ChatOpenRouter extends ChatOpenAI to support OpenRouter-specific features,
|
||||
* OpenRouterChatModel extends ChatOpenAI to support OpenRouter-specific features,
|
||||
* particularly reasoning/thinking tokens.
|
||||
*
|
||||
* OpenRouter exposes thinking tokens via the `reasoning` request parameter
|
||||
|
|
@ -19,7 +19,7 @@ type OpenRouterMessageParam = OpenAI.ChatCompletionMessageParam;
|
|||
*
|
||||
* @see https://openrouter.ai/docs/use-cases/reasoning-tokens
|
||||
*/
|
||||
export interface ChatOpenRouterInput extends BaseChatModelParams {
|
||||
export interface OpenRouterChatModelInput extends BaseChatModelParams {
|
||||
/**
|
||||
* Enable reasoning/thinking tokens from OpenRouter
|
||||
* When true, requests will include reasoning parameters
|
||||
|
|
@ -60,7 +60,7 @@ export interface ChatOpenRouterInput extends BaseChatModelParams {
|
|||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class ChatOpenRouter extends ChatOpenAI {
|
||||
export class OpenRouterChatModel extends ChatOpenAI {
|
||||
private enableReasoning: boolean;
|
||||
private reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
private enablePromptCaching: boolean;
|
||||
|
|
@ -68,7 +68,7 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
/** True when the configured baseURL belongs to the OpenRouter gateway. */
|
||||
private isOpenRouter: boolean;
|
||||
|
||||
constructor(fields: ChatOpenRouterInput) {
|
||||
constructor(fields: OpenRouterChatModelInput) {
|
||||
const {
|
||||
enableReasoning = false,
|
||||
reasoningEffort,
|
||||
52
src/modelManagement/providers/supportedProviders.ts
Normal file
52
src/modelManagement/providers/supportedProviders.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Single source of truth for the providers Copilot can instantiate via its
|
||||
* first-class LangChain adapters. The catalog filter, Add Provider dialog,
|
||||
* migration step, and (eventually) the eslint-enforced adapter registry all
|
||||
* reference this constant.
|
||||
*
|
||||
* To add a new provider:
|
||||
* 1. Add an adapter class under `src/modelManagement/providers/adapters/`.
|
||||
* 2. Add its canonical `models.dev` id to this array.
|
||||
*
|
||||
* IDs use the canonical `models.dev/api.json` shape (verified against the
|
||||
* live API). Notably: `xai` not `x-ai`, `amazon-bedrock` not `aws-bedrock`.
|
||||
*
|
||||
* Excluded from earlier drafts (no first-class LangChain adapter in this
|
||||
* plugin): `togetherai`, `fireworks-ai`, `perplexity`. Users who want them
|
||||
* can still configure them via the `openai-compatible` custom-provider path.
|
||||
*
|
||||
* See: designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §1.2.
|
||||
*/
|
||||
export const SUPPORTED_PROVIDER_IDS = [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"google",
|
||||
"groq",
|
||||
"mistral",
|
||||
"xai",
|
||||
"deepseek",
|
||||
"openrouter",
|
||||
"cohere",
|
||||
"azure",
|
||||
"amazon-bedrock",
|
||||
"github-copilot",
|
||||
"ollama",
|
||||
"lmstudio",
|
||||
"siliconflow",
|
||||
"openai-compatible",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Union of the canonical provider ids Copilot natively supports.
|
||||
* Custom providers (user-added via Add Provider → Custom) use the
|
||||
* `custom:<uuid>` prefix and are NOT part of this union.
|
||||
*/
|
||||
export type SupportedProviderId = (typeof SUPPORTED_PROVIDER_IDS)[number];
|
||||
|
||||
/**
|
||||
* Convenience predicate. Useful in filter/migration code where we receive
|
||||
* arbitrary strings from `models.dev/api.json` or legacy settings.
|
||||
*/
|
||||
export function isSupportedProviderId(id: string): id is SupportedProviderId {
|
||||
return (SUPPORTED_PROVIDER_IDS as readonly string[]).includes(id);
|
||||
}
|
||||
301
src/modelManagement/providers/verifyProvider.test.ts
Normal file
301
src/modelManagement/providers/verifyProvider.test.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { verifyProvider } from "@/modelManagement/providers/verifyProvider";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = jest.fn();
|
||||
jest.mock("@/utils", () => {
|
||||
const actual = jest.requireActual<Record<string, unknown>>("@/utils");
|
||||
return {
|
||||
...actual,
|
||||
safeFetchNoThrow: (url: string, options?: RequestInit): unknown => mockFetch(url, options),
|
||||
};
|
||||
});
|
||||
|
||||
function ok(status = 200, body: unknown = { data: [] }): Partial<Response> {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: () => Promise.resolve(typeof body === "string" ? body : JSON.stringify(body)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Last call to the mocked safeFetchNoThrow, typed. */
|
||||
function lastCall(): { url: string; options: RequestInit } {
|
||||
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
return { url, options };
|
||||
}
|
||||
|
||||
function lastHeaders(): Record<string, string> {
|
||||
return (lastCall().options.headers ?? {}) as Record<string, string>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("verifyProvider — anthropic", () => {
|
||||
it("returns ok on 2xx", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
const result = await verifyProvider({
|
||||
providerId: "anthropic",
|
||||
apiKey: "sk-ant-test",
|
||||
type: "anthropic",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(lastCall().url).toBe("https://api.anthropic.com/v1/models");
|
||||
expect(lastCall().options.method).toBe("GET");
|
||||
expect(lastHeaders()["x-api-key"]).toBe("sk-ant-test");
|
||||
expect(lastHeaders()["anthropic-version"]).toBe("2023-06-01");
|
||||
});
|
||||
|
||||
it("honors a custom baseUrl", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
await verifyProvider({
|
||||
providerId: "anthropic",
|
||||
apiKey: "sk-ant-test",
|
||||
baseUrl: "https://proxy.example.com/anthropic/",
|
||||
type: "anthropic",
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://proxy.example.com/anthropic/v1/models",
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("fails on 401 with parsed error", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(401, { error: { message: "invalid x-api-key" } }));
|
||||
const result = await verifyProvider({
|
||||
providerId: "anthropic",
|
||||
apiKey: "bogus",
|
||||
type: "anthropic",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("invalid x-api-key");
|
||||
});
|
||||
|
||||
it("fails without an API key", async () => {
|
||||
const result = await verifyProvider({
|
||||
providerId: "anthropic",
|
||||
apiKey: "",
|
||||
type: "anthropic",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyProvider — openai-compatible", () => {
|
||||
it("hits the canonical OpenAI endpoint when no baseUrl is set", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
await verifyProvider({
|
||||
providerId: "openai",
|
||||
apiKey: "sk-test",
|
||||
type: "openai-compatible",
|
||||
});
|
||||
expect(lastCall().url).toBe("https://api.openai.com/v1/models");
|
||||
expect(lastHeaders().Authorization).toBe("Bearer sk-test");
|
||||
});
|
||||
|
||||
it("sends OpenAI-Organization header when extra.openAIOrgId is set", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
await verifyProvider({
|
||||
providerId: "openai",
|
||||
apiKey: "sk-test",
|
||||
extra: { openAIOrgId: "org-123" },
|
||||
type: "openai-compatible",
|
||||
});
|
||||
expect(lastHeaders()["OpenAI-Organization"]).toBe("org-123");
|
||||
});
|
||||
|
||||
it("uses a user-supplied baseUrl for custom providers", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
await verifyProvider({
|
||||
providerId: "custom:abc",
|
||||
apiKey: "",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
type: "openai-compatible",
|
||||
});
|
||||
expect(lastCall().url).toBe("http://localhost:11434/v1/models");
|
||||
expect(lastCall().options.method).toBe("GET");
|
||||
// No Authorization header when key is empty (local mode).
|
||||
expect(lastHeaders().Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails when no baseUrl can be resolved", async () => {
|
||||
const result = await verifyProvider({
|
||||
providerId: "custom:xyz",
|
||||
apiKey: "sk-x",
|
||||
type: "openai-compatible",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/Base URL/i);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("OpenRouter probes /auth/key (since /models is public)", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
await verifyProvider({
|
||||
providerId: "openrouter",
|
||||
apiKey: "sk-or-test",
|
||||
type: "openai-compatible",
|
||||
});
|
||||
expect(lastCall().url).toBe("https://openrouter.ai/api/v1/auth/key");
|
||||
expect(lastHeaders().Authorization).toBe("Bearer sk-or-test");
|
||||
});
|
||||
|
||||
it("OpenRouter fails when /auth/key returns 401", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(401, { error: { message: "No auth credentials found" } }));
|
||||
const result = await verifyProvider({
|
||||
providerId: "openrouter",
|
||||
apiKey: "bogus",
|
||||
type: "openai-compatible",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/auth credentials/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyProvider — google", () => {
|
||||
it("appends the API key as a query parameter", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
await verifyProvider({
|
||||
providerId: "google",
|
||||
apiKey: "google-key",
|
||||
type: "google",
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models?key=google-key",
|
||||
expect.objectContaining({ method: "GET" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyProvider — azure", () => {
|
||||
it("constructs URL from extras when baseUrl is absent", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
await verifyProvider({
|
||||
providerId: "azure",
|
||||
apiKey: "azure-key",
|
||||
extra: {
|
||||
azureInstanceName: "myinst",
|
||||
azureApiVersion: "2024-05-01-preview",
|
||||
},
|
||||
type: "azure",
|
||||
});
|
||||
expect(lastCall().url).toBe(
|
||||
"https://myinst.openai.azure.com/openai/deployments?api-version=2024-05-01-preview"
|
||||
);
|
||||
expect(lastHeaders()["api-key"]).toBe("azure-key");
|
||||
});
|
||||
|
||||
it("parses host and api-version from a pasted baseUrl", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
await verifyProvider({
|
||||
providerId: "azure",
|
||||
apiKey: "azure-key",
|
||||
baseUrl:
|
||||
"https://myinst.openai.azure.com/openai/deployments/mydeploy/chat/completions?api-version=2024-08-01",
|
||||
type: "azure",
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://myinst.openai.azure.com/openai/deployments?api-version=2024-08-01",
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("extras take precedence over a pasted baseUrl", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
await verifyProvider({
|
||||
providerId: "azure",
|
||||
apiKey: "azure-key",
|
||||
baseUrl: "https://other.openai.azure.com?api-version=2023-12-01",
|
||||
extra: {
|
||||
azureInstanceName: "primary",
|
||||
azureApiVersion: "2024-05-01-preview",
|
||||
},
|
||||
type: "azure",
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://primary.openai.azure.com/openai/deployments?api-version=2024-05-01-preview",
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when neither baseUrl nor extras provide instance + version", async () => {
|
||||
const result = await verifyProvider({
|
||||
providerId: "azure",
|
||||
apiKey: "azure-key",
|
||||
type: "azure",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/Azure/);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyProvider — bedrock", () => {
|
||||
it("POSTs to bedrock-runtime with bearer token and default region", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
await verifyProvider({
|
||||
providerId: "amazon-bedrock",
|
||||
apiKey: "aws-bedrock-key",
|
||||
type: "bedrock",
|
||||
});
|
||||
expect(lastCall().url).toMatch(
|
||||
/^https:\/\/bedrock-runtime\.us-east-1\.amazonaws\.com\/model\//
|
||||
);
|
||||
expect(lastCall().options.method).toBe("POST");
|
||||
expect(lastHeaders().Authorization).toBe("Bearer aws-bedrock-key");
|
||||
});
|
||||
|
||||
it("uses extra.bedrockRegion when set", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(200));
|
||||
await verifyProvider({
|
||||
providerId: "amazon-bedrock",
|
||||
apiKey: "aws-bedrock-key",
|
||||
extra: { bedrockRegion: "eu-west-1" },
|
||||
type: "bedrock",
|
||||
});
|
||||
expect(lastCall().url).toMatch(/eu-west-1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyProvider — github-copilot", () => {
|
||||
it("always hard-blocks with the sign-in message", async () => {
|
||||
const result = await verifyProvider({
|
||||
providerId: "github-copilot",
|
||||
apiKey: "anything",
|
||||
type: "github-copilot",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/Sign in to GitHub Copilot/);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyProvider — error mapping", () => {
|
||||
it("maps 404 to an endpoint-not-found message", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(404, ""));
|
||||
const result = await verifyProvider({
|
||||
providerId: "openai",
|
||||
apiKey: "sk-x",
|
||||
baseUrl: "https://wrong.example.com",
|
||||
type: "openai-compatible",
|
||||
});
|
||||
expect(result.error).toMatch(/Endpoint not found/);
|
||||
});
|
||||
|
||||
it("maps 500 to a provider-error message", async () => {
|
||||
mockFetch.mockResolvedValueOnce(ok(503, ""));
|
||||
const result = await verifyProvider({
|
||||
providerId: "anthropic",
|
||||
apiKey: "sk-x",
|
||||
type: "anthropic",
|
||||
});
|
||||
expect(result.error).toMatch(/Provider error/);
|
||||
});
|
||||
});
|
||||
281
src/modelManagement/providers/verifyProvider.ts
Normal file
281
src/modelManagement/providers/verifyProvider.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* Provider verification probes — one small HTTP call per provider type to
|
||||
* confirm the API key works against the configured endpoint.
|
||||
*
|
||||
* No LangChain, no `CustomModel`, no `ChatModelManager`. Routes everything
|
||||
* through Obsidian's `requestUrl` (via `safeFetchNoThrow`) so we bypass the
|
||||
* renderer's CORS restrictions.
|
||||
*
|
||||
* See: designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §3.6.
|
||||
*/
|
||||
import { logError } from "@/logger";
|
||||
import type { ProviderConfig, ProviderId, VerificationResult } from "@/modelManagement/types";
|
||||
import { safeFetchNoThrow } from "@/utils";
|
||||
|
||||
/** Soft UI timeout — `requestUrl` cannot be cancelled, but we stop waiting. */
|
||||
const TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Shape passed in from the dialog before the provider is persisted. Mirrors
|
||||
* the `onTest` draft so the call site can forward without remapping.
|
||||
*/
|
||||
export interface ProviderDraft {
|
||||
providerId: ProviderId;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
type: ProviderConfig["type"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the provider's API with the supplied draft credentials. Always
|
||||
* resolves — never throws — so the caller can treat the return value as a
|
||||
* boolean outcome plus a human-readable error.
|
||||
*/
|
||||
export async function verifyProvider(draft: ProviderDraft): Promise<VerificationResult> {
|
||||
try {
|
||||
return await Promise.race([probeFor(draft), timeoutAfter(TIMEOUT_MS)]);
|
||||
} catch (err) {
|
||||
logError("[verifyProvider] Unexpected throw:", err);
|
||||
return fail(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function probeFor(draft: ProviderDraft): Promise<VerificationResult> {
|
||||
switch (draft.type) {
|
||||
case "anthropic":
|
||||
return verifyAnthropic(draft);
|
||||
case "openai-compatible":
|
||||
return verifyOpenAICompatible(draft);
|
||||
case "google":
|
||||
return verifyGoogle(draft);
|
||||
case "azure":
|
||||
return verifyAzure(draft);
|
||||
case "bedrock":
|
||||
return verifyBedrock(draft);
|
||||
case "github-copilot":
|
||||
return Promise.resolve(fail("Sign in to GitHub Copilot in main Copilot settings first."));
|
||||
case undefined:
|
||||
// System providers don't go through the BYOK adapter layer; the BYOK UI
|
||||
// filters them out so the dialog should never invoke `verifyProvider`
|
||||
// on one. If it does, surface a clear failure rather than crashing.
|
||||
return Promise.resolve(fail("Provider type is required for verification."));
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAnthropic(draft: ProviderDraft): Promise<VerificationResult> {
|
||||
if (!draft.apiKey.trim()) return fail("API key is required");
|
||||
const base = trimTrailingSlash(draft.baseUrl) || defaultBaseUrl(draft.providerId);
|
||||
if (!base) return fail("Base URL is required");
|
||||
const url = `${base}/v1/models`;
|
||||
const response = await safeFetchNoThrow(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-api-key": draft.apiKey.trim(),
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
});
|
||||
return interpretHttp(response);
|
||||
}
|
||||
|
||||
async function verifyOpenAICompatible(draft: ProviderDraft): Promise<VerificationResult> {
|
||||
const base = trimTrailingSlash(draft.baseUrl) || defaultBaseUrl(draft.providerId);
|
||||
if (!base) return fail("Base URL is required");
|
||||
// Probe endpoint must require auth — otherwise any string "verifies". Most
|
||||
// OpenAI-shape providers gate `/models` behind the API key, but a few
|
||||
// (notably OpenRouter) leave it public. For those we hit an auth-only path
|
||||
// instead. Local providers (Ollama / LM Studio) don't require a key, so we
|
||||
// probe `/models` without auth — connectivity is the real signal there.
|
||||
const url = `${base}${probePathFor(draft.providerId)}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (draft.apiKey.trim()) {
|
||||
headers["Authorization"] = `Bearer ${draft.apiKey.trim()}`;
|
||||
}
|
||||
const orgId = stringExtra(draft.extra, "openAIOrgId");
|
||||
if (orgId) headers["OpenAI-Organization"] = orgId;
|
||||
const response = await safeFetchNoThrow(url, { method: "GET", headers });
|
||||
return interpretHttp(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-provider path appended to the base URL. Defaults to `/models` (which
|
||||
* OpenAI, DeepSeek, xAI, Groq, Mistral, SiliconFlow, Cohere all gate behind
|
||||
* auth). Override only when a provider's `/models` is public.
|
||||
*/
|
||||
function probePathFor(providerId: ProviderId): string {
|
||||
switch (providerId) {
|
||||
case "openrouter":
|
||||
// `/api/v1/auth/key` requires auth; `/api/v1/models` is public.
|
||||
return "/auth/key";
|
||||
default:
|
||||
return "/models";
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyGoogle(draft: ProviderDraft): Promise<VerificationResult> {
|
||||
if (!draft.apiKey.trim()) return fail("API key is required");
|
||||
const base = trimTrailingSlash(draft.baseUrl) || defaultBaseUrl(draft.providerId);
|
||||
if (!base) return fail("Base URL is required");
|
||||
const url = `${base}/v1beta/models?key=${encodeURIComponent(draft.apiKey.trim())}`;
|
||||
const response = await safeFetchNoThrow(url, { method: "GET" });
|
||||
return interpretHttp(response);
|
||||
}
|
||||
|
||||
async function verifyAzure(draft: ProviderDraft): Promise<VerificationResult> {
|
||||
if (!draft.apiKey.trim()) return fail("API key is required");
|
||||
// Extras are the source of truth; baseUrl is a fallback that we parse for
|
||||
// the host and any embedded `api-version` query param.
|
||||
const instance = stringExtra(draft.extra, "azureInstanceName");
|
||||
const version = stringExtra(draft.extra, "azureApiVersion");
|
||||
const fallback = parseAzureUrl(draft.baseUrl);
|
||||
const host = instance ? `https://${instance}.openai.azure.com` : fallback.azureHost;
|
||||
const apiVersion = version || fallback.apiVersion;
|
||||
if (!host || !apiVersion) {
|
||||
return fail("Set Azure instance and API version under Advanced.");
|
||||
}
|
||||
const url = `${host}/openai/deployments?api-version=${encodeURIComponent(apiVersion)}`;
|
||||
const response = await safeFetchNoThrow(url, {
|
||||
method: "GET",
|
||||
headers: { "api-key": draft.apiKey.trim() },
|
||||
});
|
||||
return interpretHttp(response);
|
||||
}
|
||||
|
||||
async function verifyBedrock(draft: ProviderDraft): Promise<VerificationResult> {
|
||||
if (!draft.apiKey.trim()) return fail("AWS Bedrock API key is required");
|
||||
const region = stringExtra(draft.extra, "bedrockRegion") || "us-east-1";
|
||||
const base =
|
||||
trimTrailingSlash(draft.baseUrl) || `https://bedrock-runtime.${region}.amazonaws.com`;
|
||||
// Cheap, commonly-available Anthropic model. If the user hasn't enabled
|
||||
// this specific model in their AWS console, the response surfaces a
|
||||
// model-access error — actionable signal, not a verifier failure mode we
|
||||
// need to special-case here.
|
||||
const probeModel = "anthropic.claude-3-5-haiku-20241022-v1:0";
|
||||
const url = `${base}/model/${encodeURIComponent(probeModel)}/invoke`;
|
||||
const response = await safeFetchNoThrow(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${draft.apiKey.trim()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
anthropic_version: "bedrock-2023-05-31",
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}),
|
||||
});
|
||||
return interpretHttp(response);
|
||||
}
|
||||
|
||||
async function interpretHttp(response: Response): Promise<VerificationResult> {
|
||||
if (response.ok) return { ok: true, verifiedAt: Date.now() };
|
||||
const body = await response.text().catch(() => "");
|
||||
return fail(parseHttpError(response.status, body));
|
||||
}
|
||||
|
||||
function parseHttpError(status: number, body: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as {
|
||||
error?: { message?: string } | string;
|
||||
message?: string;
|
||||
detail?: { message?: string } | string;
|
||||
};
|
||||
if (typeof parsed.error === "string") return `${status}: ${parsed.error}`;
|
||||
if (parsed.error && typeof parsed.error === "object" && parsed.error.message) {
|
||||
return `${status}: ${parsed.error.message}`;
|
||||
}
|
||||
if (parsed.message) return `${status}: ${parsed.message}`;
|
||||
if (typeof parsed.detail === "string") return `${status}: ${parsed.detail}`;
|
||||
if (parsed.detail && typeof parsed.detail === "object" && parsed.detail.message) {
|
||||
return `${status}: ${parsed.detail.message}`;
|
||||
}
|
||||
} catch {
|
||||
// Body isn't JSON — fall through to the generic mapping.
|
||||
}
|
||||
if (status === 401) return "401: Invalid API key";
|
||||
if (status === 403) return "403: Forbidden — check key permissions";
|
||||
if (status === 404) return "404: Endpoint not found — check Base URL";
|
||||
if (status >= 500) return `${status}: Provider error — try again later`;
|
||||
return body ? `${status}: ${body.slice(0, 200)}` : `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the Azure host (`https://{instance}.openai.azure.com`) and any
|
||||
* `api-version` query param from a URL the user may have pasted in full.
|
||||
* Listing deployments lives at the account host, not under any
|
||||
* deployment-specific path — so we keep only the origin, not the path.
|
||||
*/
|
||||
function parseAzureUrl(raw: string | undefined): {
|
||||
azureHost: string | undefined;
|
||||
apiVersion: string | undefined;
|
||||
} {
|
||||
if (!raw?.trim()) return { azureHost: undefined, apiVersion: undefined };
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return { azureHost: undefined, apiVersion: undefined };
|
||||
}
|
||||
return {
|
||||
azureHost: `${url.protocol}//${url.host}`,
|
||||
apiVersion: url.searchParams.get("api-version") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical endpoint for a known provider id. Used both as the verify-time
|
||||
* fallback when the user leaves Base URL blank and as the UI placeholder so
|
||||
* users see where the request will land.
|
||||
*/
|
||||
export function defaultBaseUrl(providerId: ProviderId): string | undefined {
|
||||
switch (providerId) {
|
||||
case "anthropic":
|
||||
return "https://api.anthropic.com";
|
||||
case "google":
|
||||
return "https://generativelanguage.googleapis.com";
|
||||
case "openai":
|
||||
return "https://api.openai.com/v1";
|
||||
case "openrouter":
|
||||
return "https://openrouter.ai/api/v1";
|
||||
case "deepseek":
|
||||
return "https://api.deepseek.com";
|
||||
case "xai":
|
||||
return "https://api.x.ai/v1";
|
||||
case "groq":
|
||||
return "https://api.groq.com/openai/v1";
|
||||
case "mistral":
|
||||
return "https://api.mistral.ai/v1";
|
||||
case "siliconflow":
|
||||
return "https://api.siliconflow.cn/v1";
|
||||
case "cohere":
|
||||
return "https://api.cohere.com/compatibility/v1";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function trimTrailingSlash(s: string | undefined): string | undefined {
|
||||
if (!s) return undefined;
|
||||
const trimmed = s.trim().replace(/\/+$/, "");
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function stringExtra(extra: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const v = extra?.[key];
|
||||
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
||||
}
|
||||
|
||||
function fail(error: string): VerificationResult {
|
||||
return { ok: false, error, verifiedAt: Date.now() };
|
||||
}
|
||||
|
||||
function timeoutAfter(ms: number): Promise<VerificationResult> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(() => resolve(fail(`No response after ${Math.round(ms / 1000)}s`)), ms);
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
253
src/modelManagement/ui/components/ByokGlobalTable.tsx
Normal file
253
src/modelManagement/ui/components/ByokGlobalTable.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* Single global table that renders every BYOK provider as a section row
|
||||
* with indented model rows beneath it.
|
||||
*
|
||||
* Layout:
|
||||
*
|
||||
* ▼ Anthropic 4 models ⋮
|
||||
* Claude Sonnet 4.5
|
||||
* Claude Opus 4.1
|
||||
*
|
||||
* - OpenCode-bundled and Copilot Plus models do NOT appear here, ever.
|
||||
* - Model rows are display-only. To edit which models are registered for a
|
||||
* provider, use the Configure entry in the section's overflow menu.
|
||||
* - Providers with no registered models are filtered out upstream.
|
||||
*/
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ModelCatalogService } from "@/modelManagement/catalog/ModelCatalogService";
|
||||
import type { ProviderConfig, RegistryEntry } from "@/modelManagement/types";
|
||||
import {
|
||||
formatContextWindow,
|
||||
formatReleaseDate,
|
||||
} from "@/modelManagement/ui/utils/formatModelMetadata";
|
||||
import { ChevronDown, ChevronRight, MoreVertical, Settings2, Trash2 } from "lucide-react";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* One provider row plus the registry entries that belong to it. The owning
|
||||
* panel sorts / filters / groups; this component just renders.
|
||||
*/
|
||||
export interface ByokTableProviderGroup {
|
||||
provider: ProviderConfig;
|
||||
entries: RegistryEntry[];
|
||||
}
|
||||
|
||||
interface ByokGlobalTableProps {
|
||||
groups: ByokTableProviderGroup[];
|
||||
/** Called when the user picks "Configure" from the section's overflow menu. */
|
||||
onConfigureProvider: (providerId: string) => void;
|
||||
/** Called when the user confirms "Remove provider" from the overflow menu. */
|
||||
onRemoveProvider: (providerId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ByokGlobalTable` — single CSS-grid "table" so model rows can sit
|
||||
* underneath provider section rows in the same column layout. A real
|
||||
* `<table>` would force every section header into a single `<tr>` row,
|
||||
* which fights the design's "section row + indented children" shape.
|
||||
*/
|
||||
export const ByokGlobalTable: React.FC<ByokGlobalTableProps> = ({
|
||||
groups,
|
||||
onConfigureProvider,
|
||||
onRemoveProvider,
|
||||
}) => {
|
||||
// TODO(persistence): The spec says "remembers state per provider"; M4
|
||||
// accepts in-component state and resets across remounts. A future
|
||||
// pass can lift this into settings or sessionStorage keyed by id.
|
||||
const [openProviders, setOpenProviders] = useState<Record<string, boolean>>({});
|
||||
// Portal target for the per-row DropdownMenuContent. Without this, Radix
|
||||
// portals into `activeDocument.body` — outside Obsidian's settings modal —
|
||||
// where pointer events don't reach the menu items.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggle = (providerId: string): void => {
|
||||
setOpenProviders((prev) => ({
|
||||
...prev,
|
||||
// Default to open if the key has never been touched.
|
||||
[providerId]: prev[providerId] === undefined ? false : !prev[providerId],
|
||||
}));
|
||||
};
|
||||
|
||||
const isOpen = (providerId: string): boolean => openProviders[providerId] !== false;
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-6 tw-text-center tw-text-sm tw-text-muted">
|
||||
No providers match the current filters.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"tw-w-full tw-overflow-hidden tw-rounded-md tw-border tw-border-solid tw-border-border"
|
||||
)}
|
||||
role="table"
|
||||
>
|
||||
{groups.map((group, idx) => (
|
||||
<React.Fragment key={group.provider.id}>
|
||||
{idx > 0 && <div role="separator" className="tw-h-px tw-bg-primary-alt" />}
|
||||
<ProviderSection
|
||||
group={group}
|
||||
isOpen={isOpen(group.provider.id)}
|
||||
onToggle={() => toggle(group.provider.id)}
|
||||
onConfigure={() => onConfigureProvider(group.provider.id)}
|
||||
onRemove={() => onRemoveProvider(group.provider.id)}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ProviderSectionProps {
|
||||
group: ByokTableProviderGroup;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
onConfigure: () => void;
|
||||
onRemove: () => void;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider section: header row + collapsible model rows.
|
||||
*/
|
||||
const ProviderSection: React.FC<ProviderSectionProps> = ({
|
||||
group,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onConfigure,
|
||||
onRemove,
|
||||
containerRef,
|
||||
}) => {
|
||||
const { provider, entries } = group;
|
||||
const headerClass = cn(
|
||||
"tw-flex tw-items-center tw-gap-2 tw-px-3 tw-py-2 tw-text-sm",
|
||||
"tw-cursor-pointer hover:tw-bg-primary-alt/50"
|
||||
);
|
||||
const Chevron = isOpen ? ChevronDown : ChevronRight;
|
||||
|
||||
return (
|
||||
<div role="rowgroup">
|
||||
<div
|
||||
role="row"
|
||||
className={headerClass}
|
||||
onClick={onToggle}
|
||||
data-provider-id={provider.id}
|
||||
data-testid={`byok-section-${provider.id}`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={
|
||||
isOpen ? `Collapse ${provider.displayName}` : `Expand ${provider.displayName}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<Chevron className="tw-size-4" />
|
||||
</Button>
|
||||
<span className="tw-font-medium tw-text-normal">{provider.displayName}</span>
|
||||
<span className="tw-text-xs tw-text-muted">
|
||||
{entries.length} {entries.length === 1 ? "model" : "models"}
|
||||
</span>
|
||||
{provider.kind === "custom" && (
|
||||
<Badge variant="outline" className="tw-text-ui-smaller">
|
||||
custom endpoint
|
||||
</Badge>
|
||||
)}
|
||||
<span className="tw-flex-1" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`More actions for ${provider.displayName}`}
|
||||
>
|
||||
<MoreVertical className="tw-size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" container={containerRef.current}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfigure();
|
||||
}}
|
||||
>
|
||||
<Settings2 className="tw-mr-2 tw-size-4" />
|
||||
Configure
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="tw-text-error"
|
||||
>
|
||||
<Trash2 className="tw-mr-2 tw-size-4" />
|
||||
Remove provider
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{isOpen &&
|
||||
entries.map((entry) => (
|
||||
<ModelRow key={`${entry.providerId}:${entry.modelId}`} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModelRowProps {
|
||||
entry: RegistryEntry;
|
||||
}
|
||||
|
||||
const ModelRow: React.FC<ModelRowProps> = ({ entry }) => {
|
||||
// Capabilities (context window, release date) are looked up at render time
|
||||
// from the catalog — never snapshotted into the registry entry. When the
|
||||
// catalog hasn't loaded a provider/model yet (e.g. custom models, or before
|
||||
// first refresh) both cells render as an em dash.
|
||||
const catalogModel = ModelCatalogService.getInstance().getModel(entry.providerId, entry.modelId);
|
||||
const contextLabel = formatContextWindow(catalogModel?.limit?.context);
|
||||
const releaseLabel = formatReleaseDate(catalogModel?.release_date);
|
||||
return (
|
||||
<div
|
||||
role="row"
|
||||
data-testid={`byok-model-${entry.providerId}-${entry.modelId}`}
|
||||
className={cn(
|
||||
"tw-grid tw-grid-cols-[1fr_auto_auto] tw-items-center tw-gap-3 tw-px-3 tw-py-1.5 tw-pl-10 tw-text-sm"
|
||||
)}
|
||||
>
|
||||
<div className="tw-truncate tw-text-normal">{entry.displayName}</div>
|
||||
<span
|
||||
className="tw-shrink-0 tw-text-xs tw-text-muted"
|
||||
data-testid={`byok-model-context-${entry.providerId}-${entry.modelId}`}
|
||||
>
|
||||
{contextLabel ?? "—"}
|
||||
</span>
|
||||
<span
|
||||
className="tw-w-20 tw-shrink-0 tw-text-right tw-text-xs tw-text-muted"
|
||||
data-testid={`byok-model-release-${entry.providerId}-${entry.modelId}`}
|
||||
>
|
||||
{releaseLabel || "—"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ByokGlobalTable.displayName = "ByokGlobalTable";
|
||||
349
src/modelManagement/ui/components/ProviderCatalogList.tsx
Normal file
349
src/modelManagement/ui/components/ProviderCatalogList.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
/**
|
||||
* `ProviderCatalogList` — reusable checklist component used inside
|
||||
* `ConfigureProviderDialog` to surface a provider's catalog models.
|
||||
*
|
||||
* Layout per designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §5.2:
|
||||
*
|
||||
* ┌────────────────────────────────────────────────────────┐
|
||||
* │ ☑ Claude Sonnet 4.5 200k Sep 2025 ⋯ │
|
||||
* │ ☐ Claude Opus 4.1 200k Aug 2024 ⋯ │
|
||||
* └────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Catalog models render as `<row>` with a checkbox + name + context window
|
||||
* + right-aligned release date column.
|
||||
* - In `edit` state, registered rows show a `⋯` kebab (View docs / Remove
|
||||
* from registry).
|
||||
* - OpenRouter (whose ids look like `<upstream>/<modelId>`) gets sticky
|
||||
* `<h>` upstream-provider headers grouping models by upstream namespace.
|
||||
*
|
||||
* The component is presentation-only — filter + search logic is applied by
|
||||
* the caller via `searchModels()` results. Persistence (which entries are
|
||||
* "selected") flows through the `selectedModelIds` prop and `onToggle`
|
||||
* callback.
|
||||
*/
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CatalogModel } from "@/modelManagement/catalog/modelsCatalog.types";
|
||||
import type { ProviderId } from "@/modelManagement/types";
|
||||
import {
|
||||
formatContextWindow,
|
||||
formatReleaseDate,
|
||||
} from "@/modelManagement/ui/utils/formatModelMetadata";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Extract the upstream-provider namespace from an OpenRouter model id
|
||||
* (e.g. `anthropic/claude-sonnet-4.5` → `anthropic`). Returns `null` if the
|
||||
* id is not a `<namespace>/<rest>` shape.
|
||||
*/
|
||||
function openRouterUpstream(modelId: string): string | null {
|
||||
const slash = modelId.indexOf("/");
|
||||
if (slash <= 0) return null;
|
||||
return modelId.slice(0, slash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a flat catalog-model array by OpenRouter upstream. Non-OpenRouter
|
||||
* callers get a single `"_"` bucket containing every model in original
|
||||
* order — so callers can always iterate the same shape.
|
||||
*/
|
||||
interface ProviderCatalogGroup {
|
||||
/** Upstream label (e.g. "anthropic"), or `null` for ungrouped lists. */
|
||||
upstream: string | null;
|
||||
models: CatalogModel[];
|
||||
}
|
||||
|
||||
function groupForOpenRouter(models: CatalogModel[]): ProviderCatalogGroup[] {
|
||||
// Preserve insertion order while bucketing by upstream.
|
||||
const buckets = new Map<string, CatalogModel[]>();
|
||||
const ungrouped: CatalogModel[] = [];
|
||||
for (const model of models) {
|
||||
const upstream = openRouterUpstream(model.id);
|
||||
if (upstream === null) {
|
||||
ungrouped.push(model);
|
||||
continue;
|
||||
}
|
||||
const existing = buckets.get(upstream);
|
||||
if (existing) {
|
||||
existing.push(model);
|
||||
} else {
|
||||
buckets.set(upstream, [model]);
|
||||
}
|
||||
}
|
||||
const groups: ProviderCatalogGroup[] = [];
|
||||
for (const [upstream, list] of buckets) {
|
||||
groups.push({ upstream, models: list });
|
||||
}
|
||||
if (ungrouped.length > 0) {
|
||||
groups.push({ upstream: null, models: ungrouped });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export interface ProviderCatalogListProps {
|
||||
/** The provider whose catalog we're listing — drives header grouping (e.g. OpenRouter). */
|
||||
providerId: ProviderId;
|
||||
/** Catalog models to render (already filtered/searched upstream). */
|
||||
models: CatalogModel[];
|
||||
/** `<providerId>:<modelId>` keys that are currently checked. */
|
||||
selectedModelIds: ReadonlySet<string>;
|
||||
/** Toggle one model's check state. */
|
||||
onToggle: (modelId: string) => void;
|
||||
/**
|
||||
* Edit-state mode: register existence of a `⋯` menu on already-registered
|
||||
* entries (View docs / Remove from registry). Defaults to `false`.
|
||||
*/
|
||||
showKebab?: boolean;
|
||||
/** Set of `<providerId>:<modelId>` keys that are in the registry. Used to
|
||||
* show the kebab only for entries that actually live in the registry. */
|
||||
registeredModelIds?: ReadonlySet<string>;
|
||||
/** Edit-state callback when the user picks "View docs". */
|
||||
onViewDocs?: (modelId: string) => void;
|
||||
/** Edit-state callback when the user picks "Remove from registry". */
|
||||
onRemoveFromRegistry?: (modelId: string) => void;
|
||||
/** Rendered when `models.length === 0`. */
|
||||
emptyMessage?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ProviderCatalogList` — see file header comment for layout + behavior.
|
||||
*/
|
||||
export const ProviderCatalogList: React.FC<ProviderCatalogListProps> = ({
|
||||
providerId,
|
||||
models,
|
||||
selectedModelIds,
|
||||
onToggle,
|
||||
showKebab = false,
|
||||
registeredModelIds,
|
||||
onViewDocs,
|
||||
onRemoveFromRegistry,
|
||||
emptyMessage,
|
||||
}) => {
|
||||
const groups = useMemo<ProviderCatalogGroup[]>(() => {
|
||||
if (providerId === "openrouter") {
|
||||
return groupForOpenRouter(models);
|
||||
}
|
||||
return [{ upstream: null, models }];
|
||||
}, [providerId, models]);
|
||||
|
||||
// Portal target for the per-row DropdownMenuContent. Without this, Radix
|
||||
// portals into `activeDocument.body` — outside the dialog's modal container —
|
||||
// where pointer events don't reach the menu items.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-4 tw-text-center tw-text-sm tw-text-muted"
|
||||
data-testid="catalog-list-empty"
|
||||
>
|
||||
{emptyMessage ?? "No models match the current filters."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"tw-flex tw-flex-col tw-overflow-hidden tw-rounded-md tw-border tw-border-solid tw-border-border"
|
||||
)}
|
||||
role="list"
|
||||
data-testid={`catalog-list-${providerId}`}
|
||||
>
|
||||
{groups.map((group, groupIdx) => (
|
||||
<CatalogGroupSection
|
||||
key={group.upstream ?? `__ungrouped-${groupIdx}`}
|
||||
providerId={providerId}
|
||||
group={group}
|
||||
selectedModelIds={selectedModelIds}
|
||||
onToggle={onToggle}
|
||||
showKebab={showKebab}
|
||||
registeredModelIds={registeredModelIds}
|
||||
onViewDocs={onViewDocs}
|
||||
onRemoveFromRegistry={onRemoveFromRegistry}
|
||||
isLastGroup={groupIdx === groups.length - 1}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CatalogGroupSectionProps {
|
||||
providerId: ProviderId;
|
||||
group: ProviderCatalogGroup;
|
||||
selectedModelIds: ReadonlySet<string>;
|
||||
onToggle: (modelId: string) => void;
|
||||
showKebab: boolean;
|
||||
registeredModelIds?: ReadonlySet<string>;
|
||||
onViewDocs?: (modelId: string) => void;
|
||||
onRemoveFromRegistry?: (modelId: string) => void;
|
||||
isLastGroup: boolean;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const CatalogGroupSection: React.FC<CatalogGroupSectionProps> = ({
|
||||
providerId,
|
||||
group,
|
||||
selectedModelIds,
|
||||
onToggle,
|
||||
showKebab,
|
||||
registeredModelIds,
|
||||
onViewDocs,
|
||||
onRemoveFromRegistry,
|
||||
isLastGroup,
|
||||
containerRef,
|
||||
}) => {
|
||||
const hasHeader = group.upstream !== null;
|
||||
return (
|
||||
<div role="group">
|
||||
{hasHeader && (
|
||||
<div
|
||||
className={cn(
|
||||
// Sticky upstream-provider header — sits at the top of its
|
||||
// group while the scroll container scrolls beneath it.
|
||||
"tw-sticky tw-top-0 tw-border-b tw-border-solid tw-border-border",
|
||||
"tw-bg-secondary-alt tw-px-3 tw-py-1 tw-text-ui-smaller tw-font-medium tw-text-muted"
|
||||
)}
|
||||
data-testid={`catalog-upstream-${group.upstream}`}
|
||||
>
|
||||
{group.upstream}
|
||||
</div>
|
||||
)}
|
||||
{group.models.map((model, modelIdx) => {
|
||||
const key = `${providerId}:${model.id}`;
|
||||
const isLastInGroup = modelIdx === group.models.length - 1;
|
||||
const isFinal = isLastGroup && isLastInGroup;
|
||||
return (
|
||||
<CatalogModelRow
|
||||
key={key}
|
||||
providerId={providerId}
|
||||
model={model}
|
||||
checked={selectedModelIds.has(key)}
|
||||
onToggle={() => onToggle(model.id)}
|
||||
showKebab={showKebab && (registeredModelIds?.has(key) ?? false)}
|
||||
onViewDocs={onViewDocs}
|
||||
onRemoveFromRegistry={onRemoveFromRegistry}
|
||||
isFinal={isFinal}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CatalogModelRowProps {
|
||||
providerId: ProviderId;
|
||||
model: CatalogModel;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
showKebab: boolean;
|
||||
onViewDocs?: (modelId: string) => void;
|
||||
onRemoveFromRegistry?: (modelId: string) => void;
|
||||
isFinal: boolean;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const CatalogModelRow: React.FC<CatalogModelRowProps> = ({
|
||||
providerId,
|
||||
model,
|
||||
checked,
|
||||
onToggle,
|
||||
showKebab,
|
||||
onViewDocs,
|
||||
onRemoveFromRegistry,
|
||||
isFinal,
|
||||
containerRef,
|
||||
}) => {
|
||||
const contextLabel = formatContextWindow(model.limit?.context);
|
||||
const releaseLabel = formatReleaseDate(model.release_date);
|
||||
const rowId = `${providerId}:${model.id}`;
|
||||
return (
|
||||
<div
|
||||
role="listitem"
|
||||
data-testid={`catalog-row-${providerId}-${model.id}`}
|
||||
className={cn(
|
||||
"tw-flex tw-items-center tw-gap-3 tw-px-3 tw-py-1.5 tw-text-sm",
|
||||
!isFinal && "tw-border-b tw-border-solid tw-border-border",
|
||||
"hover:tw-bg-primary-alt/40"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`row-${rowId}`}
|
||||
checked={checked}
|
||||
onCheckedChange={onToggle}
|
||||
aria-label={`Select ${model.name}`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`row-${rowId}`}
|
||||
className="tw-flex tw-min-w-0 tw-flex-1 tw-cursor-pointer tw-items-center tw-gap-2"
|
||||
>
|
||||
<span className="tw-truncate tw-text-normal">{model.name}</span>
|
||||
{model.id !== model.name && (
|
||||
<Badge variant="outline" className="tw-shrink-0 tw-text-ui-smaller tw-text-muted">
|
||||
{model.id}
|
||||
</Badge>
|
||||
)}
|
||||
</label>
|
||||
<span className="tw-shrink-0 tw-text-xs tw-text-muted">{contextLabel ?? ""}</span>
|
||||
<span
|
||||
className="tw-w-20 tw-shrink-0 tw-text-right tw-text-xs tw-text-muted"
|
||||
data-testid={`catalog-release-${providerId}-${model.id}`}
|
||||
>
|
||||
{releaseLabel}
|
||||
</span>
|
||||
{showKebab ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`More actions for ${model.name}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="tw-size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" container={containerRef.current}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDocs?.(model.id);
|
||||
}}
|
||||
>
|
||||
View docs
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="tw-text-error"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveFromRegistry?.(model.id);
|
||||
}}
|
||||
>
|
||||
Remove from registry
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
// Reserve same width as the kebab so the column line up regardless
|
||||
// of state. Using a sized spacer keeps the right-aligned release
|
||||
// column stable.
|
||||
<span aria-hidden className="tw-w-7" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProviderCatalogList.displayName = "ProviderCatalogList";
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* `ByokGlobalTable` rendering + interaction tests.
|
||||
*
|
||||
* Scope (M4):
|
||||
* - Provider section rows + indented model rows render from props.
|
||||
* - Chevron toggles fold/unfold and hides children when collapsed.
|
||||
* - Section actions (Configure + Remove provider, both in the kebab) fire
|
||||
* the right callbacks. The full filter-chip behavior is covered by the
|
||||
* panel test; this file focuses on the inert rendering surface.
|
||||
*/
|
||||
import { ModelCatalogService } from "@/modelManagement/catalog/ModelCatalogService";
|
||||
import {
|
||||
ByokGlobalTable,
|
||||
type ByokTableProviderGroup,
|
||||
} from "@/modelManagement/ui/components/ByokGlobalTable";
|
||||
import type { ProviderConfig, RegistryEntry } from "@/modelManagement/types";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
// Catalog is the single source of truth for capabilities (context window,
|
||||
// release date). Each test sets `getModel` to whatever it needs.
|
||||
jest.mock("@/modelManagement/catalog/ModelCatalogService", () => ({
|
||||
ModelCatalogService: {
|
||||
getInstance: jest.fn(),
|
||||
},
|
||||
}));
|
||||
const mockGetInstance = ModelCatalogService.getInstance as jest.Mock;
|
||||
const mockGetModel = jest.fn();
|
||||
beforeEach(() => {
|
||||
mockGetModel.mockReset();
|
||||
mockGetInstance.mockReturnValue({ getModel: mockGetModel });
|
||||
});
|
||||
|
||||
// Radix DropdownMenu uses pointer events + a portal that jsdom doesn't
|
||||
// fully support. Replace it with a transparent wrapper so the menu items
|
||||
// are always present in the rendered tree.
|
||||
jest.mock("@/components/ui/dropdown-menu", () => {
|
||||
const passthrough = ({ children }: { children?: React.ReactNode }) => <>{children}</>;
|
||||
const item = ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => (
|
||||
<div role="menuitem" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return {
|
||||
DropdownMenu: passthrough,
|
||||
DropdownMenuTrigger: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: passthrough,
|
||||
DropdownMenuItem: item,
|
||||
};
|
||||
});
|
||||
|
||||
// Radix DropdownMenu reaches for `activeDocument.body` via the codebase's
|
||||
// shared portal helper. jsdom doesn't define `activeDocument` — polyfill
|
||||
// it by aliasing to `window.document` (single-window environment).
|
||||
beforeAll(() => {
|
||||
(window as unknown as { activeDocument: Document }).activeDocument = window.document;
|
||||
});
|
||||
|
||||
function makeProvider(overrides: Partial<ProviderConfig>): ProviderConfig {
|
||||
return {
|
||||
id: "anthropic",
|
||||
kind: "builtin",
|
||||
displayName: "Anthropic",
|
||||
type: "anthropic",
|
||||
addedAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntry(overrides: Partial<RegistryEntry>): RegistryEntry {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
addedAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ByokGlobalTable", () => {
|
||||
it("renders provider section rows + nested model rows", () => {
|
||||
const groups: ByokTableProviderGroup[] = [
|
||||
{
|
||||
provider: makeProvider({}),
|
||||
entries: [
|
||||
makeEntry({}),
|
||||
makeEntry({ modelId: "claude-opus-4-1", displayName: "Claude Opus 4.1" }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<ByokGlobalTable
|
||||
groups={groups}
|
||||
onConfigureProvider={jest.fn()}
|
||||
onRemoveProvider={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Section header content
|
||||
expect(screen.getByText("Anthropic")).toBeTruthy();
|
||||
expect(screen.getByText("2 models")).toBeTruthy();
|
||||
|
||||
// Both model rows visible by default (sections are open by default).
|
||||
expect(screen.getByTestId("byok-model-anthropic-claude-sonnet-4-5")).toBeTruthy();
|
||||
expect(screen.getByTestId("byok-model-anthropic-claude-opus-4-1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("collapses a section when the chevron is clicked and re-expands on click again", () => {
|
||||
const groups: ByokTableProviderGroup[] = [
|
||||
{
|
||||
provider: makeProvider({}),
|
||||
entries: [makeEntry({})],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<ByokGlobalTable
|
||||
groups={groups}
|
||||
onConfigureProvider={jest.fn()}
|
||||
onRemoveProvider={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const chevron = screen.getByLabelText("Collapse Anthropic");
|
||||
fireEvent.click(chevron);
|
||||
expect(screen.queryByTestId("byok-model-anthropic-claude-sonnet-4-5")).toBeNull();
|
||||
|
||||
const reopen = screen.getByLabelText("Expand Anthropic");
|
||||
fireEvent.click(reopen);
|
||||
expect(screen.getByTestId("byok-model-anthropic-claude-sonnet-4-5")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the custom-endpoint badge for kind:'custom' providers", () => {
|
||||
const groups: ByokTableProviderGroup[] = [
|
||||
{
|
||||
provider: makeProvider({
|
||||
id: "custom:abc",
|
||||
kind: "custom",
|
||||
displayName: "Local Ollama",
|
||||
type: "openai-compatible",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
}),
|
||||
entries: [],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<ByokGlobalTable
|
||||
groups={groups}
|
||||
onConfigureProvider={jest.fn()}
|
||||
onRemoveProvider={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("custom endpoint")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fires onConfigureProvider when Configure is picked from the overflow menu", () => {
|
||||
const onConfigureProvider = jest.fn();
|
||||
const groups: ByokTableProviderGroup[] = [
|
||||
{ provider: makeProvider({}), entries: [makeEntry({})] },
|
||||
];
|
||||
|
||||
render(
|
||||
<ByokGlobalTable
|
||||
groups={groups}
|
||||
onConfigureProvider={onConfigureProvider}
|
||||
onRemoveProvider={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const configureItem = screen.getByRole("menuitem", { name: /Configure/i });
|
||||
fireEvent.click(configureItem);
|
||||
expect(onConfigureProvider).toHaveBeenCalledWith("anthropic");
|
||||
});
|
||||
|
||||
it("renders the empty-groups fallback message", () => {
|
||||
render(
|
||||
<ByokGlobalTable groups={[]} onConfigureProvider={jest.fn()} onRemoveProvider={jest.fn()} />
|
||||
);
|
||||
|
||||
expect(screen.getByText(/No providers match the current filters\./i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders context window + release date looked up from the catalog at render time", () => {
|
||||
mockGetModel.mockImplementation((providerId: string, modelId: string) => {
|
||||
if (providerId === "anthropic" && modelId === "claude-sonnet-4-5") {
|
||||
return { limit: { context: 200_000 }, release_date: "2025-09-29" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const groups: ByokTableProviderGroup[] = [
|
||||
{
|
||||
provider: makeProvider({}),
|
||||
entries: [makeEntry({})],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<ByokGlobalTable
|
||||
groups={groups}
|
||||
onConfigureProvider={jest.fn()}
|
||||
onRemoveProvider={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("byok-model-context-anthropic-claude-sonnet-4-5").textContent).toBe(
|
||||
"200k"
|
||||
);
|
||||
expect(screen.getByTestId("byok-model-release-anthropic-claude-sonnet-4-5").textContent).toBe(
|
||||
"Sep 2025"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to '—' when the catalog has no entry for the model (custom or pre-load)", () => {
|
||||
mockGetModel.mockReturnValue(undefined);
|
||||
const groups: ByokTableProviderGroup[] = [
|
||||
{
|
||||
provider: makeProvider({}),
|
||||
entries: [makeEntry({})],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<ByokGlobalTable
|
||||
groups={groups}
|
||||
onConfigureProvider={jest.fn()}
|
||||
onRemoveProvider={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("byok-model-context-anthropic-claude-sonnet-4-5").textContent).toBe(
|
||||
"—"
|
||||
);
|
||||
expect(screen.getByTestId("byok-model-release-anthropic-claude-sonnet-4-5").textContent).toBe(
|
||||
"—"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* `ProviderCatalogList` tests — confirms the rendering contract used by the
|
||||
* Configure Provider dialog.
|
||||
*
|
||||
* Scope (M5):
|
||||
* - Ungrouped rendering for regular providers.
|
||||
* - Sticky upstream-provider headers for OpenRouter (id includes `/`).
|
||||
* - Checkbox toggles fire `onToggle` with the model id (not the key).
|
||||
* - Empty state renders when no models are supplied.
|
||||
*/
|
||||
import {
|
||||
ProviderCatalogList,
|
||||
type ProviderCatalogListProps,
|
||||
} from "@/modelManagement/ui/components/ProviderCatalogList";
|
||||
import type { CatalogModel } from "@/modelManagement/catalog/modelsCatalog.types";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
(window as unknown as { activeDocument: Document }).activeDocument = window.document;
|
||||
});
|
||||
|
||||
function makeModel(overrides: Partial<CatalogModel> & { id: string }): CatalogModel {
|
||||
const { id, name, limit, ...rest } = overrides;
|
||||
return {
|
||||
id,
|
||||
name: name ?? id,
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: limit?.context ?? 200_000, output: limit?.output ?? 8_000 },
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function renderList(props: Partial<ProviderCatalogListProps>): void {
|
||||
render(
|
||||
<ProviderCatalogList
|
||||
providerId={props.providerId ?? "anthropic"}
|
||||
models={props.models ?? []}
|
||||
selectedModelIds={props.selectedModelIds ?? new Set<string>()}
|
||||
onToggle={props.onToggle ?? jest.fn()}
|
||||
showKebab={props.showKebab}
|
||||
registeredModelIds={props.registeredModelIds}
|
||||
onViewDocs={props.onViewDocs}
|
||||
onRemoveFromRegistry={props.onRemoveFromRegistry}
|
||||
emptyMessage={props.emptyMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ProviderCatalogList", () => {
|
||||
it("renders an empty-state message when no models match", () => {
|
||||
renderList({ models: [] });
|
||||
expect(screen.getByTestId("catalog-list-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders rows ungrouped for a non-OpenRouter provider", () => {
|
||||
renderList({
|
||||
providerId: "anthropic",
|
||||
models: [
|
||||
makeModel({ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }),
|
||||
makeModel({ id: "claude-opus-4-1", name: "Claude Opus 4.1" }),
|
||||
],
|
||||
});
|
||||
expect(screen.getByTestId("catalog-row-anthropic-claude-sonnet-4-5")).toBeTruthy();
|
||||
expect(screen.getByTestId("catalog-row-anthropic-claude-opus-4-1")).toBeTruthy();
|
||||
// No upstream-provider header for non-OpenRouter providers.
|
||||
expect(screen.queryByTestId(/^catalog-upstream-/)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders sticky upstream-provider headers for OpenRouter", () => {
|
||||
renderList({
|
||||
providerId: "openrouter",
|
||||
models: [
|
||||
makeModel({ id: "anthropic/claude-sonnet-4.5", name: "Claude Sonnet 4.5" }),
|
||||
makeModel({ id: "anthropic/claude-opus-4.1", name: "Claude Opus 4.1" }),
|
||||
makeModel({ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" }),
|
||||
],
|
||||
});
|
||||
expect(screen.getByTestId("catalog-upstream-anthropic")).toBeTruthy();
|
||||
expect(screen.getByTestId("catalog-upstream-google")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fires onToggle with the model id when a checkbox is clicked", () => {
|
||||
const onToggle = jest.fn();
|
||||
renderList({
|
||||
providerId: "anthropic",
|
||||
models: [makeModel({ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" })],
|
||||
onToggle,
|
||||
});
|
||||
|
||||
// Click the row's label — Radix Checkbox proxies the click through.
|
||||
const row = screen.getByTestId("catalog-row-anthropic-claude-sonnet-4-5");
|
||||
const checkbox = row.querySelector("button[role='checkbox']") as HTMLElement;
|
||||
fireEvent.click(checkbox);
|
||||
expect(onToggle).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("only shows the kebab on registered rows when showKebab is true", () => {
|
||||
renderList({
|
||||
providerId: "anthropic",
|
||||
models: [
|
||||
makeModel({ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }),
|
||||
makeModel({ id: "claude-opus-4-1", name: "Claude Opus 4.1" }),
|
||||
],
|
||||
showKebab: true,
|
||||
registeredModelIds: new Set(["anthropic:claude-sonnet-4-5"]),
|
||||
});
|
||||
// Kebab present on the registered row only.
|
||||
expect(screen.getByLabelText("More actions for Claude Sonnet 4.5")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("More actions for Claude Opus 4.1")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats release_date into a human-readable column", () => {
|
||||
renderList({
|
||||
providerId: "anthropic",
|
||||
models: [
|
||||
makeModel({
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
release_date: "2025-09-15",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const cell = screen.getByTestId("catalog-release-anthropic-claude-sonnet-4-5");
|
||||
expect(cell.textContent).toMatch(/Sep 2025/);
|
||||
});
|
||||
});
|
||||
207
src/modelManagement/ui/dialogs/AddCustomModelDialog.tsx
Normal file
207
src/modelManagement/ui/dialogs/AddCustomModelDialog.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* `AddCustomModelDialog` — add a one-off model under an existing provider.
|
||||
*
|
||||
* Layout:
|
||||
*
|
||||
* [An] Add custom model · under Anthropic ✕
|
||||
* Display name [Claude Sonnet 4.5 (preview) ]
|
||||
* Model ID [claude-sonnet-4-5-20260601-preview ] [Test]
|
||||
* [Cancel] [Add]
|
||||
*
|
||||
* `[Test]` pings the provider's API with the entered model id; success → ✓,
|
||||
* failure → ⚠ + inline error. `[Add]` calls `onAdd(...)` with the assembled
|
||||
* registry entry and the parent dialog re-renders to show the new row as
|
||||
* already-checked.
|
||||
*/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FormField } from "@/components/ui/form-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTabOptional } from "@/contexts/TabContext";
|
||||
import { logError } from "@/logger";
|
||||
import type { ProviderConfig, RegistryEntry } from "@/modelManagement/types";
|
||||
import { CheckCircle2, Loader2, XCircle } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
export interface AddCustomModelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The parent provider context — drives the title and pings. */
|
||||
provider: ProviderConfig;
|
||||
/**
|
||||
* Test the (provider, modelId) combination. Rejects on failure. The
|
||||
* caller controls how to ping (e.g. via `ChatModelManager.ping`).
|
||||
*/
|
||||
onTest: (modelId: string) => Promise<void>;
|
||||
/**
|
||||
* Called when the user clicks `[Add]` after filling the form. The
|
||||
* `addedAt` field is added by `ModelRegistry.add` so callers may omit it
|
||||
* when assembling the entry.
|
||||
*/
|
||||
onAdd: (entry: Omit<RegistryEntry, "addedAt">) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `AddCustomModelDialog` — see file header comment.
|
||||
*/
|
||||
export const AddCustomModelDialog: React.FC<AddCustomModelDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
provider,
|
||||
onTest,
|
||||
onAdd,
|
||||
}) => {
|
||||
const modalContainer = useTabOptional()?.modalContainer ?? null;
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [modelId, setModelId] = useState("");
|
||||
const [testState, setTestState] = useState<
|
||||
| { kind: "idle" }
|
||||
| { kind: "testing" }
|
||||
| { kind: "success" }
|
||||
| { kind: "error"; message: string }
|
||||
>({ kind: "idle" });
|
||||
|
||||
const reset = (): void => {
|
||||
setDisplayName("");
|
||||
setModelId("");
|
||||
setTestState({ kind: "idle" });
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean): void => {
|
||||
if (!next) reset();
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const trimmedModelId = modelId.trim();
|
||||
const trimmedDisplayName = displayName.trim();
|
||||
const canAdd = trimmedModelId.length > 0 && trimmedDisplayName.length > 0;
|
||||
|
||||
const handleTest = async (): Promise<void> => {
|
||||
if (!trimmedModelId) return;
|
||||
setTestState({ kind: "testing" });
|
||||
try {
|
||||
await onTest(trimmedModelId);
|
||||
setTestState({ kind: "success" });
|
||||
} catch (err) {
|
||||
logError("[AddCustomModelDialog] Test failed:", err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setTestState({ kind: "error", message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (): Promise<void> => {
|
||||
if (!canAdd) return;
|
||||
const entry: Omit<RegistryEntry, "addedAt"> = {
|
||||
providerId: provider.id,
|
||||
modelId: trimmedModelId,
|
||||
displayName: trimmedDisplayName,
|
||||
};
|
||||
await onAdd(entry);
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="tw-max-h-[80vh] tw-overflow-y-auto sm:tw-max-w-[480px]"
|
||||
container={modalContainer}
|
||||
data-testid="add-custom-model-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add custom model · under {provider.displayName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Use this for preview models, fine-tunes, private deployments, or anything not in the
|
||||
catalog. Provider connection (key, base URL) is reused.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="tw-flex tw-flex-col tw-gap-3">
|
||||
<FormField label="Display name">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Claude Sonnet 4.5 (preview)"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
data-testid="add-custom-model-display-name"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Model ID">
|
||||
<div className="tw-flex tw-gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="claude-sonnet-4-5-20260601-preview"
|
||||
value={modelId}
|
||||
onChange={(e) => {
|
||||
setModelId(e.target.value);
|
||||
// Edits invalidate the previous test outcome.
|
||||
setTestState({ kind: "idle" });
|
||||
}}
|
||||
className="tw-flex-1"
|
||||
data-testid="add-custom-model-id"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleTest}
|
||||
disabled={!trimmedModelId || testState.kind === "testing"}
|
||||
data-testid="add-custom-model-test"
|
||||
>
|
||||
{testState.kind === "testing" ? (
|
||||
<>
|
||||
<Loader2 className="tw-size-3.5 tw-animate-spin" />
|
||||
Test
|
||||
</>
|
||||
) : (
|
||||
"Test"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
{testState.kind === "success" && (
|
||||
<div
|
||||
className="tw-flex tw-items-center tw-gap-2 tw-text-sm tw-text-success"
|
||||
data-testid="add-custom-model-success"
|
||||
>
|
||||
<CheckCircle2 className="tw-size-4" />
|
||||
Test succeeded.
|
||||
</div>
|
||||
)}
|
||||
{testState.kind === "error" && (
|
||||
<div
|
||||
className="tw-flex tw-items-start tw-gap-2 tw-text-sm tw-text-error"
|
||||
data-testid="add-custom-model-error"
|
||||
>
|
||||
<XCircle className="tw-mt-0.5 tw-size-4 tw-shrink-0" />
|
||||
<span className="tw-break-words">{testState.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tw-flex tw-items-center tw-justify-end tw-gap-2">
|
||||
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleAdd}
|
||||
disabled={!canAdd}
|
||||
data-testid="add-custom-model-add"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
AddCustomModelDialog.displayName = "AddCustomModelDialog";
|
||||
333
src/modelManagement/ui/dialogs/AddProviderDialog.tsx
Normal file
333
src/modelManagement/ui/dialogs/AddProviderDialog.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
/**
|
||||
* `AddProviderDialog` — entry point for picking a new provider to add.
|
||||
*
|
||||
* Layout per the consolidated "AddProviderV1 — categorized list" design
|
||||
* (final.jsx ➜ FinalAddProvider):
|
||||
*
|
||||
* [🔍 Search providers…]
|
||||
*
|
||||
* Recommended
|
||||
* [An] Anthropic — Claude family [ Add → ]
|
||||
* [Op] OpenAI — GPT family [ Add → ]
|
||||
* [Go] Google — Gemini family [ Add → ]
|
||||
*
|
||||
* More providers
|
||||
* [Co] Cohere +
|
||||
* [De] DeepSeek +
|
||||
* …
|
||||
* ───────────────────────────────────────────────────
|
||||
* ┌─ + Add a custom provider ─────────────────────────┐
|
||||
* │ Bring your own endpoint (OpenAI-compatible / …) │
|
||||
* └────────────────────────────────────────────────────┘
|
||||
*
|
||||
* Behaviors:
|
||||
* - Providers already in `settings.providers` are filtered out (the user
|
||||
* can edit them via Configure rather than re-add).
|
||||
* - "More providers" excludes `openai-compatible` — that path is reserved
|
||||
* for the custom-provider flow.
|
||||
* - The search box does a case-insensitive substring match against both
|
||||
* the display name and the provider id.
|
||||
* - Picking a built-in row calls `onPickBuiltin(providerId)`; the custom
|
||||
* CTA calls `onPickCustom()`.
|
||||
*/
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { SearchBar } from "@/components/ui/SearchBar";
|
||||
import { useTabOptional } from "@/contexts/TabContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProviderConfig, ProviderId } from "@/modelManagement/types";
|
||||
import { SUPPORTED_PROVIDER_IDS } from "@/modelManagement/providers/supportedProviders";
|
||||
import { Plus } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
/**
|
||||
* Top-row recommended providers per the design. Order matters: Anthropic →
|
||||
* OpenAI → Google.
|
||||
*/
|
||||
const RECOMMENDED_PROVIDER_IDS: readonly ProviderId[] = ["anthropic", "openai", "google"];
|
||||
|
||||
/**
|
||||
* Excluded from the "More providers" picker — these are special:
|
||||
* - `openai-compatible` is used internally as the custom-provider's type;
|
||||
* it never appears as a pickable built-in.
|
||||
*/
|
||||
const EXCLUDED_FROM_MORE: ReadonlySet<ProviderId> = new Set(["openai-compatible"]);
|
||||
|
||||
/**
|
||||
* Display-friendly labels for built-in providers. The values mirror what
|
||||
* `models.dev/api.json` returns in `name` for the providers that exist in
|
||||
* the catalog; providers without catalog entries (ollama / lmstudio /
|
||||
* openai-compatible) use hand-rolled labels.
|
||||
*/
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
google: "Google",
|
||||
groq: "Groq",
|
||||
mistral: "Mistral",
|
||||
xai: "xAI",
|
||||
deepseek: "DeepSeek",
|
||||
openrouter: "OpenRouter",
|
||||
cohere: "Cohere",
|
||||
azure: "Azure",
|
||||
"amazon-bedrock": "Amazon Bedrock",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
ollama: "Ollama",
|
||||
lmstudio: "LM Studio",
|
||||
siliconflow: "SiliconFlow",
|
||||
"openai-compatible": "OpenAI-compatible",
|
||||
};
|
||||
|
||||
/**
|
||||
* Short descriptors shown next to each Recommended provider name. The "More"
|
||||
* list intentionally hides descriptions to keep the row dense and scannable.
|
||||
*/
|
||||
const RECOMMENDED_DESCRIPTIONS: Partial<Record<ProviderId, string>> = {
|
||||
anthropic: "Claude family",
|
||||
openai: "GPT family",
|
||||
google: "Gemini family",
|
||||
};
|
||||
|
||||
/** Stable 2-char glyph for a provider — same rule as `ByokGlobalTable`. */
|
||||
function providerGlyph(name: string): string {
|
||||
const words = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return "??";
|
||||
if (words.length === 1) {
|
||||
const w = words[0];
|
||||
return (w[0] + (w[1] ?? "")).toUpperCase();
|
||||
}
|
||||
return (words[0][0] + words[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
/** Look up the display name for a provider id; falls back to the id. */
|
||||
function getDisplayName(id: ProviderId): string {
|
||||
return PROVIDER_DISPLAY_NAMES[id] ?? id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `id` matches the search query — case-insensitive substring
|
||||
* match against both the display name and the id itself.
|
||||
*/
|
||||
function matchesQuery(id: ProviderId, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
return getDisplayName(id).toLowerCase().includes(needle) || id.toLowerCase().includes(needle);
|
||||
}
|
||||
|
||||
export interface AddProviderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** All providers currently in `settings.providers` — used to filter out. */
|
||||
existingProviders: ProviderConfig[];
|
||||
/** Called when the user picks a built-in provider row. */
|
||||
onPickBuiltin: (providerId: ProviderId) => void;
|
||||
/** Called when the user clicks "Add a custom provider". */
|
||||
onPickCustom: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* `AddProviderDialog` — see file header comment.
|
||||
*/
|
||||
export const AddProviderDialog: React.FC<AddProviderDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
existingProviders,
|
||||
onPickBuiltin,
|
||||
onPickCustom,
|
||||
}) => {
|
||||
const modalContainer = useTabOptional()?.modalContainer ?? null;
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const existingIds = useMemo<Set<ProviderId>>(
|
||||
() => new Set(existingProviders.map((p) => p.id)),
|
||||
[existingProviders]
|
||||
);
|
||||
|
||||
const { recommended, more } = useMemo(() => {
|
||||
const recommended: ProviderId[] = RECOMMENDED_PROVIDER_IDS.filter(
|
||||
(id) => !existingIds.has(id) && matchesQuery(id, query)
|
||||
);
|
||||
const rest = SUPPORTED_PROVIDER_IDS.filter(
|
||||
(id) =>
|
||||
!RECOMMENDED_PROVIDER_IDS.includes(id) &&
|
||||
!EXCLUDED_FROM_MORE.has(id) &&
|
||||
!existingIds.has(id) &&
|
||||
matchesQuery(id, query)
|
||||
);
|
||||
// Alphabetical per §5.2 ("More providers — alphabetical").
|
||||
const more = rest.slice().sort((a, b) => getDisplayName(a).localeCompare(getDisplayName(b)));
|
||||
return { recommended, more };
|
||||
}, [existingIds, query]);
|
||||
|
||||
const noMatches = recommended.length === 0 && more.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="tw-max-h-[80vh] tw-overflow-y-auto sm:tw-max-w-screen-sm"
|
||||
container={modalContainer}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick a provider to configure, or bring your own endpoint as a custom provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
<SearchBar value={query} onChange={setQuery} placeholder="Search providers…" />
|
||||
|
||||
{recommended.length > 0 && (
|
||||
<section data-testid="add-provider-recommended">
|
||||
<div className="tw-mb-2 tw-text-ui-smaller tw-font-medium tw-uppercase tw-tracking-wide tw-text-muted">
|
||||
Recommended
|
||||
</div>
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
{recommended.map((id) => (
|
||||
<RecommendedRow key={id} providerId={id} onClick={() => onPickBuiltin(id)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{more.length > 0 && (
|
||||
<section data-testid="add-provider-more">
|
||||
<div className="tw-mb-2 tw-text-ui-smaller tw-font-medium tw-uppercase tw-tracking-wide tw-text-muted">
|
||||
More providers
|
||||
</div>
|
||||
<div className="tw-flex tw-max-h-60 tw-flex-col tw-gap-0.5 tw-overflow-y-auto">
|
||||
{more.map((id) => (
|
||||
<MoreProviderRow key={id} providerId={id} onClick={() => onPickBuiltin(id)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{noMatches && (
|
||||
<div className="tw-rounded-md tw-border tw-border-dashed tw-border-border tw-px-4 tw-py-6 tw-text-center tw-text-sm tw-text-muted">
|
||||
No providers match your search.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickCustom}
|
||||
data-testid="add-provider-custom-cta"
|
||||
className={cn(
|
||||
"tw-mt-2 tw-flex tw-w-full tw-cursor-pointer tw-flex-col tw-items-center tw-gap-1 tw-rounded-md",
|
||||
"tw-border tw-border-dashed tw-bg-interactive-accent/10 tw-border-interactive-accent/60",
|
||||
"tw-p-4 tw-text-center tw-text-sm tw-text-accent",
|
||||
"hover:tw-border-interactive-accent hover:tw-bg-interactive-accent/20"
|
||||
)}
|
||||
>
|
||||
<div className="tw-flex tw-items-center tw-gap-1.5 tw-font-medium">
|
||||
<Plus className="tw-size-4" />
|
||||
Add a custom provider
|
||||
</div>
|
||||
<div className="tw-text-ui-smaller tw-text-muted">
|
||||
Bring your own endpoint (OpenAI-compatible, Anthropic, or Google API).
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface ProviderRowProps {
|
||||
providerId: ProviderId;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider glyph badge — small rounded square with 2 uppercase initials.
|
||||
*/
|
||||
const ProviderGlyph: React.FC<{ name: string; small?: boolean }> = ({ name, small }) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"tw-inline-flex tw-shrink-0 tw-items-center tw-justify-center tw-rounded-sm",
|
||||
"tw-bg-secondary-alt tw-font-medium tw-text-normal",
|
||||
small ? "tw-size-5 tw-text-ui-smaller" : "tw-size-6 tw-text-ui-smaller"
|
||||
)}
|
||||
>
|
||||
{providerGlyph(name)}
|
||||
</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* Recommended row: glyph + bold name + "— family" description + "Add →" pill.
|
||||
* The whole row is a single clickable button — clicking the pill or the row
|
||||
* both fire `onClick`.
|
||||
*/
|
||||
const RecommendedRow: React.FC<ProviderRowProps> = ({ providerId, onClick }) => {
|
||||
const name = getDisplayName(providerId);
|
||||
const description = RECOMMENDED_DESCRIPTIONS[providerId];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
data-testid={`add-provider-card-${providerId}`}
|
||||
aria-label={`Add ${name} provider`}
|
||||
className={cn(
|
||||
"tw-group tw-flex tw-w-full tw-cursor-pointer tw-items-center tw-gap-3 tw-rounded-md",
|
||||
"tw-border tw-border-solid tw-border-border tw-bg-primary tw-px-3 tw-py-2 tw-text-left",
|
||||
"hover:tw-border-interactive-accent hover:tw-bg-primary-alt/40"
|
||||
)}
|
||||
>
|
||||
<ProviderGlyph name={name} />
|
||||
<span className="tw-flex tw-min-w-0 tw-flex-1 tw-items-baseline tw-gap-1.5 tw-truncate">
|
||||
<span className="tw-truncate tw-text-sm tw-font-semibold tw-text-normal">{name}</span>
|
||||
{description && (
|
||||
<span className="tw-truncate tw-text-ui-smaller tw-text-muted">— {description}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"tw-inline-flex tw-shrink-0 tw-items-center tw-rounded-md tw-px-2 tw-py-1",
|
||||
"tw-bg-interactive-accent tw-text-ui-smaller tw-font-medium tw-text-on-accent",
|
||||
"group-hover:tw-bg-interactive-accent-hover"
|
||||
)}
|
||||
>
|
||||
Add →
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* More-providers row: smaller glyph + name + right-side "+" affordance. Tight
|
||||
* vertical padding so the alphabetical list stays scannable at scale.
|
||||
*/
|
||||
const MoreProviderRow: React.FC<ProviderRowProps> = ({ providerId, onClick }) => {
|
||||
const name = getDisplayName(providerId);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
data-testid={`add-provider-card-${providerId}`}
|
||||
aria-label={`Add ${name} provider`}
|
||||
className={cn(
|
||||
"tw-flex tw-w-full tw-cursor-pointer tw-items-center tw-gap-3 tw-rounded-md",
|
||||
"tw-border tw-border-solid tw-border-transparent tw-px-3 tw-py-1.5 tw-text-left",
|
||||
"hover:tw-border-border hover:tw-bg-primary-alt/40"
|
||||
)}
|
||||
>
|
||||
<ProviderGlyph name={name} small />
|
||||
<span className="tw-min-w-0 tw-flex-1 tw-truncate tw-text-sm tw-text-normal">{name}</span>
|
||||
<Plus
|
||||
aria-hidden
|
||||
className="tw-size-4 tw-shrink-0 tw-text-muted group-hover:tw-text-normal"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
AddProviderDialog.displayName = "AddProviderDialog";
|
||||
1019
src/modelManagement/ui/dialogs/ConfigureProviderDialog.tsx
Normal file
1019
src/modelManagement/ui/dialogs/ConfigureProviderDialog.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* `AddCustomModelDialog` tests — verifies the trimmed field set.
|
||||
*/
|
||||
import { AddCustomModelDialog } from "@/modelManagement/ui/dialogs/AddCustomModelDialog";
|
||||
import type { ProviderConfig } from "@/modelManagement/types";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
(window as unknown as { activeDocument: Document }).activeDocument = window.document;
|
||||
});
|
||||
|
||||
function makeProvider(): ProviderConfig {
|
||||
return {
|
||||
id: "anthropic",
|
||||
kind: "builtin",
|
||||
displayName: "Anthropic",
|
||||
type: "anthropic",
|
||||
addedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AddCustomModelDialog", () => {
|
||||
it("renders only Display name / Model ID fields", () => {
|
||||
render(
|
||||
<AddCustomModelDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
provider={makeProvider()}
|
||||
onTest={jest.fn().mockResolvedValue(undefined)}
|
||||
onAdd={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("add-custom-model-display-name")).toBeTruthy();
|
||||
expect(screen.getByTestId("add-custom-model-id")).toBeTruthy();
|
||||
|
||||
// No context window field.
|
||||
expect(screen.queryByTestId("add-custom-model-context")).toBeNull();
|
||||
// No capability checkbox/group rendered.
|
||||
expect(screen.queryByText(/vision/i)).toBeNull();
|
||||
expect(screen.queryByText(/reasoning/i)).toBeNull();
|
||||
expect(screen.queryByText(/tool use/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("disables [Add] until display name + model id are filled", () => {
|
||||
render(
|
||||
<AddCustomModelDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
provider={makeProvider()}
|
||||
onTest={jest.fn().mockResolvedValue(undefined)}
|
||||
onAdd={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const addBtn = screen.getByTestId("add-custom-model-add");
|
||||
expect((addBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByTestId("add-custom-model-display-name"), {
|
||||
target: { value: "Custom Preview" },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("add-custom-model-id"), {
|
||||
target: { value: "claude-sonnet-preview" },
|
||||
});
|
||||
|
||||
expect((addBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("calls onAdd with the trimmed identifiers", async () => {
|
||||
const onAdd = jest.fn();
|
||||
const onOpenChange = jest.fn();
|
||||
render(
|
||||
<AddCustomModelDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
provider={makeProvider()}
|
||||
onTest={jest.fn().mockResolvedValue(undefined)}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId("add-custom-model-display-name"), {
|
||||
target: { value: "Custom Preview" },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("add-custom-model-id"), {
|
||||
target: { value: "claude-sonnet-preview" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("add-custom-model-add"));
|
||||
});
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-preview",
|
||||
displayName: "Custom Preview",
|
||||
});
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("surfaces test success and failure inline", async () => {
|
||||
const onTest = jest
|
||||
.fn<Promise<void>, [string]>()
|
||||
.mockRejectedValueOnce(new Error("404 model not found"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
render(
|
||||
<AddCustomModelDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
provider={makeProvider()}
|
||||
onTest={onTest}
|
||||
onAdd={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId("add-custom-model-id"), {
|
||||
target: { value: "bad-id" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("add-custom-model-test"));
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId("add-custom-model-error")).toBeTruthy());
|
||||
expect(screen.getByTestId("add-custom-model-error").textContent).toContain(
|
||||
"404 model not found"
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId("add-custom-model-id"), {
|
||||
target: { value: "good-id" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("add-custom-model-test"));
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId("add-custom-model-success")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* `AddProviderDialog` tests — covers the picker layout and filtering rules.
|
||||
*
|
||||
* Scope (M5):
|
||||
* - Renders Recommended + More providers sections.
|
||||
* - Already-added providers are filtered out.
|
||||
* - Picking a built-in card invokes `onPickBuiltin`.
|
||||
* - Picking "Add a custom provider" invokes `onPickCustom`.
|
||||
*/
|
||||
import { AddProviderDialog } from "@/modelManagement/ui/dialogs/AddProviderDialog";
|
||||
import type { ProviderConfig } from "@/modelManagement/types";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
// Radix portals reach for the codebase's shared `activeDocument`; jsdom
|
||||
// doesn't define it. Alias to the test document.
|
||||
beforeAll(() => {
|
||||
(window as unknown as { activeDocument: Document }).activeDocument = window.document;
|
||||
});
|
||||
|
||||
function makeProvider(id: string): ProviderConfig {
|
||||
return {
|
||||
id,
|
||||
kind: "builtin",
|
||||
displayName: id,
|
||||
type: "openai-compatible",
|
||||
addedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AddProviderDialog", () => {
|
||||
it("renders Recommended + More providers sections", () => {
|
||||
render(
|
||||
<AddProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
existingProviders={[]}
|
||||
onPickBuiltin={jest.fn()}
|
||||
onPickCustom={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("add-provider-recommended")).toBeTruthy();
|
||||
expect(screen.getByTestId("add-provider-more")).toBeTruthy();
|
||||
// Three recommended providers visible.
|
||||
expect(screen.getByTestId("add-provider-card-anthropic")).toBeTruthy();
|
||||
expect(screen.getByTestId("add-provider-card-openai")).toBeTruthy();
|
||||
expect(screen.getByTestId("add-provider-card-google")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("filters out already-added providers", () => {
|
||||
render(
|
||||
<AddProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
existingProviders={[makeProvider("anthropic"), makeProvider("openai")]}
|
||||
onPickBuiltin={jest.fn()}
|
||||
onPickCustom={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-provider-card-anthropic")).toBeNull();
|
||||
expect(screen.queryByTestId("add-provider-card-openai")).toBeNull();
|
||||
// Google still visible.
|
||||
expect(screen.getByTestId("add-provider-card-google")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("excludes openai-compatible from the More providers picker", () => {
|
||||
render(
|
||||
<AddProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
existingProviders={[]}
|
||||
onPickBuiltin={jest.fn()}
|
||||
onPickCustom={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-provider-card-openai-compatible")).toBeNull();
|
||||
});
|
||||
|
||||
it("invokes onPickBuiltin with the provider id when a card is clicked", () => {
|
||||
const onPickBuiltin = jest.fn();
|
||||
render(
|
||||
<AddProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
existingProviders={[]}
|
||||
onPickBuiltin={onPickBuiltin}
|
||||
onPickCustom={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-provider-card-anthropic"));
|
||||
expect(onPickBuiltin).toHaveBeenCalledWith("anthropic");
|
||||
});
|
||||
|
||||
it("invokes onPickCustom when the custom CTA card is clicked", () => {
|
||||
const onPickCustom = jest.fn();
|
||||
render(
|
||||
<AddProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
existingProviders={[]}
|
||||
onPickBuiltin={jest.fn()}
|
||||
onPickCustom={onPickCustom}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-provider-custom-cta"));
|
||||
expect(onPickCustom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("filters provider lists as the user types in the search box", () => {
|
||||
render(
|
||||
<AddProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
existingProviders={[]}
|
||||
onPickBuiltin={jest.fn()}
|
||||
onPickCustom={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Search providers…");
|
||||
fireEvent.change(searchInput, { target: { value: "anth" } });
|
||||
|
||||
expect(screen.getByTestId("add-provider-card-anthropic")).toBeTruthy();
|
||||
expect(screen.queryByTestId("add-provider-card-openai")).toBeNull();
|
||||
expect(screen.queryByTestId("add-provider-card-google")).toBeNull();
|
||||
expect(screen.queryByTestId("add-provider-card-cohere")).toBeNull();
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "zzzz-no-match" } });
|
||||
expect(screen.queryByTestId("add-provider-card-anthropic")).toBeNull();
|
||||
expect(screen.getByText("No providers match your search.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders no Recommended section when every recommended provider is already added", () => {
|
||||
render(
|
||||
<AddProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
existingProviders={[
|
||||
makeProvider("anthropic"),
|
||||
makeProvider("openai"),
|
||||
makeProvider("google"),
|
||||
]}
|
||||
onPickBuiltin={jest.fn()}
|
||||
onPickCustom={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-provider-recommended")).toBeNull();
|
||||
// The More providers section still renders.
|
||||
expect(screen.getByTestId("add-provider-more")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
/**
|
||||
* `ConfigureProviderDialog` tests — covers state transitions and the
|
||||
* carve-outs from §5.2.
|
||||
*
|
||||
* Scope (M5):
|
||||
* - `new-byok`: shows API key field + base URL (read-only); no Availability row.
|
||||
* - `new-custom`: shows display name + type radio + editable base URL.
|
||||
* - `edit`: shows Remove provider button; ✓ Verified badge when
|
||||
* `lastVerifiedAt` is set.
|
||||
* - Save in a new state calls `onSave` with the assembled payload.
|
||||
*/
|
||||
import { ConfigureProviderDialog } from "@/modelManagement/ui/dialogs/ConfigureProviderDialog";
|
||||
import type { ProviderConfig, RegistryEntry } from "@/modelManagement/types";
|
||||
import type { CatalogProvider } from "@/modelManagement/catalog/modelsCatalog.types";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
(window as unknown as { activeDocument: Document }).activeDocument = window.document;
|
||||
});
|
||||
|
||||
// Stub the catalog service singleton — none of the tests hit disk or the
|
||||
// network. Each test seeds a small per-provider catalog via the
|
||||
// `catalog` prop, so the singleton is only used by paths that fall back
|
||||
// to it (none here).
|
||||
jest.mock("@/modelManagement/catalog/ModelCatalogService", () => ({
|
||||
ModelCatalogService: {
|
||||
getInstance: () => ({
|
||||
ensureLoaded: jest.fn().mockResolvedValue(undefined),
|
||||
getProvider: () => undefined,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function makeCatalogProvider(
|
||||
id: string,
|
||||
entries: Array<Partial<{ id: string; name: string; context: number; release_date: string }>>
|
||||
): CatalogProvider {
|
||||
const models: CatalogProvider["models"] = {};
|
||||
for (const e of entries) {
|
||||
const mid = e.id ?? "model";
|
||||
models[mid] = {
|
||||
id: mid,
|
||||
name: e.name ?? mid,
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: e.context ?? 200_000, output: 8000 },
|
||||
release_date: e.release_date,
|
||||
};
|
||||
}
|
||||
return { id, name: id, env: [], models };
|
||||
}
|
||||
|
||||
function makeCatalog(
|
||||
byId: Record<string, CatalogProvider>
|
||||
): React.ComponentProps<typeof ConfigureProviderDialog>["catalog"] {
|
||||
return {
|
||||
ensureLoaded: jest.fn().mockResolvedValue(undefined),
|
||||
getProvider: (id: string) => byId[id],
|
||||
};
|
||||
}
|
||||
|
||||
function makeProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||||
return {
|
||||
id: "anthropic",
|
||||
kind: "builtin",
|
||||
displayName: "Anthropic",
|
||||
type: "anthropic",
|
||||
addedAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntry(overrides: Partial<RegistryEntry> = {}): RegistryEntry {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
addedAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultTest = jest.fn().mockResolvedValue({ ok: true, verifiedAt: 1_700_000_000 });
|
||||
|
||||
describe("ConfigureProviderDialog", () => {
|
||||
it("new-byok: shows API key field and editable base URL; no Availability row", async () => {
|
||||
const catalog = makeCatalog({
|
||||
anthropic: makeCatalogProvider("anthropic", [
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
]),
|
||||
});
|
||||
render(
|
||||
<ConfigureProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
state="new-byok"
|
||||
providerId="anthropic"
|
||||
builtinDisplayName="Anthropic"
|
||||
onTest={defaultTest}
|
||||
onSave={jest.fn()}
|
||||
catalog={catalog}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("configure-api-key")).toBeTruthy();
|
||||
const baseUrl = screen.getByTestId("configure-base-url");
|
||||
// Base URL is editable for built-ins so users can route through proxies.
|
||||
expect(baseUrl.getAttribute("readonly")).toBeNull();
|
||||
expect(baseUrl.getAttribute("disabled")).toBeNull();
|
||||
|
||||
// Custom-extras section MUST NOT render in new-byok.
|
||||
expect(screen.queryByTestId("configure-custom-extras")).toBeNull();
|
||||
|
||||
// No availability row — search for "availability" string anywhere.
|
||||
expect(screen.queryByText(/availability/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("new-custom: shows display name + type radio + editable base URL", () => {
|
||||
render(
|
||||
<ConfigureProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
state="new-custom"
|
||||
onTest={defaultTest}
|
||||
onSave={jest.fn()}
|
||||
catalog={makeCatalog({})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("configure-custom-extras")).toBeTruthy();
|
||||
expect(screen.getByTestId("configure-display-name")).toBeTruthy();
|
||||
expect(screen.getByTestId("configure-type-openai-compatible")).toBeTruthy();
|
||||
expect(screen.getByTestId("configure-type-anthropic")).toBeTruthy();
|
||||
expect(screen.getByTestId("configure-type-google")).toBeTruthy();
|
||||
const baseUrl = screen.getByTestId("configure-base-url");
|
||||
expect(baseUrl.getAttribute("readonly")).toBeNull();
|
||||
});
|
||||
|
||||
it("edit: shows Remove provider button + 'Last verified' helper when lastVerifiedAt is set", () => {
|
||||
const provider = makeProvider({
|
||||
lastVerifiedAt: 1_700_000_000,
|
||||
});
|
||||
render(
|
||||
<ConfigureProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
state="edit"
|
||||
providerId={provider.id}
|
||||
existingProvider={provider}
|
||||
existingEntries={[makeEntry()]}
|
||||
onTest={defaultTest}
|
||||
onSave={jest.fn()}
|
||||
onRemoveProvider={jest.fn()}
|
||||
catalog={makeCatalog({
|
||||
anthropic: makeCatalogProvider("anthropic", [
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
]),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("configure-remove-provider")).toBeTruthy();
|
||||
// Historic lastVerifiedAt no longer auto-credits; user must re-test.
|
||||
expect(screen.queryByTestId("configure-verified")).toBeNull();
|
||||
expect(screen.getByTestId("configure-last-verified")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("edit: hides the ✓ Verified badge when never verified", () => {
|
||||
const provider = makeProvider({ lastVerifiedAt: undefined });
|
||||
render(
|
||||
<ConfigureProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
state="edit"
|
||||
providerId={provider.id}
|
||||
existingProvider={provider}
|
||||
existingEntries={[]}
|
||||
onTest={defaultTest}
|
||||
onSave={jest.fn()}
|
||||
onRemoveProvider={jest.fn()}
|
||||
catalog={makeCatalog({})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("configure-verified")).toBeNull();
|
||||
expect(screen.queryByTestId("configure-last-verified")).toBeNull();
|
||||
});
|
||||
|
||||
it("save in new-byok calls onSave with the selected entries", async () => {
|
||||
const onSave = jest.fn();
|
||||
const onOpenChange = jest.fn();
|
||||
const catalog = makeCatalog({
|
||||
anthropic: makeCatalogProvider("anthropic", [
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-opus-4-1", name: "Claude Opus 4.1" },
|
||||
]),
|
||||
});
|
||||
render(
|
||||
<ConfigureProviderDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
state="new-byok"
|
||||
providerId="anthropic"
|
||||
builtinDisplayName="Anthropic"
|
||||
onTest={defaultTest}
|
||||
onSave={onSave}
|
||||
catalog={catalog}
|
||||
/>
|
||||
);
|
||||
|
||||
// Type a key.
|
||||
fireEvent.change(screen.getByTestId("configure-api-key"), {
|
||||
target: { value: "sk-test-key" },
|
||||
});
|
||||
|
||||
// Check Claude Sonnet 4.5.
|
||||
const sonnetRow = screen.getByTestId("catalog-row-anthropic-claude-sonnet-4-5");
|
||||
const checkbox = sonnetRow.querySelector("button[role='checkbox']") as HTMLElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Save is gated on a successful Test — run it first.
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("configure-test-key"));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("configure-verify-save"));
|
||||
});
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
const payload = onSave.mock.calls[0][0] as {
|
||||
providerId: string;
|
||||
providerConfig: ProviderConfig;
|
||||
selectedEntries: Array<{
|
||||
modelId: string;
|
||||
}>;
|
||||
};
|
||||
expect(payload.providerId).toBe("anthropic");
|
||||
expect(payload.providerConfig.id).toBe("anthropic");
|
||||
expect(payload.providerConfig.kind).toBe("builtin");
|
||||
expect(payload.providerConfig.apiKeyRef).toEqual({ kind: "inline", value: "sk-test-key" });
|
||||
expect(payload.selectedEntries).toHaveLength(1);
|
||||
expect(payload.selectedEntries[0].modelId).toBe("claude-sonnet-4-5");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("verify error decorates the dialog with an inline message", async () => {
|
||||
const onTest = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, error: "401 Unauthorized", verifiedAt: 0 });
|
||||
const catalog = makeCatalog({
|
||||
anthropic: makeCatalogProvider("anthropic", [
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
]),
|
||||
});
|
||||
render(
|
||||
<ConfigureProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
state="new-byok"
|
||||
providerId="anthropic"
|
||||
builtinDisplayName="Anthropic"
|
||||
onTest={onTest}
|
||||
onSave={jest.fn()}
|
||||
catalog={catalog}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId("configure-api-key"), {
|
||||
target: { value: "sk-bad" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("configure-test-key"));
|
||||
});
|
||||
|
||||
const errorEl = screen.getByTestId("configure-test-error");
|
||||
expect(errorEl.textContent).toContain("401 Unauthorized");
|
||||
});
|
||||
|
||||
it("edit state shows a kebab on registered rows", () => {
|
||||
const provider = makeProvider({});
|
||||
const entry = makeEntry({});
|
||||
const catalog = makeCatalog({
|
||||
anthropic: makeCatalogProvider("anthropic", [
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-opus-4-1", name: "Claude Opus 4.1" },
|
||||
]),
|
||||
});
|
||||
render(
|
||||
<ConfigureProviderDialog
|
||||
open={true}
|
||||
onOpenChange={jest.fn()}
|
||||
state="edit"
|
||||
providerId={provider.id}
|
||||
existingProvider={provider}
|
||||
existingEntries={[entry]}
|
||||
onTest={defaultTest}
|
||||
onSave={jest.fn()}
|
||||
onRemoveProvider={jest.fn()}
|
||||
catalog={catalog}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("More actions for Claude Sonnet 4.5")).toBeTruthy();
|
||||
// Opus is not registered, so no kebab.
|
||||
const opusRow = screen.getByTestId("catalog-row-anthropic-claude-opus-4-1");
|
||||
expect(within(opusRow).queryByLabelText(/More actions/)).toBeNull();
|
||||
});
|
||||
|
||||
it("Remove provider button invokes the callback", async () => {
|
||||
const onRemoveProvider = jest.fn();
|
||||
const onOpenChange = jest.fn();
|
||||
render(
|
||||
<ConfigureProviderDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
state="edit"
|
||||
providerId="anthropic"
|
||||
existingProvider={makeProvider()}
|
||||
existingEntries={[]}
|
||||
onTest={defaultTest}
|
||||
onSave={jest.fn()}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
catalog={makeCatalog({})}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("configure-remove-provider"));
|
||||
});
|
||||
|
||||
expect(onRemoveProvider).toHaveBeenCalledWith("anthropic");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
437
src/modelManagement/ui/tabs/ByokPanel.tsx
Normal file
437
src/modelManagement/ui/tabs/ByokPanel.tsx
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
/**
|
||||
* BYOK settings panel — the central registry UI.
|
||||
*
|
||||
* Threads the Obsidian {@link App} in via props rather than reading the
|
||||
* global, for popout-window safety and testability.
|
||||
*/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchBar } from "@/components/ui/SearchBar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { logError, logInfo } from "@/logger";
|
||||
import { ModelCatalogService } from "@/modelManagement/catalog/ModelCatalogService";
|
||||
import { ProviderRegistry } from "@/modelManagement/providers/ProviderRegistry";
|
||||
import { verifyProvider } from "@/modelManagement/providers/verifyProvider";
|
||||
import { ModelRegistry } from "@/modelManagement/registry/ModelRegistry";
|
||||
import type { ProviderConfig, ProviderId, RegistryEntry } from "@/modelManagement/types";
|
||||
import {
|
||||
ByokGlobalTable,
|
||||
type ByokTableProviderGroup,
|
||||
} from "@/modelManagement/ui/components/ByokGlobalTable";
|
||||
import { AddProviderDialog } from "@/modelManagement/ui/dialogs/AddProviderDialog";
|
||||
import {
|
||||
ConfigureProviderDialog,
|
||||
type ConfigureProviderState,
|
||||
type ConfigureProviderSavePayload,
|
||||
} from "@/modelManagement/ui/dialogs/ConfigureProviderDialog";
|
||||
import { App, Notice, Platform } from "obsidian";
|
||||
import { Plus } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
/** 24 hours in millis — see §5.1 stale-refresh trigger. */
|
||||
const STALE_REFRESH_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface ByokPanelProps {
|
||||
/**
|
||||
* Obsidian {@link App} — required for opening confirm modals. Threaded
|
||||
* through React props from `SettingsMainV2` so we never reach for the
|
||||
* global `app` (popout-window safety + testability).
|
||||
*/
|
||||
app: App;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ByokPanel` — the central BYOK tab.
|
||||
*/
|
||||
export const ByokPanel: React.FC<ByokPanelProps> = ({ app }) => {
|
||||
const catalog = ModelCatalogService.getInstance();
|
||||
const providerRegistry = ProviderRegistry.getInstance();
|
||||
const modelRegistry = ModelRegistry.getInstance();
|
||||
|
||||
const [loadState, setLoadState] = useState<"loading" | "ready">("loading");
|
||||
// Filter out `kind: "system"` providers (e.g. `opencode`, `copilot-plus`).
|
||||
// System providers exist in `settings.providers` so the RegistryEntry FK
|
||||
// invariant holds, but they're credentialed by their agent backend — there's
|
||||
// no API key, base URL, or per-model toggle the user can configure here.
|
||||
// Showing them in the BYOK tab would be confusing and offer no actions.
|
||||
const [providers, setProviders] = useState<ProviderConfig[]>(() =>
|
||||
providerRegistry.list().filter((p) => p.kind !== "system")
|
||||
);
|
||||
const [registry, setRegistry] = useState<RegistryEntry[]>(() => modelRegistry.list());
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// Dialog state — keeps the picker/configure flow local to the panel so
|
||||
// SettingsMainV2 doesn't have to know about the model-management modals.
|
||||
const [addProviderOpen, setAddProviderOpen] = useState(false);
|
||||
// `null` = dialog closed; otherwise we're in the matching state.
|
||||
const [configureState, setConfigureState] = useState<{
|
||||
state: ConfigureProviderState;
|
||||
providerId?: ProviderId;
|
||||
builtinDisplayName?: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
catalog
|
||||
.ensureLoaded()
|
||||
.then(() => {
|
||||
if (cancelled) return;
|
||||
setLoadState("ready");
|
||||
// Background refresh if stale.
|
||||
const meta = catalog.getMeta();
|
||||
const now = Date.now();
|
||||
if (!meta.fetchedAt || now - meta.fetchedAt > STALE_REFRESH_THRESHOLD_MS) {
|
||||
// Fire-and-forget — surface errors via logging only.
|
||||
catalog
|
||||
.refresh()
|
||||
.then((result) => {
|
||||
if (!result.ok) {
|
||||
logInfo("[ByokPanel] Background refresh skipped:", result.error);
|
||||
}
|
||||
})
|
||||
.catch((err) => logError("[ByokPanel] Background refresh failed:", err));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logError("[ByokPanel] ensureLoaded failed:", err);
|
||||
if (!cancelled) setLoadState("ready");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [catalog]);
|
||||
|
||||
// Subscribe to provider / model registry mutations.
|
||||
useEffect(() => {
|
||||
const unsubProviders = providerRegistry.onChange(() => {
|
||||
// Same `kind !== "system"` filter as the initial-state read above.
|
||||
setProviders(providerRegistry.list().filter((p) => p.kind !== "system"));
|
||||
});
|
||||
const unsubRegistry = modelRegistry.onChange(() => {
|
||||
setRegistry(modelRegistry.list());
|
||||
});
|
||||
return () => {
|
||||
unsubProviders();
|
||||
unsubRegistry();
|
||||
};
|
||||
}, [providerRegistry, modelRegistry]);
|
||||
|
||||
const groups = useMemo<ByokTableProviderGroup[]>(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return providers
|
||||
.map((provider) => {
|
||||
const entries = registry.filter((entry) => {
|
||||
if (entry.providerId !== provider.id) return false;
|
||||
if (normalizedQuery) {
|
||||
const haystack = `${entry.displayName} ${entry.modelId}`.toLowerCase();
|
||||
if (!haystack.includes(normalizedQuery)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (entries.length === 0) {
|
||||
return { provider, entries, hidden: true };
|
||||
}
|
||||
return { provider, entries, hidden: false };
|
||||
})
|
||||
.filter((g): g is ByokTableProviderGroup & { hidden: false } => !g.hidden)
|
||||
.map(({ provider, entries }) => ({ provider, entries }));
|
||||
}, [providers, registry, query]);
|
||||
|
||||
/** Open the Configure dialog in edit-state for the given provider. */
|
||||
const handleConfigureProvider = (providerId: ProviderId): void => {
|
||||
const provider = providerRegistry.get(providerId);
|
||||
if (!provider) return;
|
||||
setConfigureState({ state: "edit", providerId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Common save handler used by the Configure dialog in all three states.
|
||||
* Replaces the provider config + bulk-sets the registry entries.
|
||||
*/
|
||||
const handleSaveProvider = async (payload: ConfigureProviderSavePayload): Promise<void> => {
|
||||
try {
|
||||
const existing = providerRegistry.get(payload.providerId);
|
||||
if (existing) {
|
||||
await providerRegistry.update(payload.providerId, payload.providerConfig);
|
||||
} else {
|
||||
await providerRegistry.add(payload.providerConfig);
|
||||
}
|
||||
await modelRegistry.bulkSet(
|
||||
payload.providerId,
|
||||
payload.selectedEntries.map((e) => ({ ...e, addedAt: Date.now() }))
|
||||
);
|
||||
logInfo(
|
||||
`[ByokPanel] Saved provider ${payload.providerId} with ${payload.selectedEntries.length} model(s).`
|
||||
);
|
||||
} catch (err) {
|
||||
logError("[ByokPanel] Save provider failed:", err);
|
||||
new Notice("Failed to save provider. See console for details.");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `[Test]` verifier — calls `verifyProvider` to make a real HTTP probe.
|
||||
* In edit state, the dialog leaves `apiKey` blank when the user hasn't
|
||||
* touched the field, so we substitute the stored credential before
|
||||
* dispatching.
|
||||
*/
|
||||
const handleTestKey: React.ComponentProps<typeof ConfigureProviderDialog>["onTest"] = async (
|
||||
draft
|
||||
) => {
|
||||
let apiKey = draft.apiKey;
|
||||
if (!apiKey.trim()) {
|
||||
const stored = await providerRegistry.getApiKey(draft.providerId);
|
||||
if (stored) apiKey = stored;
|
||||
}
|
||||
return verifyProvider({ ...draft, apiKey });
|
||||
};
|
||||
|
||||
/**
|
||||
* `discoverModels` against an OpenAI-compatible `<baseUrl>/models`. The
|
||||
* implementation lives here (vs the dialog) so the dialog stays
|
||||
* presentation-only and we can test the parser independently if needed.
|
||||
*/
|
||||
const handleDiscoverModels: React.ComponentProps<
|
||||
typeof ConfigureProviderDialog
|
||||
>["discoverModels"] = async (baseUrl, apiKey) => {
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const url = `${normalized}/models`;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (apiKey.trim()) headers["Authorization"] = `Bearer ${apiKey.trim()}`;
|
||||
const response = await fetch(url, { method: "GET", headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} from ${url}`);
|
||||
}
|
||||
const payload = (await response.json()) as { data?: Array<{ id?: string }> };
|
||||
const ids = (payload.data ?? [])
|
||||
.map((m) => m.id)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
||||
// Map back to a minimal CatalogModel shape so the existing picker can
|
||||
// render the rows without a separate code path.
|
||||
return ids.map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
limit: { context: 0, output: 0 },
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-model `[Test]` — reduces to a provider-account check. The HTTP probe
|
||||
* validates the key against the provider, not a specific model; once the
|
||||
* key works, the user can choose any model they have access to.
|
||||
*/
|
||||
const handleTestModel: React.ComponentProps<
|
||||
typeof ConfigureProviderDialog
|
||||
>["onTestModel"] = async (providerId, modelId) => {
|
||||
const provider = providerRegistry.get(providerId);
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown provider '${providerId}'`);
|
||||
}
|
||||
const apiKey = (await providerRegistry.getApiKey(providerId)) ?? "";
|
||||
const result = await verifyProvider({
|
||||
providerId,
|
||||
apiKey,
|
||||
baseUrl: provider.baseUrl,
|
||||
extra: provider.extra,
|
||||
type: provider.type,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error ?? `Test failed for ${modelId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveProvider = (providerId: string): void => {
|
||||
const provider = providerRegistry.get(providerId);
|
||||
if (!provider) return;
|
||||
// Lazy require so the BYOK barrel doesn't pull obsidian.Modal at module
|
||||
// evaluation time — that would break the deep-dependency-chain test
|
||||
// suites that mock `obsidian` without exporting Modal. See AGENTS.md
|
||||
// §"Avoiding Deep Dependency Chains".
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ConfirmModal } = require("@/components/modals/ConfirmModal") as {
|
||||
ConfirmModal: new (
|
||||
app: App,
|
||||
onConfirm: () => void | Promise<void>,
|
||||
content: string,
|
||||
title: string,
|
||||
confirmButtonText?: string,
|
||||
cancelButtonText?: string
|
||||
) => { open: () => void };
|
||||
};
|
||||
const modal = new ConfirmModal(
|
||||
app,
|
||||
async () => {
|
||||
try {
|
||||
await providerRegistry.remove(providerId);
|
||||
} catch (err) {
|
||||
logError("[ByokPanel] Remove provider failed:", err);
|
||||
new Notice("Failed to remove provider");
|
||||
}
|
||||
},
|
||||
`Remove ${provider.displayName}? This will also remove all of its registered models.`,
|
||||
"Remove provider",
|
||||
"Remove",
|
||||
"Cancel"
|
||||
);
|
||||
modal.open();
|
||||
};
|
||||
|
||||
if (loadState === "loading") {
|
||||
return <ByokSkeleton />;
|
||||
}
|
||||
|
||||
const isMobile = Platform.isMobile;
|
||||
|
||||
const headerActions = (
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-gap-2",
|
||||
isMobile ? "tw-flex-col tw-items-stretch" : "tw-flex-row tw-items-center"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setAddProviderOpen(true)}
|
||||
data-testid="byok-add-provider"
|
||||
>
|
||||
<Plus className="tw-size-3.5" />
|
||||
Add provider
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const configureDialog = configureState && (
|
||||
<ConfigureProviderDialog
|
||||
open={true}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setConfigureState(null);
|
||||
}}
|
||||
state={configureState.state}
|
||||
providerId={configureState.providerId}
|
||||
existingProvider={
|
||||
configureState.state === "edit" && configureState.providerId
|
||||
? providerRegistry.get(configureState.providerId)
|
||||
: undefined
|
||||
}
|
||||
existingEntries={
|
||||
configureState.providerId
|
||||
? modelRegistry.list({ providerId: configureState.providerId })
|
||||
: []
|
||||
}
|
||||
builtinDisplayName={configureState.builtinDisplayName}
|
||||
onTest={handleTestKey}
|
||||
discoverModels={handleDiscoverModels}
|
||||
onTestModel={handleTestModel}
|
||||
onSave={handleSaveProvider}
|
||||
onRemoveProvider={async (id) => {
|
||||
await providerRegistry.remove(id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
<AddProviderDialog
|
||||
open={addProviderOpen}
|
||||
onOpenChange={setAddProviderOpen}
|
||||
existingProviders={providers}
|
||||
onPickBuiltin={(id) => {
|
||||
setAddProviderOpen(false);
|
||||
setConfigureState({
|
||||
state: "new-byok",
|
||||
providerId: id,
|
||||
builtinDisplayName: catalog.getProvider(id)?.name ?? id,
|
||||
});
|
||||
}}
|
||||
onPickCustom={() => {
|
||||
setAddProviderOpen(false);
|
||||
setConfigureState({ state: "new-custom" });
|
||||
}}
|
||||
/>
|
||||
{configureDialog}
|
||||
</>
|
||||
);
|
||||
|
||||
if (providers.length === 0) {
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4 tw-py-6">
|
||||
<ByokDescription />
|
||||
<div className="tw-flex tw-justify-center tw-py-12">
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-w-full tw-max-w-md tw-flex-col tw-items-center tw-gap-3 tw-rounded-md",
|
||||
"tw-border tw-border-dashed tw-border-border tw-p-8 tw-text-center tw-bg-primary-alt/30"
|
||||
)}
|
||||
>
|
||||
<div className="tw-text-sm tw-text-muted">No providers configured yet.</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setAddProviderOpen(true)}
|
||||
data-testid="byok-add-provider-empty"
|
||||
>
|
||||
<Plus className="tw-size-4" />
|
||||
Add provider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{dialogs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4 tw-py-4">
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-gap-3",
|
||||
isMobile
|
||||
? "tw-flex-col tw-items-stretch"
|
||||
: "tw-flex-row tw-items-start tw-justify-between"
|
||||
)}
|
||||
>
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
<div className="tw-text-base tw-font-semibold tw-text-normal">BYOK</div>
|
||||
<ByokDescription />
|
||||
</div>
|
||||
{headerActions}
|
||||
</div>
|
||||
<FilterBar query={query} setQuery={setQuery} />
|
||||
<ByokGlobalTable
|
||||
groups={groups}
|
||||
onConfigureProvider={handleConfigureProvider}
|
||||
onRemoveProvider={handleRemoveProvider}
|
||||
/>
|
||||
{dialogs}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ByokDescription: React.FC = () => (
|
||||
<div className="tw-max-w-xl tw-text-sm tw-text-muted">
|
||||
Bring your own providers and models to use in Copilot.
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Skeleton used while `ensureLoaded()` is in flight. */
|
||||
const ByokSkeleton: React.FC = () => (
|
||||
<div className="tw-flex tw-flex-col tw-gap-3 tw-py-6" data-testid="byok-skeleton">
|
||||
<div className="tw-h-4 tw-w-32 tw-animate-pulse tw-rounded-sm tw-bg-secondary-alt" />
|
||||
<div className="tw-h-3 tw-w-full tw-animate-pulse tw-rounded-sm tw-bg-secondary-alt" />
|
||||
<div className="tw-h-3 tw-w-3/4 tw-animate-pulse tw-rounded-sm tw-bg-secondary-alt" />
|
||||
<div className="tw-mt-3 tw-h-24 tw-w-full tw-animate-pulse tw-rounded-sm tw-bg-secondary-alt" />
|
||||
</div>
|
||||
);
|
||||
|
||||
interface FilterBarProps {
|
||||
query: string;
|
||||
setQuery: (q: string) => void;
|
||||
}
|
||||
|
||||
const FilterBar: React.FC<FilterBarProps> = ({ query, setQuery }) => (
|
||||
<div className="tw-flex tw-flex-wrap tw-items-center tw-gap-2">
|
||||
<div className="tw-min-w-48 tw-flex-1">
|
||||
<SearchBar value={query} onChange={setQuery} placeholder="Filter models…" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
222
src/modelManagement/ui/tabs/__tests__/ByokPanel.test.tsx
Normal file
222
src/modelManagement/ui/tabs/__tests__/ByokPanel.test.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* `ByokPanel` rendering tests.
|
||||
*
|
||||
* Scope:
|
||||
* - Mounts a skeleton during ensureLoaded() and swaps to the populated /
|
||||
* empty state once it resolves.
|
||||
* - Empty state shows when no providers are configured.
|
||||
* - Populated state renders provider sections.
|
||||
* - Search input filters model rows by name.
|
||||
*
|
||||
* The catalog + registry singletons are mocked at the module level so we
|
||||
* don't drag in the settings store, the migration system, or Obsidian
|
||||
* APIs we don't care about for this view.
|
||||
*/
|
||||
import type { CatalogMeta } from "@/modelManagement/catalog/modelsCatalog.types";
|
||||
import type { ProviderConfig, RegistryEntry } from "@/modelManagement/types";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
// Radix DropdownMenu portal helper uses the codebase's shared
|
||||
// `activeDocument` global. jsdom doesn't define it — alias to
|
||||
// `window.document` (single-window environment).
|
||||
beforeAll(() => {
|
||||
(window as unknown as { activeDocument: Document }).activeDocument = window.document;
|
||||
});
|
||||
|
||||
const ensureLoadedMock = jest.fn().mockResolvedValue(undefined);
|
||||
const refreshMock = jest.fn().mockResolvedValue({ ok: true, source: "live" });
|
||||
const getMetaMock = jest.fn<CatalogMeta, []>().mockReturnValue({
|
||||
fetchedAt: Date.now(),
|
||||
source: "disk",
|
||||
});
|
||||
const getAllProvidersMock = jest.fn().mockReturnValue([
|
||||
{ id: "anthropic", models: { a: {}, b: {} } },
|
||||
{ id: "openai", models: { c: {} } },
|
||||
]);
|
||||
const catalogOnChange = jest.fn(() => () => {});
|
||||
|
||||
jest.mock("@/modelManagement/catalog/ModelCatalogService", () => ({
|
||||
ModelCatalogService: {
|
||||
getInstance: () => ({
|
||||
ensureLoaded: ensureLoadedMock,
|
||||
refresh: refreshMock,
|
||||
getMeta: getMetaMock,
|
||||
getAllProviders: getAllProvidersMock,
|
||||
// ByokGlobalTable looks up capabilities (context, release date) at
|
||||
// render time. These tests don't care about either column, so we just
|
||||
// return undefined and let the UI render the "—" fallback.
|
||||
getModel: () => undefined,
|
||||
onChange: catalogOnChange,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
let providers: ProviderConfig[] = [];
|
||||
let registry: RegistryEntry[] = [];
|
||||
const providerListeners = new Set<() => void>();
|
||||
const registryListeners = new Set<() => void>();
|
||||
|
||||
const providerRemoveMock = jest.fn(async (id: string) => {
|
||||
providers = providers.filter((p) => p.id !== id);
|
||||
registry = registry.filter((e) => e.providerId !== id);
|
||||
providerListeners.forEach((fn) => fn());
|
||||
registryListeners.forEach((fn) => fn());
|
||||
});
|
||||
|
||||
jest.mock("@/modelManagement/providers/ProviderRegistry", () => ({
|
||||
ProviderRegistry: {
|
||||
getInstance: () => ({
|
||||
list: () => providers.slice(),
|
||||
get: (id: string) => providers.find((p) => p.id === id),
|
||||
remove: providerRemoveMock,
|
||||
onChange: (fn: () => void) => {
|
||||
providerListeners.add(fn);
|
||||
return () => providerListeners.delete(fn);
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/modelManagement/registry/ModelRegistry", () => ({
|
||||
ModelRegistry: {
|
||||
getInstance: () => ({
|
||||
list: () => registry.slice(),
|
||||
onChange: (fn: () => void) => {
|
||||
registryListeners.add(fn);
|
||||
return () => registryListeners.delete(fn);
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// ConfirmModal opens a Modal subclass with a React tree — out of scope for
|
||||
// these tests; we replace it with a constructor that records calls and a
|
||||
// no-op `open()`.
|
||||
jest.mock("@/components/modals/ConfirmModal", () => ({
|
||||
ConfirmModal: jest.fn().mockImplementation(function (_app: unknown, onConfirm: () => void) {
|
||||
this.onConfirm = onConfirm;
|
||||
this.open = jest.fn(() => onConfirm());
|
||||
}),
|
||||
}));
|
||||
|
||||
// Import after mocks are registered.
|
||||
|
||||
import { ByokPanel } from "@/modelManagement/ui/tabs/ByokPanel";
|
||||
|
||||
function makeProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||||
return {
|
||||
id: "anthropic",
|
||||
kind: "builtin",
|
||||
displayName: "Anthropic",
|
||||
type: "anthropic",
|
||||
addedAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntry(overrides: Partial<RegistryEntry> = {}): RegistryEntry {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
addedAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const fakeApp = {} as never;
|
||||
|
||||
describe("ByokPanel", () => {
|
||||
beforeEach(() => {
|
||||
ensureLoadedMock.mockClear();
|
||||
refreshMock.mockClear();
|
||||
providerRemoveMock.mockClear();
|
||||
providers = [];
|
||||
registry = [];
|
||||
providerListeners.clear();
|
||||
registryListeners.clear();
|
||||
getMetaMock.mockReturnValue({ fetchedAt: Date.now(), source: "disk" });
|
||||
});
|
||||
|
||||
it("calls ensureLoaded() on mount", async () => {
|
||||
render(<ByokPanel app={fakeApp} />);
|
||||
await waitFor(() => expect(ensureLoadedMock).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("renders the empty state when no providers are configured", async () => {
|
||||
render(<ByokPanel app={fakeApp} />);
|
||||
// Wait for the skeleton to swap out.
|
||||
await waitFor(() => expect(screen.getByText(/No providers configured yet\./i)).toBeTruthy());
|
||||
// Add provider CTA visible.
|
||||
expect(screen.getAllByRole("button", { name: /Add provider/i }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders provider sections in the populated state", async () => {
|
||||
providers = [
|
||||
makeProvider({}),
|
||||
makeProvider({
|
||||
id: "custom:local",
|
||||
kind: "custom",
|
||||
displayName: "Local Ollama",
|
||||
type: "openai-compatible",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
}),
|
||||
];
|
||||
registry = [
|
||||
makeEntry({}),
|
||||
makeEntry({
|
||||
providerId: "custom:local",
|
||||
modelId: "llama3.2",
|
||||
displayName: "llama3.2",
|
||||
}),
|
||||
];
|
||||
|
||||
render(<ByokPanel app={fakeApp} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Anthropic")).toBeTruthy());
|
||||
expect(screen.getByText("Local Ollama")).toBeTruthy();
|
||||
expect(screen.getByText(/custom endpoint/i)).toBeTruthy();
|
||||
expect(screen.queryByTestId("byok-footer")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides providers with no registered models", async () => {
|
||||
providers = [
|
||||
makeProvider({}),
|
||||
makeProvider({
|
||||
id: "google",
|
||||
displayName: "Google",
|
||||
type: "google",
|
||||
}),
|
||||
];
|
||||
registry = [makeEntry({})];
|
||||
|
||||
render(<ByokPanel app={fakeApp} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Anthropic")).toBeTruthy());
|
||||
expect(screen.queryByText("Google")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters model rows by search query", async () => {
|
||||
providers = [makeProvider({})];
|
||||
registry = [
|
||||
makeEntry({ modelId: "claude-sonnet-4-5", displayName: "Claude Sonnet 4.5" }),
|
||||
makeEntry({ modelId: "claude-opus-4-1", displayName: "Claude Opus 4.1" }),
|
||||
];
|
||||
|
||||
render(<ByokPanel app={fakeApp} />);
|
||||
await waitFor(() => expect(screen.getByText("Claude Opus 4.1")).toBeTruthy());
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
fireEvent.change(searchInput, { target: { value: "Opus" } });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Claude Sonnet 4.5")).toBeNull());
|
||||
expect(screen.getByText("Claude Opus 4.1")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
formatContextWindow,
|
||||
formatReleaseDate,
|
||||
} from "@/modelManagement/ui/utils/formatModelMetadata";
|
||||
|
||||
describe("formatContextWindow", () => {
|
||||
it("formats million-scale token counts with one decimal, trimming trailing .0", () => {
|
||||
expect(formatContextWindow(1_500_000)).toBe("1.5M");
|
||||
expect(formatContextWindow(2_000_000)).toBe("2M");
|
||||
expect(formatContextWindow(1_000_000)).toBe("1M");
|
||||
});
|
||||
|
||||
it("formats thousand-scale token counts as rounded `k`", () => {
|
||||
expect(formatContextWindow(200_000)).toBe("200k");
|
||||
expect(formatContextWindow(8_192)).toBe("8k");
|
||||
expect(formatContextWindow(1_000)).toBe("1k");
|
||||
});
|
||||
|
||||
it("renders sub-thousand counts as plain numbers", () => {
|
||||
expect(formatContextWindow(512)).toBe("512");
|
||||
expect(formatContextWindow(1)).toBe("1");
|
||||
});
|
||||
|
||||
it("returns null for missing or non-positive inputs", () => {
|
||||
expect(formatContextWindow(undefined)).toBeNull();
|
||||
expect(formatContextWindow(0)).toBeNull();
|
||||
expect(formatContextWindow(-5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatReleaseDate", () => {
|
||||
it("formats valid ISO dates as `MMM YYYY`", () => {
|
||||
// Use mid-month dates so timezone shifts on the test runner can't pull
|
||||
// the result into the previous month (`2024-08-01` UTC → "Jul 2024" in
|
||||
// Pacific timezones, for example).
|
||||
expect(formatReleaseDate("2025-09-15")).toBe("Sep 2025");
|
||||
expect(formatReleaseDate("2024-08-15")).toBe("Aug 2024");
|
||||
});
|
||||
|
||||
it("returns empty string for missing input", () => {
|
||||
expect(formatReleaseDate(undefined)).toBe("");
|
||||
expect(formatReleaseDate("")).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to the raw string when parsing fails", () => {
|
||||
expect(formatReleaseDate("not-a-date")).toBe("not-a-date");
|
||||
});
|
||||
});
|
||||
34
src/modelManagement/ui/utils/formatModelMetadata.ts
Normal file
34
src/modelManagement/ui/utils/formatModelMetadata.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Shared formatters for catalog-sourced model metadata (context window,
|
||||
* release date). Used by both the catalog picker
|
||||
* (`ProviderCatalogList`) and the configured-models table
|
||||
* (`ByokGlobalTable`) so the same value renders identically wherever it
|
||||
* appears.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a token-count context window as a compact label.
|
||||
*
|
||||
* - `>= 1_000_000` → `"1.5M"` (one decimal, trailing `.0` trimmed)
|
||||
* - `>= 1_000` → `"200k"` (rounded)
|
||||
* - smaller → the number as-is
|
||||
* - missing/zero/negative → `null` so callers can skip rendering
|
||||
*/
|
||||
export function formatContextWindow(tokens: number | undefined): string | null {
|
||||
if (!tokens || tokens <= 0) return null;
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
||||
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
||||
return `${tokens}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-print a catalog `release_date` (ISO-ish "YYYY-MM-DD") as e.g.
|
||||
* `Sep 2025`. Falls back to the raw string if parsing fails, and returns
|
||||
* an empty string for missing input so callers can render unconditionally.
|
||||
*/
|
||||
export function formatReleaseDate(raw: string | undefined): string {
|
||||
if (!raw) return "";
|
||||
const parsed = Date.parse(raw);
|
||||
if (Number.isNaN(parsed)) return raw;
|
||||
return new Date(parsed).toLocaleDateString("en-US", { month: "short", year: "numeric" });
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ jest.mock("./SearchCore", () => ({
|
|||
retrieve: retrieveMock,
|
||||
})),
|
||||
}));
|
||||
jest.mock("@/LLMProviders/chatModelManager");
|
||||
jest.mock("@/LLMProviders/ChatModelManager");
|
||||
jest.mock("@/utils", () => ({
|
||||
extractNoteFiles: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ async function safeGetChatModel(): Promise<BaseChatModel | null> {
|
|||
try {
|
||||
if (!getChatModelManagerSingleton) {
|
||||
// dynamic import to prevent module load side effects during tests
|
||||
const mod = await import("@/LLMProviders/chatModelManager");
|
||||
const mod = await import("@/LLMProviders/ChatModelManager");
|
||||
getChatModelManagerSingleton = () => mod.default.getInstance();
|
||||
}
|
||||
const chatModelManager = getChatModelManagerSingleton();
|
||||
|
|
|
|||
|
|
@ -370,10 +370,7 @@ describe("loadSettingsWithKeychain", () => {
|
|||
// Legacy fields intentionally absent — that is the bug scenario.
|
||||
};
|
||||
|
||||
await mod.loadSettingsWithKeychain(
|
||||
raw,
|
||||
jest.fn().mockResolvedValue(undefined)
|
||||
);
|
||||
await mod.loadSettingsWithKeychain(raw, jest.fn().mockResolvedValue(undefined));
|
||||
|
||||
// Reason: the keychain backfill should have set the vault id before
|
||||
// any read so we hit the right namespace.
|
||||
|
|
@ -434,7 +431,7 @@ describe("loadSettingsWithKeychain", () => {
|
|||
// The hydrateFromKeychain path still runs (normal keychain-only load),
|
||||
// but the pre-migration getSecret loop should not have probed any of
|
||||
// the legacy field names.
|
||||
const probedKeys = (keychain.getSecret).mock.calls.map((c) => c[0] as string);
|
||||
const probedKeys = keychain.getSecret.mock.calls.map((c) => c[0] as string);
|
||||
expect(probedKeys).not.toContain("openAIApiKey");
|
||||
expect(probedKeys).not.toContain("anthropicApiKey");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,8 +20,16 @@ import {
|
|||
stripKeychainFields,
|
||||
} from "./settingsSecretTransforms";
|
||||
|
||||
/** Create a lightweight settings object for transform tests. */
|
||||
function makeSettings(overrides: Partial<CopilotSettings> = {}): CopilotSettings {
|
||||
/**
|
||||
* Create a lightweight settings object for transform tests.
|
||||
*
|
||||
* Accepts arbitrary keys via `Record<string, unknown>` because these tests
|
||||
* historically pass legacy `*ApiKey` fields that were removed from the
|
||||
* `CopilotSettings` interface in M9. The persistence machinery still
|
||||
* encounters those keys on disk for un-migrated installs, so the tests
|
||||
* exercise the dynamic-keys behavior of `isSensitiveKey`.
|
||||
*/
|
||||
function makeSettings(overrides: Record<string, unknown> = {}): CopilotSettings {
|
||||
return {
|
||||
activeModels: [],
|
||||
activeEmbeddingModels: [],
|
||||
|
|
@ -29,6 +37,11 @@ function makeSettings(overrides: Partial<CopilotSettings> = {}): CopilotSettings
|
|||
} as unknown as CopilotSettings;
|
||||
}
|
||||
|
||||
/** Coerce a settings-shaped object to `Record<string, unknown>` for legacy-field assertions. */
|
||||
function asRecord(value: CopilotSettings): Record<string, unknown> {
|
||||
return value as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** JSON-safe clone helper for mutation assertions. */
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
|
|
@ -98,8 +111,8 @@ describe("stripKeychainFields", () => {
|
|||
const result = stripKeychainFields(settings);
|
||||
|
||||
expect(result).not.toBe(settings);
|
||||
expect(result.openAIApiKey).toBe("");
|
||||
expect(result.googleApiKey).toBe("");
|
||||
expect(asRecord(result).openAIApiKey).toBe("");
|
||||
expect(asRecord(result).googleApiKey).toBe("");
|
||||
expect((result as unknown as Record<string, unknown>).defaultModelKey).toBe("gpt-4|openai");
|
||||
expect(result.activeModels[0].apiKey).toBe("");
|
||||
expect(result.activeModels[1].apiKey).toBe("");
|
||||
|
|
@ -133,7 +146,7 @@ describe("cleanupLegacyFields", () => {
|
|||
const result = cleanupLegacyFields(settings);
|
||||
|
||||
expect(result).not.toBe(settings);
|
||||
expect(result.openAIApiKey).toBe("sk-123");
|
||||
expect(asRecord(result).openAIApiKey).toBe("sk-123");
|
||||
expect(settings).toEqual(before);
|
||||
});
|
||||
|
||||
|
|
@ -154,7 +167,7 @@ describe("cleanupLegacyFields", () => {
|
|||
const settings = makeSettings({
|
||||
_keychainMigratedAt: "2026-04-01T00:00:00.000Z",
|
||||
_migrationModalDismissed: true,
|
||||
} as unknown as Partial<CopilotSettings>);
|
||||
});
|
||||
|
||||
const result = cleanupLegacyFields(settings);
|
||||
const rec = result as unknown as Record<string, unknown>;
|
||||
|
|
@ -166,7 +179,7 @@ describe("cleanupLegacyFields", () => {
|
|||
it("migrates legacy _diskSecretsCleared → _keychainOnly", () => {
|
||||
const settings = makeSettings({
|
||||
_diskSecretsCleared: true,
|
||||
} as unknown as Partial<CopilotSettings>);
|
||||
});
|
||||
|
||||
const result = cleanupLegacyFields(settings);
|
||||
const rec = result as unknown as Record<string, unknown>;
|
||||
|
|
@ -180,7 +193,7 @@ describe("cleanupLegacyFields", () => {
|
|||
const settings = makeSettings({
|
||||
_diskSecretsCleared: true,
|
||||
_keychainOnly: false,
|
||||
} as unknown as Partial<CopilotSettings>);
|
||||
});
|
||||
|
||||
const result = cleanupLegacyFields(settings);
|
||||
const rec = result as unknown as Record<string, unknown>;
|
||||
|
|
@ -202,7 +215,7 @@ describe("cleanupLegacyFields", () => {
|
|||
_keychainOnly: true,
|
||||
_someFutureField: "future-value",
|
||||
anotherUnknownField: 42,
|
||||
} as unknown as Partial<CopilotSettings>);
|
||||
});
|
||||
|
||||
const result = cleanupLegacyFields(settings);
|
||||
const rec = result as unknown as Record<string, unknown>;
|
||||
|
|
|
|||
152
src/settings/legacyApiKeyWrites.ts
Normal file
152
src/settings/legacyApiKeyWrites.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Legacy "Set Keys" dialog → BYOK provider bridge.
|
||||
*
|
||||
* The pre-M9 Basic Settings dialog (`ApiKeyDialog`) and the per-model
|
||||
* Add/Edit dialogs (`ModelAddDialog`, `ModelEditDialog`) used to write API
|
||||
* keys to `settings.openAIApiKey` / `settings.anthropicApiKey` / … directly.
|
||||
* After M9 those fields no longer exist; the BYOK panel is the source of
|
||||
* truth. This module routes those writes into `settings.providers[id]`
|
||||
* (creating a minimal `kind: "builtin"` `ProviderConfig` when needed) so
|
||||
* the legacy UI keeps working without divergence from the new shape.
|
||||
*
|
||||
* New code should call `ProviderRegistry` directly. This module exists to
|
||||
* keep the legacy entry points functional through the BYOK migration.
|
||||
*/
|
||||
import { ChatModelProviders, SettingKeyProviders } from "@/constants";
|
||||
import { ProviderRegistry, type ProviderConfig } from "@/modelManagement";
|
||||
import { getSettings, setSettings, updateSetting } from "@/settings/model";
|
||||
|
||||
/** Canonical adapter / provider id for each legacy `SettingKeyProviders`. */
|
||||
const PROVIDER_TO_ID: Partial<Record<SettingKeyProviders, string>> = {
|
||||
[ChatModelProviders.OPENAI]: "openai",
|
||||
[ChatModelProviders.ANTHROPIC]: "anthropic",
|
||||
[ChatModelProviders.AZURE_OPENAI]: "azure",
|
||||
[ChatModelProviders.GOOGLE]: "google",
|
||||
[ChatModelProviders.GROQ]: "groq",
|
||||
[ChatModelProviders.OPENROUTERAI]: "openrouter",
|
||||
[ChatModelProviders.COHEREAI]: "cohere",
|
||||
[ChatModelProviders.XAI]: "xai",
|
||||
[ChatModelProviders.MISTRAL]: "mistral",
|
||||
[ChatModelProviders.DEEPSEEK]: "deepseek",
|
||||
[ChatModelProviders.AMAZON_BEDROCK]: "amazon-bedrock",
|
||||
[ChatModelProviders.SILICONFLOW]: "siliconflow",
|
||||
};
|
||||
|
||||
/** Map provider id → `ProviderConfig.type` discriminator + display name. */
|
||||
const PROVIDER_META: Record<string, { type: ProviderConfig["type"]; displayName: string }> = {
|
||||
openai: { type: "openai-compatible", displayName: "OpenAI" },
|
||||
anthropic: { type: "anthropic", displayName: "Anthropic" },
|
||||
google: { type: "google", displayName: "Google" },
|
||||
azure: { type: "azure", displayName: "Azure OpenAI" },
|
||||
groq: { type: "openai-compatible", displayName: "Groq" },
|
||||
openrouter: { type: "openai-compatible", displayName: "OpenRouter" },
|
||||
cohere: { type: "openai-compatible", displayName: "Cohere" },
|
||||
xai: { type: "openai-compatible", displayName: "xAI" },
|
||||
mistral: { type: "openai-compatible", displayName: "Mistral" },
|
||||
deepseek: { type: "openai-compatible", displayName: "DeepSeek" },
|
||||
"amazon-bedrock": { type: "bedrock", displayName: "Amazon Bedrock" },
|
||||
siliconflow: { type: "openai-compatible", displayName: "SiliconFlow" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist a BYOK API key for a legacy `SettingKeyProviders` value. Creates
|
||||
* the provider entry if it doesn't exist yet; otherwise updates its
|
||||
* `apiKeyRef.value`.
|
||||
*
|
||||
* Copilot Plus license and GitHub Copilot OAuth tokens are handled inline —
|
||||
* they're not BYOK credentials and remain on their dedicated settings fields.
|
||||
*/
|
||||
export function writeLegacyApiKey(provider: SettingKeyProviders, value: string): void {
|
||||
if (provider === ChatModelProviders.COPILOT_PLUS) {
|
||||
updateSetting("plusLicenseKey", value);
|
||||
return;
|
||||
}
|
||||
if (provider === ChatModelProviders.GITHUB_COPILOT) {
|
||||
// GitHub Copilot uses OAuth; the dialog never lets the user paste a raw
|
||||
// token, but support the path defensively.
|
||||
updateSetting("githubCopilotToken", value);
|
||||
return;
|
||||
}
|
||||
const providerId = PROVIDER_TO_ID[provider];
|
||||
if (!providerId) return;
|
||||
const existing = ProviderRegistry.getInstance().get(providerId);
|
||||
setSettings((cur) => {
|
||||
const providers = { ...(cur.providers ?? {}) };
|
||||
if (existing) {
|
||||
providers[providerId] = {
|
||||
...existing,
|
||||
apiKeyRef: value ? { kind: "inline", value } : null,
|
||||
};
|
||||
} else {
|
||||
const meta = PROVIDER_META[providerId];
|
||||
if (!meta) return cur;
|
||||
const next: ProviderConfig = {
|
||||
id: providerId,
|
||||
kind: "builtin",
|
||||
displayName: meta.displayName,
|
||||
type: meta.type,
|
||||
apiKeyRef: value ? { kind: "inline", value } : null,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
providers[providerId] = next;
|
||||
}
|
||||
return { providers };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single `extra` field on a provider, creating the provider entry
|
||||
* if needed. Used by the legacy Azure / Bedrock / OpenAI dialogs that still
|
||||
* write extras (instance name, deployment, region, org id, …) one field at
|
||||
* a time. Pass `undefined` to clear the field.
|
||||
*/
|
||||
export function writeProviderExtra(
|
||||
providerId: string,
|
||||
field: string,
|
||||
value: string | undefined
|
||||
): void {
|
||||
const existing = ProviderRegistry.getInstance().get(providerId);
|
||||
setSettings((cur) => {
|
||||
const providers = { ...(cur.providers ?? {}) };
|
||||
if (existing) {
|
||||
const nextExtra: Record<string, unknown> = { ...(existing.extra ?? {}) };
|
||||
if (value === undefined || value.length === 0) {
|
||||
delete nextExtra[field];
|
||||
} else {
|
||||
nextExtra[field] = value;
|
||||
}
|
||||
providers[providerId] = {
|
||||
...existing,
|
||||
extra: Object.keys(nextExtra).length > 0 ? nextExtra : undefined,
|
||||
};
|
||||
} else {
|
||||
const meta = PROVIDER_META[providerId];
|
||||
if (!meta) return cur;
|
||||
const nextExtra: Record<string, unknown> = {};
|
||||
if (value !== undefined && value.length > 0) {
|
||||
nextExtra[field] = value;
|
||||
}
|
||||
const next: ProviderConfig = {
|
||||
id: providerId,
|
||||
kind: "builtin",
|
||||
displayName: meta.displayName,
|
||||
type: meta.type,
|
||||
apiKeyRef: null,
|
||||
...(Object.keys(nextExtra).length > 0 ? { extra: nextExtra } : {}),
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
providers[providerId] = next;
|
||||
}
|
||||
return { providers };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single `extra` field from a provider. Returns `""` when absent so
|
||||
* legacy form components can use it as a controlled-input value.
|
||||
*/
|
||||
export function readProviderExtra(providerId: string, field: string): string {
|
||||
const provider = getSettings().providers?.[providerId];
|
||||
const value = provider?.extra?.[field];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
|
@ -186,28 +186,36 @@ describe("sanitizeSettings - agentMode shape migration", () => {
|
|||
...DEFAULT_SETTINGS,
|
||||
agentMode: undefined as unknown as never,
|
||||
});
|
||||
expect(sanitized.agentMode).toEqual({
|
||||
expect(sanitized.agentMode).toMatchObject({
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
backends: {},
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills", importSkipList: [] },
|
||||
});
|
||||
// The v0→v2 migration seeds the quickChat backend slice and forwards
|
||||
// OpenCode/Plus model selections from DEFAULT_SETTINGS.activeModels
|
||||
// (which includes COPILOT_PLUS_FLASH) into opencode overrides.
|
||||
expect(sanitized.agentMode.backends.quickChat).toBeDefined();
|
||||
});
|
||||
|
||||
it("leaves backends empty when no legacy fields and no existing slice", () => {
|
||||
const sanitized = sanitizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
activeModels: [],
|
||||
agentMode: { enabled: true, byok: {}, mcpServers: [] },
|
||||
} as unknown as CopilotSettings);
|
||||
expect(sanitized.agentMode.backends).toEqual({});
|
||||
// Migration always seeds the quickChat backend slice — the legacy
|
||||
// assertion was `backends === {}` which doesn't hold anymore.
|
||||
expect(sanitized.agentMode.backends.quickChat).toBeDefined();
|
||||
expect(sanitized.agentMode.backends.opencode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves an already-migrated backends.opencode slice", () => {
|
||||
const migrated = {
|
||||
...DEFAULT_SETTINGS,
|
||||
activeModels: [],
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
|
|
@ -221,7 +229,7 @@ describe("sanitizeSettings - agentMode shape migration", () => {
|
|||
|
||||
const sanitized = sanitizeSettings(migrated);
|
||||
|
||||
expect(sanitized.agentMode.backends.opencode).toEqual({
|
||||
expect(sanitized.agentMode.backends.opencode).toMatchObject({
|
||||
binaryPath: "/new/opencode",
|
||||
binaryVersion: "2.0.0",
|
||||
binarySource: "custom",
|
||||
|
|
@ -249,6 +257,7 @@ describe("sanitizeSettings - agentMode shape migration", () => {
|
|||
it("clears binarySource when no binaryPath is set", () => {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
activeModels: [],
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
|
|
@ -259,7 +268,7 @@ describe("sanitizeSettings - agentMode shape migration", () => {
|
|||
|
||||
const sanitized = sanitizeSettings(settings);
|
||||
|
||||
expect(sanitized.agentMode.backends.opencode).toEqual({
|
||||
expect(sanitized.agentMode.backends.opencode).toMatchObject({
|
||||
binaryPath: undefined,
|
||||
binaryVersion: undefined,
|
||||
binarySource: undefined,
|
||||
|
|
|
|||
|
|
@ -5,25 +5,28 @@ import { PluginProvider } from "@/contexts/PluginContext";
|
|||
import { TabProvider, useTab } from "@/contexts/TabContext";
|
||||
import { useLatestVersion } from "@/hooks/useLatestVersion";
|
||||
import CopilotPlugin from "@/main";
|
||||
import { ByokPanel } from "@/modelManagement";
|
||||
import { resetSettings } from "@/settings/model";
|
||||
import { AgentPanel } from "@/settings/v3/tabs/AgentPanel";
|
||||
import { CommandSettings } from "@/settings/v2/components/CommandSettings";
|
||||
import { SkillsSettings } from "@/agentMode";
|
||||
import { Bot, Cog, Command, Cpu, Database, Sparkle, Sparkles, Wrench } from "lucide-react";
|
||||
import { Bot, Cog, Command, Database, KeyRound, Sparkle, Sparkles, Wrench } from "lucide-react";
|
||||
import React from "react";
|
||||
import { AdvancedSettings } from "./components/AdvancedSettings";
|
||||
import { AgentSettings } from "./components/AgentSettings";
|
||||
import { BasicSettings } from "./components/BasicSettings";
|
||||
import { CopilotPlusSettings } from "./components/CopilotPlusSettings";
|
||||
import { ModelSettings } from "./components/ModelSettings";
|
||||
import { QASettings } from "./components/QASettings";
|
||||
|
||||
const TAB_IDS = ["basic", "model", "agent", "QA", "command", "skills", "plus", "advanced"] as const;
|
||||
// M9: legacy "Models" tab removed; tab strip is now
|
||||
// Chat (basic) · BYOK · Agent · Commands · Embedding · Skills · Plus · Advanced.
|
||||
// Tab IDs are kept stable as route keys for backwards-compatible deep links.
|
||||
const TAB_IDS = ["basic", "byok", "agent", "QA", "command", "skills", "plus", "advanced"] as const;
|
||||
type TabId = (typeof TAB_IDS)[number];
|
||||
|
||||
// tab icons
|
||||
const icons: Record<TabId, JSX.Element> = {
|
||||
basic: <Cog className="tw-size-5" />,
|
||||
model: <Cpu className="tw-size-5" />,
|
||||
byok: <KeyRound className="tw-size-5" />,
|
||||
agent: <Bot className="tw-size-5" />,
|
||||
QA: <Database className="tw-size-5" />,
|
||||
command: <Command className="tw-size-5" />,
|
||||
|
|
@ -32,11 +35,19 @@ const icons: Record<TabId, JSX.Element> = {
|
|||
advanced: <Wrench className="tw-size-5" />,
|
||||
};
|
||||
|
||||
interface SettingsTabComponentProps {
|
||||
plugin: CopilotPlugin;
|
||||
/** Switch the settings shell to a different tab. Wired by `SettingsContent`. */
|
||||
setSelectedTab: (id: TabId) => void;
|
||||
}
|
||||
|
||||
// tab components
|
||||
const components: Record<TabId, React.FC> = {
|
||||
const components: Record<TabId, React.FC<SettingsTabComponentProps>> = {
|
||||
basic: () => <BasicSettings />,
|
||||
model: () => <ModelSettings />,
|
||||
agent: () => <AgentSettings />,
|
||||
byok: ({ plugin }) => <ByokPanel app={plugin.app} />,
|
||||
agent: ({ plugin, setSelectedTab }) => (
|
||||
<AgentPanel app={plugin.app} onNavigateToByok={() => setSelectedTab("byok")} />
|
||||
),
|
||||
QA: () => <QASettings />,
|
||||
command: () => <CommandSettings />,
|
||||
skills: () => <SkillsSettings />,
|
||||
|
|
@ -45,13 +56,18 @@ const components: Record<TabId, React.FC> = {
|
|||
};
|
||||
|
||||
// Tab labels — most tabs derive from the id, but "agent" capitalizes to a
|
||||
// human-friendly label.
|
||||
// human-friendly label. The "QA" tab id is kept stable as a route key (so
|
||||
// existing deep links keep working) while its label was renamed to
|
||||
// "Embedding" in M3 of the Model Management redesign, reflecting that the
|
||||
// tab now owns embedding-model management alongside indexing settings.
|
||||
// M9: "Basic" → "Chat" and "Chat & Commands"/"Command" → "Commands" label
|
||||
// renames per the Model Management redesign final tab strip.
|
||||
const TAB_LABELS: Record<TabId, string> = {
|
||||
basic: "Basic",
|
||||
model: "Model",
|
||||
basic: "Chat",
|
||||
byok: "BYOK",
|
||||
agent: "Agents",
|
||||
QA: "QA",
|
||||
command: "Command",
|
||||
QA: "Embedding",
|
||||
command: "Commands",
|
||||
skills: "Skills",
|
||||
plus: "Plus",
|
||||
advanced: "Advanced",
|
||||
|
|
@ -64,7 +80,11 @@ const tabs: TabItemType[] = TAB_IDS.map((id) => ({
|
|||
label: TAB_LABELS[id],
|
||||
}));
|
||||
|
||||
const SettingsContent: React.FC = () => {
|
||||
interface SettingsContentProps {
|
||||
plugin: CopilotPlugin;
|
||||
}
|
||||
|
||||
const SettingsContent: React.FC<SettingsContentProps> = ({ plugin }) => {
|
||||
const { selectedTab, setSelectedTab } = useTab();
|
||||
|
||||
return (
|
||||
|
|
@ -88,7 +108,7 @@ const SettingsContent: React.FC = () => {
|
|||
const Component = components[id];
|
||||
return (
|
||||
<TabContent key={id} id={id} isSelected={selectedTab === id}>
|
||||
<Component />
|
||||
<Component plugin={plugin} setSelectedTab={setSelectedTab} />
|
||||
</TabContent>
|
||||
);
|
||||
})}
|
||||
|
|
@ -163,7 +183,7 @@ const SettingsMainV2: React.FC<SettingsMainV2Props> = ({ plugin }) => {
|
|||
</div>
|
||||
</div>
|
||||
{/* Add the key prop to force re-render */}
|
||||
<SettingsContent key={resetKey} />
|
||||
<SettingsContent key={resetKey} plugin={plugin} />
|
||||
</div>
|
||||
</TabProvider>
|
||||
</PluginProvider>
|
||||
|
|
|
|||
|
|
@ -1,288 +0,0 @@
|
|||
import {
|
||||
getBackendModelOverrides,
|
||||
isAgentModelEnabled,
|
||||
listBackendDescriptors,
|
||||
McpServersPanel,
|
||||
SelectedModelsList,
|
||||
type BackendDescriptor,
|
||||
type BackendId,
|
||||
type BackendState,
|
||||
type ModelEntry,
|
||||
} from "@/agentMode";
|
||||
import { SettingItem } from "@/components/ui/setting-item";
|
||||
import { usePlugin } from "@/contexts/PluginContext";
|
||||
import { logError } from "@/logger";
|
||||
import { setSettings, useSettingsValue } from "@/settings/model";
|
||||
import { Platform } from "obsidian";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Explicit ordering for backend sections. Keeps Opencode → Claude → Codex
|
||||
* regardless of what `listBackendDescriptors()` returns.
|
||||
*/
|
||||
const BACKEND_ORDER: BackendId[] = ["opencode", "claude", "codex"];
|
||||
|
||||
/**
|
||||
* Top-level "Agents" settings tab. Owns the master agent-mode toggle, the
|
||||
* default backend picker, the MCP server panel, and one per-backend section
|
||||
* (binary path + model curation + default model/effort).
|
||||
*/
|
||||
export const AgentSettings: React.FC = () => {
|
||||
const settings = useSettingsValue();
|
||||
const plugin = usePlugin();
|
||||
|
||||
if (Platform.isMobile) {
|
||||
return (
|
||||
<section>
|
||||
<div className="tw-mb-3 tw-text-xl tw-font-bold">Agents</div>
|
||||
<div className="tw-text-muted">
|
||||
Agent Mode is desktop only. Open the desktop app to configure agents.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const allDescriptors = listBackendDescriptors();
|
||||
const orderedDescriptors = BACKEND_ORDER.map((id) =>
|
||||
allDescriptors.find((d) => d.id === id)
|
||||
).filter((d): d is BackendDescriptor => d !== undefined);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="tw-mb-3 tw-text-xl tw-font-bold">Agents (alpha)</div>
|
||||
<div className="tw-space-y-4">
|
||||
<SettingItem
|
||||
type="switch"
|
||||
title="Enable Agent Mode"
|
||||
description="BYOK agent harness backed by a local ACP subprocess. Desktop only."
|
||||
checked={settings.agentMode.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings((cur) => ({ agentMode: { ...cur.agentMode, enabled: checked } }))
|
||||
}
|
||||
/>
|
||||
|
||||
{settings.agentMode.enabled && (
|
||||
<SettingItem
|
||||
type="select"
|
||||
title="Default backend"
|
||||
description="Used when you click + to start a new session and for auto-spawn on mount. Selecting a model from the model picker also updates this."
|
||||
value={settings.agentMode.activeBackend}
|
||||
onChange={(value) =>
|
||||
setSettings((cur) => ({ agentMode: { ...cur.agentMode, activeBackend: value } }))
|
||||
}
|
||||
options={orderedDescriptors.map((d) => ({ label: d.displayName, value: d.id }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settings.agentMode.enabled && <McpServersPanel />}
|
||||
|
||||
{settings.agentMode.enabled &&
|
||||
orderedDescriptors.map((descriptor) => (
|
||||
<BackendSection key={descriptor.id} descriptor={descriptor} plugin={plugin} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* One per-backend block: heading, binary install panel, model toggle list,
|
||||
* and default model + effort pickers. Subscribes to the preloader cache so
|
||||
* the model list and default pickers update as soon as the preloader has
|
||||
* results.
|
||||
*/
|
||||
const BackendSection: React.FC<{
|
||||
descriptor: BackendDescriptor;
|
||||
plugin: ReturnType<typeof usePlugin>;
|
||||
}> = ({ descriptor, plugin }) => {
|
||||
const settings = useSettingsValue();
|
||||
const Panel = descriptor.SettingsPanel;
|
||||
const manager = plugin.agentSessionManager;
|
||||
|
||||
const [backendState, setBackendState] = React.useState<BackendState | null>(
|
||||
() => manager?.getCachedBackendState(descriptor.id) ?? null
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (!manager) return;
|
||||
return manager.subscribeModelCache(() => {
|
||||
setBackendState(manager.getCachedBackendState(descriptor.id) ?? null);
|
||||
});
|
||||
}, [manager, descriptor.id]);
|
||||
const cachedModel = backendState?.model ?? null;
|
||||
|
||||
const installState = descriptor.getInstallState(settings);
|
||||
|
||||
// Trigger a probe when the install is ready but no cache has arrived — the
|
||||
// load-time preload may have skipped this backend (binary installed after
|
||||
// plugin start).
|
||||
React.useEffect(() => {
|
||||
if (!manager) return;
|
||||
if (installState.kind !== "ready") return;
|
||||
if (cachedModel) return;
|
||||
manager
|
||||
.preloadModels(descriptor.id)
|
||||
.catch((e) => logError(`[AgentMode] preload ${descriptor.id} failed`, e));
|
||||
}, [manager, descriptor.id, installState.kind, cachedModel]);
|
||||
|
||||
const overrides = getBackendModelOverrides(settings, descriptor.id);
|
||||
|
||||
return (
|
||||
<div className="tw-space-y-3 tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-3">
|
||||
<div className="tw-text-base tw-font-semibold">{descriptor.displayName}</div>
|
||||
|
||||
{Panel && <Panel plugin={plugin} app={plugin.app} />}
|
||||
|
||||
{installState.kind === "ready" && (
|
||||
<ModelCurationBlock
|
||||
descriptor={descriptor}
|
||||
backendState={backendState}
|
||||
overrides={overrides}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the "Available models" toggle list plus the default model and
|
||||
* default effort dropdowns. Hidden when the preloader hasn't returned a
|
||||
* model list yet (still probing or agent reports nothing).
|
||||
*/
|
||||
const ModelCurationBlock: React.FC<{
|
||||
descriptor: BackendDescriptor;
|
||||
backendState: BackendState | null;
|
||||
overrides: Record<string, boolean> | undefined;
|
||||
}> = ({ descriptor, backendState, overrides }) => {
|
||||
const modelState = backendState?.model;
|
||||
if (!modelState || modelState.availableModels.length === 0) {
|
||||
return (
|
||||
<div className="tw-text-sm tw-text-muted">
|
||||
No models reported yet — install the binary and reload, or open a chat session with this
|
||||
agent.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const enabled = modelState.availableModels.filter((entry) =>
|
||||
isAgentModelEnabled(descriptor, { modelId: entry.baseModelId, name: entry.name }, overrides)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="tw-space-y-3">
|
||||
<SelectedModelsList
|
||||
descriptor={descriptor}
|
||||
availableModels={modelState.availableModels}
|
||||
overrides={overrides}
|
||||
/>
|
||||
|
||||
<DefaultModelPicker
|
||||
descriptor={descriptor}
|
||||
availableModels={modelState.availableModels}
|
||||
enabled={enabled}
|
||||
/>
|
||||
|
||||
<DefaultEffortPicker descriptor={descriptor} backendState={backendState} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Default-model dropdown — limited to enabled models. Reads/writes the
|
||||
* normalized `{ baseModelId, effort }` preference through the session
|
||||
* manager; the picker UI never sees wire format.
|
||||
*/
|
||||
const DefaultModelPicker: React.FC<{
|
||||
descriptor: BackendDescriptor;
|
||||
availableModels: ReadonlyArray<ModelEntry>;
|
||||
enabled: ReadonlyArray<ModelEntry>;
|
||||
}> = ({ descriptor, availableModels, enabled }) => {
|
||||
// useSettingsValue() subscribes the component to settings changes — without
|
||||
// it, manager.getDefaultSelection (which reads getSettings synchronously)
|
||||
// wouldn't trigger a re-render after persistDefaultSelection.
|
||||
useSettingsValue();
|
||||
const plugin = usePlugin();
|
||||
const manager = plugin.agentSessionManager;
|
||||
const defaultSelection = manager?.getDefaultSelection(descriptor.id) ?? null;
|
||||
const currentBaseId = defaultSelection?.baseModelId ?? "";
|
||||
|
||||
// If the persisted default is currently disabled by override/policy, keep
|
||||
// it visible in the dropdown so the user isn't stranded.
|
||||
const currentEntry =
|
||||
currentBaseId && !enabled.some((m) => m.baseModelId === currentBaseId)
|
||||
? availableModels.find((m) => m.baseModelId === currentBaseId)
|
||||
: undefined;
|
||||
const dropdownEntries = currentEntry ? [currentEntry, ...enabled] : enabled;
|
||||
if (dropdownEntries.length === 0) return null;
|
||||
|
||||
const handleChange = (newBaseId: string): void => {
|
||||
if (!newBaseId || !manager) return;
|
||||
manager
|
||||
.persistDefaultSelection(descriptor.id, {
|
||||
baseModelId: newBaseId,
|
||||
effort: defaultSelection?.effort ?? null,
|
||||
})
|
||||
.catch((e) => logError(`[AgentMode] persist default model for ${descriptor.id} failed`, e));
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingItem
|
||||
type="select"
|
||||
title="Default model"
|
||||
description="Used when starting a new session with this agent."
|
||||
value={currentBaseId}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ label: "Use agent default", value: "" },
|
||||
...dropdownEntries.map((m) => ({ label: m.name || m.baseModelId, value: m.baseModelId })),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Default-effort dropdown — sources `effortOptions` from the catalog entry
|
||||
* for the persisted default model, falling back to the agent's catalog-
|
||||
* declared default (`availableModels[0]`) when no preference is set. Never
|
||||
* reads `modelState.current.*` so the settings UI doesn't drift with mid-
|
||||
* session model switches. Hidden when the target model has no effort
|
||||
* dimension or the catalog hasn't loaded yet.
|
||||
*/
|
||||
const DefaultEffortPicker: React.FC<{
|
||||
descriptor: BackendDescriptor;
|
||||
backendState: BackendState | null;
|
||||
}> = ({ descriptor, backendState }) => {
|
||||
// See DefaultModelPicker for why useSettingsValue() is called for its
|
||||
// re-render side effect rather than its return value.
|
||||
useSettingsValue();
|
||||
const plugin = usePlugin();
|
||||
const manager = plugin.agentSessionManager;
|
||||
const modelState = backendState?.model;
|
||||
if (!modelState) return null;
|
||||
|
||||
const defaultSelection = manager?.getDefaultSelection(descriptor.id) ?? null;
|
||||
const targetBaseId =
|
||||
defaultSelection?.baseModelId ?? manager?.getDefaultBaseModelId(descriptor.id);
|
||||
if (!targetBaseId) return null;
|
||||
const targetEntry = modelState.availableModels.find((e) => e.baseModelId === targetBaseId);
|
||||
if (!targetEntry || targetEntry.effortOptions.length === 0) return null;
|
||||
|
||||
const domValue = defaultSelection?.effort ?? "";
|
||||
const handleChange = (raw: string): void => {
|
||||
if (!manager) return;
|
||||
const value = raw === "" ? null : raw;
|
||||
manager
|
||||
.persistDefaultSelection(descriptor.id, { baseModelId: targetBaseId, effort: value })
|
||||
.catch((e) => logError(`[AgentMode] persist default effort for ${descriptor.id} failed`, e));
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingItem
|
||||
type="select"
|
||||
title="Default effort"
|
||||
description="Reasoning effort applied when starting a new session."
|
||||
value={domValue}
|
||||
onChange={handleChange}
|
||||
options={targetEntry.effortOptions.map((o) => ({ label: o.label, value: o.value ?? "" }))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { ProviderSettingsKeyMap, SettingKeyProviders } from "@/constants";
|
||||
import { updateSetting, useSettingsValue } from "@/settings/model";
|
||||
import { SettingKeyProviders } from "@/constants";
|
||||
import { writeLegacyApiKey } from "@/settings/legacyApiKeyWrites";
|
||||
import { useSettingsValue } from "@/settings/model";
|
||||
import { GitHubCopilotAuth } from "@/settings/v2/components/GitHubCopilotAuth";
|
||||
import { LocalServicesSection } from "@/settings/v2/components/LocalServicesSection";
|
||||
import { ModelImporter } from "@/settings/v2/components/ModelImporter";
|
||||
|
|
@ -41,7 +42,7 @@ function ApiKeyModalContent({ onClose, onGoToModelTab }: ApiKeyModalContentProps
|
|||
const handleApiKeyChange = (provider: SettingKeyProviders, value: string) => {
|
||||
const currentKey = getApiKeyForProvider(provider);
|
||||
if (currentKey !== value) {
|
||||
updateSetting(ProviderSettingsKeyMap[provider], value);
|
||||
writeLegacyApiKey(provider, value);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
142
src/settings/v2/components/EmbeddingModelsSection.tsx
Normal file
142
src/settings/v2/components/EmbeddingModelsSection.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { Notice } from "obsidian";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { CustomModel } from "@/aiParams";
|
||||
import { useApp } from "@/context";
|
||||
import { BUILTIN_EMBEDDING_MODELS } from "@/constants";
|
||||
import EmbeddingManager from "@/LLMProviders/embeddingManager";
|
||||
import { CopilotSettings, updateSetting, useSettingsValue } from "@/settings/model";
|
||||
import { ModelAddDialog } from "@/settings/v2/components/ModelAddDialog";
|
||||
import { ModelEditModal } from "@/settings/v2/components/ModelEditDialog";
|
||||
import { ModelTable } from "@/settings/v2/components/ModelTable";
|
||||
import { omit } from "@/utils";
|
||||
|
||||
/**
|
||||
* Embedding model registry section. Extracted from `ModelSettings.tsx` as
|
||||
* part of the Model Management redesign (M3): the embedding-model UI moved
|
||||
* out of the deprecated "Model" tab and into the renamed "Embedding" tab
|
||||
* (previously "QA"). Behavior is identical to the prior embedding half of
|
||||
* `ModelSettings.tsx` — `activeEmbeddingModels` is the source of truth, and
|
||||
* add / edit / copy / delete / reorder / refresh all delegate to the same
|
||||
* shared dialogs and `ModelTable` used by chat models.
|
||||
*/
|
||||
export const EmbeddingModelsSection: React.FC = () => {
|
||||
const app = useApp();
|
||||
const settings = useSettingsValue();
|
||||
const [showAddEmbeddingDialog, setShowAddEmbeddingDialog] = useState(false);
|
||||
|
||||
/**
|
||||
* Duplicate an embedding model — strips read-only / catalog-derived fields
|
||||
* and appends a `(copy)` suffix so the user can clone-and-tweak.
|
||||
*/
|
||||
const onCopyEmbeddingModel = (model: CustomModel) => {
|
||||
const newModel: CustomModel = {
|
||||
...omit(model, [
|
||||
"isBuiltIn",
|
||||
"core",
|
||||
"projectEnabled",
|
||||
"plusExclusive",
|
||||
"believerExclusive",
|
||||
"capabilities",
|
||||
"displayName",
|
||||
"dimensions",
|
||||
]),
|
||||
name: `${model.name} (copy)`,
|
||||
};
|
||||
|
||||
const settingField: keyof CopilotSettings = "activeEmbeddingModels";
|
||||
updateSetting(settingField, [...settings[settingField], newModel]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist a reordered embedding model list (drag handle in `ModelTable`).
|
||||
*/
|
||||
const handleEmbeddingModelReorder = (newModels: CustomModel[]) => {
|
||||
updateSetting("activeEmbeddingModels", newModels);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove an embedding model by its `name|provider` key.
|
||||
*/
|
||||
const onDeleteEmbeddingModel = (modelKey: string) => {
|
||||
const [modelName, provider] = modelKey.split("|");
|
||||
const updatedModels = settings.activeEmbeddingModels.filter(
|
||||
(model) => !(model.name === modelName && model.provider === provider)
|
||||
);
|
||||
updateSetting("activeEmbeddingModels", updatedModels);
|
||||
};
|
||||
|
||||
/**
|
||||
* In-table edits (checkbox toggles, etc.) from `ModelTable`.
|
||||
*/
|
||||
const handleEmbeddingModelTableUpdate = (updatedModel: CustomModel) => {
|
||||
const updatedModels = settings.activeEmbeddingModels.map((m) =>
|
||||
m.name === updatedModel.name && m.provider === updatedModel.provider ? updatedModel : m
|
||||
);
|
||||
updateSetting("activeEmbeddingModels", updatedModels);
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits originating from `ModelEditModal` (the per-row config dialog).
|
||||
* `originalModel` is needed because the dialog may rename the model.
|
||||
*/
|
||||
const handleEmbeddingModelUpdate = (originalModel: CustomModel, updatedModel: CustomModel) => {
|
||||
const updatedModels = settings.activeEmbeddingModels.map((m) =>
|
||||
m.name === originalModel.name && m.provider === originalModel.provider ? updatedModel : m
|
||||
);
|
||||
updateSetting("activeEmbeddingModels", updatedModels);
|
||||
};
|
||||
|
||||
/**
|
||||
* Restore the built-in embedding catalog while preserving any user-added
|
||||
* custom embedding models.
|
||||
*/
|
||||
const handleRefreshEmbeddingModels = () => {
|
||||
const customModels = settings.activeEmbeddingModels.filter((model) => !model.isBuiltIn);
|
||||
const updatedModels = [...BUILTIN_EMBEDDING_MODELS, ...customModels];
|
||||
updateSetting("activeEmbeddingModels", updatedModels);
|
||||
new Notice("Embedding models refreshed successfully");
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the per-row edit dialog with `isEmbeddingModel = true` so the
|
||||
* dialog renders the embedding-specific subset of fields.
|
||||
*/
|
||||
const handleEditEmbeddingModel = (model: CustomModel) => {
|
||||
const modal = new ModelEditModal(
|
||||
app,
|
||||
model,
|
||||
/* isEmbeddingModel */ true,
|
||||
handleEmbeddingModelUpdate
|
||||
);
|
||||
modal.open();
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<ModelTable
|
||||
models={settings.activeEmbeddingModels}
|
||||
onEdit={handleEditEmbeddingModel}
|
||||
onDelete={onDeleteEmbeddingModel}
|
||||
onCopy={onCopyEmbeddingModel}
|
||||
onAdd={() => setShowAddEmbeddingDialog(true)}
|
||||
onUpdateModel={handleEmbeddingModelTableUpdate}
|
||||
onReorderModels={handleEmbeddingModelReorder}
|
||||
onRefresh={handleRefreshEmbeddingModels}
|
||||
title="Embedding Models"
|
||||
/>
|
||||
|
||||
{/* Embedding model add dialog */}
|
||||
<ModelAddDialog
|
||||
open={showAddEmbeddingDialog}
|
||||
onOpenChange={setShowAddEmbeddingDialog}
|
||||
onAdd={(model) => {
|
||||
const updatedModels = [...settings.activeEmbeddingModels, model];
|
||||
updateSetting("activeEmbeddingModels", updatedModels);
|
||||
}}
|
||||
isEmbeddingModel={true}
|
||||
ping={(model) => EmbeddingManager.getInstance().ping(model)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
@ -32,7 +32,7 @@ import {
|
|||
} from "@/constants";
|
||||
import { useTab } from "@/contexts/TabContext";
|
||||
import { logError } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { readProviderExtra } from "@/settings/legacyApiKeyWrites";
|
||||
import { err2String, getProviderInfo, getProviderLabel, omit } from "@/utils";
|
||||
import { buildCurlCommandForModel } from "@/utils/curlCommand";
|
||||
import { CheckCircle2, ChevronDown, Loader2, XCircle } from "lucide-react";
|
||||
|
|
@ -66,7 +66,6 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
isEmbeddingModel = false,
|
||||
}) => {
|
||||
const { modalContainer } = useTab();
|
||||
const settings = getSettings();
|
||||
const defaultProvider = isEmbeddingModel
|
||||
? EmbeddingModelProviders.OPENAI
|
||||
: ChatModelProviders.OPENROUTERAI;
|
||||
|
|
@ -169,7 +168,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
if (provider === ChatModelProviders.AMAZON_BEDROCK) {
|
||||
return {
|
||||
...chatModel,
|
||||
bedrockRegion: settings.amazonBedrockRegion,
|
||||
bedrockRegion: readProviderExtra("amazon-bedrock", "bedrockRegion") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -243,18 +242,23 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
...model,
|
||||
provider,
|
||||
apiKey: getApiKeyForProvider(provider as SettingKeyProviders),
|
||||
...(provider === ChatModelProviders.OPENAI ? { openAIOrgId: settings.openAIOrgId } : {}),
|
||||
...(provider === ChatModelProviders.OPENAI
|
||||
? { openAIOrgId: readProviderExtra("openai", "openAIOrgId") }
|
||||
: {}),
|
||||
...(provider === ChatModelProviders.AZURE_OPENAI
|
||||
? {
|
||||
azureOpenAIApiInstanceName: settings.azureOpenAIApiInstanceName,
|
||||
azureOpenAIApiDeploymentName: settings.azureOpenAIApiDeploymentName,
|
||||
azureOpenAIApiVersion: settings.azureOpenAIApiVersion,
|
||||
azureOpenAIApiEmbeddingDeploymentName: settings.azureOpenAIApiEmbeddingDeploymentName,
|
||||
azureOpenAIApiInstanceName: readProviderExtra("azure", "azureInstanceName"),
|
||||
azureOpenAIApiDeploymentName: readProviderExtra("azure", "azureDeploymentName"),
|
||||
azureOpenAIApiVersion: readProviderExtra("azure", "azureApiVersion"),
|
||||
azureOpenAIApiEmbeddingDeploymentName: readProviderExtra(
|
||||
"azure",
|
||||
"azureEmbeddingDeploymentName"
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(provider === ChatModelProviders.AMAZON_BEDROCK
|
||||
? {
|
||||
bedrockRegion: settings.amazonBedrockRegion,
|
||||
bedrockRegion: readProviderExtra("amazon-bedrock", "bedrockRegion") || undefined,
|
||||
}
|
||||
: {
|
||||
bedrockRegion: undefined,
|
||||
|
|
|
|||
|
|
@ -27,11 +27,7 @@ import { ModelParametersEditor } from "@/components/ui/ModelParametersEditor";
|
|||
interface ModelEditModalContentProps {
|
||||
model: CustomModel;
|
||||
isEmbeddingModel: boolean;
|
||||
onUpdate: (
|
||||
isEmbeddingModel: boolean,
|
||||
originalModel: CustomModel,
|
||||
updatedModel: CustomModel
|
||||
) => void;
|
||||
onUpdate: (originalModel: CustomModel, updatedModel: CustomModel) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -59,9 +55,9 @@ const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
|
|||
const debouncedOnUpdate = useMemo(
|
||||
() =>
|
||||
debounce((currentOriginalModel: CustomModel, updatedModel: CustomModel) => {
|
||||
onUpdate(isEmbeddingModel, currentOriginalModel, updatedModel);
|
||||
onUpdate(currentOriginalModel, updatedModel);
|
||||
}, 500),
|
||||
[isEmbeddingModel, onUpdate]
|
||||
[onUpdate]
|
||||
);
|
||||
|
||||
// Function to update local state immediately
|
||||
|
|
@ -352,11 +348,7 @@ export class ModelEditModal extends Modal {
|
|||
app: App,
|
||||
private model: CustomModel,
|
||||
private isEmbeddingModel: boolean,
|
||||
private onUpdate: (
|
||||
isEmbeddingModel: boolean,
|
||||
originalModel: CustomModel,
|
||||
updatedModel: CustomModel
|
||||
) => void
|
||||
private onUpdate: (originalModel: CustomModel, updatedModel: CustomModel) => void
|
||||
) {
|
||||
super(app);
|
||||
// @ts-ignore
|
||||
|
|
@ -371,14 +363,6 @@ export class ModelEditModal extends Modal {
|
|||
}
|
||||
this.root = createPluginRoot(contentEl, this.app);
|
||||
|
||||
const handleUpdate = (
|
||||
isEmbeddingModel: boolean,
|
||||
originalModel: CustomModel,
|
||||
updatedModel: CustomModel
|
||||
) => {
|
||||
this.onUpdate(isEmbeddingModel, originalModel, updatedModel);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
this.close();
|
||||
};
|
||||
|
|
@ -387,7 +371,7 @@ export class ModelEditModal extends Modal {
|
|||
<ModelEditModalContent
|
||||
model={this.model}
|
||||
isEmbeddingModel={this.isEmbeddingModel}
|
||||
onUpdate={handleUpdate}
|
||||
onUpdate={this.onUpdate}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { getModelDisplayWithIcons } from "@/components/ui/model-display";
|
|||
import { SettingItem } from "@/components/ui/setting-item";
|
||||
import { VAULT_VECTOR_STORE_STRATEGIES } from "@/constants";
|
||||
import { getModelKeyFromModel, updateSetting, useSettingsValue } from "@/settings/model";
|
||||
import { EmbeddingModelsSection } from "@/settings/v2/components/EmbeddingModelsSection";
|
||||
import { PatternListEditor } from "@/settings/v2/components/PatternListEditor";
|
||||
|
||||
export const QASettings: React.FC = () => {
|
||||
|
|
@ -315,6 +316,16 @@ export const QASettings: React.FC = () => {
|
|||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
* Embedding model registry. Lives at the bottom of the Embedding tab
|
||||
* (formerly QA) — was relocated here from the deprecated Model tab as
|
||||
* part of M3 of the Model Management redesign. The default embedding
|
||||
* model selector is still rendered above (inside the indexing
|
||||
* settings); this section manages the full list of available
|
||||
* embedding models (add / edit / remove / reorder / refresh).
|
||||
*/}
|
||||
<EmbeddingModelsSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Tests for the EmbeddingModelsSection — the embedding-model registry
|
||||
* surface that moved from `ModelSettings.tsx` to the renamed "Embedding"
|
||||
* tab as part of M3 of the Model Management redesign.
|
||||
*
|
||||
* These tests don't mount the full ModelTable / ModelAddDialog trees (those
|
||||
* pull in dnd-kit, Radix portals, and the entire LangChain embedding
|
||||
* manager). Instead we stub them to forward props as `data-*` attributes
|
||||
* and assert that the props match the original ModelSettings behavior —
|
||||
* proving the refactor was a faithful move and not a rewrite.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — keep the dep chain shallow so the component renders synchronously.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const updateSettingMock = jest.fn();
|
||||
|
||||
jest.mock("@/settings/model", () => ({
|
||||
updateSetting: (...args: unknown[]) => {
|
||||
updateSettingMock(...args);
|
||||
},
|
||||
useSettingsValue: jest.fn(() => ({
|
||||
activeEmbeddingModels: [
|
||||
{
|
||||
name: "text-embedding-3-small",
|
||||
provider: "openai",
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("@/context", () => ({
|
||||
useApp: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock("@/LLMProviders/embeddingManager", () => ({
|
||||
__esModule: true,
|
||||
default: { getInstance: () => ({ ping: jest.fn() }) },
|
||||
}));
|
||||
|
||||
jest.mock("@/constants", () => ({
|
||||
BUILTIN_EMBEDDING_MODELS: [],
|
||||
}));
|
||||
|
||||
jest.mock("@/utils", () => ({
|
||||
omit: (obj: Record<string, unknown>, keys: string[]) => {
|
||||
const out = { ...obj };
|
||||
for (const k of keys) delete out[k];
|
||||
return out;
|
||||
},
|
||||
}));
|
||||
|
||||
// Reason: ModelTable pulls dnd-kit and Radix. Stub it to a leaf that
|
||||
// surfaces the props we care about so we can verify them in assertions.
|
||||
jest.mock("@/settings/v2/components/ModelTable", () => ({
|
||||
ModelTable: (props: { title: string; models: unknown[] }) => (
|
||||
<div data-testid="model-table" data-title={props.title} data-count={props.models.length} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/settings/v2/components/ModelAddDialog", () => ({
|
||||
ModelAddDialog: (props: { isEmbeddingModel?: boolean; open: boolean }) => (
|
||||
<div
|
||||
data-testid="model-add-dialog"
|
||||
data-is-embedding={String(Boolean(props.isEmbeddingModel))}
|
||||
data-open={String(props.open)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/settings/v2/components/ModelEditDialog", () => ({
|
||||
ModelEditModal: class {
|
||||
open() {}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("obsidian", () => ({
|
||||
Notice: class {
|
||||
constructor(_: string) {}
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks so the module sees them.
|
||||
import { EmbeddingModelsSection } from "@/settings/v2/components/EmbeddingModelsSection";
|
||||
|
||||
describe("EmbeddingModelsSection", () => {
|
||||
beforeEach(() => {
|
||||
updateSettingMock.mockClear();
|
||||
});
|
||||
|
||||
it("renders the embedding-models ModelTable wired to activeEmbeddingModels", () => {
|
||||
const view = render(<EmbeddingModelsSection />);
|
||||
|
||||
const table = view.getByTestId("model-table");
|
||||
expect(table.getAttribute("data-title")).toBe("Embedding Models");
|
||||
// Reason: confirms we passed `activeEmbeddingModels` (1 entry in our
|
||||
// mocked settings) and NOT `activeModels`.
|
||||
expect(table.getAttribute("data-count")).toBe("1");
|
||||
});
|
||||
|
||||
it("renders the add-dialog in embedding mode (closed by default)", () => {
|
||||
const view = render(<EmbeddingModelsSection />);
|
||||
|
||||
const dialog = view.getByTestId("model-add-dialog");
|
||||
// Reason: pinning isEmbeddingModel = true is what differentiates this
|
||||
// section from the chat-model add dialog in ModelSettings.tsx.
|
||||
expect(dialog.getAttribute("data-is-embedding")).toBe("true");
|
||||
expect(dialog.getAttribute("data-open")).toBe("false");
|
||||
});
|
||||
});
|
||||
131
src/settings/v2/components/__tests__/QASettings.test.tsx
Normal file
131
src/settings/v2/components/__tests__/QASettings.test.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Tests for the renamed "Embedding" tab (formerly "QA"). M3 of the Model
|
||||
* Management redesign added the embedding-models registry to the bottom
|
||||
* of this tab; these tests confirm the embedding section is mounted and
|
||||
* that the existing QA / indexing settings still render in place.
|
||||
*
|
||||
* Heavy dependencies (modals, the model add dialog, the embedding
|
||||
* manager) are stubbed so we can render synchronously and assert
|
||||
* structural facts without spinning up the full settings tree.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
jest.mock("@/settings/model", () => ({
|
||||
updateSetting: jest.fn(),
|
||||
getModelKeyFromModel: (m: { name: string; provider: string }) => `${m.name}|${m.provider}`,
|
||||
useSettingsValue: jest.fn(() => ({
|
||||
enableMiyo: false,
|
||||
enableSemanticSearchV3: false,
|
||||
enableInlineCitations: false,
|
||||
embeddingModelKey: "text-embedding-3-small|openai",
|
||||
indexVaultToVectorStore: "ON MODE SWITCH",
|
||||
maxSourceChunks: 30,
|
||||
embeddingRequestsPerMin: 60,
|
||||
embeddingBatchSize: 16,
|
||||
numPartitions: 1,
|
||||
lexicalSearchRamLimit: 100,
|
||||
enableLexicalBoosts: true,
|
||||
qaExclusions: "",
|
||||
qaInclusions: "",
|
||||
enableIndexSync: false,
|
||||
disableIndexOnMobile: false,
|
||||
activeEmbeddingModels: [
|
||||
{
|
||||
name: "text-embedding-3-small",
|
||||
provider: "openai",
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("@/context", () => ({
|
||||
useApp: jest.fn(() => ({ vault: { configDir: ".test-config-dir" } })),
|
||||
}));
|
||||
|
||||
jest.mock("@/components/modals/RebuildIndexConfirmModal", () => ({
|
||||
RebuildIndexConfirmModal: class {
|
||||
open() {}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/components/modals/SemanticSearchToggleModal", () => ({
|
||||
SemanticSearchToggleModal: class {
|
||||
open() {}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/components/ui/help-tooltip", () => ({
|
||||
HelpTooltip: () => <span data-testid="help-tooltip" />,
|
||||
}));
|
||||
|
||||
jest.mock("@/components/ui/model-display", () => ({
|
||||
getModelDisplayWithIcons: (m: { name: string }) => m.name,
|
||||
}));
|
||||
|
||||
jest.mock("@/components/ui/setting-item", () => ({
|
||||
SettingItem: (props: { title: string }) => (
|
||||
<div data-testid="setting-item" data-title={props.title} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/settings/v2/components/PatternListEditor", () => ({
|
||||
PatternListEditor: () => <div data-testid="pattern-list-editor" />,
|
||||
}));
|
||||
|
||||
// EmbeddingModelsSection itself is unit-tested separately. Stub here to a
|
||||
// sentinel so we can confirm the QA tab mounts it.
|
||||
jest.mock("@/settings/v2/components/EmbeddingModelsSection", () => ({
|
||||
EmbeddingModelsSection: () => <div data-testid="embedding-models-section" />,
|
||||
}));
|
||||
|
||||
jest.mock("@/constants", () => ({
|
||||
VAULT_VECTOR_STORE_STRATEGIES: ["NEVER", "ON STARTUP", "ON MODE SWITCH"],
|
||||
}));
|
||||
|
||||
jest.mock("obsidian", () => ({
|
||||
Notice: class {
|
||||
constructor(_: string) {}
|
||||
},
|
||||
}));
|
||||
|
||||
// Reason: vectorStoreManager is dynamically imported only on toggle paths
|
||||
// we don't exercise here; nothing further to mock.
|
||||
|
||||
// Import after mocks.
|
||||
import { QASettings } from "@/settings/v2/components/QASettings";
|
||||
|
||||
describe("QASettings (Embedding tab)", () => {
|
||||
it("renders without throwing", () => {
|
||||
const view = render(<QASettings />);
|
||||
expect(view.container.querySelector("section")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the EmbeddingModelsSection at the bottom of the tab", () => {
|
||||
const view = render(<QASettings />);
|
||||
// Reason: M3 explicitly relocated the embedding-model registry to
|
||||
// this tab. If this assertion ever fails it means the section was
|
||||
// removed or moved elsewhere — both are regressions.
|
||||
expect(view.queryByTestId("embedding-models-section")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("still renders the existing QA / indexing settings (unchanged surface)", () => {
|
||||
const view = render(<QASettings />);
|
||||
const titles = Array.from(view.queryAllByTestId("setting-item")).map((el) =>
|
||||
el.getAttribute("data-title")
|
||||
);
|
||||
// Reason: smoke-check that the move-rename refactor didn't accidentally
|
||||
// drop any of the existing settings. We don't assert the full list to
|
||||
// keep this test resilient to copy edits.
|
||||
expect(titles).toContain("Enable Semantic Search");
|
||||
expect(titles).toContain("Embedding Model");
|
||||
expect(titles).toContain("Auto-Index Strategy");
|
||||
expect(titles).toContain("Max Sources");
|
||||
});
|
||||
});
|
||||
36
src/settings/v2/components/__tests__/SettingsMainV2.test.tsx
Normal file
36
src/settings/v2/components/__tests__/SettingsMainV2.test.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Test that the settings tab strip exposes the renamed "Embedding" tab in
|
||||
* the same slot the legacy "QA" tab used to occupy. M3 of the Model
|
||||
* Management redesign relabeled the tab without moving its position or
|
||||
* changing its route key — this test pins both invariants so a future
|
||||
* accidental rename or reordering surfaces as a failing test.
|
||||
*/
|
||||
|
||||
describe("SettingsMainV2 tab labels", () => {
|
||||
it("exposes the renamed 'Embedding' label in the QA tab slot", () => {
|
||||
// Reason: we can't easily render the full SettingsMainV2 tree (it
|
||||
// pulls in plugin context, agent mode UI, and the latest-version
|
||||
// hook), so we read the source file as text and assert the
|
||||
// tab-label map directly. This is brittle in exchange for being
|
||||
// dependency-free and accurate.
|
||||
//
|
||||
// The two facts this test pins:
|
||||
// 1. The tab id stays "QA" (route key untouched).
|
||||
// 2. The visible label was renamed to "Embedding".
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const fs = require("fs") as typeof import("fs");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const path = require("path") as typeof import("path");
|
||||
const file = path.resolve(__dirname, "../../SettingsMainV2.tsx");
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
|
||||
// Tab id stays the same.
|
||||
expect(source).toMatch(/TAB_IDS\s*=\s*\[[^\]]*"QA"/);
|
||||
|
||||
// Label is renamed.
|
||||
expect(source).toMatch(/QA:\s*"Embedding"/);
|
||||
|
||||
// Negative: the old label is gone from TAB_LABELS.
|
||||
expect(source).not.toMatch(/QA:\s*"QA"/);
|
||||
});
|
||||
});
|
||||
233
src/settings/v3/components/BackendModelPicker.tsx
Normal file
233
src/settings/v3/components/BackendModelPicker.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* BackendModelPicker — shared "Models in this backend's picker" surface.
|
||||
*
|
||||
* Used by all four Agent sub-panels (OpenCode / Claude Code / Codex /
|
||||
* Quick chat). Renders a checkbox list. Persists toggles by writing a
|
||||
* `<modelKey, boolean>` map back to `agentMode.backends.<id>.modelEnabledOverrides`
|
||||
* via the `onToggle` callback so the component stays storage-agnostic.
|
||||
*
|
||||
* Two render modes:
|
||||
* - Flat: pass `rows: Row[]` — one list, no headers.
|
||||
* - Sectioned: pass `sections: Section[]` — used by the OpenCode panel
|
||||
* to group Bundled / Plus / BYOK with subtle visual dividers.
|
||||
*
|
||||
* Header includes a `Manage in BYOK →` link routed via a callback so this
|
||||
* component never knows how the host modal navigates between tabs.
|
||||
*
|
||||
* See: designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §5.4 / §5.4.1 / §5.4.3.
|
||||
*/
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* One row in the model picker. `key` is the persistence key written into
|
||||
* `modelEnabledOverrides` — the bare `baseModelId` the backend reports
|
||||
* (e.g. `"anthropic/claude-sonnet-4-5"`, `"bigpickle/big-pickle"`,
|
||||
* `"claude-3-5-sonnet-20241022"`). No prefix: the per-backend scoping
|
||||
* comes from the storage path, not the key.
|
||||
*/
|
||||
export interface BackendModelPickerRow {
|
||||
/** Stable persistence key. The model's bare `baseModelId`. */
|
||||
key: string;
|
||||
/** Display name (e.g. "Claude Sonnet 4.5"). */
|
||||
name: string;
|
||||
/** Optional muted provider hint (e.g. "Anthropic"). */
|
||||
providerLabel?: string;
|
||||
/** Optional meta hint (e.g. "200k ctx", "local · 8B"). */
|
||||
meta?: string;
|
||||
/** Whether the row is currently enabled (checkbox checked). */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section grouping for sectioned mode. Used by the OpenCode panel to render
|
||||
* Bundled / Plus / BYOK as three groups with subtle dividers.
|
||||
*/
|
||||
export interface BackendModelPickerSection {
|
||||
/** Section title (e.g. "OpenCode-bundled"). */
|
||||
title: string;
|
||||
/** Rows under the title. Empty arrays render the optional `emptyPlaceholder`. */
|
||||
rows: BackendModelPickerRow[];
|
||||
/**
|
||||
* Placeholder line when `rows` is empty (e.g. "OpenCode-bundled models
|
||||
* will appear here"). When omitted, an empty section is suppressed.
|
||||
*/
|
||||
emptyPlaceholder?: string;
|
||||
}
|
||||
|
||||
interface CommonProps {
|
||||
/** Whether to show the `Manage in BYOK →` link in the header. Default `true`. */
|
||||
showManageInByokLink?: boolean;
|
||||
/**
|
||||
* Called when the user clicks `Manage in BYOK →`. Host wires this to its
|
||||
* tab-switching mechanism. Leaving this undefined hides the link.
|
||||
*/
|
||||
onManageInByok?: () => void;
|
||||
/**
|
||||
* Persistence callback. The component itself doesn't know about settings —
|
||||
* the host passes a callback that writes
|
||||
* `agentMode.backends.<id>.modelEnabledOverrides[key] = enabled`.
|
||||
*/
|
||||
onToggle: (key: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
interface FlatProps extends CommonProps {
|
||||
rows: BackendModelPickerRow[];
|
||||
sections?: undefined;
|
||||
/** Empty-state copy when `rows.length === 0`. */
|
||||
emptyPlaceholder?: string;
|
||||
}
|
||||
|
||||
interface SectionedProps extends CommonProps {
|
||||
sections: BackendModelPickerSection[];
|
||||
rows?: undefined;
|
||||
emptyPlaceholder?: undefined;
|
||||
}
|
||||
|
||||
export type BackendModelPickerProps = FlatProps | SectionedProps;
|
||||
|
||||
/**
|
||||
* Render one row. Wrapped in a label so the entire row toggles when clicked.
|
||||
*/
|
||||
const ModelRow: React.FC<{
|
||||
row: BackendModelPickerRow;
|
||||
onToggle: (key: string, enabled: boolean) => void;
|
||||
}> = ({ row, onToggle }) => {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"tw-flex tw-cursor-pointer tw-items-center tw-justify-between tw-rounded tw-px-2 tw-py-1.5",
|
||||
"hover:tw-bg-modifier-hover"
|
||||
)}
|
||||
data-testid={`backend-model-row-${row.key}`}
|
||||
>
|
||||
<div className="tw-flex tw-min-w-0 tw-items-center tw-gap-3">
|
||||
<Checkbox
|
||||
checked={row.enabled}
|
||||
onCheckedChange={(checked) => onToggle(row.key, checked === true)}
|
||||
aria-label={`Toggle ${row.name}`}
|
||||
data-testid={`backend-model-checkbox-${row.key}`}
|
||||
/>
|
||||
<div className="tw-min-w-0">
|
||||
<div className="tw-flex tw-items-center tw-gap-2 tw-truncate">
|
||||
<span className="tw-truncate tw-text-ui-small">{row.name}</span>
|
||||
{row.providerLabel && (
|
||||
<span className="tw-text-ui-smaller tw-text-muted">{row.providerLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{row.meta && <span className="tw-text-ui-smaller tw-text-muted">{row.meta}</span>}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the picker. Behaves as flat or sectioned based on which prop the
|
||||
* caller provides.
|
||||
*/
|
||||
export const BackendModelPicker: React.FC<BackendModelPickerProps> = (props) => {
|
||||
const { onToggle, onManageInByok, showManageInByokLink = true } = props;
|
||||
const isSectioned = "sections" in props && props.sections !== undefined;
|
||||
|
||||
return (
|
||||
<div data-testid="backend-model-picker">
|
||||
<div className="tw-mb-2 tw-flex tw-flex-wrap tw-items-baseline tw-justify-between tw-gap-2">
|
||||
<div className="tw-min-w-0">
|
||||
<div className="tw-text-ui-small tw-font-medium">Models in this backend's picker</div>
|
||||
<div className="tw-text-ui-smaller tw-text-muted">
|
||||
Tick which models show up when you switch model mid-session.
|
||||
</div>
|
||||
</div>
|
||||
{showManageInByokLink && onManageInByok && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageInByok}
|
||||
className="tw-flex tw-items-center tw-gap-1 tw-bg-transparent tw-text-ui-smaller tw-text-accent hover:tw-text-accent-hover hover:tw-underline"
|
||||
data-testid="manage-in-byok"
|
||||
>
|
||||
Manage in BYOK
|
||||
<ArrowRight className="tw-size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSectioned ? (
|
||||
<SectionedList sections={props.sections} onToggle={onToggle} />
|
||||
) : (
|
||||
<FlatList rows={props.rows} onToggle={onToggle} emptyPlaceholder={props.emptyPlaceholder} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Flat-mode body — one continuous list with an optional empty state.
|
||||
*/
|
||||
const FlatList: React.FC<{
|
||||
rows: BackendModelPickerRow[];
|
||||
emptyPlaceholder?: string;
|
||||
onToggle: (key: string, enabled: boolean) => void;
|
||||
}> = ({ rows, emptyPlaceholder, onToggle }) => {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="tw-rounded tw-border tw-border-dashed tw-border-border tw-px-3 tw-py-4 tw-text-center tw-text-ui-small tw-text-muted"
|
||||
data-testid="backend-model-picker-empty"
|
||||
>
|
||||
{emptyPlaceholder ?? "No models available yet."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="tw-space-y-0.5">
|
||||
{rows.map((row) => (
|
||||
<ModelRow key={row.key} row={row} onToggle={onToggle} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sectioned-mode body — one block per section, subtle divider between.
|
||||
*/
|
||||
const SectionedList: React.FC<{
|
||||
sections: BackendModelPickerSection[];
|
||||
onToggle: (key: string, enabled: boolean) => void;
|
||||
}> = ({ sections, onToggle }) => {
|
||||
return (
|
||||
<div className="tw-space-y-3">
|
||||
{sections.map((section, idx) => {
|
||||
const isEmpty = section.rows.length === 0;
|
||||
if (isEmpty && !section.emptyPlaceholder) return null;
|
||||
return (
|
||||
<div
|
||||
key={section.title}
|
||||
data-testid={`backend-model-section-${section.title}`}
|
||||
className={cn(idx > 0 && "copilot-divider-t tw-pt-3")}
|
||||
>
|
||||
<div className="tw-mb-1 tw-text-ui-smaller tw-font-semibold tw-uppercase tw-text-muted">
|
||||
{section.title}
|
||||
</div>
|
||||
{isEmpty ? (
|
||||
<div
|
||||
className="tw-rounded tw-border tw-border-dashed tw-border-border tw-p-3 tw-text-center tw-text-ui-smaller tw-text-muted"
|
||||
data-testid={`backend-model-section-empty-${section.title}`}
|
||||
>
|
||||
{section.emptyPlaceholder}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tw-space-y-0.5">
|
||||
{section.rows.map((row) => (
|
||||
<ModelRow key={row.key} row={row} onToggle={onToggle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
72
src/settings/v3/components/BackendSubtabs.tsx
Normal file
72
src/settings/v3/components/BackendSubtabs.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* BackendSubtabs — four-way sub-tab strip for the Agent panel.
|
||||
*
|
||||
* Order: OpenCode · Claude Code · Codex · Quick chat (Quick chat last).
|
||||
*
|
||||
* See: designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md §5.4.
|
||||
*/
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Backend ids surfaced by the Agent panel. Order in this union is
|
||||
* intentional — Quick chat is last per spec §5.4.
|
||||
*/
|
||||
export type AgentBackendTabId = "opencode" | "claude" | "codex" | "quickChat";
|
||||
|
||||
export interface AgentBackendTabDescriptor {
|
||||
id: AgentBackendTabId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical display order for the four Agent sub-tabs. Quick chat last.
|
||||
*/
|
||||
export const AGENT_BACKEND_TAB_ORDER: ReadonlyArray<AgentBackendTabDescriptor> = [
|
||||
{ id: "opencode", label: "OpenCode" },
|
||||
{ id: "claude", label: "Claude Code" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "quickChat", label: "Quick chat" },
|
||||
];
|
||||
|
||||
interface BackendSubtabsProps {
|
||||
/** Which sub-tab the user is currently looking at. */
|
||||
selectedTab: AgentBackendTabId;
|
||||
onSelectTab: (tab: AgentBackendTabId) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the four-way sub-tab strip.
|
||||
*/
|
||||
export const BackendSubtabs: React.FC<BackendSubtabsProps> = ({ selectedTab, onSelectTab }) => {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Agent backend"
|
||||
className="tw-flex tw-flex-wrap tw-gap-1 tw-border-b tw-border-solid tw-border-border"
|
||||
data-testid="backend-subtabs"
|
||||
>
|
||||
{AGENT_BACKEND_TAB_ORDER.map((tab) => {
|
||||
const isSelected = tab.id === selectedTab;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
data-testid={`backend-subtab-${tab.id}`}
|
||||
onClick={() => onSelectTab(tab.id)}
|
||||
className={cn(
|
||||
"tw-flex tw-items-center tw-gap-2 tw-rounded-t-md tw-border-x tw-border-t tw-border-solid tw-border-transparent tw-bg-transparent tw-px-3 tw-py-2 tw-text-ui-small tw-text-muted tw-transition-colors",
|
||||
"hover:tw-text-normal",
|
||||
isSelected &&
|
||||
"tw-border-border tw-bg-primary tw-text-normal tw-shadow-[0_1px_0_0_var(--background-primary)]"
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
155
src/settings/v3/components/__tests__/BackendModelPicker.test.tsx
Normal file
155
src/settings/v3/components/__tests__/BackendModelPicker.test.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* `BackendModelPicker` rendering + interaction tests.
|
||||
*
|
||||
* Scope (M6):
|
||||
* - Flat-mode renders one row per entry; empty placeholder when rows is empty.
|
||||
* - Sectioned-mode renders section titles + nested rows; sections with no
|
||||
* rows surface their `emptyPlaceholder` placeholder.
|
||||
* - Checkbox toggles call `onToggle` with the row's key (which already
|
||||
* uses the `<providerId>:<modelId>` format).
|
||||
* - `Manage in BYOK →` link surfaces only when `onManageInByok` is set.
|
||||
*/
|
||||
import {
|
||||
BackendModelPicker,
|
||||
type BackendModelPickerRow,
|
||||
} from "@/settings/v3/components/BackendModelPicker";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
beforeAll(() => {
|
||||
(window as unknown as { activeDocument: Document }).activeDocument = window.document;
|
||||
});
|
||||
|
||||
function makeRow(overrides: Partial<BackendModelPickerRow> = {}): BackendModelPickerRow {
|
||||
return {
|
||||
key: "anthropic:claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
providerLabel: "Anthropic",
|
||||
meta: "200k ctx",
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BackendModelPicker — flat", () => {
|
||||
it("renders one row per entry", () => {
|
||||
render(
|
||||
<BackendModelPicker
|
||||
rows={[
|
||||
makeRow({ key: "anthropic:claude-sonnet-4-5", name: "Claude Sonnet 4.5" }),
|
||||
makeRow({
|
||||
key: "openai:gpt-5",
|
||||
name: "GPT-5",
|
||||
providerLabel: "OpenAI",
|
||||
enabled: false,
|
||||
}),
|
||||
]}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("backend-model-row-anthropic:claude-sonnet-4-5")).toBeTruthy();
|
||||
expect(screen.getByTestId("backend-model-row-openai:gpt-5")).toBeTruthy();
|
||||
expect(screen.queryByTestId("backend-model-picker-empty")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the empty placeholder when rows is empty", () => {
|
||||
render(
|
||||
<BackendModelPicker
|
||||
rows={[]}
|
||||
emptyPlaceholder="No models registered yet."
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("backend-model-picker-empty").textContent).toContain(
|
||||
"No models registered yet."
|
||||
);
|
||||
});
|
||||
|
||||
it("fires onToggle with the row key and the new enabled value", () => {
|
||||
const onToggle = jest.fn();
|
||||
render(
|
||||
<BackendModelPicker
|
||||
rows={[makeRow({ key: "anthropic:claude-sonnet-4-5", enabled: false })]}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
);
|
||||
const checkbox = screen.getByTestId("backend-model-checkbox-anthropic:claude-sonnet-4-5");
|
||||
fireEvent.click(checkbox);
|
||||
expect(onToggle).toHaveBeenCalledWith("anthropic:claude-sonnet-4-5", true);
|
||||
});
|
||||
|
||||
it("renders the Manage in BYOK link only when onManageInByok is provided", () => {
|
||||
const onManage = jest.fn();
|
||||
const { rerender } = render(
|
||||
<BackendModelPicker rows={[]} onToggle={() => {}} onManageInByok={onManage} />
|
||||
);
|
||||
const link = screen.getByTestId("manage-in-byok");
|
||||
expect(link).toBeTruthy();
|
||||
fireEvent.click(link);
|
||||
expect(onManage).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<BackendModelPicker rows={[]} onToggle={() => {}} />);
|
||||
expect(screen.queryByTestId("manage-in-byok")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackendModelPicker — sectioned", () => {
|
||||
it("renders section titles + rows", () => {
|
||||
render(
|
||||
<BackendModelPicker
|
||||
sections={[
|
||||
{
|
||||
title: "From BYOK",
|
||||
rows: [
|
||||
makeRow({
|
||||
key: "anthropic:claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "OpenCode-bundled",
|
||||
rows: [],
|
||||
emptyPlaceholder: "OpenCode-bundled models will appear here",
|
||||
},
|
||||
]}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("backend-model-section-From BYOK")).toBeTruthy();
|
||||
expect(screen.getByTestId("backend-model-row-anthropic:claude-sonnet-4-5")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByTestId("backend-model-section-empty-OpenCode-bundled").textContent
|
||||
).toContain("OpenCode-bundled models will appear here");
|
||||
});
|
||||
|
||||
it("toggles persist with the row's `<providerId>:<modelId>` key", () => {
|
||||
const onToggle = jest.fn();
|
||||
render(
|
||||
<BackendModelPicker
|
||||
sections={[
|
||||
{
|
||||
title: "From BYOK",
|
||||
rows: [makeRow({ key: "openai:gpt-5", enabled: true })],
|
||||
},
|
||||
]}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("backend-model-checkbox-openai:gpt-5"));
|
||||
expect(onToggle).toHaveBeenCalledWith("openai:gpt-5", false);
|
||||
});
|
||||
|
||||
it("hides a section entirely when rows is empty and no placeholder is set", () => {
|
||||
render(
|
||||
<BackendModelPicker
|
||||
sections={[
|
||||
{ title: "From BYOK", rows: [makeRow({})] },
|
||||
{ title: "Copilot Plus", rows: [] },
|
||||
]}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("backend-model-section-Copilot Plus")).toBeNull();
|
||||
});
|
||||
});
|
||||
36
src/settings/v3/components/__tests__/BackendSubtabs.test.tsx
Normal file
36
src/settings/v3/components/__tests__/BackendSubtabs.test.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* `BackendSubtabs` rendering + interaction tests.
|
||||
*/
|
||||
import { AGENT_BACKEND_TAB_ORDER, BackendSubtabs } from "@/settings/v3/components/BackendSubtabs";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
describe("BackendSubtabs", () => {
|
||||
it("renders all four sub-tabs with Quick chat LAST", () => {
|
||||
render(<BackendSubtabs selectedTab="opencode" onSelectTab={() => {}} />);
|
||||
const order = AGENT_BACKEND_TAB_ORDER.map((t) => t.id);
|
||||
expect(order).toEqual(["opencode", "claude", "codex", "quickChat"]);
|
||||
const labels = order.map((id) => screen.getByTestId(`backend-subtab-${id}`).textContent ?? "");
|
||||
expect(labels.join("|")).toContain("OpenCode");
|
||||
expect(labels.join("|")).toContain("Claude Code");
|
||||
expect(labels.join("|")).toContain("Codex");
|
||||
expect(labels[labels.length - 1]).toContain("Quick chat");
|
||||
});
|
||||
|
||||
it("marks the selected tab via aria-selected", () => {
|
||||
render(<BackendSubtabs selectedTab="quickChat" onSelectTab={() => {}} />);
|
||||
expect(screen.getByTestId("backend-subtab-quickChat").getAttribute("aria-selected")).toBe(
|
||||
"true"
|
||||
);
|
||||
expect(screen.getByTestId("backend-subtab-opencode").getAttribute("aria-selected")).toBe(
|
||||
"false"
|
||||
);
|
||||
});
|
||||
|
||||
it("fires onSelectTab with the right id when a tab is clicked", () => {
|
||||
const onSelectTab = jest.fn();
|
||||
render(<BackendSubtabs selectedTab="opencode" onSelectTab={onSelectTab} />);
|
||||
fireEvent.click(screen.getByTestId("backend-subtab-quickChat"));
|
||||
expect(onSelectTab).toHaveBeenCalledWith("quickChat");
|
||||
});
|
||||
});
|
||||
75
src/settings/v3/components/backendOverrides.ts
Normal file
75
src/settings/v3/components/backendOverrides.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Lightweight read/write helpers for `agentMode.backends.<id>.modelEnabledOverrides`.
|
||||
*
|
||||
* The legacy helpers in `@/agentMode/session/modelEnable.ts` operate on
|
||||
* `BackendDescriptor` instances and only ship for the three "real" backends
|
||||
* (opencode / claude / codex). The new Agent panel also needs to persist
|
||||
* Quick Chat overrides, which has no descriptor — so we keep a thin,
|
||||
* descriptor-free pair of helpers here that work uniformly across all four
|
||||
* agent backend slices.
|
||||
*/
|
||||
import { getSettings, setSettings, type CopilotSettings } from "@/settings/model";
|
||||
import type { AgentBackendTabId } from "@/settings/v3/components/BackendSubtabs";
|
||||
import { logInfo } from "@/logger";
|
||||
|
||||
const BYOK_DIAG = true;
|
||||
|
||||
/**
|
||||
* Read the persisted overrides map for a given backend, or `undefined` when
|
||||
* no slice exists. `undefined` means "no overrides written"; callers should
|
||||
* default missing entries to visible (true).
|
||||
*/
|
||||
export function readBackendOverrides(
|
||||
backendId: AgentBackendTabId,
|
||||
settings: CopilotSettings = getSettings()
|
||||
): Record<string, boolean> | undefined {
|
||||
const backends = settings.agentMode?.backends as
|
||||
| Record<string, { modelEnabledOverrides?: Record<string, boolean> } | undefined>
|
||||
| undefined;
|
||||
return backends?.[backendId]?.modelEnabledOverrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a single `(key, enabled)` toggle into the given backend's slice. The
|
||||
* settings atom is mutated through `setSettings(...)` so the rest of the UI
|
||||
* (Jotai subscribers) stays in sync. Composes the new map by merging the
|
||||
* previous one in — never clobbers other keys.
|
||||
*/
|
||||
export function writeBackendOverride(
|
||||
backendId: AgentBackendTabId,
|
||||
key: string,
|
||||
enabled: boolean
|
||||
): void {
|
||||
if (BYOK_DIAG) {
|
||||
logInfo("[BYOK-DIAG] writeBackendOverride", { backendId, key, enabled });
|
||||
}
|
||||
setSettings((cur) => {
|
||||
const existing = (cur.agentMode.backends as Record<string, unknown> | undefined)?.[
|
||||
backendId
|
||||
] as { modelEnabledOverrides?: Record<string, boolean> } | undefined;
|
||||
const prevOverrides = existing?.modelEnabledOverrides ?? {};
|
||||
const nextOverrides = { ...prevOverrides, [key]: enabled };
|
||||
return {
|
||||
agentMode: {
|
||||
...cur.agentMode,
|
||||
backends: {
|
||||
...cur.agentMode.backends,
|
||||
[backendId]: { ...(existing ?? {}), modelEnabledOverrides: nextOverrides },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether a model is currently visible in the backend's picker. Per
|
||||
* §2.2, missing override defaults to `true` (visible).
|
||||
*/
|
||||
export function isBackendModelEnabled(
|
||||
overrides: Record<string, boolean> | undefined,
|
||||
key: string
|
||||
): boolean {
|
||||
if (!overrides) return true;
|
||||
const value = overrides[key];
|
||||
return value !== false;
|
||||
}
|
||||
29
src/settings/v3/components/backendPanelHelpers.ts
Normal file
29
src/settings/v3/components/backendPanelHelpers.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Tiny helpers shared by the four agent backend panels (Opencode, Claude
|
||||
* Code, Codex, Quick Chat). Each panel was carrying a copy of these — they
|
||||
* live here so the panels stay focused on layout.
|
||||
*/
|
||||
import { listBackendDescriptors, type BackendDescriptor } from "@/agentMode";
|
||||
|
||||
/**
|
||||
* Compose the `<providerId>:<modelId>` key used for `modelEnabledOverrides`
|
||||
* on backends whose model identifier isn't already provider-qualified.
|
||||
*
|
||||
* Currently only Quick Chat uses this — Quick Chat routes through multiple
|
||||
* BYOK providers within a single backend slice, so the BYOK `modelId` alone
|
||||
* (e.g. `claude-opus-4-7`) can collide across providers. Pair it with
|
||||
* `providerId` to disambiguate.
|
||||
*
|
||||
* The other agent backends (OpenCode / Claude Code / Codex) store overrides
|
||||
* under the bare wire-form `baseModelId` because their `modelId` already
|
||||
* encodes the provider segment (`anthropic/claude-…`) or because the
|
||||
* backend is single-provider.
|
||||
*/
|
||||
export function modelKey(a: string, b: string): string {
|
||||
return `${a}:${b}`;
|
||||
}
|
||||
|
||||
/** Look up a backend descriptor by id via the public agentMode barrel. */
|
||||
export function findDescriptor(id: string): BackendDescriptor | undefined {
|
||||
return listBackendDescriptors().find((d) => d.id === id);
|
||||
}
|
||||
123
src/settings/v3/components/backends/ClaudeCodePanel.tsx
Normal file
123
src/settings/v3/components/backends/ClaudeCodePanel.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* ClaudeCodePanel — Agent sub-panel for the Claude Code backend.
|
||||
*
|
||||
* Per §5.4.2:
|
||||
* - Subscription card with "Authenticated as <email>" + [Re-authenticate].
|
||||
* The auth state is currently inferred from environment variables /
|
||||
* resolver presence — we don't have an email source today, so the
|
||||
* subscription line surfaces what we know (cli detected) with a
|
||||
* placeholder for the real auth wiring.
|
||||
* - Picker section sources from the backend's BUNDLED model list. The
|
||||
* Claude SDK adapter populates this via `AgentSessionManager.preloadModels`,
|
||||
* so we read from `getCachedBackendState("claude").model.availableModels`.
|
||||
*
|
||||
* Never reads global `app` — receives it via props.
|
||||
*/
|
||||
import { usePlugin } from "@/contexts/PluginContext";
|
||||
import { useSettingsValue } from "@/settings/model";
|
||||
import {
|
||||
BackendModelPicker,
|
||||
type BackendModelPickerRow,
|
||||
} from "@/settings/v3/components/BackendModelPicker";
|
||||
import {
|
||||
isBackendModelEnabled,
|
||||
readBackendOverrides,
|
||||
writeBackendOverride,
|
||||
} from "@/settings/v3/components/backendOverrides";
|
||||
import { findDescriptor } from "@/settings/v3/components/backendPanelHelpers";
|
||||
import { logError } from "@/logger";
|
||||
import type { App } from "obsidian";
|
||||
import React from "react";
|
||||
|
||||
interface ClaudeCodePanelProps {
|
||||
app: App;
|
||||
}
|
||||
|
||||
export const ClaudeCodePanel: React.FC<ClaudeCodePanelProps> = ({ app }) => {
|
||||
const settings = useSettingsValue();
|
||||
const plugin = usePlugin();
|
||||
const manager = plugin.agentSessionManager;
|
||||
|
||||
const descriptor = findDescriptor("claude");
|
||||
|
||||
// Subscribe to the preloader so newly-arrived agent-reported models
|
||||
// surface in both the picker and the dropdown.
|
||||
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
|
||||
React.useEffect(() => {
|
||||
if (!manager) return;
|
||||
return manager.subscribeModelCache(forceUpdate);
|
||||
}, [manager]);
|
||||
|
||||
const installState = descriptor?.getInstallState(settings) ?? { kind: "absent" as const };
|
||||
const overrides = readBackendOverrides("claude");
|
||||
const cachedState = manager?.getCachedBackendState("claude") ?? null;
|
||||
const availableModels = cachedState?.model?.availableModels ?? [];
|
||||
|
||||
// Probe trigger — see OpencodePanel for the same pattern.
|
||||
React.useEffect(() => {
|
||||
if (!manager) return;
|
||||
if (installState.kind !== "ready") return;
|
||||
if (cachedState) return;
|
||||
manager.preloadModels("claude").catch((e) => logError("[AgentMode] preload claude failed", e));
|
||||
}, [manager, installState.kind, cachedState]);
|
||||
|
||||
const rows: BackendModelPickerRow[] = availableModels.map((entry) => ({
|
||||
key: entry.baseModelId,
|
||||
name: entry.name || entry.baseModelId,
|
||||
providerLabel: entry.provider ?? undefined,
|
||||
meta: entry.description,
|
||||
enabled: isBackendModelEnabled(overrides, entry.baseModelId),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="tw-space-y-4">
|
||||
<SubscriptionCard installKind={installState.kind} />
|
||||
|
||||
{descriptor?.SettingsPanel && <descriptor.SettingsPanel plugin={plugin} app={app} />}
|
||||
|
||||
<BackendModelPicker
|
||||
rows={rows}
|
||||
emptyPlaceholder="Bundled Claude Code models will appear here once the backend probes successfully."
|
||||
showManageInByokLink={false}
|
||||
onToggle={(key, enabled) => writeBackendOverride("claude", key, enabled)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscription card placeholder. We don't yet have a structured signal for
|
||||
* the user's Anthropic login email — the Claude SDK inherits credentials
|
||||
* from the local CLI state. Surface what we know and leave a button for
|
||||
* re-authentication that opens the CLI install/setup helper.
|
||||
*/
|
||||
const SubscriptionCard: React.FC<{ installKind: "ready" | "absent" | "error" }> = ({
|
||||
installKind,
|
||||
}) => {
|
||||
if (installKind !== "ready") {
|
||||
return (
|
||||
<div
|
||||
className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-primary-alt tw-p-3 tw-text-ui-small"
|
||||
data-testid="claude-subscription-card"
|
||||
>
|
||||
<div className="tw-text-warning">Not signed in</div>
|
||||
<div className="tw-mt-1 tw-text-ui-smaller tw-text-muted">
|
||||
Install and sign in to the <code>claude</code> CLI to enable Claude Code.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-primary-alt tw-p-3 tw-text-ui-small"
|
||||
data-testid="claude-subscription-card"
|
||||
>
|
||||
<div>
|
||||
Authenticated via the local <code>claude</code> CLI.
|
||||
</div>
|
||||
<div className="tw-mt-1 tw-text-ui-smaller tw-text-muted">
|
||||
Re-authenticate by running <code>claude /login</code> in your shell.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
107
src/settings/v3/components/backends/CodexPanel.tsx
Normal file
107
src/settings/v3/components/backends/CodexPanel.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* CodexPanel — Agent sub-panel for the Codex backend.
|
||||
*
|
||||
* Per §5.4.2:
|
||||
* - Subscription card (OpenAI / Codex login).
|
||||
* - Picker section sources from the backend's BUNDLED model list, read
|
||||
* via `AgentSessionManager.getCachedBackendState("codex").model.availableModels`.
|
||||
*
|
||||
* Never reads global `app` — receives it via props.
|
||||
*/
|
||||
import { usePlugin } from "@/contexts/PluginContext";
|
||||
import { useSettingsValue } from "@/settings/model";
|
||||
import {
|
||||
BackendModelPicker,
|
||||
type BackendModelPickerRow,
|
||||
} from "@/settings/v3/components/BackendModelPicker";
|
||||
import {
|
||||
isBackendModelEnabled,
|
||||
readBackendOverrides,
|
||||
writeBackendOverride,
|
||||
} from "@/settings/v3/components/backendOverrides";
|
||||
import { findDescriptor } from "@/settings/v3/components/backendPanelHelpers";
|
||||
import { logError } from "@/logger";
|
||||
import type { App } from "obsidian";
|
||||
import React from "react";
|
||||
|
||||
interface CodexPanelProps {
|
||||
app: App;
|
||||
}
|
||||
|
||||
export const CodexPanel: React.FC<CodexPanelProps> = ({ app }) => {
|
||||
const settings = useSettingsValue();
|
||||
const plugin = usePlugin();
|
||||
const manager = plugin.agentSessionManager;
|
||||
|
||||
const descriptor = findDescriptor("codex");
|
||||
|
||||
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
|
||||
React.useEffect(() => {
|
||||
if (!manager) return;
|
||||
return manager.subscribeModelCache(forceUpdate);
|
||||
}, [manager]);
|
||||
|
||||
const installState = descriptor?.getInstallState(settings) ?? { kind: "absent" as const };
|
||||
const overrides = readBackendOverrides("codex");
|
||||
const cachedState = manager?.getCachedBackendState("codex") ?? null;
|
||||
const availableModels = cachedState?.model?.availableModels ?? [];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!manager) return;
|
||||
if (installState.kind !== "ready") return;
|
||||
if (cachedState) return;
|
||||
manager.preloadModels("codex").catch((e) => logError("[AgentMode] preload codex failed", e));
|
||||
}, [manager, installState.kind, cachedState]);
|
||||
|
||||
const rows: BackendModelPickerRow[] = availableModels.map((entry) => ({
|
||||
key: entry.baseModelId,
|
||||
name: entry.name || entry.baseModelId,
|
||||
providerLabel: entry.provider ?? undefined,
|
||||
meta: entry.description,
|
||||
enabled: isBackendModelEnabled(overrides, entry.baseModelId),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="tw-space-y-4">
|
||||
<SubscriptionCard installKind={installState.kind} />
|
||||
|
||||
{descriptor?.SettingsPanel && <descriptor.SettingsPanel plugin={plugin} app={app} />}
|
||||
|
||||
<BackendModelPicker
|
||||
rows={rows}
|
||||
emptyPlaceholder="Bundled Codex models will appear here once the backend probes successfully."
|
||||
showManageInByokLink={false}
|
||||
onToggle={(key, enabled) => writeBackendOverride("codex", key, enabled)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SubscriptionCard: React.FC<{ installKind: "ready" | "absent" | "error" }> = ({
|
||||
installKind,
|
||||
}) => {
|
||||
if (installKind !== "ready") {
|
||||
return (
|
||||
<div
|
||||
className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-primary-alt tw-p-3 tw-text-ui-small"
|
||||
data-testid="codex-subscription-card"
|
||||
>
|
||||
<div className="tw-text-warning">Not signed in</div>
|
||||
<div className="tw-mt-1 tw-text-ui-smaller tw-text-muted">
|
||||
Install <code>codex-acp</code> and sign in to enable Codex.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-primary-alt tw-p-3 tw-text-ui-small"
|
||||
data-testid="codex-subscription-card"
|
||||
>
|
||||
<div>Authenticated via the local Codex CLI.</div>
|
||||
<div className="tw-mt-1 tw-text-ui-smaller tw-text-muted">
|
||||
Re-authenticate by running <code>codex login</code> in your shell.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue