From 7cc816bf1bcfbe4bd93aac4804372fee81064540 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Fri, 22 May 2026 12:49:25 -0700 Subject: [PATCH] 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. --- AGENTS.md | 1 + designdocs/MODEL_DATA_MODEL_REVIEW.html | 777 +++++++++++ designdocs/MODEL_DATA_MODEL_SPEC.md | 823 +++++++++++ designdocs/MODEL_MANAGEMENT_REDESIGN.md | 908 ------------- .../MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md | 1207 +++++++++++++++++ designdocs/todo/PORTAL_CONTAINER_CONTRACT.md | 125 ++ eslint.config.mjs | 71 + .../ChatModelManager.instance.test.ts | 133 ++ .../chainRunner/AutonomousAgentChainRunner.ts | 10 +- .../chainRunner/CopilotPlusChainRunner.ts | 32 +- .../chainRunner/LLMChainRunner.ts | 27 +- .../chainRunner/VaultQAChainRunner.ts | 25 +- src/LLMProviders/embeddingManager.ts | 110 +- src/agentMode/acp/AcpBackendProcess.ts | 11 +- src/agentMode/acp/types.ts | 16 +- src/agentMode/backends/claude/descriptor.ts | 21 +- src/agentMode/backends/codex/CodexBackend.ts | 4 +- .../backends/opencode/OpencodeBackend.test.ts | 367 +++-- .../backends/opencode/OpencodeBackend.ts | 155 +-- .../backends/opencode/bundledModels.test.ts | 273 ++++ .../backends/opencode/bundledModels.ts | 227 ++++ .../backends/opencode/byokBridge.test.ts | 286 ++++ src/agentMode/backends/opencode/byokBridge.ts | 153 +++ .../backends/opencode/descriptor.test.ts | 75 +- src/agentMode/backends/opencode/descriptor.ts | 22 +- .../backends/opencode/plusModels.test.ts | 49 + src/agentMode/backends/opencode/plusModels.ts | 66 + .../backends/shared/EnvOverridesSetting.tsx | 20 +- .../backends/shared/simpleBinaryBackend.ts | 16 +- src/agentMode/index.ts | 14 + src/agentMode/session/AgentSession.ts | 28 + .../session/AgentSessionManager.test.ts | 51 +- src/agentMode/session/AgentSessionManager.ts | 108 +- src/agentMode/session/descriptor.ts | 17 +- src/agentMode/session/modelEnable.ts | 7 + src/agentMode/skills/SkillManager.ts | 4 +- .../ui/agentModelPickerHelpers.test.ts | 211 ++- src/agentMode/ui/agentModelPickerHelpers.ts | 169 ++- src/agentMode/ui/useAgentModelPicker.ts | 3 +- src/commands/customCommandChatEngine.ts | 2 +- .../chat-components/TokenLimitWarning.tsx | 6 +- src/components/ui/setting-tabs.tsx | 9 +- src/contexts/TabContext.tsx | 11 + src/core/ChatManager.test.ts | 2 +- src/core/ContextCompactor.ts | 2 +- src/main.ts | 18 + .../catalog/ModelCatalogService.ts | 495 +++++++ .../__tests__/ModelCatalogService.test.ts | 452 ++++++ .../catalog/modelsCatalog.types.ts | 133 ++ .../fixtures/custom-provider-ollama.json | 19 + .../__tests__/fixtures/default-model-key.json | 13 + .../__tests__/fixtures/keys-only.json | 7 + .../migrations/__tests__/fkInvariant.test.ts | 150 ++ .../migrations/runMigrations.ts | 130 ++ .../providers/ProviderRegistry.ts | 195 +++ .../clients}/BedrockChatModel.test.ts | 0 .../providers/clients}/BedrockChatModel.ts | 0 .../providers/clients/LMStudioChatModel.ts} | 8 +- .../providers/clients/OpenRouterChatModel.ts} | 8 +- .../providers/supportedProviders.ts | 52 + .../providers/verifyProvider.test.ts | 301 ++++ .../providers/verifyProvider.ts | 281 ++++ .../ui/components/ByokGlobalTable.tsx | 253 ++++ .../ui/components/ProviderCatalogList.tsx | 349 +++++ .../__tests__/ByokGlobalTable.test.tsx | 246 ++++ .../__tests__/ProviderCatalogList.test.tsx | 133 ++ .../ui/dialogs/AddCustomModelDialog.tsx | 207 +++ .../ui/dialogs/AddProviderDialog.tsx | 333 +++++ .../ui/dialogs/ConfigureProviderDialog.tsx | 1019 ++++++++++++++ .../__tests__/AddCustomModelDialog.test.tsx | 144 ++ .../__tests__/AddProviderDialog.test.tsx | 163 +++ .../ConfigureProviderDialog.test.tsx | 342 +++++ src/modelManagement/ui/tabs/ByokPanel.tsx | 437 ++++++ .../ui/tabs/__tests__/ByokPanel.test.tsx | 222 +++ .../__tests__/formatModelMetadata.test.ts | 48 + .../ui/utils/formatModelMetadata.ts | 34 + src/search/v3/TieredLexicalRetriever.test.ts | 2 +- src/search/v3/TieredLexicalRetriever.ts | 2 +- src/services/settingsPersistence.test.ts | 7 +- src/services/settingsSecretTransforms.test.ts | 31 +- src/settings/legacyApiKeyWrites.ts | 152 +++ src/settings/model.test.ts | 19 +- src/settings/v2/SettingsMainV2.tsx | 52 +- src/settings/v2/components/AgentSettings.tsx | 288 ---- src/settings/v2/components/ApiKeyDialog.tsx | 7 +- .../v2/components/EmbeddingModelsSection.tsx | 142 ++ src/settings/v2/components/ModelAddDialog.tsx | 22 +- .../v2/components/ModelEditDialog.tsx | 26 +- src/settings/v2/components/QASettings.tsx | 11 + .../__tests__/EmbeddingModelsSection.test.tsx | 115 ++ .../components/__tests__/QASettings.test.tsx | 131 ++ .../__tests__/SettingsMainV2.test.tsx | 36 + .../v3/components/BackendModelPicker.tsx | 233 ++++ src/settings/v3/components/BackendSubtabs.tsx | 72 + .../__tests__/BackendModelPicker.test.tsx | 155 +++ .../__tests__/BackendSubtabs.test.tsx | 36 + .../v3/components/backendOverrides.ts | 75 + .../v3/components/backendPanelHelpers.ts | 29 + .../components/backends/ClaudeCodePanel.tsx | 123 ++ .../v3/components/backends/CodexPanel.tsx | 107 ++ .../v3/components/backends/OpencodePanel.tsx | 235 ++++ .../v3/components/backends/QuickChatPanel.tsx | 66 + .../__tests__/ClaudeCodePanel.test.tsx | 107 ++ .../backends/__tests__/CodexPanel.test.tsx | 96 ++ .../backends/__tests__/OpencodePanel.test.tsx | 238 ++++ .../__tests__/QuickChatPanel.test.tsx | 109 ++ src/settings/v3/tabs/AgentPanel.tsx | 85 ++ .../v3/tabs/__tests__/AgentPanel.test.tsx | 179 +++ src/styles/tailwind.css | 5 + src/tools/memoryTools.ts | 2 +- src/utils.test.ts | 48 +- src/utils.ts | 87 +- src/utils/curlCommand.ts | 2 +- src/utils/modelUtils.ts | 56 +- 114 files changed, 14088 insertions(+), 1946 deletions(-) create mode 100644 designdocs/MODEL_DATA_MODEL_REVIEW.html create mode 100644 designdocs/MODEL_DATA_MODEL_SPEC.md delete mode 100644 designdocs/MODEL_MANAGEMENT_REDESIGN.md create mode 100644 designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md create mode 100644 designdocs/todo/PORTAL_CONTAINER_CONTRACT.md create mode 100644 src/LLMProviders/__tests__/ChatModelManager.instance.test.ts create mode 100644 src/agentMode/backends/opencode/bundledModels.test.ts create mode 100644 src/agentMode/backends/opencode/bundledModels.ts create mode 100644 src/agentMode/backends/opencode/byokBridge.test.ts create mode 100644 src/agentMode/backends/opencode/byokBridge.ts create mode 100644 src/agentMode/backends/opencode/plusModels.test.ts create mode 100644 src/agentMode/backends/opencode/plusModels.ts create mode 100644 src/modelManagement/catalog/ModelCatalogService.ts create mode 100644 src/modelManagement/catalog/__tests__/ModelCatalogService.test.ts create mode 100644 src/modelManagement/catalog/modelsCatalog.types.ts create mode 100644 src/modelManagement/migrations/__tests__/fixtures/custom-provider-ollama.json create mode 100644 src/modelManagement/migrations/__tests__/fixtures/default-model-key.json create mode 100644 src/modelManagement/migrations/__tests__/fixtures/keys-only.json create mode 100644 src/modelManagement/migrations/__tests__/fkInvariant.test.ts create mode 100644 src/modelManagement/migrations/runMigrations.ts create mode 100644 src/modelManagement/providers/ProviderRegistry.ts rename src/{LLMProviders => modelManagement/providers/clients}/BedrockChatModel.test.ts (100%) rename src/{LLMProviders => modelManagement/providers/clients}/BedrockChatModel.ts (100%) rename src/{LLMProviders/ChatLMStudio.ts => modelManagement/providers/clients/LMStudioChatModel.ts} (92%) rename src/{LLMProviders/ChatOpenRouter.ts => modelManagement/providers/clients/OpenRouterChatModel.ts} (98%) create mode 100644 src/modelManagement/providers/supportedProviders.ts create mode 100644 src/modelManagement/providers/verifyProvider.test.ts create mode 100644 src/modelManagement/providers/verifyProvider.ts create mode 100644 src/modelManagement/ui/components/ByokGlobalTable.tsx create mode 100644 src/modelManagement/ui/components/ProviderCatalogList.tsx create mode 100644 src/modelManagement/ui/components/__tests__/ByokGlobalTable.test.tsx create mode 100644 src/modelManagement/ui/components/__tests__/ProviderCatalogList.test.tsx create mode 100644 src/modelManagement/ui/dialogs/AddCustomModelDialog.tsx create mode 100644 src/modelManagement/ui/dialogs/AddProviderDialog.tsx create mode 100644 src/modelManagement/ui/dialogs/ConfigureProviderDialog.tsx create mode 100644 src/modelManagement/ui/dialogs/__tests__/AddCustomModelDialog.test.tsx create mode 100644 src/modelManagement/ui/dialogs/__tests__/AddProviderDialog.test.tsx create mode 100644 src/modelManagement/ui/dialogs/__tests__/ConfigureProviderDialog.test.tsx create mode 100644 src/modelManagement/ui/tabs/ByokPanel.tsx create mode 100644 src/modelManagement/ui/tabs/__tests__/ByokPanel.test.tsx create mode 100644 src/modelManagement/ui/utils/__tests__/formatModelMetadata.test.ts create mode 100644 src/modelManagement/ui/utils/formatModelMetadata.ts create mode 100644 src/settings/legacyApiKeyWrites.ts delete mode 100644 src/settings/v2/components/AgentSettings.tsx create mode 100644 src/settings/v2/components/EmbeddingModelsSection.tsx create mode 100644 src/settings/v2/components/__tests__/EmbeddingModelsSection.test.tsx create mode 100644 src/settings/v2/components/__tests__/QASettings.test.tsx create mode 100644 src/settings/v2/components/__tests__/SettingsMainV2.test.tsx create mode 100644 src/settings/v3/components/BackendModelPicker.tsx create mode 100644 src/settings/v3/components/BackendSubtabs.tsx create mode 100644 src/settings/v3/components/__tests__/BackendModelPicker.test.tsx create mode 100644 src/settings/v3/components/__tests__/BackendSubtabs.test.tsx create mode 100644 src/settings/v3/components/backendOverrides.ts create mode 100644 src/settings/v3/components/backendPanelHelpers.ts create mode 100644 src/settings/v3/components/backends/ClaudeCodePanel.tsx create mode 100644 src/settings/v3/components/backends/CodexPanel.tsx create mode 100644 src/settings/v3/components/backends/OpencodePanel.tsx create mode 100644 src/settings/v3/components/backends/QuickChatPanel.tsx create mode 100644 src/settings/v3/components/backends/__tests__/ClaudeCodePanel.test.tsx create mode 100644 src/settings/v3/components/backends/__tests__/CodexPanel.test.tsx create mode 100644 src/settings/v3/components/backends/__tests__/OpencodePanel.test.tsx create mode 100644 src/settings/v3/components/backends/__tests__/QuickChatPanel.test.tsx create mode 100644 src/settings/v3/tabs/AgentPanel.tsx create mode 100644 src/settings/v3/tabs/__tests__/AgentPanel.test.tsx diff --git a/AGENTS.md b/AGENTS.md index e7e2c005..9761d2f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,6 +208,7 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE - After editing CSS, always run `npm run build` to regenerate `styles.css` - **No arbitrary font-size values**: Never use Tailwind's arbitrary-value syntax for typography (e.g. `tw-text-[10.5px]`, `tw-text-[13px]`). Stick to the configured `fontSize` tokens (`tw-text-ui-smaller`, `tw-text-ui-small`, `tw-text-xs`, `tw-text-smallest`, etc.) so type stays consistent with Obsidian's CSS variables. If none of the existing tokens fit, extend the `fontSize` scale in `tailwind.config.js` rather than hard-coding a pixel value at the call site. - **No inline `style={{ ... }}`**: Reserve the `style` prop for values that must change dynamically at runtime (computed positions, animated transforms). Static visual changes belong in Tailwind classes or the shared component (e.g. `Button` variants/sizes) — do not zero out component chrome with inline `border: 0`, `background: "transparent"`, `padding: 0`, etc. +- **Borders need style + color, not just width (preflight is off)**: Tailwind's preflight is disabled, so the default `border-style: solid` / `border-width: 0` reset is not injected. A visible full-perimeter border is three classes: `tw-border tw-border-solid tw-border-border` — `tw-border` alone renders nothing. For one-sided dividers, do **not** use `tw-border-b`/`-t`/`-l`/`-r` with `tw-border-solid` — `border-style` applies to all four sides and the other sides fall back to the browser default `medium` width, so you get a full perimeter instead of a single divider. Use the shared helper `.copilot-divider-b` in `src/styles/tailwind.css` (and add `.copilot-divider-t`/`-l`/`-r` siblings there if you need other sides). Use the configured color tokens (`tw-border-border`, `tw-border-border-hover`, `tw-border-border-focus`, `tw-border-interactive-accent`) — no arbitrary colors. Width is `DEFAULT` only; do not introduce `tw-border-2` or `tw-border-[Npx]`, and do not extend the `borderWidth` scale. Prefer the UI primitives in `src/components/ui/` (`Card`, `Input`, `Popover`, etc.) which already encode borders correctly. - **Always wrap Tailwind class strings with `cn()`** (from `@/lib/utils`) whenever the classes live anywhere other than a literal `className=` attribute on a JSX element — variable assignments, ternaries, function returns, props passed to other components, etc. `eslint-plugin-tailwindcss` only lints classes it can statically see inside JSX `className` literals or inside calls to its registered callees (`cn`, `clsx`, `classnames`, `ctl`, `cva`), so a bare string assigned to a variable silently bypasses class-order, shorthand, and contradicting-class checks. Use `cn()` for composition too — instead of a ternary between two whole class strings, merge a shared base with conditional fragments: `cn("tw-flex tw-text-sm", expandable && "tw-cursor-pointer")`. ## Testing Guidelines diff --git a/designdocs/MODEL_DATA_MODEL_REVIEW.html b/designdocs/MODEL_DATA_MODEL_REVIEW.html new file mode 100644 index 00000000..8cb890b6 --- /dev/null +++ b/designdocs/MODEL_DATA_MODEL_REVIEW.html @@ -0,0 +1,777 @@ + + + + + +Model Management — Data-Model Review + + + +
+ +

Model Management — Data-Model Review

+

Designed from first principles against described behavior. Single-page reference. The implementation on zero/model-settings-redesign does not fully match this design — the companion Markdown spec at designdocs/MODEL_DATA_MODEL_SPEC.md includes a reconciliation appendix.

+ + + +

1. TL;DR

+ + +

2. Old world (master, pre-refactor)

+

For context. The master branch (pre-zero/model-settings-redesign) had a much flatter shape. Everything was a CustomModel; credentials lived as a flat bag of fields on CopilotSettings.

+ +

2.1 The CustomModel mega-type

+

Quoted verbatim from git show master:src/aiParams.ts:

+
export interface CustomModel {
+  name: string;
+  provider: string;
+  baseUrl?: string;
+  apiKey?: string;
+  enabled: boolean;
+  isEmbeddingModel?: boolean;
+  isBuiltIn?: boolean;
+  enableCors?: boolean;
+  core?: boolean;
+  stream?: boolean;
+  streamUsage?: boolean;
+  temperature?: number;
+  maxTokens?: number;
+  topP?: number;
+  frequencyPenalty?: number;
+
+  // Ollama specific fields
+  numCtx?: number;
+
+  // LM Studio specific fields
+  useResponsesApi?: boolean;
+
+  // OpenRouter specific fields
+  enablePromptCaching?: boolean;
+
+  projectEnabled?: boolean;
+  plusExclusive?: boolean;
+  believerExclusive?: boolean;
+  capabilities?: ModelCapability[];
+  displayName?: string;
+
+  // Embedding models only (Jina at the moment)
+  dimensions?: number;
+  // OpenAI specific fields
+  openAIOrgId?: string;
+
+  // Azure OpenAI specific fields
+  azureOpenAIApiInstanceName?: string;
+  azureOpenAIApiDeploymentName?: string;
+  azureOpenAIApiVersion?: string;
+  azureOpenAIApiEmbeddingDeploymentName?: string;
+
+  // Amazon Bedrock specific fields
+  bedrockRegion?: string;
+
+  // OpenAI GPT-5 and O-series specific fields
+  reasoningEffort?: ReasoningEffort;
+  verbosity?: Verbosity;
+}
+ +

2.2 Settings — credentials as a flat bag

+

Excerpted from git show master:src/settings/model.ts (CopilotSettings):

+
  openAIApiKey: string;
+  openAIOrgId: string;
+  huggingfaceApiKey: string;
+  cohereApiKey: string;
+  anthropicApiKey: string;
+  azureOpenAIApiKey: string;
+  azureOpenAIApiInstanceName: string;
+  azureOpenAIApiDeploymentName: string;
+  azureOpenAIApiVersion: string;
+  azureOpenAIApiEmbeddingDeploymentName: string;
+  googleApiKey: string;
+  openRouterAiApiKey: string;
+  xaiApiKey: string;
+  mistralApiKey: string;
+  deepseekApiKey: string;
+  amazonBedrockApiKey: string;
+  amazonBedrockRegion: string;
+  siliconflowApiKey: string;
+  githubCopilotAccessToken: string;
+  githubCopilotToken: string;
+  githubCopilotTokenExpiresAt: number;
+  ...
+  defaultChainType: ChainType;
+  defaultModelKey: string;       // "<name>|<provider>"
+  embeddingModelKey: string;
+  temperature: number;
+  maxTokens: number;
+  ...
+  activeModels: Array<CustomModel>;
+  activeEmbeddingModels: Array<CustomModel>;
+ +

2.3 Provider enum

+

From git show master:src/constants.ts:

+
export enum ChatModelProviders {
+  OPENROUTERAI = "openrouterai",
+  OPENAI = "openai",
+  OPENAI_FORMAT = "3rd party (openai-format)",
+  ANTHROPIC = "anthropic",
+  GOOGLE = "google",
+  XAI = "xai",
+  AMAZON_BEDROCK = "amazon-bedrock",
+  AZURE_OPENAI = "azure openai",
+  GROQ = "groq",
+  OLLAMA = "ollama",
+  LM_STUDIO = "lm-studio",
+  COPILOT_PLUS = "copilot-plus",
+  MISTRAL = "mistralai",
+  DEEPSEEK = "deepseek",
+  COHEREAI = "cohereai",
+  SILICONFLOW = "siliconflow",
+  GITHUB_COPILOT = "github-copilot",
+}
+ +
Takeaway. Every concept that's now an entity (catalog metadata, credential set, enrollment, consumer selection) was a field — or worse, a string — in one big record. Identity was "<name>|<provider>"; one default per app; one credential per provider type. Multi-instance was structurally impossible.
+ +

3. Behaviors driving the new design

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

4. Entity catalog

+ +

4.1 ProviderType read-only

+

Describes a kind of provider. Sourced from models.dev plus our corrections layer. Not persisted in user settings.

+
interface ProviderType {
+  id: string;                          // "anthropic", "openai", "google", "mistral", "groq", "deepseek", "openrouter", …
+  displayName: string;
+  adapter: AdapterKind;                // closed at six values (see §5)
+  defaultBaseUrl?: string;             // catalog-supplied
+  auth: "api-key" | "oauth" | "none";
+  envVars?: string[];
+  pipelines: { langchain: boolean; opencode: boolean };  // see §6
+  models: Record<string, CatalogModel>;
+}
+
+type AdapterKind =
+  | "anthropic"
+  | "openai-compatible"
+  | "google"
+  | "azure"
+  | "bedrock"
+  | "github-copilot";
+ +

4.2 ProviderInstance persisted

+

A user-configured credential set for a ProviderType. Multiple instances per type are allowed.

+
interface ProviderInstance {
+  instanceId: string;                  // UUID — primary key; keychain namespace
+  providerTypeId: string;              // FK to ProviderType.id (non-unique)
+  displayName: string;                 // "Anthropic (prod)" / "Anthropic (staging)"
+  baseUrl?: string;                    // overrides ProviderType.defaultBaseUrl
+  apiKey?: KeychainRef | null;
+  extras?: Record<string, unknown>;    // azure deployment, bedrock region, openai org id, …
+  addedAt: number;
+  lastVerifiedAt?: number;
+  lastVerificationError?: string;
+}
+
+type KeychainRef =
+  | { kind: "keychain"; id: string }   // OS keychain entry, vault-scoped
+  | { kind: "inline"; value: string }; // plaintext fallback
+ +

Example:

+
{
+  "instanceId": "anthropic-prod-uuid",
+  "providerTypeId": "anthropic",
+  "displayName": "Anthropic (prod)",
+  "apiKey": { "kind": "keychain", "id": "provider-anthropic-prod-uuid-apiKey" },
+  "addedAt": 1716000000000
+}
+ +

4.3 CatalogModel read-only

+
interface CatalogModel {
+  id: string;                          // wire form, e.g. "claude-sonnet-4-5"
+  displayName: string;
+  family?: string;                     // "Claude 4"
+  modalities: { input: string[]; output: string[] };
+  limits: { context: number; output: number };
+  capabilities: {
+    reasoning?: boolean;
+    toolCall?: boolean;
+    attachment?: boolean;
+    temperature?: boolean;
+  };
+  cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
+  knowledge?: string;
+  releaseDate?: string;
+  lastUpdated?: string;
+  openWeights?: boolean;
+}
+ +

4.4 CustomModel persisted

+

For self-hosted endpoints with no catalog. Attached to a ProviderInstance, not a ProviderType.

+
interface CustomModel {
+  instanceId: string;                  // FK to ProviderInstance (must be synthetic-typed)
+  modelId: string;                     // "llama3.3:70b"
+  displayName: string;
+  declaredCapabilities?: { reasoning?: boolean; toolCall?: boolean; attachment?: boolean };
+  contextLimit?: number;
+  extras?: Record<string, unknown>;    // ollama numCtx, etc.
+  addedAt: number;
+}
+ +

4.5 ByokEnrollment persisted unit of identity

+
interface ByokEnrollment {
+  instanceId: string;
+  modelId: string;                     // catalog model OR custom model (never both)
+  displayName: string;
+  enrolledAt: number;
+  overrides?: Record<string, unknown>; // per-model knobs — NOT temperature/maxTokens
+  lastVerifiedAt?: number;
+  lastVerificationError?: string;
+}
+
Stable enrollment key: enrollmentRef = ${instanceId}::${modelId}. Used everywhere downstream as a single string handle. Double colon avoids collisions with ids that contain /.
+

Multi-instance example — same model enrolled under two Anthropic credentials:

+
[
+  { "instanceId": "anthropic-prod-uuid", "modelId": "claude-sonnet-4-5", ... },
+  { "instanceId": "anthropic-staging-uuid", "modelId": "claude-sonnet-4-5", ... }
+]
+ +

4.6 ConsumerConfig persisted

+
type ConsumerId =
+  | "chat" | "vault-qa" | "project" | "copilot-plus" | "quick-chat"
+  | "agent:opencode" | "agent:claude-code" | "agent:codex"
+  | `command:${string}`;
+
+interface ConsumerConfig {
+  consumerId: ConsumerId;
+  enabledModels: ConsumerModelRef[];   // empty = "use default heuristic"
+  defaultModel?: ConsumerModelRef | null;
+}
+ +

4.7 ConsumerModelRef

+
type ConsumerModelRef =
+  | { source: "byok"; enrollmentRef: string }
+  | { source: "backend-bundled"; backendId: BackendId; backendModelId: string }
+  | { source: "copilot-plus"; modelId: string };
+
+type BackendId = "opencode" | "claude-code" | "codex";
+

Examples:

+
// Simple Chat enables GPT-5 via the user's only OpenAI instance
+{ "source": "byok", "enrollmentRef": "openai-uuid-1::gpt-5" }
+
+// OpenCode agent enables an OpenCode-bundled model
+{ "source": "backend-bundled", "backendId": "opencode", "backendModelId": "bigpickle/big-pickle" }
+
+// Quick Chat enables a Copilot Plus hosted model
+{ "source": "copilot-plus", "modelId": "copilot-plus-flash" }
+
+// OpenCode agent enables Sonnet via the prod Anthropic instance (NOT staging)
+{ "source": "byok", "enrollmentRef": "anthropic-prod-uuid::claude-sonnet-4-5" }
+ +

4.8 BackendInventory runtime-only

+

Held in memory; never persisted.

+
interface BackendInventory {
+  backendId: BackendId;
+  models: BackendModelEntry[];   // bundled + BYOK-bridged + hosted
+}
+
+interface BackendModelEntry {
+  backendModelId: string;
+  origin:
+    | { kind: "bundled" }
+    | { kind: "byok"; enrollmentRef: string }
+    | { kind: "copilot-plus" };
+  displayName?: string;
+  capabilities?: CatalogModel["capabilities"];
+}
+ +

5. AdapterKind rationale

+

AdapterKind is not the same thing as ProviderType.id. The id is the catalog handle (one per models.dev entry, potentially 30+). The adapter is the protocol / SDK we wire to internally — closed at six values.

+ + + + + + + + + + + + + +
AdapterKindLangChain pathOpenCode path
anthropic@langchain/anthropic (ChatAnthropic)provider.set type=anthropic
openai-compatible@langchain/openai + custom baseUrlprovider.set type=openai-compatible
google@langchain/google-genaiprovider.set type=google
azure@langchain/openai (Azure path) + deployment knobsprovider.set type=azure
bedrock@langchain/awsprovider.set type=bedrock
github-copilotCustom bridge (OAuth-based)provider.set type=github-copilot
+ +

Why six and not more. Each one is real wiring in one or both pipelines. Adding an AdapterKind requires shipping LangChain integration code AND ensuring (or explicitly disclaiming via pipelines) OpenCode support. We don't multiply adapter kinds just because a new provider exists.

+ +

Why we still surface dozens of providers. The catalog (e.g. Mistral, Groq, DeepSeek, Together, Perplexity, xAI, OpenRouter, SiliconFlow, …) maps most providers onto openai-compatible with their own defaultBaseUrl. So the Add Provider list mirrors the full models.dev catalog; what differs is the adapter the runtime uses behind the scenes.

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

6. Pipeline compatibility

+

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

+ +

6.1 Consumer ↔ pipeline mapping

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

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

+ +

6.2 What causes a flag to flip false

+

pipelines.langchain = false

+ +

pipelines.opencode = false

+ + +

6.3 Worked examples

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

7. ER diagram

+
+                            ┌───────────────────────────────┐
+                            │ ProviderType (read-only)       │
+                            │   id (PK)                      │
+                            │   adapter                      │
+                            │   pipelines {langchain,opencode}│
+                            │   defaultBaseUrl               │
+                            │   models: {id → CatalogModel}  │
+                            └───────────┬───────────────────┘
+                                        │ 1
+                                        │
+                                        │ 1..n
+                            ┌───────────▼───────────┐
+                            │ ProviderInstance       │
+                            │   instanceId (PK)      │
+                            │   providerTypeId (FK)  │
+                            │   displayName          │
+                            │   baseUrl, apiKey,extras│
+                            └───┬────────────┬──────┘
+                                │            │
+                              1 │          1 │
+                                │            │
+                          0..n  │      0..n  │
+              ┌─────────────────▼──┐   ┌────▼──────────────────┐
+              │ CustomModel         │   │ ByokEnrollment        │
+              │   instanceId (FK)   │   │   instanceId (FK)     │
+              │   modelId (PK part) │   │   modelId (PK part)   │
+              │   declaredCaps      │   │   displayName         │
+              └────────┬────────────┘   │   overrides           │
+                       │                │                       │
+                       └─── resolves ──►│   (modelId points     │
+                                        │    at catalog or      │
+                                        │    customModel)       │
+                                        └───────────┬───────────┘
+                                                    │ 1
+                                                    │
+                                              0..n  │
+                       ┌────────────────────────────▼───────────┐
+                       │ ConsumerConfig                          │
+                       │   consumerId (PK)                       │
+                       │   enabledModels[]: ConsumerModelRef     │
+                       │   defaultModel?: ConsumerModelRef       │
+                       └────────────────────────────────────────┘
+                                ▲
+                                │ runtime-only intersection
+                       ┌────────┴────────────────────────────────┐
+                       │ BackendInventory (in-memory)            │
+                       │   per BackendId: bundled + bridged BYOK │
+                       │                  + Copilot Plus models  │
+                       └─────────────────────────────────────────┘
+
+ +

8. Invariants

+
    +
  1. enrollment.instanceId ∈ keys(providers). (write path, migration)
  2. +
  3. (instanceId, modelId) pair resolves either via the instance's ProviderType catalog OR via a CustomModel row — never both. (write path)
  4. +
  5. At most one ByokEnrollment per (instanceId, modelId). (write path)
  6. +
  7. At most one CustomModel per (instanceId, modelId). (write path)
  8. +
  9. If ProviderType.auth = "api-key", ProviderInstance.apiKey is non-null; otherwise the provider is "incomplete" and its enrollments are greyed in pickers. (runtime)
  10. +
  11. Every ConsumerModelRef with source: "byok" points at a present enrollment. Broken refs surface in the UI — never silently pruned. (runtime)
  12. +
  13. When a consumer's defaultModel is removed from enabledModels, defaultModel is set to null. (write path)
  14. +
  15. Deleting a ProviderInstance cascades to its enrollments and custom models. Consumer refs pointing at those enrollments become broken refs (surfaced, not pruned). (write path)
  16. +
  17. A consumer whose pipeline is langchain only renders enrollments whose ProviderType.pipelines.langchain = true; same for opencode. Ineligible enrollments are shown as ineligible in the BYOK panel with a reason — not hidden. (runtime)
  18. +
  19. NO providerTypeId uniqueness constraint across providers. Multi-instance is the whole point. (explicit non-constraint)
  20. +
+ +

9. Resolution traces

+ +

Trace A — Simple Chat, single OpenAI instance

+
+1. User opens BYOK panel
+   ── catalog shows OpenAI ProviderType.models
+
+2. User adds OpenAI provider
+   ── providers["openai-uuid-1"] = {
+        instanceId: "openai-uuid-1",
+        providerTypeId: "openai",
+        apiKey: { kind: "keychain", id: "provider-openai-uuid-1-apiKey" }
+      }
+
+3. User enrolls "gpt-5"
+   ── enrollments += { instanceId: "openai-uuid-1", modelId: "gpt-5" }
+
+4. User enables it in Simple Chat
+   ── consumers["chat"] = {
+        consumerId: "chat",
+        enabledModels: [{ source: "byok", enrollmentRef: "openai-uuid-1::gpt-5" }],
+        defaultModel:   { source: "byok", enrollmentRef: "openai-uuid-1::gpt-5" }
+      }
+
+5. Picker renders
+   ── iterate enabledModels → resolve enrollmentRef
+   ── join with catalog → render "GPT-5"
+
+ +

Trace B — OpenCode-bundled (not BYOK)

+
+1. Plugin starts → OpenCode CLI launched
+   ── BackendInventory["opencode"].models includes
+        { backendModelId: "bigpickle/big-pickle", origin: { kind: "bundled" }, ... }
+
+2. User enables it in OpenCode consumer
+   ── consumers["agent:opencode"].enabledModels += {
+        source: "backend-bundled",
+        backendId: "opencode",
+        backendModelId: "bigpickle/big-pickle"
+      }
+
+3. Picker renders
+   ── resolve against live BackendInventory["opencode"] by backendModelId
+
+ +

Trace C — BYOK shared with OpenCode (byokBridge)

+
+Pre-existing:
+  providers["anthropic-prod-uuid"] = { providerTypeId: "anthropic", displayName: "Anthropic (prod)", apiKey: K1 }
+  enrollments += { instanceId: "anthropic-prod-uuid", modelId: "claude-sonnet-4-5" }
+
+1. User enables it in OpenCode consumer
+   ── consumers["agent:opencode"].enabledModels += {
+        source: "byok",
+        enrollmentRef: "anthropic-prod-uuid::claude-sonnet-4-5"
+      }
+
+2. byokBridge runs at session start
+   ── registers prod Anthropic creds with OpenCode (one bridged provider per ProviderInstance, keyed by instanceId)
+   ── OpenCode now reports a model with origin: { kind: "byok", enrollmentRef: "anthropic-prod-uuid::claude-sonnet-4-5" }
+
+3. Picker reconciles
+   ── matches consumer's "byok" ref to inventory entry by enrollmentRef
+   ── if user also enabled staging-instance enrollment, both render as separate rows
+      disambiguated by parent ProviderInstance.displayName
+
+ +

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

+
+1. providers["anthropic-prod-uuid"] = { ..., displayName: "Anthropic (prod)", apiKey: K1 }
+
+2. User clicks "Add Provider → Anthropic" AGAIN
+   ── BYOK UI does NOT filter Anthropic out (multi-instance is allowed)
+   ── providers["anthropic-staging-uuid"] = { ..., displayName: "Anthropic (staging)", apiKey: K2 }
+
+3. User enrolls Sonnet 4.5 on both instances
+   ── enrollments += { instanceId: "anthropic-prod-uuid", modelId: "claude-sonnet-4-5" }
+   ── enrollments += { instanceId: "anthropic-staging-uuid", modelId: "claude-sonnet-4-5" }
+
+4. Simple Chat picker
+   ── shows TWO rows: "Claude Sonnet 4.5 (prod)" and "Claude Sonnet 4.5 (staging)"
+
+5. Delete staging instance
+   ── cascade: enrollment removed
+   ── any consumer ref pointing at it becomes a broken ref (surfaced in UI)
+   ── prod enrollment untouched
+
+ +

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

+
+1. providers["ollama-uuid-1"] = {
+     providerTypeId: "ollama",
+     displayName: "Ollama (laptop)",
+     baseUrl: "http://localhost:11434",
+     apiKey: null
+   }
+
+2. providers["ollama-uuid-2"] = {
+     providerTypeId: "ollama",
+     displayName: "Ollama (workstation)",
+     baseUrl: "http://192.168.1.50:11434",
+     apiKey: null
+   }
+
+3. customModels += { instanceId: "ollama-uuid-1", modelId: "llama3.3:70b" }
+   customModels += { instanceId: "ollama-uuid-2", modelId: "mistral:7b" }
+   (each instance owns its own model list)
+
+4. enrollments += two rows, one per instance
+   (from here, same as Trace A)
+
+ +

10. Per-consumer picker formulas

+
visibleEntries(consumerId) =
+    let pipeline = pipelineOf(consumerId)
+    let inventory = (pipeline === "opencode")
+        ? BackendInventory[backendIdOf(consumerId)]
+        : null
+
+    enabled = consumer.enabledModels        // empty ⇒ default-selection heuristic
+
+    for each ref in enabled:
+        case ref.source:
+          "byok":
+            let e = enrollments[ref.enrollmentRef]
+            let p = providers[e.instanceId]
+            let t = providerTypes[p.providerTypeId]
+            require t.pipelines[pipeline]
+            require p.apiKey != null OR t.auth == "none"
+            if pipeline == "opencode":
+              find matching inventory entry by enrollmentRef
+            emit entry
+
+          "backend-bundled":
+            require inventory != null && inventory.includes(ref.backendModelId)
+            emit entry
+
+          "copilot-plus":
+            require copilotPlusCatalog.includes(ref.modelId)
+            emit entry
+

No capability filtering. No runtime "this model doesn't support tool_call so hide it" check — runtime errors surface incompatibility via the normal error path.

+ +

11. Explicit non-goals

+ + +

12. Open questions

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

For implementation handoff: see designdocs/MODEL_DATA_MODEL_SPEC.md — same data model in canonical form, plus a reconciliation appendix diffing this design against the current src/modelManagement/ branch.

+ +
+ + diff --git a/designdocs/MODEL_DATA_MODEL_SPEC.md b/designdocs/MODEL_DATA_MODEL_SPEC.md new file mode 100644 index 00000000..42c2c1ee --- /dev/null +++ b/designdocs/MODEL_DATA_MODEL_SPEC.md @@ -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; +} + +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--apiKey`). */ + instanceId: string; + + /** FK to ProviderType.id. NON-UNIQUE — two Anthropic instances both have + * `providerTypeId: "anthropic"` with different `instanceId`. */ + providerTypeId: string; + + /** User-editable. Defaults to ProviderType.displayName. The UI suggests + * a numbered / qualified default ("Anthropic (prod)") when a second + * instance of the same type is added; uniqueness is recommended but + * not enforced by the data model. */ + displayName: string; + + /** Overrides ProviderType.defaultBaseUrl. */ + baseUrl?: string; + + /** Where the credential lives. `null` for auth=none providers. */ + apiKey?: KeychainRef | null; + + /** Adapter-specific opaque payload validated at instantiation time: + * - azure: { azureInstanceName, azureDeploymentName, azureApiVersion } + * - bedrock: { bedrockRegion } + * - openai (when used as adapter): { openAIOrgId } + * etc. */ + extras?: Record; + + /** Bookkeeping. */ + addedAt: number; + lastVerifiedAt?: number; + lastVerificationError?: string; +} + +type KeychainRef = + | { kind: "keychain"; id: string } // OS keychain entry id, vault-scoped + | { kind: "inline"; value: string }; // Plaintext fallback when keychain is unavailable +``` + +### 3.3 CatalogModel (catalog-derived, read-only) + +```ts +interface CatalogModel { + /** Wire-form id the provider's API accepts. "claude-sonnet-4-5". */ + 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; // 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; + + 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`. + +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; + customModels: CustomModel[]; // keyed by (instanceId, modelId) + enrollments: ByokEnrollment[]; // keyed by (instanceId, modelId) + consumers: Record; + + // Out-of-scope but reserved: + // embeddingConsumers: Record; + // (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: "/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:` 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` where + `providerTypeId` is a non-unique FK; multi-instance allowed. +- **Branch**: `providers: Record` keyed by the + *provider type id* itself ("anthropic", "openai", "custom:", …). + **Singleton per type** — the BYOK Add Provider dialog filters out already- + configured providers, so multiple Anthropic keys are impossible without + routing through a `custom:` workaround. (See + `src/modelManagement/types.ts` `ProviderConfig`, and + `src/modelManagement/ui/dialogs/AddProviderDialog.tsx`.) +- **Branch has** a `kind: "builtin" | "custom" | "system"` discriminator on + `ProviderConfig` to support system providers (`opencode`, `copilot-plus`) + as first-class entries solely to satisfy the FK invariant of + `RegistryEntry.providerId`. +- **Spec does not** make system providers first-class `ProviderInstance` + rows — they sit as pseudo-`ProviderType`s with no instance. The FK + invariant on enrollments is unaffected because those models are not + `ByokEnrollment` records to begin with; they're `ConsumerModelRef`s of + `source: "backend-bundled" | "copilot-plus"`. + +### A.2 ProviderType / catalog + +- **Spec**: explicit `ProviderType` entity with `adapter: AdapterKind`, + `pipelines: {langchain, opencode}`, `auth`, `defaultBaseUrl`, `models: + Record`. +- **Branch**: catalog lives in `ModelCatalogService` (`src/modelManagement/catalog/`) + with `CatalogProvider` / `CatalogModel` types that mostly match the spec's + shape, **minus** the `pipelines` field and minus an explicit `adapter` + declaration. Provider→adapter mapping is implicit in the chat-model + factory; pipeline reachability isn't modeled at all. + +### A.3 Enrollment record + +- **Spec**: `ByokEnrollment` with `instanceId` FK, `displayName`, + `enrolledAt`, optional `overrides`, verification timestamps. Stable key + `${instanceId}::${modelId}`. +- **Branch**: `RegistryEntry` (`src/modelManagement/types.ts`) with + `providerId` (which is the singleton type id, not an `instanceId`), + `modelId`, `displayName`, `addedAt`, `lastVerifiedAt`, + `lastVerificationError`, optional `extra`. Functionally close, but + identity is `(providerId, modelId)` instead of `(instanceId, modelId)`, + which forecloses multi-instance. + +### A.4 Custom models + +- **Spec**: separate `customModels: CustomModel[]` table, FK'd to + `instanceId`, intentionally distinct from `ByokEnrollment` — a user + declares the model exists, then separately enrolls it. +- **Branch**: no `customModels` table. A "custom model" is simply a + `RegistryEntry` whose `providerId` is a `custom:` provider. Catalog + has no record of it; the entry IS the declaration. Functionally works, + but conflates "this model exists" with "I want to use this model" — the + user can't list out their declared-but-not-enrolled models. + +### A.5 Per-consumer model selection + +- **Spec**: `consumers: Record` with explicit + per-consumer `enabledModels: ConsumerModelRef[]` and `defaultModel`. A + single `ConsumerModelRef` shape (polymorphic) handles BYOK, backend- + bundled, and Copilot Plus uniformly. +- **Branch**: per-backend `modelEnabledOverrides: Record` + with **per-backend-different key formats** (OpenCode uses + `/`, Claude/Codex use bare model ids, Quick Chat + uses `:`). Not a single uniform shape; relies on + tribal knowledge about which key format each backend expects. (See + `src/agentMode/session/modelEnable.ts` and migration step 6 in + `src/modelManagement/migrations/v0-to-v2.ts`.) +- **Branch missing**: `consumers` for LangChain-side use cases (Simple Chat, + Vault QA, Project, Quick Chat). The current model still uses + `settings.defaultModelRef: { providerId, modelId } | null` as a single + "default chat model" pointer with no per-consumer allow-list. + +### A.6 Default model representation + +- **Spec**: every `ConsumerConfig` has its own `defaultModel: ConsumerModelRef | null`. +- **Branch**: `settings.defaultModelRef: { providerId, modelId } | null` is + a single global chat default. No per-consumer defaults beyond what + `agentMode.backends..defaultModel` carries for agent consumers. + +### A.7 Pipeline compatibility + +- **Spec**: `ProviderType.pipelines = {langchain, opencode}` is a first- + class field that drives per-consumer picker visibility. +- **Branch**: not modeled. The byokBridge attempts to register every BYOK + provider with OpenCode regardless of whether OpenCode actually supports + that provider type. Failures surface at runtime, not at the data model + level. + +### A.8 Capabilities & global knobs + +- **Spec**: no `capabilities` on `ByokEnrollment`; no global chat knobs in + the data model; no per-agent capability filtering. +- **Branch**: matches the spec on `RegistryEntry` (no capabilities stored). + **Differs** on global knobs — `temperature`, `maxTokens`, `reasoningEffort`, + `verbosity` still live in `CopilotSettings` and feed every chat invocation + via `ChatDefaults` (`src/modelManagement/types.ts`). The spec drops these. + +### A.9 Embedding side + +- **Spec**: parallel structure, deferred. Legacy `activeEmbeddingModels` + + `embeddingModelKey` stays in the interim. +- **Branch**: matches (legacy embedding fields untouched on `CopilotSettings`). + +### A.10 Migration status + +- **Spec**: assumes the agreed target shape is the destination. Migration + from current (v2) → target shape is a separate task, not specified here. +- **Branch**: `runModelManagementMigrations` runs a v0→v2 migration today. + A v2→v3 migration to land this spec needs to: + - Synthesize `instanceId`s for existing single-instance providers. + - Translate `RegistryEntry` → `ByokEnrollment` (keying change). + - Extract custom-provider `RegistryEntry`s into `CustomModel` rows + new + `ByokEnrollment`s. + - Translate per-backend `modelEnabledOverrides` into `ConsumerConfig`s + for the agent consumers; create new `ConsumerConfig`s for LangChain + consumers (probably initialized as "everything enabled" to preserve + current behavior). + - Add `pipelines: {langchain, opencode}` to the catalog corrections + layer. diff --git a/designdocs/MODEL_MANAGEMENT_REDESIGN.md b/designdocs/MODEL_MANAGEMENT_REDESIGN.md deleted file mode 100644 index 2fe43902..00000000 --- a/designdocs/MODEL_MANAGEMENT_REDESIGN.md +++ /dev/null @@ -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 ` enabled · 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 () →** — 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 ", `[Re-authenticate]` button. Unauthenticated state: "Not signed in to . [Sign in via ]". -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 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 ]` (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`, `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 ` 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: ". 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. diff --git a/designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md b/designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md new file mode 100644 index 00000000..2ed5cddb --- /dev/null +++ b/designdocs/MODEL_MANAGEMENT_REDESIGN_TECH_SPEC.md @@ -0,0 +1,1207 @@ +# Model Management Redesign — Technical Design & Implementation Plan + +> **Companion to:** `designdocs/MODEL_MANAGEMENT_REDESIGN.md` (product / UX spec). +> **This doc owns:** library choice, data model, migration strategy, code-architecture changes, milestone breakdown with verification checklists. +> **Audience:** A background implementation agent. Each milestone is self-contained and verifiable without human approval. +> **Status:** Updated 2026-05-20 (rev 3) — reflects the latest design bundle (`copilot-model-settings/project/screens/final.jsx`) and additional scope decisions: +> +> - BYOK is the central registry for _user-brought_ keys only (no built-in models, no OpenCode/Plus models). +> - Removal of all per-model and per-provider "Availability" / "Capability" toggles; model rows are display-only inside one global table. +> - Quick Chat as a fourth agent sub-tab (skeleton only; routing in the follow-up doc). +> - Lazy `models.dev` fetching (BYOK-tab-triggered, not on plugin boot). +> - `src/modelManagement/` module with enforced import boundary. +> - **Built-in models are eliminated** — migration drops any built-in `activeModels` entry whose provider lacks a configured API key; built-in entries with a key migrate as ordinary registry entries. +> - **Embedding models move to a renamed "Embedding" tab** (was "QA"); other QA settings stay where they are, embedding section sits at the bottom. No separate Embeddings tab. +> - **Welcome modal is out of scope** for this plan. + +--- + +## 0. Context + +The current model management implementation has three duplicated UI surfaces (Basic Settings provider keys, Models Settings table, Agent Mode model curation), with provider API keys scattered as ~25 top-level fields on `CopilotSettings`, and `activeModels` doubling as both "enabled chat models" and "API credential carrier". The redesign (`MODEL_MANAGEMENT_REDESIGN.md`) consolidates this into a single **BYOK** tab + dedicated **Agent** tab, backed by a unified provider registry and a live `models.dev` catalog. + +**BYOK is the central place to configure the _new_ keys and models a user brings to Copilot** (Anthropic / OpenAI / Google / Ollama / custom endpoints / etc). It is **not** a master list of every model the plugin can reach: OpenCode-bundled models (Big Pickle, …) and Copilot-Plus hosted models (Plus Flash, …) **never appear in BYOK**. Those live exclusively in the OpenCode sub-tab of the Agent panel. The BYOK description copy makes this explicit: _"The central place to configure the providers and models you bring to Copilot. OpenCode-bundled and Copilot Plus models are configured in the Agent → OpenCode sub-tab."_ + +Curation of which models surface in a specific agent's in-session picker is a per-agent concern that lives in the Agent tab. + +This implementation plan also takes the opportunity to **separate concerns** that are currently tangled: + +| Concern | Today | After redesign | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Provider credentials | ~25 ad-hoc fields on `CopilotSettings` (`openAIApiKey`, `anthropicApiKey`, `amazonBedrockRegion`, …) | One typed `providers: Record` map | +| Enabled models | `activeModels: CustomModel[]` (also carries API keys, base URLs, per-model overrides) | One `registry: RegistryEntry[]` referencing provider by id | +| Built-in catalog of pre-listed models | Hard-coded in `src/constants.ts` (`BUILTIN_CHAT_MODELS`) — pre-populated regardless of whether the user has the key | **Eliminated.** The registry contains _only_ models the user explicitly registered. Migration drops any pre-listed entry whose provider has no configured key (the user was never actually using it). | +| Available models per provider (for the picker) | Hard-coded built-in list | Lazy `models.dev/api.json` with disk cache | +| Default chat model | `defaultModelKey` field | **Removed** (kept temporarily as input to the Quick Chat agent's seeding; see follow-up doc) | +| Per-model overrides (temp, max_tokens, capabilities, …) | Per-`CustomModel` fields | **Removed** — all chains use global defaults; capabilities are not stored on registry entries (consulted from the catalog at the point of use) | +| Per-provider "Availability" toggles (chat/agent/mobile) | Implicit in code | **Removed** — registered = available; per-agent picker curation handles "show in X" | +| Per-model "Hide from picker" checkbox in BYOK table | `enabled: false` on `CustomModel` | **Removed** — to hide a model, uncheck it inside Configure Provider (this removes it from the registry; per-agent curation lives in Agent tab) | +| Settings versioning | Heuristic (presence/type checks) | Explicit `settingsVersion: number` with a registered migration chain | +| Embedding model management | Inside "Models" tab alongside chat models | Bottom section of the **renamed "Embedding" tab** (was "QA"); other QA settings stay in place. | +| LangChain chat | Implicit "chain mode" coupled to chat input | **Becomes the "Quick Chat" agent backend** — see `designdocs/QUICK_CHAT_AGENT_INTEGRATION.md` (follow-up doc; out of scope for this plan) | + +--- + +## 1. Library / Data Source Choice + +### 1.1 Model catalog: lazy `models.dev` + disk cache + +**Decision:** Fetch [`https://models.dev/api.json`](https://models.dev) **lazily** — only when the user actually needs catalog data (visits the BYOK tab, opens Configure Provider, opens Add Provider, or clicks `[Refresh catalog]`). **No fetch on plugin boot**, no fetch during chat sessions, no fetch during agent sessions. Persist successful fetches to a local disk cache. Before the first successful fetch in this vault, the catalog is empty. + +**Two-tier read priority** (top wins): + +1. **Memory cache** — populated on first `ensureLoaded()` call (lazy). Refreshed by a successful live fetch. +2. **Disk cache** — `{vault}/.obsidian/plugins/copilot/.modelsCatalogCache.json`. Written on every successful live fetch; read lazily on first `ensureLoaded()`. When absent, memory starts empty until the first `refresh()` lands. + +**Fetch triggers** (the only ones): + +- User opens the BYOK tab (`ensureLoaded()` + auto-refresh if last successful fetch >24h old, or if no disk cache exists). +- User opens **Configure Provider** or **Add Provider** dialog (`ensureLoaded()`; no auto-refresh — the BYOK tab open already covered freshness). +- User clicks the **`[Refresh catalog]`** ghost button in the BYOK header. + +**Not triggered:** + +- ❌ Plugin `onload` — catalog service stays uninitialized until a BYOK-side caller asks. +- ❌ Chat session start — chat reads `ProviderRegistry` + `ModelRegistry` (which don't need the catalog at all; catalog only powers the BYOK _picker_). +- ❌ Agent session start — same reason. + +**Fetch behavior:** + +- 5s timeout. On timeout or non-200, log a `logWarn` and keep serving the existing memory/disk source. +- The catalog service emits change events so the BYOK tab + Configure Provider dialog re-render when fresh data arrives. + +**Why lazy:** + +- Most plugin sessions never touch the BYOK tab (users configure once, then chat); fetching on boot wastes a network round-trip every launch. +- The catalog is metadata for the _picker_ — registered models live in `settings.registry` and are usable without ever calling `ModelCatalogService`. The runtime chat/agent paths don't need it. + +**Why two tiers:** + +- Live fetching keeps the catalog fresh as `models.dev` adds new models — no waiting for plugin updates to see GPT-5.6 etc. +- Disk cache keeps offline use seamless across sessions once we've fetched at least once. + +**Why no bundled snapshot:** Earlier drafts shipped a tree-shaken `modelsCatalog.fallback.json` for the fresh-install + offline case. That introduced two ongoing costs (a maintainer ritual to refresh it; a synchronous JSON import in the migration) for a narrow benefit. Fresh-install offline now shows an empty picker with a `[Refresh catalog]` retry CTA — acceptable since BYOK fundamentally needs internet to validate keys anyway. + +**Why not live-only on every read:** Catalog data is too large to fetch on every render; disk cache amortizes across sessions. + +### 1.2 Provider allowlist (only providers with first-class LangChain adapters) + +**Scoped to providers this plugin can actually instantiate via its existing LangChain adapters** (the `ChatModelProviders` enum in `chatModelManager.ts`). Showing catalog entries for providers we can't actually call is a UX trap. + +``` +anthropic, openai, google, groq, mistral, xai, deepseek, +openrouter, cohere, azure, amazon-bedrock, github-copilot, +ollama, lmstudio, siliconflow, openai-compatible +``` + +**Excluded** from the earlier draft: `togetherai`, `fireworks-ai`, `perplexity` — no first-class LangChain adapter in this plugin. Users who want them can still add them as a custom provider using the `openai-compatible` path. + +(Note on ids: `xai` not `x-ai`; `amazon-bedrock` not `aws-bedrock`. Verified against live `api.json`.) + +The live fetch + disk cache hold the filtered set (filtered client-side after fetch since `models.dev` doesn't accept server-side filters). + +> **Single source of truth for the allowlist:** Export a `SUPPORTED_PROVIDER_IDS` constant from `src/modelManagement/providers/supportedProviders.ts`. The catalog filter, the Add Provider dialog's list, the migration step, and the `eslint`-enforced provider adapter registry all reference the same constant. Adding a new provider = (1) add adapter class, (2) add id to `SUPPORTED_PROVIDER_IDS`. Nothing else. + +### 1.3 Catalog shape (hand-rolled TypeScript types) + +Place in `src/data/modelsCatalog.types.ts`: + +```typescript +export interface CatalogProvider { + id: string; // "anthropic" + name: string; // "Anthropic" + env: string[]; // ["ANTHROPIC_API_KEY"] + npm?: string; + api?: string; // default base URL + models: Record; +} + +export interface CatalogModel { + id: string; // "claude-sonnet-4-5-20250929" + name: string; // "Claude Sonnet 4.5" + family?: string; + attachment?: boolean; // accepts attachments + reasoning?: boolean; + tool_call?: boolean; + temperature?: boolean; + knowledge?: string; // training cutoff + release_date?: string; // surfaced as "Sep 2025" column in Configure Provider + last_updated?: string; + open_weights?: boolean; + modalities: { input: string[]; output: string[] }; // input includes "image" → Vision (used for routing only, not UI) + limit: { context: number; output: number }; + cost?: { input: number; output: number; cache_read?: number; cache_write?: number }; +} + +export type ModelsCatalog = Record; +``` + +> **Registry entries do not store capabilities.** The `capabilities` field has been removed from `RegistryEntry`. The catalog's per-model capability fields (`reasoning`, `tool_call`, `modalities.input`) remain on `CatalogModel` for any consumer that needs to consult them at the moment of use, but they are no longer materialized into the registry. See the follow-up section "Future migration: CustomModel capability removal" for the legacy `CustomModel.capabilities` consumers still reading the v0-side enum. + +### 1.4 Catalog service + +Create `src/modelManagement/catalog/ModelCatalogService.ts` — a thin lazy read-only facade with the two-tier read path: + +```typescript +class ModelCatalogService { + // Idempotent. First call triggers disk read; subsequent calls are no-ops. + // Returns immediately after memory cache is populated (empty if no disk cache yet). + ensureLoaded(): Promise; + + // All reads are synchronous after ensureLoaded() resolves. + getProvider(id: ProviderId): CatalogProvider | undefined; + getModel(providerId: ProviderId, modelId: string): CatalogModel | undefined; + getAllProviders(): CatalogProvider[]; // sorted: recommended first (Anthropic / OpenAI / Google) + searchModels(providerId: ProviderId, query: string, filters: CatalogFilters): CatalogModel[]; + + // Live data integration + refresh(): Promise; // user-triggered or auto-triggered (BYOK tab open + 24h stale) + getMeta(): { fetchedAt: number | null; source: "live" | "disk" | "bundled" }; + onChange(listener: () => void): () => void; // emits when memory cache updates +} + +interface CatalogFilters { + contextAtLeast?: number; // ≥ 200k ctx chip + maxCostPerMillion?: number; // ≤ $1/M chip + releasedWithinMonths?: number; // Released ≤ 6mo chip +} +``` + +**No `capability` filter in `searchModels`.** Capability filters (Vision / Reasoning / Tool use) are removed from the UI per the latest design. Catalog code can still inspect `tool_call` / `reasoning` / `modalities.input` for internal routing decisions (the Quick Chat agent will need to know whether a model supports tool calls, for example). + +**No OpenCode source augmentation.** The previous draft routed OpenCode-enumerated models through this service. That's gone — `ModelCatalogService` is for the **BYOK picker only**, and BYOK never shows OpenCode-bundled or Copilot-Plus models. The OpenCode sub-tab in the Agent panel queries OpenCode directly for its bundled list (Big Pickle, etc.) and Copilot-Plus for its hosted list; those are merged with BYOK agent-capable registry entries to populate the OpenCode picker at render time. See §5.4.1 and M8 below. + +--- + +## 2. New data model + +### 2.1 Top-level `CopilotSettings` additions + +```typescript +interface CopilotSettings { + // … existing unrelated fields … + + /** Monotonic settings schema version. Migrations run on load when this is < current. */ + settingsVersion: number; // current = 2 after this redesign + + /** Provider credentials & display config, keyed by provider id. */ + providers: Record; + + /** User's enabled models — the BYOK registry. */ + registry: RegistryEntry[]; + + // existing fields like agentMode, activeEmbeddingModels (untouched) remain +} + +type ProviderId = string; +// Built-in providers use canonical models.dev ids: "anthropic", "openai", etc. +// Custom providers use uuid-prefixed ids: "custom:550e8400-e29b-41d4-a716-446655440000" +// System-managed: "opencode" and "copilot-plus" — never appear in `providers` map. + +interface ProviderConfig { + id: ProviderId; + kind: "builtin" | "custom"; // determines `type` editability + displayName: string; // "Anthropic" or user-given "Ollama (local)" + type: "openai-compatible" | "anthropic" | "google" | "azure" | "bedrock" | "github-copilot"; + baseUrl?: string; // optional override; for `custom` always present + apiKeyRef?: KeychainRef | null; // null = no key required (some local servers) + // Opaque provider-specific payload. Validated by the provider class's Zod schema + // (see §3.6) — keeps `ProviderConfig` flexible without ballooning the union type. + extra?: Record; + addedAt: number; + lastVerifiedAt?: number; + lastVerificationError?: string; +} + +interface RegistryEntry { + providerId: ProviderId; // "anthropic" | "custom:…" — never "opencode" / "copilot-plus" + modelId: string; // "claude-sonnet-4-5-20250929" + displayName: string; // "Claude Sonnet 4.5" + addedAt: number; + lastVerifiedAt?: number; + lastVerificationError?: string; +} +``` + +**Capabilities are not stored on the registry.** Per-model capability tags (`reasoning`, `tool_call`, vision via `modalities.input`, context window, release date) live on `CatalogModel` and are consulted at the point of use — they're not materialized into a registry-side enum. See the follow-up section "Future migration: CustomModel capability removal" for the legacy CustomModel-based code paths that still carry their own pre-v2 `ModelCapability` enum. + +**Notable removals from earlier draft:** + +- `ProviderConfig.availability` (chat/opencode/mobile checkboxes) — removed entirely. Once a provider is registered, its models are available to whatever agent backend can use them. Per-agent curation moved to Agent tab. +- `RegistryEntry.visible` (per-model checkbox in BYOK table) — removed. Registered = visible. To hide a model: uncheck it inside Configure Provider's model picker and save (which removes it from the registry). +- `RegistryEntry.origin` — removed. Every registry entry is a BYOK entry. OpenCode-bundled and Copilot-Plus models **never become registry entries** (see §5.4.1 for how the OpenCode picker assembles its model list at render time instead). +- `ProviderConfig.extra` is now an opaque `Record` (was a typed union). Provider classes own the shape via their own Zod schemas (§3.6) — keeps the core type stable while letting providers evolve their own payloads. + +### 2.2 Per-agent model picker curation + +Each agent backend (including the new Quick Chat backend defined in the follow-up doc) maintains its own `modelEnabledOverrides`. The map is **scoped per backend by its storage path** (`agentMode.backends..modelEnabledOverrides`), so the key inside the map never repeats the backend id: + +```typescript +interface AgentBackendCommonSettings { + defaultModel?: ModelSelection | null; // { baseModelId, effort } + // Which registry models surface in this backend's in-session model picker. + // Missing entry = default to true (visible). Explicit false = hidden. + modelEnabledOverrides?: Record; +} +``` + +Applied to all backends: `agentMode.backends.opencode`, `agentMode.backends.claude`, `agentMode.backends.codex`, **and** the new `agentMode.backends.quickChat` (declared here as a structural placeholder; full integration is in the follow-up doc). + +**Source of truth for each backend's picker:** + +- **OpenCode picker** — assembles its model list at render time from **three sources, unioned**: + 1. OpenCode's own enumeration of bundled models (Big Pickle, etc.) — queried directly from the running OpenCode binary. + 2. Copilot-Plus hosted models (Plus Flash, etc.) — queried from the Plus license endpoint when active. + 3. BYOK registry entries — `ModelRegistry.list()`. + Then filtered via `agentMode.backends.opencode.modelEnabledOverrides`. None of these enter the BYOK table; they're a per-backend picker concern. +- **Claude Code / Codex pickers** — read each backend's _bundled_ model list (still hard-coded per backend; these are subscription-bound and don't reference the BYOK registry), filtered via `modelEnabledOverrides`. +- **Quick Chat picker (follow-up doc)** — reads `ModelRegistry.list()`, filtered via `agentMode.backends.quickChat.modelEnabledOverrides`. + +**Model-key format inside `modelEnabledOverrides`** depends on what uniquely identifies a model within a single backend's catalog: + +- **OpenCode**: bare wire-form `baseModelId` (`anthropic/claude-sonnet-4-5`, `bigpickle/big-pickle`, `copilot-plus/copilot-plus-flash`, `custom:abc-uuid/llama-3.3`). The provider segment is already part of the wire form, so the same `modelId` from two providers (e.g. Anthropic vs OpenRouter) maps to two distinct keys — no collision. +- **Claude Code / Codex**: bare `baseModelId` (`claude-sonnet-4-5`, `gpt-5`). Single-provider backends; `modelId` alone is unique. +- **Quick Chat**: `:` (e.g. `anthropic:claude-opus-4-7`). Quick Chat routes through multiple BYOK providers within one backend slice, so the bare `modelId` would collide; pairing with `providerId` disambiguates. + +The runtime picker (`isAgentModelEnabled` in `src/agentMode/session/modelEnable.ts`) and every settings panel MUST agree on the same key shape per backend — divergence here was the bug behind the M9 follow-up that introduced this section. These keys exist only inside `modelEnabledOverrides` — never in the `registry` array. + +### 2.3 Removed from `CopilotSettings` (legacy → migrated) + +| Removed field | Migrated to | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `openAIApiKey`, `openAIOrgId`, `azureOpenAI*`, `anthropicApiKey`, `googleApiKey`, `cohereApiKey`, `mistralApiKey`, `deepseekApiKey`, `groqApiKey`, `xaiApiKey`, `openRouterAiApiKey`, `siliconflowApiKey`, `amazonBedrockApiKey`, `amazonBedrockRegion`, `huggingfaceApiKey`, `openAIProxyBaseUrl` | `providers[].apiKeyRef` + `providers[].baseUrl` + `providers[].extra` | +| `activeModels: CustomModel[]` (chat half) | `registry: RegistryEntry[]` | +| `defaultModelKey` | **Re-seeded as** `agentMode.backends.quickChat.defaultModel` (so the user's last-chosen chat model is what Quick Chat boots with). Field itself deleted. | +| Per-`CustomModel` overrides: `temperature`, `maxTokens`, `topP`, `frequencyPenalty`, `numCtx`, `reasoningEffort`, `verbosity`, `stream`, `streamUsage`, `useResponsesApi`, `enablePromptCaching`, `enableCors`, `capabilities` (user-set) | **Dropped** — surfaced via one-time toast (§4.4) | +| `agentMode.enabled` | Removed — desktop is always agent-capable | + +**Kept unchanged:** + +- `activeEmbeddingModels` (moved to a new "Embeddings" tab UI-wise in M3, but the data field is untouched) +- `agentMode.backends.opencode`, `agentMode.backends.claude`, `agentMode.backends.codex` (existing `modelEnabledOverrides` are now keyed by the bare wire-form `baseModelId` after migration; see §2.2) +- `agentMode.byok` (kept; OpenCode reads it for agent-mode credentials) +- `temperature`, `maxTokens`, `reasoningEffort`, `verbosity` (kept as **global** defaults, no longer per-model) +- GitHub Copilot OAuth fields (`githubCopilotAccessToken` etc.) — these are OAuth tokens not user-entered keys; they live alongside `providers["github-copilot"]` but aren't migrated into `apiKeyRef` + +### 2.4 Settings version field + +Add `settingsVersion: number` to `CopilotSettings` with a default of `0` for unmigrated settings. + +- `0` = original (any settings written before this redesign ships) +- `2` = post-migration + +**Migration runner** (`src/settings/migrations/runMigrations.ts`): + +```typescript +type Migration = (raw: any) => any; +const MIGRATIONS: Record = { + 2: migrateV0toV2, +}; +function runMigrations(raw: any): { settings: any; migrationsApplied: number[] }; +``` + +`runMigrations` is called once inside `sanitizeSettings` before any other normalization, on every settings load. Idempotent (no-op when `settingsVersion === current`). + +**How we know a user has migrated:** `settings.settingsVersion >= 2`. We also write a sticky breadcrumb `settings._migrationBreadcrumbs: Array<{ from: number; to: number; appliedAt: number; droppedFields?: string[] }>` for forensics (also surfaces the toast content in M2). + +### 2.5 API key storage + +The existing `KeychainService` (`src/services/keychainService.ts`) already supports per-field keychain storage with a vault-scoped ID scheme. Extend it with a new namespace: + +``` +copilot-v{8hex-vault-id}-provider-{providerId}-apiKey +copilot-v{8hex-vault-id}-provider-{providerId}-extra-{name} +``` + +`ProviderConfig.apiKeyRef` is `{ kind: "keychain"; id: string }` or `{ kind: "inline"; value: string }` depending on whether keychain is available (the `_keychainOnly` setting governs this — preserved from current behavior). Migration moves keys from the existing top-level keychain entries (`copilot-v{id}-openai-api-key`) to the new namespace. + +--- + +## 3. Code architecture — separation of concerns + +### 3.0 One module, one boundary: `src/modelManagement/` + +All provider, model, catalog, and BYOK-UI code lives in a single top-level module under `src/modelManagement/`. The module has **one public entry point** — `src/modelManagement/index.ts` — and an **eslint-enforced import boundary** prevents the rest of the codebase from reaching past it into internals. + +**Public API surface** (everything else is private to the module): + +```typescript +// src/modelManagement/index.ts — the ONLY file outside callers may import from +export { ProviderRegistry } from "./providers/ProviderRegistry"; +export { ModelRegistry } from "./registry/ModelRegistry"; +export { ModelCatalogService } from "./catalog/ModelCatalogService"; +export { ChatModelManager } from "./chatModel/ChatModelManager"; +export type { + ProviderId, + ProviderConfig, + RegistryEntry, + ModelCapability, + KeychainRef, + VerificationResult, +} from "./types"; +export { ByokPanel } from "./ui/tabs/ByokPanel"; +export { runModelManagementMigrations } from "./migrations/runMigrations"; +export { SUPPORTED_PROVIDER_IDS } from "./providers/supportedProviders"; +``` + +**Enforcement:** `eslint-plugin-import`'s `no-restricted-paths` rule: + +```js +// .eslintrc.js (or eslint.config.js) +"import/no-restricted-paths": ["error", { + zones: [{ + target: "./src/!(modelManagement)/**", + from: "./src/modelManagement/!(index.ts)", + message: + "Import model management code via @/modelManagement (the module's public API), " + + "not from internal files. See designdocs/MODEL_MANAGEMENT_IMPLEMENTATION.md §3.0.", + }], +}] +``` + +**Why a single module:** Today provider knowledge is scattered across `src/LLMProviders/`, `src/settings/v2/components/ModelSettings.tsx`, `src/constants.ts` (BUILTIN_CHAT_MODELS), and ~25 top-level settings fields. Pulling all of that into one boundary makes the responsibility explicit: model management owns providers + models + catalog + the BYOK UI. Consumers (agent backends, chat input, embeddings) talk to it through a small surface and never reach inside. + +**What stays outside the module:** + +- Agent backend internals (`src/agentMode/backends/*`) — they consume `ModelRegistry` + `ProviderRegistry` via the public API. +- Chat view + chat input — same. +- Embeddings management — keeps its own surface in `src/embedding/` (separate workstream). +- Settings shell / non-model tabs (`Chat`, `Commands`, etc.) — they just register `ByokPanel` as a tab component. + +### 3.1 `src/modelManagement/providers/ProviderRegistry.ts` (NEW) + +Source of truth for provider credentials and metadata. Wraps `settings.providers` + keychain. + +```typescript +class ProviderRegistry { + list(): ProviderConfig[]; + get(id: ProviderId): ProviderConfig | undefined; + add(config: Omit): Promise; + update(id: ProviderId, patch: Partial): Promise; + remove(id: ProviderId): Promise; // removes provider + all its registry entries + getApiKey(id: ProviderId): Promise; + verify(id: ProviderId): Promise; +} +``` + +### 3.2 `src/modelManagement/registry/ModelRegistry.ts` (NEW) + +Source of truth for enabled models (BYOK registry). Wraps `settings.registry`. + +```typescript +class ModelRegistry { + list(filter?: { providerId?: ProviderId; capability?: ModelCapability }): RegistryEntry[]; + get(providerId: ProviderId, modelId: string): RegistryEntry | undefined; + add(entry: Omit): Promise; + remove(providerId: ProviderId, modelId: string): Promise; + // Used by chat input model picker — delegates to the active agent backend's picker + // (resolution logic lives in the follow-up doc). + // Used by Agent tab's per-backend picker section + listForAgentPicker(backendId: BackendId): RegistryEntry[]; + // Bulk operations for Configure Provider modal save + bulkSet(providerId: ProviderId, entries: RegistryEntry[]): Promise; +} +``` + +Notable: `updateVisibility` is **removed** from the earlier draft. Visibility is no longer a per-registry-entry property — it's a per-agent-backend curation handled by each backend's `modelEnabledOverrides`. + +### 3.3 `src/modelManagement/catalog/ModelCatalogService.ts` (NEW) + +Already covered in §1.4. Lazy, read-only facade with two-tier fallback. + +### 3.4 `ChatModelManager` refactor + +Current: reads ~15 different fields off `CopilotSettings` to build LangChain clients. + +After: reads `ProviderRegistry.get(...)` + `ModelRegistry.get(...)` to assemble a `ChatModelConfig`. Per-model overrides removed; global `temperature` / `maxTokens` / `reasoningEffort` still honored. + +A pure helper `buildLangChainConfig(provider: ProviderConfig, entry: RegistryEntry, defaults: ChatDefaults): ChatModelConfig` lives in its own file (no LangChain imports — unit-testable per `AGENTS.md` testing guidance). + +**Out of scope here:** The integration between `ChatModelManager` and the new Quick Chat agent backend (so the chat input → Quick Chat → LangChain wiring works end-to-end) — see `designdocs/QUICK_CHAT_AGENT_INTEGRATION.md`. + +### 3.5 Folder layout (everything model-related lives here) + +``` +src/ +├─ modelManagement/ ← THE MODULE (one boundary) +│ ├─ index.ts ← public API surface (§3.0) +│ ├─ types.ts ← ProviderConfig, RegistryEntry, … +│ ├─ catalog/ +│ │ ├─ ModelCatalogService.ts ← lazy + 3-tier read +│ │ └─ modelsCatalog.types.ts +│ ├─ providers/ +│ │ ├─ ProviderRegistry.ts +│ │ ├─ supportedProviders.ts ← SUPPORTED_PROVIDER_IDS (single source) +│ │ └─ adapters/ ← was src/LLMProviders/ — relocated +│ │ ├─ AnthropicAdapter.ts (each adapter exports langchain factory +│ │ ├─ OpenAIAdapter.ts + extraSchema: z.ZodSchema) +│ │ ├─ GoogleAdapter.ts +│ │ ├─ AzureAdapter.ts ← uses extraSchema for instance/deployment/version +│ │ ├─ BedrockAdapter.ts ← uses extraSchema for region +│ │ ├─ ... +│ │ └─ index.ts ← adapter registry keyed by ProviderId +│ ├─ registry/ +│ │ └─ ModelRegistry.ts +│ ├─ chatModel/ +│ │ ├─ ChatModelManager.ts ← refactored to read Provider+Model registries +│ │ └─ buildLangChainConfig.ts ← pure helper (no LangChain import) +│ ├─ migrations/ +│ │ ├─ runMigrations.ts +│ │ ├─ v0-to-v2.ts +│ │ └─ __tests__/ ← fixture-based migration tests +│ └─ ui/ +│ ├─ tabs/ +│ │ └─ ByokPanel.tsx +│ ├─ dialogs/ +│ │ ├─ AddProviderDialog.tsx +│ │ ├─ ConfigureProviderDialog.tsx +│ │ └─ AddCustomModelDialog.tsx +│ └─ components/ +│ ├─ ByokGlobalTable.tsx ← one global table w/ provider section rows +│ └─ ProviderCatalogList.tsx +│ +├─ settings/ +│ ├─ model.ts ← schema only; thin (uses modelManagement types) +│ ├─ v2/ ← legacy; selected files retired in M9 +│ │ └─ components/ +│ │ ├─ QASettings.tsx (renamed "Embedding" tab, extended in M3) +│ │ └─ EmbeddingModelsSection.tsx (extracted in M3 — used by QASettings) +│ └─ v3/ ← only the NON-model tabs (Agent, …) +│ ├─ tabs/ +│ │ └─ AgentPanel.tsx (consumes @/modelManagement) +│ └─ components/ +│ ├─ BackendSubtabs.tsx +│ ├─ BackendStatusCard.tsx +│ └─ BackendModelPicker.tsx ← shared "Models in this backend's picker" +│ +``` + +No build-time snapshot. The catalog is purely a runtime concern — live fetch + disk cache, no bundled JSON. + +### 3.6 Provider adapters own their `extra` shape + +Each adapter under `src/modelManagement/providers/adapters/` exports two things: + +```typescript +// Example: AzureAdapter.ts +import { z } from "zod"; + +export const extraSchema = z.object({ + azureInstanceName: z.string().min(1), + azureDeploymentName: z.string().min(1), + azureApiVersion: z.string().min(1), +}).strict(); + +export function buildLangChainClient( + provider: ProviderConfig, + entry: RegistryEntry, + defaults: ChatDefaults, +): BaseChatModel { + const extra = extraSchema.parse(provider.extra); // throws → caught + surfaced as verification error + return new AzureChatOpenAI({ ... extra ... }); +} +``` + +`extraSchema` defaults to `z.object({}).strict()` for adapters with no extras. The Configure Provider dialog uses the schema (via a `getExtraFormFields(adapter)` helper) to render the right inputs in the "advanced" section — adding a new provider's extra field is a one-line schema change, no UI rewrite required. + +This keeps `ProviderConfig.extra: Record` opaque at the core-type level while letting each provider declare exactly what it needs. + +--- + +## 4. Migration strategy (v0 → v2) + +### 4.1 Where it runs + +Inside `sanitizeSettings(raw)` in `src/settings/model.ts`, immediately after parsing data.json and **before** field normalization. Single entry point; runs on every load; idempotent when `settingsVersion >= 2`. + +### 4.2 Migration steps (v0 → v2) + +Implemented in `src/settings/migrations/v0-to-v2.ts`. Order matters: + +1. **Initialize new shape** — `settings.providers = {}`, `settings.registry = []`, `settings._migrationBreadcrumbs = settings._migrationBreadcrumbs ?? []`. + +2. **Provider keys → `providers` map.** For each non-empty legacy field, synthesize a `ProviderConfig`: + + | Legacy field(s) | Synthesized provider | + | ------------------------------------------------------ | --------------------------------------------------------------------- | + | `openAIApiKey` (+ `openAIOrgId`, `openAIProxyBaseUrl`) | `providers["openai"]` | + | `anthropicApiKey` | `providers["anthropic"]` | + | `googleApiKey` | `providers["google"]` | + | `cohereApiKey` | `providers["cohere"]` | + | `mistralApiKey` | `providers["mistral"]` | + | `deepseekApiKey` | `providers["deepseek"]` | + | `groqApiKey` | `providers["groq"]` | + | `xaiApiKey` | `providers["xai"]` | + | `openRouterAiApiKey` | `providers["openrouter"]` | + | `siliconflowApiKey` | `providers["siliconflow"]` | + | `amazonBedrockApiKey` (+ `amazonBedrockRegion`) | `providers["amazon-bedrock"]` (with `extra.bedrockRegion`) | + | `azureOpenAIApiKey` (+ instance/deployment/version) | `providers["azure"]` (with `extra.azure*`) | + | `huggingfaceApiKey` | **Dropped** (not in design's provider allowlist — log to breadcrumbs) | + + Each synthesized provider gets `kind: "builtin"`, `addedAt: Date.now()`. No `availability` field (removed from the data model). + +3. **Custom-provider `CustomModel` entries → `providers["custom:"]`.** Group `activeModels` entries by unique `{baseUrl, apiKey}` tuple (when `provider` is `OPENAI_FORMAT`, `OLLAMA`, `LM_STUDIO`, or any `isBuiltIn: false` entry with a `baseUrl`). Each group becomes one custom `ProviderConfig`: + + ``` + id: "custom:" + kind: "custom" + displayName: + type: provider type inferred from `provider` field (default: "openai-compatible") + baseUrl: + apiKeyRef: + ``` + +4. **`activeModels` (chat half) → `registry`.** For each entry, decide first whether to drop or migrate: + - **Drop** (do not create a registry entry; log to breadcrumbs) if **any** of these are true: + - `entry.isEmbeddingModel === true` — handled by `activeEmbeddingModels`, unchanged. + - `entry.enabled === false` — the new model has no per-model visibility flag; treat as deleted. + - `entry.isBuiltIn === true` **and** the provider step (2 or 3) did NOT produce a `ProviderConfig` for that provider (i.e., the user never set up an API key for the provider that ships this built-in model). The user was never actually using it — it was just visual clutter from the legacy `BUILTIN_CHAT_MODELS` list. + - `entry.provider` is OpenCode-bundled or Copilot Plus — those no longer live in the registry. Their selections are forwarded into `agentMode.backends.opencode.modelEnabledOverrides` keyed by the bare wire-form `baseModelId` (e.g. `bigpickle/big-pickle`, `copilot-plus/copilot-plus-flash`) so the OpenCode picker keeps showing them. + + - Otherwise **migrate** to a `RegistryEntry`: + ``` + providerId: canonical-id-for-entry.provider OR the custom-provider id from step 3 + modelId: entry.name + displayName: entry.displayName ?? entry.name + addedAt: Date.now() + ``` + + Migration does not read the catalog. Capability tags, context window, and release date are not stored on the registry — catalog data is consulted at the point of use instead. See the follow-up section "Future migration: CustomModel capability removal" for the legacy capability consumers still living on `CustomModel`. + + Net effect for built-in handling: a user who never configured any keys ends up with `providers = {}` and `registry = []` — nothing to migrate, nothing surfaced. A user who configured the OpenAI key keeps only the OpenAI models they were actually using (built-in entries for OpenAI migrate in; built-in entries for other providers without keys are dropped). The toast in §4.4 reports the dropped count. + +5. **`activeModels` per-model overrides → dropped, logged to breadcrumbs.** For each entry that had any of `temperature`, `maxTokens`, `topP`, `frequencyPenalty`, `numCtx`, `reasoningEffort`, `verbosity`, `stream`, `streamUsage`, `useResponsesApi`, `enablePromptCaching`, `enableCors`, `capabilities`, push to `_migrationBreadcrumbs[*].droppedFields`. The toast in §4.4 reads from this. + +6. **Agent overrides re-keyed.** Existing `agentMode.backends..modelEnabledOverrides` uses model-name keys (or panel-prefixed `:` keys from an earlier draft of M9). Normalize per §2.2: + - **opencode**: bare wire-form `baseModelId`. Legacy `|` becomes `/` (resolved against the providers map; orphans dropped to breadcrumbs); panel-prefixed `opencode:` keeps `` (already wire form); `copilot-plus:` becomes `copilot-plus/`; `:` (longest-prefix match against the providers map) becomes `/`. + - **claude / codex**: bare model name. Legacy `|` keeps ``; panel-prefixed `:` keeps ``. + - **quickChat**: keep `:` form. + If a key cannot be resolved to a registered model (e.g. the model was disabled in step 4), drop the override (logged to breadcrumbs). Overrides are **kept** for all four backends — none are folded into a registry-level flag. + +7. **`defaultModelKey` → Quick Chat default.** Resolve `defaultModelKey` (`|` format) to a `RegistryEntry`. Seed `agentMode.backends.quickChat = { defaultModel: { baseModelId: ":", effort: null }, modelEnabledOverrides: {} }`. If no match, leave Quick Chat without a default. Then delete `defaultModelKey`. + + _(The Quick Chat backend infrastructure itself is in the follow-up doc; this migration step only ensures the data is in place so the follow-up implementation has something to read.)_ + +8. **`agentMode.enabled` → dropped.** Desktop is always agent-capable. + +9. **API keys → keychain.** If keychain is available (`_keychainOnly` is true), each migrated provider's `apiKeyRef` is moved into the new `provider--apiKey` keychain entry and the legacy entry (`-api-key`) is deleted. If keychain unavailable, `apiKeyRef = { kind: "inline", value: }`. + +10. **Delete legacy top-level fields** — remove all the legacy provider key fields (see §2.3 table) from the settings object so they're gone from `data.json` after first save. + +11. **Stamp version** — `settings.settingsVersion = 2`; append breadcrumb `{ from: 0, to: 2, appliedAt: Date.now(), droppedFields: [...all dropped per-model override field-paths] }`. + +### 4.3 Backwards-compatibility safety net + +If migration **fails** mid-way (throws), we: + +- catch the error in `runMigrations`, +- restore the pre-migration settings object, +- log to `console.error` via `logError`, +- show a one-time `Notice` toast: _"Couldn't upgrade your Copilot settings — please report this. The plugin will keep working with your existing settings for now."_, +- leave `settingsVersion` at the old value so we retry next launch. + +Settings are never partially-mutated — the migration runs on a deep clone and only assigns back on success. + +### 4.4 Migration notice (one-time toast) + +After successful v0→v2 migration, on the next plugin tick, surface a dismissible `Notice` with: + +``` +Copilot settings upgraded. +• Per-model temperature / max-tokens / capability overrides removed. +• Default chat model now lives under Agent → Quick Chat. +• Provider keys moved to the new BYOK tab. +• Pre-listed built-in models removed for providers you hadn't configured. (4 removed) +Open BYOK tab → Dismiss +``` + +The exact line items are dynamically built from `_migrationBreadcrumbs[last].droppedFields`. The user can dismiss; we set `settings._migrationNoticeDismissed = true` so it never reappears. The toast is also a link to the new BYOK tab. + +### 4.5 How an implementer or user can tell if migration ran + +| Signal | Where | +| ----------------- | ------------------------------------------------------------------------------------------------------------------- | +| Primary indicator | `settings.settingsVersion >= 2` | +| Forensic detail | `settings._migrationBreadcrumbs[*]` (preserved across loads) | +| User-facing | One-time `Notice` (§4.4); also surfaces in **Settings → About** card: _"Settings schema v2 (migrated Mar 4, 2026)"_ | +| Programmatic | `ProviderRegistry.list()` returns non-empty when keys existed pre-migration | + +A new dev command `Copilot: Show settings migration status` (registered in `main.ts`) opens a small modal that dumps the breadcrumbs — useful for support. + +--- + +## 5. UI surface map + +| Tab (settings modal) | Implementation | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------- | +| **BYOK** (NEW; central registry — providers + models) | `src/modelManagement/ui/tabs/ByokPanel.tsx` | +| **Agent** (REPLACED; OpenCode / Claude Code / Codex / Quick Chat sub-tabs) | `src/settings/v3/tabs/AgentPanel.tsx` | +| **Embedding** (RENAMED from "QA"; existing QA settings stay where they are, embedding model section added at the bottom) | existing QA tab file, extended in-place | +| Chat (renamed from "Basic"; cosmetic only — content unchanged) | existing v2 component, header label tweak | +| Commands (renamed from "Chat & Commands"; cosmetic only) | existing v2 component, header label tweak | +| Advanced, etc. (UNCHANGED) | existing v2 components, no rewrite | + +| Modal | Implementation | +| ----------------------------- | ------------------------------------------------------------ | +| Add Provider | `src/modelManagement/ui/dialogs/AddProviderDialog.tsx` | +| Configure Provider (3 states) | `src/modelManagement/ui/dialogs/ConfigureProviderDialog.tsx` | +| Add Custom Model | `src/modelManagement/ui/dialogs/AddCustomModelDialog.tsx` | + +The existing `src/settings/v2/components/BasicSettings.tsx` (model picker portion) and `ModelSettings.tsx` (chat half) are **deleted** at the end of M9. The v2 folder remains for `Chat` (renamed) and other untouched tabs. + +> **Welcome modal is out of scope for this plan.** A first-run / empty-state onboarding entry point will be designed and shipped separately. The BYOK tab's empty state (§5.1) handles the "no providers yet" baseline. + +### 5.1 BYOK panel layout (the global table) + +The populated BYOK panel is **one global table** with provider section rows: + +``` +┌────────────────────────────────────────────────────────────────┐ +│ BYOK [↻ Refresh catalog] [Manage providers] [+Add]│ +│ The central place to configure the providers and models you │ +│ bring to Copilot. OpenCode-bundled and Copilot Plus models are │ +│ configured in the Agent → OpenCode sub-tab. │ +│ │ +│ [🔍 Filter models…] [All] [local] [≥ 200k ctx] │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Model Meta │ │ +│ ├──────────────────────────────────────────────────────────┤ │ +│ │ ▼ [An] Anthropic 4 models [⚙ Configure] ⋯ │ │ +│ │ Claude Sonnet 4.5 200k │ │ +│ │ Claude Opus 4.1 200k │ │ +│ │ Claude Haiku 4.5 200k │ │ +│ │ Claude Sonnet 3.7 200k │ │ +│ │ ▶ [Op] OpenAI 3 models [⚙ Configure] ⋯ │ │ +│ │ ▼ [Ol] Ollama (local) 2 models [custom endpoint] [⚙][⋯]│ +│ │ llama3.2 local · 8B │ │ +│ │ qwen2.5-coder local · 7B │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ 9 enabled across 3 providers · 87 available in catalog │ +└────────────────────────────────────────────────────────────────┘ +``` + +Key properties: + +- **OpenCode-bundled and Copilot Plus models do NOT appear here.** They live in the Agent → OpenCode sub-tab. The description copy at the top makes this explicit. +- **Provider section rows** are styled headers inside one continuous table. Click chevron to fold/unfold. +- **Per-provider actions** on the right of each section row: `[⚙ Configure]` ghost button + `⋯` kebab (menu has ONE option: **Remove provider**). +- **Custom endpoints** (Ollama) show a `custom endpoint` badge alongside their actions. +- **Model rows have no checkbox, no kebab, no badges**. Just `Model name` + `Meta` (context window, "local · 8B", etc.). Model rows are display-only — to change which models are registered, open Configure Provider. +- **Filter bar:** Search + `All` / `local` / `≥ 200k ctx` chips. **No** Vision / Reasoning / Tool use chips. +- **Top-right header buttons:** `[↻ Refresh catalog]` (ghost, with last-fetched timestamp tooltip) + `[Manage providers]` (ghost) + `[+ Add provider]` (primary). +- **Catalog load timing:** On first BYOK-tab open in a session, the panel awaits `ModelCatalogService.ensureLoaded()` (renders a skeleton during the load — usually <50ms since it's just a disk read). If `getMeta().fetchedAt` is older than 24h or the catalog is empty (no disk cache), also kicks off a `refresh()`. + +### 5.2 Configure Provider dialog (3 states) + +Header: provider glyph + name. Edit state shows a `✓ Verified` badge after a successful key test. + +Fields, all states: + +- **API key** — editable text field (mono). `[Test]` button. In edit state, the field is **directly editable** (no "Replace" button); existing value shows masked dots. +- **Base URL** — editable in `new-custom`, read-only in `new-byok` / `edit`. + +Custom-only extras (top of dialog, above API key): + +- **Display name** — user-set label. +- **Type** — radio: OpenAI-compatible / Anthropic / Google. + +**No Availability row.** No `chat / agent / mobile` checkboxes anywhere. (Confirmed against `final.jsx` lines 230-250.) + +Models section header: + +- Title: just **"Models"** (no subtitle like "GET /models → 6 found"). +- Right side: `[+ Add from catalog]` (edit only) + `[+ Add custom model]`. + +Filter bar above the model list: + +- Search input +- Chips: `All`, `≥ 200k ctx`, `≤ $1/M`, `Released ≤ 6mo`. **No Vision / Reasoning / Tool use chips.** + +Model picker rows (catalog list): + +- Checkbox + model name + meta (context) + release date column (right-aligned, e.g. "Sep 2025") +- In edit state, registered models also show a `⋯` kebab with `View docs` / `Remove from registry`. + +Footer: + +- New states: `[Cancel]` `[Verify & save]`. +- Edit state: `[Remove provider]` (ghost danger, left) · `[Cancel]` `[Save changes]`. + +### 5.3 Add Custom Model dialog + +``` +┌──────────────────────────────────────────────────────────┐ +│ [An] Add custom model · under Anthropic ✕ │ +│ Use this for preview models, fine-tunes, private deploy- │ +│ ments, or anything not in the catalog. Provider's │ +│ connection (key, base URL) is reused. │ +│ │ +│ Display name [Claude Sonnet 4.5 (preview) ] │ +│ Model ID [claude-sonnet-4-5-20260601-preview ] │ +│ [Test] │ +│ │ +│ Test once before saving — minimal "ping" request. │ +│ [Cancel] [Add] │ +└──────────────────────────────────────────────────────────┘ +``` + +Fields: **Display name, Model ID. That's it.** No Capabilities checkboxes. No Availability row. No context window. No capabilities are stored on registry entries. + +### 5.4 Agent panel layout + +Top sub-tab strip (in order): **OpenCode · Claude Code · Codex · Quick chat**. Quick chat is **last** (lowest priority — it's the smallest of the four backends). + +Each sub-tab includes: + +- **Status card** — version, binary path, `[Use this backend]`, `[Reinstall]`, `[Browse…]`. Three states: `✓ Active backend` / `○ Configured, not active` / `⚠ Not installed`. +- **Models in this backend's picker** — list of registry models the user can check/uncheck to control which surface in the in-session picker. Header has a `Manage in BYOK →` link to the canonical registry. Sub-text under the section title: _"tick which models show up when you switch model mid-session"_. + +> Per-backend "Default model" + "Default reasoning effort" were dropped — new sessions inherit (model, effort) from the previous active session on the same backend via `AgentSessionManager.getLastSelection`, falling back to the backend's catalog default. The in-memory map is wiped on plugin reload. + +#### 5.4.1 OpenCode sub-tab + +- **Picker section is a UNION of three sources** (per §2.2): + 1. OpenCode's enumerated bundled models (Big Pickle, etc.) — keys are the bare wire-form `baseModelId` (e.g. `bigpickle/big-pickle`). + 2. Copilot-Plus hosted models (when Plus license active) — keys `copilot-plus/` (wire form). + 3. BYOK registry entries — keys are the wire form `/` (e.g. `anthropic/claude-sonnet-4-5`). +- These are merged at render time, never written to the BYOK `registry`. A subtle visual divider in the picker section groups them (OpenCode-bundled, then Plus, then BYOK). +- No `★ default` column (default is set via the dropdown above). +- "OpenCode not installed" empty-state when source #1 is unavailable. + +#### 5.4.2 Claude Code / Codex sub-tabs + +- Subscription card — `Authenticated as ` + `[Re-authenticate]`. Unauthenticated state: `⚠ Not signed in`. +- Picker section sources from the backend's **bundled** model list (not the BYOK registry — these backends are subscription-bound). + +#### 5.4.3 Quick chat sub-tab + +- Picker section sources from `ModelRegistry.list()`. +- Implementation is a **placeholder** in this plan (UI shell + persistence wiring only). The complete wiring of "user picks a Quick-Chat-curated model in the chat input → LangChain chat fires in the new chat view" is the subject of the follow-up doc (`designdocs/QUICK_CHAT_AGENT_INTEGRATION.md`). + +--- + +## 6. Implementation Milestones + +Each milestone is independently shippable and verifiable. Verification = unit tests + a short manual checklist the implementing agent can execute. + +> **Convention for the implementing agent:** Before starting any milestone, run `npm run lint` and `npm run test` to ensure baseline passes. At end of each milestone, run both again — they must still pass. + +--- + +### M1 — Module skeleton + catalog (lazy, BYOK-tab-triggered) + +**Goal:** Establish the `src/modelManagement/` module with its public API + eslint boundary, plus `ModelCatalogService` with lazy fetching. + +**Deliverables:** + +- **Module skeleton:** + - `src/modelManagement/index.ts` with the public API barrel per §3.0. + - `src/modelManagement/types.ts` with `ProviderConfig`, `RegistryEntry`, `ProviderId`, etc. + - `src/modelManagement/providers/supportedProviders.ts` — `SUPPORTED_PROVIDER_IDS` constant per §1.2. + - ESLint configuration: add `import/no-restricted-paths` rule per §3.0. Verify a deliberate violation (an import of `src/modelManagement/catalog/ModelCatalogService` from anywhere outside the module) fails `npm run lint`. +- **Catalog:** + - `src/modelManagement/catalog/modelsCatalog.types.ts` — types from §1.3. + - `src/modelManagement/catalog/ModelCatalogService.ts` — **lazy** two-tier read path (no plugin-onload fetch): + - `ensureLoaded()`: on first call, read disk cache; if missing, leave memory empty until a successful `refresh()`. Memory cache after that. + - `refresh()`: triggers live fetch (5s timeout), writes disk on success, emits change. + - Disk cache path: resolve via `app.vault.adapter` + `manifest.dir`; file is `.modelsCatalogCache.json` with `{ fetchedAt, data }` structure. + - No `setOpenCodeSource` (removed per §1.4). +- Unit tests in `src/modelManagement/catalog/__tests__/ModelCatalogService.test.ts`: + - `ensureLoaded()` is idempotent and lazy (no fetch happens until `refresh()` is called or invoked manually). + - Two-tier behavior (no disk cache → empty memory; live fetch failure → keeps last loaded). + - `refresh()` triggers fetch + disk write + change event. + - Filter behavior (`contextAtLeast`, `maxCostPerMillion`, `releasedWithinMonths`). + +**Agent verification checklist:** + +1. `npm run test -- ModelCatalogService` — all green. +2. `npm run lint` — clean. Then add a deliberate `import { ModelCatalogService } from "@/modelManagement/catalog/ModelCatalogService"` in `src/main.ts` → `npm run lint` should report a boundary violation. Revert. +3. Manual smoke (load plugin): + - Open Settings → existing legacy tabs work normally; plugin onload **does not** trigger any models.dev request (verify via dev-tools network panel). + - `ModelCatalogService.getInstance().getMeta()` returns `{ fetchedAt: null, source: "bundled" }` until something invokes `ensureLoaded()`. (The `source` string still reads `"bundled"` as a legacy sentinel; semantically it means "no live data yet.") + - Calling `ensureLoaded()` (e.g. via a temporary dev command) reads disk cache or leaves memory empty; calling `refresh()` triggers a live fetch and updates disk + memory. + +**Out of scope:** No UI consumes the catalog yet beyond a dev-mode smoke logger; lazy hook into BYOK tab open is wired in M4. + +--- + +### M2 — Schema, migration, and service skeleton + +**Goal:** Introduce `settingsVersion`, `providers`, `registry`; write v0→v2 migration; refactor `ChatModelManager` to read the new shape. After this milestone, the plugin behaves identically to before from the user's perspective — only the internal data layout has changed. + +**Deliverables:** + +- `src/settings/model.ts` — slimmed to schema only. Re-exports `ProviderConfig` / `RegistryEntry` / `ProviderId` etc. from `@/modelManagement` rather than redefining. Add `settingsVersion`, `providers`, `registry`, `_migrationBreadcrumbs`, `_migrationNoticeDismissed` fields. Remove the legacy provider-key fields, `defaultModelKey`, and `agentMode.enabled` from the interface. Add `agentMode.backends.quickChat` skeleton type (per §2.2). +- `src/modelManagement/migrations/runMigrations.ts` + `v0-to-v2.ts` — runner + v0→v2 implementation. **Migration must run synchronously** — it cannot await the catalog service. It does not read the catalog at all; registry entries are built from v0 data alone (no capability inference, no contextWindow/releaseDate enrichment). +- `src/modelManagement/providers/ProviderRegistry.ts` and `src/modelManagement/registry/ModelRegistry.ts` — full implementations. +- `src/modelManagement/providers/adapters/` — relocate `src/LLMProviders/*` here. Each adapter file exports `buildLangChainClient(...)` + `extraSchema: z.ZodSchema` per §3.6. `index.ts` exposes the adapter registry. +- `src/modelManagement/chatModel/ChatModelManager.ts` — refactored to read from `ProviderRegistry` + `ModelRegistry` only; consults the adapter registry for instantiation. +- `src/modelManagement/chatModel/buildLangChainConfig.ts` — extracted pure helper + unit tests. +- Migration notice toast (§4.4) wired in `main.ts` `onload` after settings load. +- Dev command `Copilot: Show settings migration status` registered. +- `src/modelManagement/migrations/__tests__/v0-to-v2.test.ts` — fixture-based tests: + - Fresh install (empty settings) → `settingsVersion = 2`, `providers = {}`, `registry = []`, `agentMode.backends.quickChat = { defaultModel: null, modelEnabledOverrides: {} }`. + - Settings with only OpenAI key (no `activeModels`) → one provider, no registry entries. + - Settings with `activeModels` containing only built-in entries and **no** provider keys → `providers = {}`, `registry = []` (every built-in dropped because no key existed). + - Settings with OpenAI key configured + built-in entries from Anthropic, Google, OpenAI → only the OpenAI built-ins migrate; Anthropic + Google built-ins dropped (no keys); breadcrumb lists the dropped models. + - Settings with `activeModels` mix of built-in + custom + Ollama → providers + registry entries split correctly; embedding models stay in `activeEmbeddingModels`. + - Settings with per-model overrides → overrides dropped, breadcrumbs populated. + - Settings with `activeModels[*].enabled = false` → entry dropped, breadcrumb logged. + - Settings with `agentMode.backends..modelEnabledOverrides` keyed by old `|` → normalized per §2.2 (opencode → wire form `/`; claude/codex → bare ``; quickChat → `:`); orphans dropped. + - Settings with `defaultModelKey = "claude-sonnet-4-5|anthropic"` → `agentMode.backends.quickChat.defaultModel.baseModelId = "anthropic:claude-sonnet-4-5"`. + - Settings with no fields at all (corrupt) → migration falls back gracefully. + - Idempotency: running migration twice produces the same output. + - OpenCode-bundled / Plus models in `activeModels`: skipped from `registry`; forwarded into `agentMode.backends.opencode.modelEnabledOverrides` with bare wire-form `baseModelId` keys (bundled stays as-is; Plus prepends `copilot-plus/`) per migration step 4. + +**Agent verification checklist:** + +1. `npm run test -- migrations` — fixtures green. +2. Run `npm run build` then load plugin in test vault with a pre-existing `data.json` from `git stash` (or seed one); verify: + - Notice toast appears once. + - Opening Settings → existing Basic + Models tabs still render (UI hasn't migrated yet) and show the migrated data correctly. + - Chat works — pick a model in chat input, send a message, get a response. + - Restart plugin → toast does NOT reappear. +3. Run `Copilot: Show settings migration status` command → modal shows breadcrumb with `from: 0, to: 2`. +4. Garbage `data.json` → safety net catches it; toast says "Couldn't upgrade", plugin still works in degraded mode. +5. Manual screenshot before/after — settings model picker still shows correct selection. + +**Risk:** Any consumer outside `ChatModelManager` that reads `settings.openAIApiKey` etc. directly will break. **Pre-flight grep** (see Appendix A) and update each call site to read from `ProviderRegistry`. + +--- + +### M3 — Move embedding models into the QA tab (and rename to "Embedding") + +**Goal:** Pull the embedding model section out of the old `ModelSettings.tsx` and drop it into the existing QA tab at the bottom. Rename the tab from "QA" to "Embedding". All other QA settings stay exactly where they are. + +**Deliverables:** + +- `src/settings/v2/components/QASettings.tsx` (the existing QA tab file) — extended: + - Existing QA settings (semantic-search settings, indexing settings, exclusions, etc.) stay in place at the top. + - **New section at the bottom**: heading "Embedding models" + the embedding-model table moved verbatim from `ModelSettings.tsx`. Same add/edit/delete flows, same `activeEmbeddingModels` field, same existing dialog components. Code is _moved_ (not copied) — the embedding table component lives in its own file extracted from `ModelSettings.tsx`. + - Tab label updated to **"Embedding"**. +- `src/settings/v2/components/ModelSettings.tsx` — embedding portion removed; only chat table remains (chat table itself goes away in M4–M9 as BYOK takes over). +- `src/settings/v2/SettingsMainV2.tsx` — tab label changed from "QA" to "Embedding"; tab order unchanged. +- Extracted embedding table component (e.g. `src/settings/v2/components/EmbeddingModelsSection.tsx`) so M3 is mostly a move-rename refactor with no behavior change. +- Snapshot tests for the renamed tab. + +**Agent verification checklist:** + +1. Open settings → tab strip shows "Embedding" where "QA" was. Same position in the strip. +2. Open Embedding tab → existing QA settings visible at the top (semantic search, indexing, exclusions, etc.); embedding models table at the bottom. +3. Embedding model add / edit / delete / toggle flows all work; vector store rebuild still works (trigger via existing command). +4. Old "Models" tab → only chat models visible; embedding table gone. +5. No other settings tabs moved or renamed. + +--- + +### M4 — BYOK panel (global table + provider sections, no dialogs yet) + +**Goal:** Implement the new BYOK tab as one global table with provider section rows. Existing add/edit flows still go through the old `ModelAddDialog` for this milestone (we wire the new dialogs in M5). The BYOK tab and old Models tab **both** exist after this milestone — but BYOK becomes the new primary. + +**Deliverables:** + +- `src/modelManagement/ui/tabs/ByokPanel.tsx`: + - **On mount**: `await ModelCatalogService.getInstance().ensureLoaded()` (skeleton during load); then if `getMeta().fetchedAt < Date.now() - 24h`, fire-and-forget `refresh()`. + - Empty state with `[+ Add provider]` (button opens a placeholder modal until M5). + - Populated state with one global table per §5.1. + - Header: title + the user-bring description copy per §5.1 + `[↻ Refresh catalog]` (with last-fetched timestamp tooltip) + `[Manage providers]` + `[+ Add provider]`. + - Filter bar: search input + `All` / `local` / `≥ 200k ctx` chips (no capability chips). + - Footer: ` enabled across providers · available in catalog`. +- `src/modelManagement/ui/components/ByokGlobalTable.tsx` — one global table component handling provider section rows + indented model rows: + - Provider section row: chevron · glyph · name · count · badge (`custom endpoint` if `kind === "custom"`) · `[⚙ Configure]` (ghost) · `⋯` (kebab → single "Remove provider" item). + - Model rows: `Model name` + `Meta` only. No checkbox. No kebab. No badges. + - Foldable per provider (default: open; remembers state per provider). +- `src/settings/v2/SettingsMainV2.tsx` — register "BYOK" tab; rename old "Models" tab to "Models (legacy)" with a strikethrough style — to be removed in M9. +- Mobile rendering: `useIsMobile()` hook adapts header copy and stacks controls; provider sections look the same (since OpenCode/Plus rows are gone anyway). +- Tests: snapshot for populated/empty states; interaction tests for fold/unfold, Remove provider confirm; verify `ensureLoaded` runs on mount and `refresh()` fires only when stale. + +**Agent verification checklist:** + +1. Open settings → BYOK tab visible. Plugin reload + open Settings (without visiting BYOK) makes zero models.dev requests. +2. **First-time BYOK tab open**: skeleton shows briefly → `ensureLoaded()` resolves → table populates. Second open in the same session is instant (memory cache). +3. With migrated data: table populated with provider section rows; counts match `ModelRegistry.list().length`; model rows are display-only. +4. **No OpenCode or Copilot Plus rows in BYOK**, ever. Even when OpenCode is running, only BYOK providers (Anthropic / OpenAI / Ollama / etc.) appear. +5. Click chevron → section folds; click again → unfolds. State persists across modal open/close. +6. Provider section kebab → Remove provider → confirm → all that provider's rows disappear; restart plugin → still gone. +7. Click `[⚙ Configure]` on a provider section → opens legacy edit modal (placeholder until M5). +8. Click `[↻ Refresh catalog]` → spinner → timestamp updates; rows show any newly-released models. +9. Filter bar: type "claude" → only Anthropic section + Claude rows show; click `local` chip → filters to Ollama; clear → all sections back. +10. Mobile build: layout adapts; no OpenCode/Plus sections (they were never there). + +--- + +### M5 — Configure Provider + Add Provider + Add Custom Model dialogs + +**Goal:** All three new dialogs from §5.2 / §5.3, wired into the BYOK tab. After this milestone, users can complete the full BYOK flow without ever touching legacy UI. + +**Deliverables:** + +- `src/settings/v3/dialogs/AddProviderDialog.tsx`: + - Provider picker with `Recommended` (Anthropic / OpenAI / Google) + `More providers` (alphabetical). + - "Add a custom provider" CTA card at the bottom (dashed border, accent tint per the design). + - Already-added providers filtered out. +- `src/settings/v3/dialogs/ConfigureProviderDialog.tsx` — single component supporting `state: "new-byok" | "new-custom" | "edit"`: + - Header adapts (✓ Verified badge in edit state; no badge in new states per the design). + - Connection fields with 120px label gutter. **No Availability row.** + - API key field directly editable in all states (no "Replace" button); test button present. + - Models section header: just "Models" subtitle; right side has `[+ Add from catalog]` (edit only) + `[+ Add custom model]`. + - Filter bar: search + `All` / `≥ 200k ctx` / `≤ $1/M` / `Released ≤ 6mo` chips. **No Vision / Reasoning / Tool use chips.** + - Model picker rows: checkbox + name + context + release date column. Edit state adds `⋯` kebab on registered rows (View docs / Remove from registry). + - Sticky upstream-provider headers for OpenRouter. + - Footer adapts (edit: `[Remove provider]` left, `[Save changes]` right; new: `[Verify & save]` right with selection count left). + - On save: writes to `ProviderRegistry` + `ModelRegistry`; verification calls dispatched async, errors decorate rows with ⚠. +- `src/settings/v3/dialogs/AddCustomModelDialog.tsx`: + - Three fields only: Display name, Model ID, Context window. + - **No Capabilities checkboxes. No Availability row.** Capabilities default to `["chat", "agent"]`. + - `[Test]` button next to Model ID. +- `src/settings/v3/components/ProviderCatalogList.tsx` — checklist used inside Configure Provider. +- BYOK provider section `[⚙ Configure]` button → opens **new** dialog in edit state. +- BYOK `[+ Add provider]` → opens **new** dialog flow. +- Tests: each dialog's states; verification happy + error paths; OpenRouter sticky-header rendering. + +**Agent verification checklist:** + +1. From empty BYOK tab → `[+ Add provider]` → AddProviderDialog opens → pick Anthropic → ConfigureProviderDialog opens in `new-byok` state with API key focused. +2. Paste a real Anthropic key → click `[Test]` → ✓ within ~2s. +3. Recommended models pre-checked, release date visible on each → click `[Verify & save]` → dialog closes → Anthropic section + rows appear in BYOK table. +4. Chat with the new model — succeeds (proves the migration → service → ChatModelManager path). +5. Click `[⚙ Configure]` on the Anthropic section → edit state with all fields pre-filled and ✓ Verified badge. **No Availability row visible.** Key field is editable directly (no "Replace" button). +6. Click `[+ Add custom model]` → fields are only Display name / Model ID / Context window. Enter a preview model ID → `[Test]` → ✓ → Add → row appears in the table. +7. Add a custom Ollama provider (`http://localhost:11434/v1`) — discovered model list populates from `/models`; pick two → save → Ollama section appears with `custom endpoint` badge. +8. Add OpenRouter — sticky section headers visible per upstream provider; `≤ $1/M` filter chip works. +9. Configure Provider model picker: `Released ≤ 6mo` chip filters to recent models. + +--- + +### M6 — Agent tab redesign (with Quick Chat sub-tab skeleton) + +**Goal:** Replace the old Agent settings UI with the new Agent tab per §5.4. **Includes** the Quick Chat sub-tab as a UI skeleton (persistence + curation list working; actual chat-input → backend wiring is in the follow-up doc). + +**Deliverables:** + +- `src/settings/v3/tabs/AgentPanel.tsx` — top-level layout with `BackendSubtabs` and per-backend sub-panel. +- `src/settings/v3/components/BackendSubtabs.tsx` — four-way sub-tab strip (**OpenCode / Claude Code / Codex / Quick chat** — Quick chat last) with active-vs-viewed distinction. +- `src/settings/v3/components/BackendStatusCard.tsx` — shared status card with three states (`✓ Active backend` / `○ Configured, not active` / `⚠ Not installed`) and `[Use this backend]` / `[Reinstall]` / `[Browse…]` actions. +- `src/settings/v3/components/BackendModelPicker.tsx` — shared "Models in this backend's picker" component used by all four sub-tabs: + - Header: title + sub-text ("tick which models show up when you switch model mid-session") + `Manage in BYOK →` link. + - Rows: checkbox + name + provider (muted) + meta. No ★ default badge column. + - Persists to `agentMode.backends..modelEnabledOverrides`. +- Per-backend sub-panels in `src/settings/v3/components/backends/`: + - **OpencodePanel.tsx:** Status + BackendModelPicker sourced from `ModelRegistry.list({ capability: "agent" })`. + - **ClaudeCodePanel.tsx / CodexPanel.tsx:** Status + Subscription card (re-auth) + BackendModelPicker sourced from the backend's bundled model list. + - **QuickChatPanel.tsx (SKELETON):** Status (always "Active — runs in the plugin"; no install needed) + BackendModelPicker sourced from `ModelRegistry.list({ capability: "chat" })`. The picker writes to `agentMode.backends.quickChat.modelEnabledOverrides`. **No runtime routing wiring yet** — clicking around saves settings but the chat input still routes through the legacy ChatModelManager path. The follow-up doc connects the wires. + +Per-backend "Default model" + "Default reasoning effort" controls were dropped in the model-settings redesign. New sessions inherit (model, effort) from the previous active session on the same backend via `AgentSessionManager.getLastSelection`; on a fresh plugin load the manager falls back to the backend's catalog default. Picker selections feed `AgentSessionManager.rememberLastSelection` rather than `setSettings`. + +- `src/settings/v2/SettingsMainV2.tsx` — replace old Agent tab registration with new one. +- Tests: tab switch preserves state; `[Use this backend]` updates `agentMode.activeBackend`; picker persistence per backend. + +**Agent verification checklist:** + +1. Open Agent tab → 4 sub-tabs visible in order: OpenCode · Claude Code · Codex · Quick chat. OpenCode sub-tab active by default. +2. Status card shows correct state per backend. +3. Switch sub-tabs → panel changes; each preserves its own state. +4. Click `[Use this backend]` in Claude Code's status card → it becomes active; OpenCode flips to `○ Configured`. +5. OpenCode picker: tick/untick a model → reload plugin → state persists; chat input agent picker reflects. +6. Quick chat sub-tab: status card says active; picker lists all chat-capable models. Tick some models → save → restart → state persists. **Chat still routes through legacy path** — this is expected. + +--- + +### M7 — _(skipped — Welcome modal is out of scope for this plan)_ + +The standalone Welcome modal designed in `final.jsx` is deferred to a separate workstream. The BYOK tab's existing empty state (one big `[+ Add provider]` CTA per §5.1) is the only first-run surface this plan ships. Milestone numbers M8/M9 are kept as-is to preserve cross-references; M7 is intentionally a no-op slot. + +--- + +### M8 — BYOK→OpenCode agent bridge + OpenCode panel model sources + +**Goal:** Make BYOK custom providers usable in agent mode (JTBD-17), and complete the OpenCode sub-tab's three-source picker (OpenCode-bundled ⊕ Copilot Plus ⊕ BYOK agent-capable). **OpenCode-bundled and Copilot Plus models stay out of the BYOK registry entirely.** + +**Deliverables:** + +- `src/agentMode/backends/opencode/byokBridge.ts` — on OpenCode startup (and on `ProviderRegistry` changes), register every BYOK provider into OpenCode's config. For built-in providers, just register the API key; for custom providers, register the full endpoint config. +- `src/agentMode/backends/opencode/bundledModels.ts` — sync wrapper that exposes OpenCode's enumeration of bundled models (Big Pickle, etc.) via a `listBundledModels(): Promise` function. Reads from the running OpenCode binary's JSON-RPC or config; isolated here so the OpenCode panel doesn't have to know the wire format. +- `src/agentMode/backends/opencode/plusModels.ts` — same shape for Copilot Plus hosted models; gated by `isPlusUser`. +- `src/settings/v3/components/backends/OpencodePanel.tsx` (extended from M6 skeleton): + - `BackendModelPicker` is replaced/wrapped to display three sources unioned: + 1. `listBundledModels()` rows (header: "OpenCode-bundled"). + 2. `listPlusModels()` rows (header: "Copilot Plus", only when Plus active). + 3. `ModelRegistry.list({ capability: "agent" })` rows (header: "From BYOK"). + - Each row has a checkbox writing to `agentMode.backends.opencode.modelEnabledOverrides[]`. + - `` format: the bare wire-form `baseModelId` the running OpenCode binary reports (e.g. `bigpickle/big-pickle`, `copilot-plus/copilot-plus-flash`, `anthropic/claude-sonnet-4-5`). The provider segment is intrinsic to the wire form, so two providers offering the same `modelId` never collide. + - "OpenCode not installed" empty-state in the panel when source #1 is unavailable; the BYOK row sources still render so users can preview them. +- **No changes to `ByokGlobalTable.tsx`.** OpenCode-bundled and Plus models intentionally never appear in BYOK. +- Tests: bridge round-trip (BYOK custom provider → OpenCode config file → readable back); OpenCode panel renders all three sources correctly when present; missing sources hide their section header. + +**Agent verification checklist:** + +1. Add a local Ollama provider via BYOK → check OpenCode's config dir contains an entry for it. Ollama row appears in the OpenCode panel's "From BYOK" section. +2. Start an agent session in OpenCode → Ollama model appears in the in-session model picker → can execute a task using it. +3. With OpenCode running: OpenCode panel shows three sections (Bundled / Plus if applicable / From BYOK); BYOK tab shows **no** OpenCode rows. +4. Stop OpenCode → "OpenCode not installed" empty-state replaces the Bundled section; BYOK section in the panel still works; the BYOK tab is unaffected. +5. Re-enable OpenCode → all three sources back. +6. With Plus license: Plus section appears in OpenCode panel; BYOK tab is unaffected. + +--- + +### M9 — Cleanup + final removals + +**Goal:** Delete legacy code paths, remove "Models (legacy)" tab, finalize tab label renames, update docs. + +**Deliverables:** + +- Delete `src/settings/v2/components/ModelSettings.tsx`, `ModelAddDialog.tsx`, `ModelEditDialog.tsx`, the model-picker portion of `BasicSettings.tsx`. +- Delete legacy provider-key field references throughout the codebase (run grep from Appendix A; nothing should match). +- `src/settings/v2/SettingsMainV2.tsx` — remove "Models (legacy)" tab; rename "Basic" → "Chat", "Chat & Commands" → "Commands". (The QA → Embedding rename already shipped in M3.) +- `src/constants.ts` — `BUILTIN_CHAT_MODELS` removed (catalog replaces it). `BUILTIN_EMBEDDING_MODELS` kept (embedding side is unchanged by this redesign). +- Delete `src/LLMProviders/` (its contents moved into `src/modelManagement/` in M2). +- Update user-facing docs (`docs/llm-providers.md`, `docs/agent-mode-and-tools.md`) per the new UI. +- Update `AGENTS.md` migration notes section. +- Final `npm run lint && npm run format && npm run test && npm run build` pass clean. + +**Agent verification checklist:** + +1. `git grep openAIApiKey src/` returns nothing. +2. `git grep activeModels src/` only returns `activeEmbeddingModels` references. +3. `git grep BUILTIN_CHAT_MODELS src/` returns nothing. +4. `src/LLMProviders/` no longer exists. +5. Tab strip shows: Chat · BYOK · Agent · Commands · Embedding · Advanced. (No "Models", no "QA".) +6. `npm run lint && npm run test && npm run build` all green. +7. Manual smoke test: + - Fresh install → BYOK empty state → `[+ Add provider]` → add provider → chat works. + - Add custom Ollama → agent mode works in OpenCode. + - Switch agent backend to Claude Code → agent session works. + - Embedding tab → rebuild vector index → semantic search works. +8. Take "before" screenshots from M2 and "after" screenshots — UI complete per the design. + +--- + +## 7. Cross-cutting verification artifacts + +The implementing agent should maintain a `TODO.md` per `AGENTS.md` guidance for session-level tracking, plus produce these artifacts as deliverables of the whole series: + +- `designdocs/MODEL_MANAGEMENT_IMPLEMENTATION_PROGRESS.md` — checked off as each milestone completes. +- Screenshots before/after each milestone (saved to `.context/screenshots/M/`) — proves the UI works. +- Migration test fixtures under `src/settings/migrations/__tests__/fixtures/`: + - `fixture-keys-only.json` — only provider keys. + - `fixture-custom-provider-ollama.json` — local Ollama with two models. + - `fixture-agent-overrides.json` — `modelEnabledOverrides` populated with old-format keys. + - `fixture-overrides-everywhere.json` — every per-model override field set, plus `enabled: false`. + - `fixture-azure-bedrock.json` — Azure + Bedrock with their extras. + - `fixture-default-model-key.json` — `defaultModelKey` populated; verifies Quick Chat seeding. + +--- + +## 8. Risks & known unknowns + +| Risk | Mitigation | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `models.dev/api.json` schema drift between runtime fetches | Hand-rolled `.d.ts` + runtime Zod validation in `ModelCatalogService.refresh()`. Bad payload → log + keep last good source. | +| `models.dev` outage or CORS issue from Obsidian's environment | Disk cache covers outage once we've fetched at least once; lazy fetch means most sessions never call out at all. CORS not a concern (Obsidian uses Node `fetch`). | +| Boundary eslint rule false positives during migration | Add explicit `import/no-restricted-paths` allowances for transition files (`src/main.ts`, the legacy `chatModelManager` shim) until M9 removes them. | +| OpenCode bundled / Plus model enumeration API changes | `bundledModels.ts` + `plusModels.ts` are the only consumers; isolated in the OpenCode backend folder. | +| Migration on corrupt data.json | Safety net in `runMigrations` — degrade gracefully. | +| Keychain unavailability on older Obsidian | Existing `_keychainOnly` flag respected; falls back to inline keys. | +| Per-model `numCtx` (Ollama) drop breaks user setups | Defaults are reasonable; documented in toast. | +| Plus license check timing during M8 | Existing `isPlusUser` reactive; OpenCode panel re-renders its "Copilot Plus" section when the flag flips. | +| Quick Chat skeleton doesn't actually route the user's chat input | Documented as "skeleton — follow-up doc completes it." Settings persist but runtime routing is unchanged until the follow-up ships. | +| Provider `extra` opaqueness lets bad payloads sneak past TypeScript | Each adapter's `extraSchema.parse(...)` is called at instantiation time; failure surfaces as a `lastVerificationError` on the provider. Migration also runs schemas to validate carried-over Azure/Bedrock extras. | + +--- + +## 9. Follow-up scope (out of this plan) + +The Quick Chat agent backend, end-to-end: + +- Chat input model picker integration (which backend gets invoked when a model is picked). +- LangChain chat as a first-class agent backend (session API parity with OpenCode et al.). +- New chat view bindings for the Quick Chat agent. +- Migration / runtime resolution rules when a model belongs to multiple backends' pickers. + +→ **`designdocs/QUICK_CHAT_AGENT_INTEGRATION.md`** (created alongside this plan; see that doc for design + milestones). + +The Quick Chat sub-tab in M6 is a **skeleton** — UI shell and persistence only. The follow-up doc owns runtime routing and the new chat view. + +### Future migration: CustomModel capability removal + +`RegistryEntry.capabilities` has been removed. However, the legacy `CustomModel` type in `src/aiParams.ts` still carries its own pre-v2 `ModelCapability` enum (from `src/constants.ts`, values `REASONING` / `VISION` / `WEB_SEARCH`). The chat path still flows through `CustomModel`, and several consumers read `customModel.capabilities` to gate behavior. + +These consumers stay as-is in this redesign and are scheduled for removal alongside a broader "chains stop supporting thinking blocks + vision gating moves to attach-time" cleanup: + +| Site | What it does today | Future action | +|---|---|---| +| `src/LLMProviders/chainRunner/LLMChainRunner.ts:92` | Excludes thinking blocks for non-reasoning models (`excludeThinking`) | Drop the gate. Chains will not support thinking blocks; rely on output-side `` stripping for open-weight reasoning models. | +| `src/LLMProviders/chainRunner/VaultQAChainRunner.ts:52` | Same | Same. | +| `src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts` (vision gate around line 570, reasoning gate around line 755) | `hasCapability(model, VISION)` strips images when model is text-only; reasoning gate mirrors LLMChainRunner | Replace vision gate with attach-time check in `ChatInput.tsx` driven by `ModelCatalogService.getModel(...)?.modalities?.input?.includes("image")`. Drop the reasoning gate with the rest of the thinking-block work. For custom (non-catalog) models, add an opt-in `supportsVision` flag on the custom provider/model config. | +| `src/modelManagement/chatModel/ChatModelManager.ts:352-396, 593` | Passes `enableReasoning` / `think` / `reasoning_effort` to OpenRouter / Ollama / LM Studio SDKs based on `customModel.capabilities` | Drop. We stop requesting thinking from any provider; default `reasoning_effort` is fine for the reasoning models that always reason internally (OpenAI o-series). | +| `src/components/ui/ModelParametersEditor.tsx:73-76` | Renders the reasoning-effort slider when the model has `REASONING` capability | Remove the slider with the rest of the thinking-block removal. | +| `src/components/ui/model-display.tsx` (`ModelCapabilityIcons`), `src/settings/v2/components/ModelTable.tsx`, `src/settings/v2/components/ModelEditDialog.tsx`, `src/settings/v2/components/ModelAddDialog.tsx` | Render capability icons / checkboxes in the legacy CustomModel-based UI | Delete with `CustomModel.capabilities` and the legacy `ModelCapability` enum. | +| `src/constants.ts` (`ModelCapability` enum + `MODEL_CAPABILITIES` record) and `src/aiParams.ts` (`CustomModel.capabilities`) | Type-system home for the legacy enum | Delete once all consumers above are gone. | + +These are deliberately separated from the registry-side cleanup: `RegistryEntry.capabilities` and `CustomModel.capabilities` are different fields on different types — removing the former does not constrain or require touching the latter. + +--- + +## Appendix A — Pre-flight grep targets + +Run these before starting M2 to map every legacy field consumer that needs updating to read via `ProviderRegistry`: + +``` +git grep -nE '(openAIApiKey|openAIOrgId|anthropicApiKey|googleApiKey|cohereApiKey|mistralApiKey|deepseekApiKey|groqApiKey|xaiApiKey|openRouterAiApiKey|siliconflowApiKey|amazonBedrockApiKey|amazonBedrockRegion|huggingfaceApiKey|azureOpenAI\w+|openAIProxyBaseUrl|openAIEmbeddingProxyBaseUrl|defaultModelKey|activeModels|agentMode\.enabled)' -- 'src/**' +``` + +Every match outside `src/settings/migrations/`, `src/settings/model.ts` (the type definition), and `src/services/ProviderRegistry.ts` is a call site to update. + +--- + +## Appendix B — Final file inventory + +| New files (M1–M9) | Purpose | +| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `src/modelManagement/index.ts` | M1 public API barrel (single entry point) | +| `src/modelManagement/types.ts` | M1 shared types (ProviderConfig, RegistryEntry, …) | +| `src/modelManagement/providers/supportedProviders.ts` | M1 `SUPPORTED_PROVIDER_IDS` | +| `src/modelManagement/catalog/modelsCatalog.types.ts` | M1 catalog types | +| `src/modelManagement/catalog/ModelCatalogService.ts` | M1 (lazy + 2-tier: memory → disk → live) | +| ESLint config additions (`import/no-restricted-paths`) | M1 boundary enforcement | +| `src/modelManagement/migrations/runMigrations.ts` | M2 | +| `src/modelManagement/migrations/v0-to-v2.ts` | M2 | +| `src/modelManagement/migrations/__tests__/v0-to-v2.test.ts` + fixtures | M2 | +| `src/modelManagement/providers/ProviderRegistry.ts` | M2 | +| `src/modelManagement/providers/adapters/*` (relocated from `src/LLMProviders/`) | M2 | +| `src/modelManagement/registry/ModelRegistry.ts` | M2 | +| `src/modelManagement/chatModel/ChatModelManager.ts` | M2 (refactored from src/LLMProviders) | +| `src/modelManagement/chatModel/buildLangChainConfig.ts` | M2 | +| `src/settings/v2/components/EmbeddingModelsSection.tsx` (extracted from ModelSettings) | M3 | +| `src/modelManagement/ui/tabs/ByokPanel.tsx` | M4 | +| `src/modelManagement/ui/components/ByokGlobalTable.tsx` | M4 | +| `src/modelManagement/ui/dialogs/{AddProviderDialog,ConfigureProviderDialog,AddCustomModelDialog}.tsx` | M5 | +| `src/modelManagement/ui/components/ProviderCatalogList.tsx` | M5 | +| `src/settings/v3/tabs/AgentPanel.tsx` | M6 | +| `src/settings/v3/components/{BackendSubtabs,BackendStatusCard,BackendModelPicker}.tsx` | M6 | +| `src/settings/v3/components/backends/{Opencode,ClaudeCode,Codex,QuickChat}Panel.tsx` | M6 | +| `src/agentMode/backends/opencode/byokBridge.ts` | M8 | +| `src/agentMode/backends/opencode/bundledModels.ts` | M8 | +| `src/agentMode/backends/opencode/plusModels.ts` | M8 | +| `designdocs/MODEL_MANAGEMENT_IMPLEMENTATION_PROGRESS.md` | Tracking artifact | + +| Deleted files (M9) | Reason | +| ---------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `src/settings/v2/components/ModelSettings.tsx` | Replaced by ByokPanel + EmbeddingsPanel | +| `src/settings/v2/components/ModelAddDialog.tsx` | Replaced by AddCustomModelDialog | +| `src/settings/v2/components/ModelEditDialog.tsx` | Replaced by ConfigureProviderDialog | +| Provider-key portion of `src/settings/v2/components/BasicSettings.tsx` | Replaced by ConfigureProviderDialog | +| `src/LLMProviders/` (entire folder) | Relocated to `src/modelManagement/providers/adapters/` + `chatModel/` in M2 | +| `BUILTIN_CHAT_MODELS` block of `src/constants.ts` | Replaced by `ModelCatalogService` (lazy live fetch + disk cache) | + +--- + +## Appendix C — Where this doc gets stored + +This is the working spec the implementing agent should read. + +- **`.context/plans/model-management-redesign-technical-design-impleme.md`** (this file) — canonical, shared with teammates. +- **On approval, copy to `designdocs/MODEL_MANAGEMENT_IMPLEMENTATION.md`** so it lives with the codebase. The two should be kept in sync; if either diverges, this `.context` file is the working draft. +- **`designdocs/QUICK_CHAT_AGENT_INTEGRATION.md`** — the follow-up doc; owns the Quick Chat agent runtime. diff --git a/designdocs/todo/PORTAL_CONTAINER_CONTRACT.md b/designdocs/todo/PORTAL_CONTAINER_CONTRACT.md new file mode 100644 index 00000000..e96187ed --- /dev/null +++ b/designdocs/todo/PORTAL_CONTAINER_CONTRACT.md @@ -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 + + ``` + + 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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 53032372..0c20c81b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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/` 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. { diff --git a/src/LLMProviders/__tests__/ChatModelManager.instance.test.ts b/src/LLMProviders/__tests__/ChatModelManager.instance.test.ts new file mode 100644 index 00000000..5e4ef26e --- /dev/null +++ b/src/LLMProviders/__tests__/ChatModelManager.instance.test.ts @@ -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); + }); +}); diff --git a/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts b/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts index 5927ef84..e787ffd1 100644 --- a/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts +++ b/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts @@ -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)); } diff --git a/src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts b/src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts index 02e45428..1de7d884 100644 --- a/src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts +++ b/src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts @@ -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 { 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({ diff --git a/src/LLMProviders/chainRunner/LLMChainRunner.ts b/src/LLMProviders/chainRunner/LLMChainRunner.ts index 90e76003..edb586a3 100644 --- a/src/LLMProviders/chainRunner/LLMChainRunner.ts +++ b/src/LLMProviders/chainRunner/LLMChainRunner.ts @@ -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 { - // 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 diff --git a/src/LLMProviders/chainRunner/VaultQAChainRunner.ts b/src/LLMProviders/chainRunner/VaultQAChainRunner.ts index 2ae374ff..63f893ee 100644 --- a/src/LLMProviders/chainRunner/VaultQAChainRunner.ts +++ b/src/LLMProviders/chainRunner/VaultQAChainRunner.ts @@ -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 { - // 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 diff --git a/src/LLMProviders/embeddingManager.ts b/src/LLMProviders/embeddingManager.ts index 6e52e796..d377555d 100644 --- a/src/LLMProviders/embeddingManager.ts +++ b/src/LLMProviders/embeddingManager.ts @@ -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> = { + [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) => Embeddings; const EMBEDDING_PROVIDER_CONSTRUCTORS = { @@ -46,17 +88,28 @@ export default class EmbeddingManager { >; private readonly providerApiKeyMap: Record 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", diff --git a/src/agentMode/acp/AcpBackendProcess.ts b/src/agentMode/acp/AcpBackendProcess.ts index 7547e82b..c532dde1 100644 --- a/src/agentMode/acp/AcpBackendProcess.ts +++ b/src/agentMode/acp/AcpBackendProcess.ts @@ -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 = { diff --git a/src/agentMode/acp/types.ts b/src/agentMode/acp/types.ts index 6fa1f9d9..a05af9db 100644 --- a/src/agentMode/acp/types.ts +++ b/src/agentMode/acp/types.ts @@ -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; + buildSpawnDescriptor(ctx: AcpSpawnContext): Promise; } diff --git a/src/agentMode/backends/claude/descriptor.ts b/src/agentMode/backends/claude/descriptor.ts index e3b5ecb1..668fea4f 100644 --- a/src/agentMode/backends/claude/descriptor.ts +++ b/src/agentMode/backends/claude/descriptor.ts @@ -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 { - const persistedEffort = settings.agentMode?.backends?.claude?.defaultModel?.effort ?? null; - await replayPersistedEffort(session, persistedEffort ?? undefined); + async applyInitialSessionConfig(session: AgentSession): Promise { + const seedEffort = session.getDefaultEffort(); + await replayPersistedEffort(session, seedEffort ?? undefined); }, }; diff --git a/src/agentMode/backends/codex/CodexBackend.ts b/src/agentMode/backends/codex/CodexBackend.ts index 23204790..8387a397 100644 --- a/src/agentMode/backends/codex/CodexBackend.ts +++ b/src/agentMode/backends/codex/CodexBackend.ts @@ -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 { + async buildSpawnDescriptor(_ctx: AcpSpawnContext): Promise { const descriptor = buildSimpleSpawnDescriptor( getSettings().agentMode?.backends?.codex?.binaryPath, "Codex binary path not configured. Open Agent Mode settings and set the path to codex-acp.", diff --git a/src/agentMode/backends/opencode/OpencodeBackend.test.ts b/src/agentMode/backends/opencode/OpencodeBackend.test.ts index 3550d8c8..cd9b6677 100644 --- a/src/agentMode/backends/opencode/OpencodeBackend.test.ts +++ b/src/agentMode/backends/opencode/OpencodeBackend.test.ts @@ -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 }; - 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 }; 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[0]); + const cfg = (await buildOpencodeConfig()) as { provider: Record }; + 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 }; + 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 }>; }; - 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 }; - 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 }>; - }; - 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; + provider: Record< + string, + { + npm?: string; + name?: string; + options?: { apiKey?: string; baseURL?: string }; + models?: Record; + } + >; }; - // 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 }; 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 }>; - }; - // 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 }; 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 }; }; - // 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/); diff --git a/src/agentMode/backends/opencode/OpencodeBackend.ts b/src/agentMode/backends/opencode/OpencodeBackend.ts index cb2ced28..f1c11213 100644 --- a/src/agentMode/backends/opencode/OpencodeBackend.ts +++ b/src/agentMode/backends/opencode/OpencodeBackend.ts @@ -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 { + async buildSpawnDescriptor(ctx: AcpSpawnContext): Promise { 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..models.` 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.` 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> { +export async function buildOpencodeConfig( + seedSelection: ModelSelection | null = null +): Promise> { 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 }; models?: Record>; }; - const provider: Record = {}; - for (const entry of decrypted) { - if (entry) provider[entry.providerId] = { options: { apiKey: entry.apiKey } }; - } + const provider: Record = { + ...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 = { provider }; // Inject a managed `copilot-build` agent so the mode picker can offer the @@ -222,9 +196,7 @@ export async function buildOpencodeConfig(): Promise> { // CLI-coding-agent prompt — wrong domain for an Obsidian vault assistant. // opencode's `cfg.agent.` 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> { }, }; - // 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 - // `/` 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`). diff --git a/src/agentMode/backends/opencode/bundledModels.test.ts b/src/agentMode/backends/opencode/bundledModels.test.ts new file mode 100644 index 00000000..aa1751be --- /dev/null +++ b/src/agentMode/backends/opencode/bundledModels.test.ts @@ -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 `|` 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" }, + ]); + }); +}); diff --git a/src/agentMode/backends/opencode/bundledModels.ts b/src/agentMode/backends/opencode/bundledModels.ts new file mode 100644 index 00000000..22b5d6dd --- /dev/null +++ b/src/agentMode/backends/opencode/bundledModels.ts @@ -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:`), 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..models.`). 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 { + 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 { + 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 `` 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); +} diff --git a/src/agentMode/backends/opencode/byokBridge.test.ts b/src/agentMode/backends/opencode/byokBridge.test.ts new file mode 100644 index 00000000..a5b2895e --- /dev/null +++ b/src/agentMode/backends/opencode/byokBridge.test.ts @@ -0,0 +1,286 @@ +/** + * `byokBridge` unit tests. + * + * `buildByokOpencodeProviderConfig` shapes the BYOK provider + model + * registries into the OpenCode `provider.` 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"); + }); +}); diff --git a/src/agentMode/backends/opencode/byokBridge.ts b/src/agentMode/backends/opencode/byokBridge.ts new file mode 100644 index 00000000..7ccb9b07 --- /dev/null +++ b/src/agentMode/backends/opencode/byokBridge.ts @@ -0,0 +1,153 @@ +/** + * Pure helper that shapes the BYOK provider + model registry into OpenCode's + * spawn-time `provider.` 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..models. = {}` so OpenCode lists them in + * `availableModels`. For custom providers this is the *only* way the + * models become visible (no models.dev snapshot for `custom:`); + * 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.`. 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; + }; + /** + * 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>; +} + +/** Map keyed by provider id, written under `OPENCODE_CONFIG_CONTENT.provider`. */ +export type OpencodeProviderMap = Record; + +/** + * 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:`, …) — 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> = {}; + 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; +} diff --git a/src/agentMode/backends/opencode/descriptor.test.ts b/src/agentMode/backends/opencode/descriptor.test.ts index cbaa75ed..d089351b 100644 --- a/src/agentMode/backends/opencode/descriptor.test.ts +++ b/src/agentMode/backends/opencode/descriptor.test.ts @@ -6,6 +6,28 @@ jest.mock("@/logger", () => ({ logError: jest.fn(), })); +const registeredProviderIds = new Set(); +const registeredModelKeys = new Set(); + +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); + }); }); diff --git a/src/agentMode/backends/opencode/descriptor.ts b/src/agentMode/backends/opencode/descriptor.ts index 8e0b4b96..e1429b21 100644 --- a/src/agentMode/backends/opencode/descriptor.ts +++ b/src/agentMode/backends/opencode/descriptor.ts @@ -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 `/` + // 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); }, diff --git a/src/agentMode/backends/opencode/plusModels.test.ts b/src/agentMode/backends/opencode/plusModels.test.ts new file mode 100644 index 00000000..dcf0b8f4 --- /dev/null +++ b/src/agentMode/backends/opencode/plusModels.test.ts @@ -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" }]); + }); +}); diff --git a/src/agentMode/backends/opencode/plusModels.ts b/src/agentMode/backends/opencode/plusModels.ts new file mode 100644 index 00000000..8c30cf59 --- /dev/null +++ b/src/agentMode/backends/opencode/plusModels.ts @@ -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. = {}`, 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 { + if (!isPlusEnabled()) return []; + // Slice so callers can't mutate the canonical list. + return PLUS_MODELS.slice(); +} diff --git a/src/agentMode/backends/shared/EnvOverridesSetting.tsx b/src/agentMode/backends/shared/EnvOverridesSetting.tsx index 2ec2b3cf..43811e73 100644 --- a/src/agentMode/backends/shared/EnvOverridesSetting.tsx +++ b/src/agentMode/backends/shared/EnvOverridesSetting.tsx @@ -115,11 +115,17 @@ export const EnvOverridesSetting: React.FC = ({ return (
-
-
Environment variables
-
- Set values for {backendDisplayName}, like {hintA} or {hintB}. +
+
+
Environment variables
+
+ Set values for {backendDisplayName}, like {hintA} or {hintB}. +
+
{rows.map((row) => { @@ -167,12 +173,6 @@ export const EnvOverridesSetting: React.FC = ({
); })} -
- -
); diff --git a/src/agentMode/backends/shared/simpleBinaryBackend.ts b/src/agentMode/backends/shared/simpleBinaryBackend.ts index 9d042480..f2fe5fbc 100644 --- a/src/agentMode/backends/shared/simpleBinaryBackend.ts +++ b/src/agentMode/backends/shared/simpleBinaryBackend.ts @@ -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 + ); } diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts index 508bd989..eda95e70 100644 --- a/src/agentMode/index.ts +++ b/src/agentMode/index.ts @@ -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 diff --git a/src/agentMode/session/AgentSession.ts b/src/agentMode/session/AgentSession.ts index a07972ab..6a644cbf 100644 --- a/src/agentMode/session/AgentSession.ts +++ b/src/agentMode/session/AgentSession.ts @@ -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 { const { backend, cwd, defaultModelSelection } = opts; try { diff --git a/src/agentMode/session/AgentSessionManager.test.ts b/src/agentMode/session/AgentSessionManager.test.ts index 6f40c859..7ec0dace 100644 --- a/src/agentMode/session/AgentSessionManager.test.ts +++ b/src/agentMode/session/AgentSessionManager.test.ts @@ -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 = - {}; - 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 }); + }); +}); diff --git a/src/agentMode/session/AgentSessionManager.ts b/src/agentMode/session/AgentSessionManager.ts index 8345a2a7..fd121d5e 100644 --- a/src/agentMode/session/AgentSessionManager.ts +++ b/src/agentMode/session/AgentSessionManager.ts @@ -28,6 +28,16 @@ const AUTOSAVE_DEBOUNCE_MS = 500; export type PermissionPrompter = (req: PermissionPrompt) => Promise; +/** + * 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(); private readonly restartingBackends = new Set(); 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(); + /** + * 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 { 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 - | 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 { - setSettings((cur) => { - const existing = (cur.agentMode.backends as Record | undefined)?.[ - backendId - ] as Record | 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 diff --git a/src/agentMode/session/descriptor.ts b/src/agentMode/session/descriptor.ts index 5ec8d76e..58f46efe 100644 --- a/src/agentMode/session/descriptor.ts +++ b/src/agentMode/session/descriptor.ts @@ -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; + applyInitialSessionConfig?(session: AgentSession): Promise; /** * Optional: identify the backend's own plan-mode plan files. Used by the diff --git a/src/agentMode/session/modelEnable.ts b/src/agentMode/session/modelEnable.ts index 56781598..cf20643b 100644 --- a/src/agentMode/session/modelEnable.ts +++ b/src/agentMode/session/modelEnable.ts @@ -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..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, diff --git a/src/agentMode/skills/SkillManager.ts b/src/agentMode/skills/SkillManager.ts index e8df6d3f..270270e5 100644 --- a/src/agentMode/skills/SkillManager.ts +++ b/src/agentMode/skills/SkillManager.ts @@ -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) { diff --git a/src/agentMode/ui/agentModelPickerHelpers.test.ts b/src/agentMode/ui/agentModelPickerHelpers.test.ts index 13feb055..615d85a1 100644 --- a/src/agentMode/ui/agentModelPickerHelpers.test.ts +++ b/src/agentMode/ui/agentModelPickerHelpers.test.ts @@ -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; - defaultSelectionById?: Record; + lastSelectionById?: Record; + 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"); + }); }); diff --git a/src/agentMode/ui/agentModelPickerHelpers.ts b/src/agentMode/ui/agentModelPickerHelpers.ts index 57bcaa78..2cb47830 100644 --- a/src/agentMode/ui/agentModelPickerHelpers.ts +++ b/src/agentMode/ui/agentModelPickerHelpers.ts @@ -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, diff --git a/src/agentMode/ui/useAgentModelPicker.ts b/src/agentMode/ui/useAgentModelPicker.ts index 911495cc..2547dc70 100644 --- a/src/agentMode/ui/useAgentModelPicker.ts +++ b/src/agentMode/ui/useAgentModelPicker.ts @@ -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; } diff --git a/src/commands/customCommandChatEngine.ts b/src/commands/customCommandChatEngine.ts index afd7da6e..445ea290 100644 --- a/src/commands/customCommandChatEngine.ts +++ b/src/commands/customCommandChatEngine.ts @@ -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"; /** diff --git a/src/components/chat-components/TokenLimitWarning.tsx b/src/components/chat-components/TokenLimitWarning.tsx index 4a2a177d..ebb1a8bd 100644 --- a/src/components/chat-components/TokenLimitWarning.tsx +++ b/src/components/chat-components/TokenLimitWarning.tsx @@ -30,11 +30,7 @@ export const TokenLimitWarning: React.FC = ({ 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); }; diff --git a/src/components/ui/setting-tabs.tsx b/src/components/ui/setting-tabs.tsx index e3740e07..39b910df 100644 --- a/src/components/ui/setting-tabs.tsx +++ b/src/components/ui/setting-tabs.tsx @@ -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 = ({ 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} diff --git a/src/contexts/TabContext.tsx b/src/contexts/TabContext.tsx index 3fc3a007..268fe692 100644 --- a/src/contexts/TabContext.tsx +++ b/src/contexts/TabContext.tsx @@ -8,6 +8,17 @@ interface TabContextType { const TabContext = createContext(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 diff --git a/src/core/ChatManager.test.ts b/src/core/ChatManager.test.ts index c641c39f..d9cf6dc0 100644 --- a/src/core/ChatManager.test.ts +++ b/src/core/ChatManager.test.ts @@ -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", () => ({ diff --git a/src/core/ContextCompactor.ts b/src/core/ContextCompactor.ts index 562cf22e..728601a9 100644 --- a/src/core/ContextCompactor.ts +++ b/src/core/ContextCompactor.ts @@ -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"; diff --git a/src/main.ts b/src/main.ts index b6d30afd..9ba2d0cc 100644 --- a/src/main.ts +++ b/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( diff --git a/src/modelManagement/catalog/ModelCatalogService.ts b/src/modelManagement/catalog/ModelCatalogService.ts new file mode 100644 index 00000000..cc58d1f4 --- /dev/null +++ b/src/modelManagement/catalog/ModelCatalogService.ts @@ -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; + read(path: string): Promise; + write(path: string, data: string): Promise; +} + +/** + * 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 | 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 { + 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 { + 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 { + // 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 { + 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 { + 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; + 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; + 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}`); + } + } + } +} diff --git a/src/modelManagement/catalog/__tests__/ModelCatalogService.test.ts b/src/modelManagement/catalog/__tests__/ModelCatalogService.test.ts new file mode 100644 index 00000000..29727433 --- /dev/null +++ b/src/modelManagement/catalog/__tests__/ModelCatalogService.test.ts @@ -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 & Pick> +): 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(); + /** Counts of operations for read-once / write-once assertions. */ + ops = { exists: 0, read: 0, write: 0 }; + + async exists(path: string): Promise { + this.ops.exists += 1; + return this.files.has(path); + } + async read(path: string): Promise { + 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 { + 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"]); + }); + }); +}); diff --git a/src/modelManagement/catalog/modelsCatalog.types.ts b/src/modelManagement/catalog/modelsCatalog.types.ts new file mode 100644 index 00000000..419a2678 --- /dev/null +++ b/src/modelManagement/catalog/modelsCatalog.types.ts @@ -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; +} + +/** + * 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; + +/** + * 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; +} diff --git a/src/modelManagement/migrations/__tests__/fixtures/custom-provider-ollama.json b/src/modelManagement/migrations/__tests__/fixtures/custom-provider-ollama.json new file mode 100644 index 00000000..722e2c8e --- /dev/null +++ b/src/modelManagement/migrations/__tests__/fixtures/custom-provider-ollama.json @@ -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": {} } +} diff --git a/src/modelManagement/migrations/__tests__/fixtures/default-model-key.json b/src/modelManagement/migrations/__tests__/fixtures/default-model-key.json new file mode 100644 index 00000000..cc53afe8 --- /dev/null +++ b/src/modelManagement/migrations/__tests__/fixtures/default-model-key.json @@ -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": {} } +} diff --git a/src/modelManagement/migrations/__tests__/fixtures/keys-only.json b/src/modelManagement/migrations/__tests__/fixtures/keys-only.json new file mode 100644 index 00000000..15464ace --- /dev/null +++ b/src/modelManagement/migrations/__tests__/fixtures/keys-only.json @@ -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": {} } +} diff --git a/src/modelManagement/migrations/__tests__/fkInvariant.test.ts b/src/modelManagement/migrations/__tests__/fkInvariant.test.ts new file mode 100644 index 00000000..8bd1bd5b --- /dev/null +++ b/src/modelManagement/migrations/__tests__/fkInvariant.test.ts @@ -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): void { + const providers = settings.providers as Record | 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; + assertProviderFkInvariant(s); + + // Spot-check the two specific system providers. + const providers = s.providers as Record; + 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; + assertProviderFkInvariant(s); + const providers = s.providers as Record; + 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; + assertProviderFkInvariant(s); + const providers = s.providers as Record; + // 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"); + }); +}); diff --git a/src/modelManagement/migrations/runMigrations.ts b/src/modelManagement/migrations/runMigrations.ts new file mode 100644 index 00000000..3498a6e4 --- /dev/null +++ b/src/modelManagement/migrations/runMigrations.ts @@ -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) => { + settings: Record; + 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 = { + 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( + 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; + try { + working = JSON.parse(JSON.stringify(raw)) as Record; + } 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 }; +} diff --git a/src/modelManagement/providers/ProviderRegistry.ts b/src/modelManagement/providers/ProviderRegistry.ts new file mode 100644 index 00000000..2acae45a --- /dev/null +++ b/src/modelManagement/providers/ProviderRegistry.ts @@ -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): Promise { + 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/LLMProviders/BedrockChatModel.test.ts b/src/modelManagement/providers/clients/BedrockChatModel.test.ts similarity index 100% rename from src/LLMProviders/BedrockChatModel.test.ts rename to src/modelManagement/providers/clients/BedrockChatModel.test.ts diff --git a/src/LLMProviders/BedrockChatModel.ts b/src/modelManagement/providers/clients/BedrockChatModel.ts similarity index 100% rename from src/LLMProviders/BedrockChatModel.ts rename to src/modelManagement/providers/clients/BedrockChatModel.ts diff --git a/src/LLMProviders/ChatLMStudio.ts b/src/modelManagement/providers/clients/LMStudioChatModel.ts similarity index 92% rename from src/LLMProviders/ChatLMStudio.ts rename to src/modelManagement/providers/clients/LMStudioChatModel.ts index 287cf1a2..3d1a8f0e 100644 --- a/src/LLMProviders/ChatLMStudio.ts +++ b/src/modelManagement/providers/clients/LMStudioChatModel.ts @@ -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; @@ -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; diff --git a/src/LLMProviders/ChatOpenRouter.ts b/src/modelManagement/providers/clients/OpenRouterChatModel.ts similarity index 98% rename from src/LLMProviders/ChatOpenRouter.ts rename to src/modelManagement/providers/clients/OpenRouterChatModel.ts index 3bf7a617..eb37dc65 100644 --- a/src/LLMProviders/ChatOpenRouter.ts +++ b/src/modelManagement/providers/clients/OpenRouterChatModel.ts @@ -11,7 +11,7 @@ type OpenRouterUsage = NonNullable; 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, diff --git a/src/modelManagement/providers/supportedProviders.ts b/src/modelManagement/providers/supportedProviders.ts new file mode 100644 index 00000000..4a609b99 --- /dev/null +++ b/src/modelManagement/providers/supportedProviders.ts @@ -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:` 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); +} diff --git a/src/modelManagement/providers/verifyProvider.test.ts b/src/modelManagement/providers/verifyProvider.test.ts new file mode 100644 index 00000000..867e57a6 --- /dev/null +++ b/src/modelManagement/providers/verifyProvider.test.ts @@ -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>("@/utils"); + return { + ...actual, + safeFetchNoThrow: (url: string, options?: RequestInit): unknown => mockFetch(url, options), + }; +}); + +function ok(status = 200, body: unknown = { data: [] }): Partial { + 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 { + return (lastCall().options.headers ?? {}) as Record; +} + +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/); + }); +}); diff --git a/src/modelManagement/providers/verifyProvider.ts b/src/modelManagement/providers/verifyProvider.ts new file mode 100644 index 00000000..9073b7ee --- /dev/null +++ b/src/modelManagement/providers/verifyProvider.ts @@ -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; + 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 { + 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 { + 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 { + 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 { + 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 = {}; + 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 { + 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 { + 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 { + 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 { + 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 | 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 { + 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); +} diff --git a/src/modelManagement/ui/components/ByokGlobalTable.tsx b/src/modelManagement/ui/components/ByokGlobalTable.tsx new file mode 100644 index 00000000..f75480b2 --- /dev/null +++ b/src/modelManagement/ui/components/ByokGlobalTable.tsx @@ -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 + * `` would force every section header into a single `` row, + * which fights the design's "section row + indented children" shape. + */ +export const ByokGlobalTable: React.FC = ({ + 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>({}); + // 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(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 ( +
+ No providers match the current filters. +
+ ); + } + + return ( +
+ {groups.map((group, idx) => ( + + {idx > 0 &&
} + toggle(group.provider.id)} + onConfigure={() => onConfigureProvider(group.provider.id)} + onRemove={() => onRemoveProvider(group.provider.id)} + containerRef={containerRef} + /> + + ))} +
+ ); +}; + +interface ProviderSectionProps { + group: ByokTableProviderGroup; + isOpen: boolean; + onToggle: () => void; + onConfigure: () => void; + onRemove: () => void; + containerRef: React.RefObject; +} + +/** + * One provider section: header row + collapsible model rows. + */ +const ProviderSection: React.FC = ({ + 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 ( +
+
+ + {provider.displayName} + + {entries.length} {entries.length === 1 ? "model" : "models"} + + {provider.kind === "custom" && ( + + custom endpoint + + )} + + + + + + + { + e.stopPropagation(); + onConfigure(); + }} + > + + Configure + + { + e.stopPropagation(); + onRemove(); + }} + className="tw-text-error" + > + + Remove provider + + + +
+ {isOpen && + entries.map((entry) => ( + + ))} +
+ ); +}; + +interface ModelRowProps { + entry: RegistryEntry; +} + +const ModelRow: React.FC = ({ 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 ( +
+
{entry.displayName}
+ + {contextLabel ?? "—"} + + + {releaseLabel || "—"} + +
+ ); +}; + +ByokGlobalTable.displayName = "ByokGlobalTable"; diff --git a/src/modelManagement/ui/components/ProviderCatalogList.tsx b/src/modelManagement/ui/components/ProviderCatalogList.tsx new file mode 100644 index 00000000..7243f1a6 --- /dev/null +++ b/src/modelManagement/ui/components/ProviderCatalogList.tsx @@ -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 `` 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 `/`) gets sticky + * `` 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 `/` 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(); + 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[]; + /** `:` keys that are currently checked. */ + selectedModelIds: ReadonlySet; + /** 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 `:` keys that are in the registry. Used to + * show the kebab only for entries that actually live in the registry. */ + registeredModelIds?: ReadonlySet; + /** 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 = ({ + providerId, + models, + selectedModelIds, + onToggle, + showKebab = false, + registeredModelIds, + onViewDocs, + onRemoveFromRegistry, + emptyMessage, +}) => { + const groups = useMemo(() => { + 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(null); + + if (models.length === 0) { + return ( +
+ {emptyMessage ?? "No models match the current filters."} +
+ ); + } + + return ( +
+ {groups.map((group, groupIdx) => ( + + ))} +
+ ); +}; + +interface CatalogGroupSectionProps { + providerId: ProviderId; + group: ProviderCatalogGroup; + selectedModelIds: ReadonlySet; + onToggle: (modelId: string) => void; + showKebab: boolean; + registeredModelIds?: ReadonlySet; + onViewDocs?: (modelId: string) => void; + onRemoveFromRegistry?: (modelId: string) => void; + isLastGroup: boolean; + containerRef: React.RefObject; +} + +const CatalogGroupSection: React.FC = ({ + providerId, + group, + selectedModelIds, + onToggle, + showKebab, + registeredModelIds, + onViewDocs, + onRemoveFromRegistry, + isLastGroup, + containerRef, +}) => { + const hasHeader = group.upstream !== null; + return ( +
+ {hasHeader && ( +
+ {group.upstream} +
+ )} + {group.models.map((model, modelIdx) => { + const key = `${providerId}:${model.id}`; + const isLastInGroup = modelIdx === group.models.length - 1; + const isFinal = isLastGroup && isLastInGroup; + return ( + onToggle(model.id)} + showKebab={showKebab && (registeredModelIds?.has(key) ?? false)} + onViewDocs={onViewDocs} + onRemoveFromRegistry={onRemoveFromRegistry} + isFinal={isFinal} + containerRef={containerRef} + /> + ); + })} +
+ ); +}; + +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; +} + +const CatalogModelRow: React.FC = ({ + 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 ( +
+ + + {contextLabel ?? ""} + + {releaseLabel} + + {showKebab ? ( + + + + + + { + e.stopPropagation(); + onViewDocs?.(model.id); + }} + > + View docs + + { + e.stopPropagation(); + onRemoveFromRegistry?.(model.id); + }} + > + Remove from registry + + + + ) : ( + // 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. + + )} +
+ ); +}; + +ProviderCatalogList.displayName = "ProviderCatalogList"; diff --git a/src/modelManagement/ui/components/__tests__/ByokGlobalTable.test.tsx b/src/modelManagement/ui/components/__tests__/ByokGlobalTable.test.tsx new file mode 100644 index 00000000..8b70abe9 --- /dev/null +++ b/src/modelManagement/ui/components/__tests__/ByokGlobalTable.test.tsx @@ -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 }) => ( +
+ {children} +
+ ); + 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 { + return { + id: "anthropic", + kind: "builtin", + displayName: "Anthropic", + type: "anthropic", + addedAt: 1, + ...overrides, + }; +} + +function makeEntry(overrides: Partial): 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( + + ); + + // 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( + + ); + + 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( + + ); + + 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( + + ); + + const configureItem = screen.getByRole("menuitem", { name: /Configure/i }); + fireEvent.click(configureItem); + expect(onConfigureProvider).toHaveBeenCalledWith("anthropic"); + }); + + it("renders the empty-groups fallback message", () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + 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( + "—" + ); + }); +}); diff --git a/src/modelManagement/ui/components/__tests__/ProviderCatalogList.test.tsx b/src/modelManagement/ui/components/__tests__/ProviderCatalogList.test.tsx new file mode 100644 index 00000000..849a78df --- /dev/null +++ b/src/modelManagement/ui/components/__tests__/ProviderCatalogList.test.tsx @@ -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 & { 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): void { + render( + ()} + 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/); + }); +}); diff --git a/src/modelManagement/ui/dialogs/AddCustomModelDialog.tsx b/src/modelManagement/ui/dialogs/AddCustomModelDialog.tsx new file mode 100644 index 00000000..51d95105 --- /dev/null +++ b/src/modelManagement/ui/dialogs/AddCustomModelDialog.tsx @@ -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; + /** + * 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) => void | Promise; +} + +/** + * `AddCustomModelDialog` — see file header comment. + */ +export const AddCustomModelDialog: React.FC = ({ + 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 => { + 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 => { + if (!canAdd) return; + const entry: Omit = { + providerId: provider.id, + modelId: trimmedModelId, + displayName: trimmedDisplayName, + }; + await onAdd(entry); + reset(); + onOpenChange(false); + }; + + return ( + + + + Add custom model · under {provider.displayName} + + Use this for preview models, fine-tunes, private deployments, or anything not in the + catalog. Provider connection (key, base URL) is reused. + + + +
+ + setDisplayName(e.target.value)} + data-testid="add-custom-model-display-name" + /> + + + +
+ { + setModelId(e.target.value); + // Edits invalidate the previous test outcome. + setTestState({ kind: "idle" }); + }} + className="tw-flex-1" + data-testid="add-custom-model-id" + /> + +
+
+ + {testState.kind === "success" && ( +
+ + Test succeeded. +
+ )} + {testState.kind === "error" && ( +
+ + {testState.message} +
+ )} +
+ +
+ + +
+
+
+ ); +}; + +AddCustomModelDialog.displayName = "AddCustomModelDialog"; diff --git a/src/modelManagement/ui/dialogs/AddProviderDialog.tsx b/src/modelManagement/ui/dialogs/AddProviderDialog.tsx new file mode 100644 index 00000000..b8ca34ce --- /dev/null +++ b/src/modelManagement/ui/dialogs/AddProviderDialog.tsx @@ -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 = 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 = { + 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> = { + 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 = ({ + open, + onOpenChange, + existingProviders, + onPickBuiltin, + onPickCustom, +}) => { + const modalContainer = useTabOptional()?.modalContainer ?? null; + const [query, setQuery] = useState(""); + + const existingIds = useMemo>( + () => 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 ( + + + + Add a provider + + Pick a provider to configure, or bring your own endpoint as a custom provider. + + + +
+ + + {recommended.length > 0 && ( +
+
+ Recommended +
+
+ {recommended.map((id) => ( + onPickBuiltin(id)} /> + ))} +
+
+ )} + + {more.length > 0 && ( +
+
+ More providers +
+
+ {more.map((id) => ( + onPickBuiltin(id)} /> + ))} +
+
+ )} + + {noMatches && ( +
+ No providers match your search. +
+ )} + + +
+
+
+ ); +}; + +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 }) => ( + + {providerGlyph(name)} + +); + +/** + * 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 = ({ providerId, onClick }) => { + const name = getDisplayName(providerId); + const description = RECOMMENDED_DESCRIPTIONS[providerId]; + return ( + + ); +}; + +/** + * More-providers row: smaller glyph + name + right-side "+" affordance. Tight + * vertical padding so the alphabetical list stays scannable at scale. + */ +const MoreProviderRow: React.FC = ({ providerId, onClick }) => { + const name = getDisplayName(providerId); + return ( + + ); +}; + +AddProviderDialog.displayName = "AddProviderDialog"; diff --git a/src/modelManagement/ui/dialogs/ConfigureProviderDialog.tsx b/src/modelManagement/ui/dialogs/ConfigureProviderDialog.tsx new file mode 100644 index 00000000..de54abc9 --- /dev/null +++ b/src/modelManagement/ui/dialogs/ConfigureProviderDialog.tsx @@ -0,0 +1,1019 @@ +/** + * `ConfigureProviderDialog` — three-state dialog driving the BYOK provider + * configuration UI. + * + * States (per §5.2): + * - `new-byok` – first-time setup of a built-in provider (Anthropic, …). + * - `new-custom` – first-time setup of a user-defined endpoint. + * - `edit` – modify an already-registered provider. + * + * Layout (abbreviated): + * + * [An] Anthropic ✓ Verified + * ────────────────────────────────────────────────────────── + * (new-custom only) + * Display name [ … ] + * Type (•) OpenAI-compatible (o) Anthropic (o) Google + * API key [ ••••••••••••• ] [Test] + * Base URL [ … ] (editable in new-custom; read-only otherwise) + * + * Models [+ Add from catalog] [+ Add custom model] + * [search] [All] [≥ 200k ctx] [≤ $1/M] [Released ≤ 6mo] + * ┌──────────────────────────────────────────────────────────────────┐ + * │ ProviderCatalogList (rows of catalog models w/ release date col) │ + * └──────────────────────────────────────────────────────────────────┘ + * + * Footer: + * new-*: [Cancel] [Verify & save] "N models selected" + * edit: [Remove provider] [Cancel] [Save changes] + * + * Caveats: + * - Capability checkboxes / availability rows are explicitly OUT (per + * redesign). + * - `[+ Add from catalog]` in edit state is a SCROLL-ONLY no-op — the + * model picker below already shows the full catalog with registered + * entries pre-checked, so a separate sub-modal would be redundant. + * Clicking the button just brings the list into view. + */ +import { Badge } from "@/components/ui/badge"; +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 { Label } from "@/components/ui/label"; +import { SearchBar } from "@/components/ui/SearchBar"; +import { useTabOptional } from "@/contexts/TabContext"; +import { cn } from "@/lib/utils"; +import { logError } from "@/logger"; +import { ModelCatalogService } from "@/modelManagement/catalog/ModelCatalogService"; +import type { CatalogModel } from "@/modelManagement/catalog/modelsCatalog.types"; +import { defaultBaseUrl } from "@/modelManagement/providers/verifyProvider"; +import type { + ProviderConfig, + ProviderId, + RegistryEntry, + VerificationResult, +} from "@/modelManagement/types"; +import { ProviderCatalogList } from "@/modelManagement/ui/components/ProviderCatalogList"; +import { AddCustomModelDialog } from "@/modelManagement/ui/dialogs/AddCustomModelDialog"; +import { CheckCircle2, Loader2, Plus, XCircle } from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; + +/** Custom-provider type discriminator surfaced to the user (radio group). */ +type CustomProviderType = "openai-compatible" | "anthropic" | "google"; + +export type ConfigureProviderState = "new-byok" | "new-custom" | "edit"; + +/** Snapshot of the dialog's editable fields — passed to the save callbacks. */ +export interface ConfigureProviderSavePayload { + /** Resolved provider id (existing for edit; freshly minted for custom). */ + providerId: ProviderId; + /** Final `ProviderConfig` to persist. */ + providerConfig: Omit & { addedAt?: number }; + /** Final list of `RegistryEntry`s (without `addedAt`) to bulk-set. */ + selectedEntries: Array>; +} + +export interface ConfigureProviderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + state: ConfigureProviderState; + /** + * - `new-byok`: provider id picked in `AddProviderDialog`. Required. + * - `new-custom`: ignored (custom providers mint their own id). + * - `edit`: existing provider id. + */ + providerId?: ProviderId; + /** Required in `edit` state. The dialog reads `displayName`, `apiKeyRef`, etc. */ + existingProvider?: ProviderConfig; + /** Already-registered models for this provider (used to pre-check rows). */ + existingEntries?: RegistryEntry[]; + /** Static label used in the header for new-byok flows (no catalog hit). */ + builtinDisplayName?: string; + /** + * Test the current `(apiKey, baseUrl, extra)` triple against the + * provider. Used by `[Test]`. Returning `ok: true` flips the verified + * badge in edit state; failure decorates the row inline. + */ + onTest: (draft: { + providerId: ProviderId; + apiKey: string; + baseUrl?: string; + extra?: Record; + type: ProviderConfig["type"]; + }) => Promise; + /** Discover models from `/models` for new-custom + openai-compat. */ + discoverModels?: (baseUrl: string, apiKey: string) => Promise; + /** Ping a custom-added (provider, modelId) — wired through to AddCustomModelDialog. */ + onTestModel?: (providerId: ProviderId, modelId: string) => Promise; + /** Save callback — `add` for new-* states; `update` for edit. */ + onSave: (payload: ConfigureProviderSavePayload) => void | Promise; + /** Edit-only: remove the provider entirely. */ + onRemoveProvider?: (providerId: ProviderId) => void | Promise; + /** Catalog facade — defaulted to the singleton so tests can swap. */ + catalog?: Pick; +} + +/** + * Custom-provider id generator — `custom:`. We don't need + * strong randomness for this; uniqueness within a vault is enough. + */ +function mintCustomProviderId(): ProviderId { + // crypto.randomUUID() is available in modern Electron + jsdom 21+. We + // gate behind a presence check to keep the function pure-ish for tests + // that might run on older runtimes. Use `window.crypto` to satisfy the + // popout-window lint that flags `globalThis` access. + const cryptoLike: Crypto | undefined = + typeof window !== "undefined" ? (window as { crypto?: Crypto }).crypto : undefined; + if (cryptoLike?.randomUUID) { + return `custom:${cryptoLike.randomUUID()}`; + } + // Fallback — sufficient for collision-avoidance within a single vault. + return `custom:${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Resolve the type → ProviderConfig.type mapping for the custom radio. + * (Identity mapping today — kept as a function so the layer is explicit.) + */ +function customTypeToProviderType(t: CustomProviderType): ProviderConfig["type"] { + return t; +} + +/** + * Single 2-char glyph for a provider; copied from `ByokGlobalTable` to keep + * the dialog's header visually consistent with the table. + */ +function providerGlyph(displayName: string): string { + const words = displayName.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(); +} + +/** + * `ConfigureProviderDialog` — see file header comment for the full spec. + */ +export const ConfigureProviderDialog: React.FC = ({ + open, + onOpenChange, + state, + providerId, + existingProvider, + existingEntries, + builtinDisplayName, + onTest, + discoverModels, + onTestModel, + onSave, + onRemoveProvider, + catalog, +}) => { + const modalContainer = useTabOptional()?.modalContainer ?? null; + const catalogSvc = catalog ?? ModelCatalogService.getInstance(); + + // Form state — initialized lazily from the props relevant to each state. + const [displayName, setDisplayName] = useState( + () => existingProvider?.displayName ?? builtinDisplayName ?? "" + ); + const [apiKey, setApiKey] = useState(""); + const [baseUrl, setBaseUrl] = useState(() => existingProvider?.baseUrl ?? ""); + const [customType, setCustomType] = useState("openai-compatible"); + const [extra, setExtra] = useState>(() => existingProvider?.extra ?? {}); + // For the API-key field: edit state masks the existing key (which lives + // in the keychain or inline ref); typing replaces it. We track the + // "user has typed" intent via a separate flag so we don't accidentally + // wipe the key on save when they didn't touch the field. + const [apiKeyTouched, setApiKeyTouched] = useState(false); + + // Selected catalog entries — `:`. + const [selectedIds, setSelectedIds] = useState>(() => { + const initial = new Set(); + for (const e of existingEntries ?? []) { + initial.add(`${e.providerId}:${e.modelId}`); + } + return initial; + }); + + // Verification UX — set after `[Test]` runs. Always starts `idle`, even + // in edit; a historical `lastVerifiedAt` does NOT auto-credit. The user + // re-tests on every session, which is what gates Save. + const [verifyState, setVerifyState] = useState< + | { kind: "idle" } + | { kind: "testing" } + | { kind: "verified"; verifiedAt: number } + | { kind: "error"; message: string } + >({ kind: "idle" }); + + // Resets verification whenever the user touches a field that affects the + // probe — keeps the Save gate honest. + const clearVerify = (): void => setVerifyState({ kind: "idle" }); + + // Discovered models for new-custom providers without a catalog entry. + const [discoveredModels, setDiscoveredModels] = useState([]); + const [discovering, setDiscovering] = useState(false); + const [discoveryError, setDiscoveryError] = useState(null); + + // Add-custom-model sub-dialog. + const [addCustomModelOpen, setAddCustomModelOpen] = useState(false); + // Locally-added custom registry entries (not in catalog) — surfaced + // alongside catalog rows so the user sees the entries they just added. + // On save we merge them into the bulk-set list. + const [localCustomEntries, setLocalCustomEntries] = useState< + Array> + >([]); + + // Filter bar state. + const [query, setQuery] = useState(""); + + // Reset form when the dialog reopens or state changes — avoids stale + // values bleeding across edits of different providers. + useEffect(() => { + if (!open) return; + setDisplayName(existingProvider?.displayName ?? builtinDisplayName ?? ""); + setBaseUrl(existingProvider?.baseUrl ?? ""); + setApiKey(""); + setApiKeyTouched(false); + setExtra(existingProvider?.extra ?? {}); + setCustomType("openai-compatible"); + setSelectedIds(() => { + const initial = new Set(); + for (const e of existingEntries ?? []) { + initial.add(`${e.providerId}:${e.modelId}`); + } + return initial; + }); + setVerifyState({ kind: "idle" }); + setLocalCustomEntries([]); + setDiscoveredModels([]); + setDiscoveryError(null); + setQuery(""); + }, [open, state, existingProvider, existingEntries, builtinDisplayName]); + + // Make sure the catalog is ready before we render the model picker. + useEffect(() => { + if (!open) return; + void catalogSvc.ensureLoaded(); + }, [open, catalogSvc]); + + // Resolved id used by the model section / save payload. Custom providers + // mint an id eagerly so `:` keys are stable across + // toggles. + const resolvedProviderId = useMemo(() => { + if (state === "edit") return existingProvider?.id ?? providerId; + if (state === "new-byok") return providerId; + // new-custom + return undefined; + }, [state, existingProvider, providerId]); + + // Custom providers need a stable id during the dialog session so checkbox + // toggles work consistently. We assign one on first reveal. + const [customId, setCustomId] = useState(undefined); + useEffect(() => { + if (!open) { + setCustomId(undefined); + return; + } + if (state === "new-custom" && !customId) { + setCustomId(mintCustomProviderId()); + } + }, [open, state, customId]); + + const effectiveProviderId = state === "new-custom" ? customId : resolvedProviderId; + + // Resolved provider type — drives the extras form and is forwarded to the + // `[Test]`/save handlers. Single source of truth so the dialog can't + // surface one type to the user and submit another. + const effectiveType: ProviderConfig["type"] = useMemo(() => { + if (state === "new-custom") return customTypeToProviderType(customType); + return ( + existingProvider?.type ?? + (state === "new-byok" ? deriveBuiltinType(providerId) : "openai-compatible") + ); + }, [state, customType, existingProvider, providerId]); + + // Decide which model list to show: discovered (new-custom + openai-compat) + // OR catalog (built-in providers + edit state). Catalog lookup happens + // inside the memo so `effectiveProviderId` is the only changing dep that + // matters — see lint exhaustive-deps. + const modelPool: CatalogModel[] = useMemo(() => { + if (state === "new-custom" && customType === "openai-compatible") { + return discoveredModels; + } + if (!effectiveProviderId) return []; + const catalogProvider = catalogSvc.getProvider(effectiveProviderId); + return catalogProvider ? Object.values(catalogProvider.models) : []; + }, [state, customType, discoveredModels, catalogSvc, effectiveProviderId]); + + // Apply search query to the pool. + const filteredModels = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return modelPool; + return modelPool.filter((model) => { + const haystack = `${model.name} ${model.id}`.toLowerCase(); + return haystack.includes(normalizedQuery); + }); + }, [modelPool, query]); + + // Build a set of "selectedIds" lookups in the same `:` + // shape `ProviderCatalogList` consumes. + const localCustomKeys = useMemo>(() => { + const out = new Set(); + for (const e of localCustomEntries) { + out.add(`${e.providerId}:${e.modelId}`); + } + return out; + }, [localCustomEntries]); + + const registeredKeys = useMemo>(() => { + const out = new Set(); + for (const e of existingEntries ?? []) { + out.add(`${e.providerId}:${e.modelId}`); + } + return out; + }, [existingEntries]); + + const toggleModel = (modelId: string): void => { + if (!effectiveProviderId) return; + const key = `${effectiveProviderId}:${modelId}`; + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const handleTest = async (): Promise => { + if (!effectiveProviderId) return; + setVerifyState({ kind: "testing" }); + try { + const result = await onTest({ + providerId: effectiveProviderId, + apiKey, + baseUrl: baseUrl || undefined, + extra, + type: effectiveType, + }); + if (result.ok) { + setVerifyState({ kind: "verified", verifiedAt: result.verifiedAt }); + } else { + setVerifyState({ kind: "error", message: result.error ?? "Unknown error" }); + } + } catch (err) { + logError("[ConfigureProviderDialog] Verify threw:", err); + const message = err instanceof Error ? err.message : String(err); + setVerifyState({ kind: "error", message }); + } + }; + + const handleDiscover = async (): Promise => { + if (!discoverModels || !baseUrl.trim()) return; + setDiscovering(true); + setDiscoveryError(null); + try { + const models = await discoverModels(baseUrl.trim(), apiKey); + setDiscoveredModels(models); + } catch (err) { + logError("[ConfigureProviderDialog] Discovery failed:", err); + const message = err instanceof Error ? err.message : String(err); + setDiscoveryError(message); + setDiscoveredModels([]); + } finally { + setDiscovering(false); + } + }; + + const handleAddCustomModel = (entry: Omit): void => { + setLocalCustomEntries((prev) => [...prev, entry]); + setSelectedIds((prev) => { + const next = new Set(prev); + next.add(`${entry.providerId}:${entry.modelId}`); + return next; + }); + }; + + const handleSave = async (): Promise => { + if (!effectiveProviderId) return; + + const kind: ProviderConfig["kind"] = state === "new-custom" ? "custom" : "builtin"; + const type = effectiveType; + + // Resolve `apiKeyRef`: + // - touched + non-empty: store inline (keychain promotion happens later). + // - touched + empty: clear the ref. + // - untouched: preserve existing. + let apiKeyRef: ProviderConfig["apiKeyRef"]; + if (apiKeyTouched) { + apiKeyRef = apiKey.trim() ? { kind: "inline", value: apiKey.trim() } : null; + } else { + apiKeyRef = existingProvider?.apiKeyRef; + } + + const finalProvider: Omit = { + id: effectiveProviderId, + kind, + displayName: displayName.trim() || builtinDisplayName || effectiveProviderId, + type, + baseUrl: baseUrl.trim() || undefined, + apiKeyRef, + extra, + }; + // Edit state — surface the verified-at timestamp when verification was + // re-run during this session. + if (verifyState.kind === "verified") { + (finalProvider as ProviderConfig).lastVerifiedAt = verifyState.verifiedAt; + } + + // Compose the registry-entry list. Catalog rows that are still checked + // produce one entry each; local custom entries are appended verbatim if + // their key is still selected. + const selectedFromCatalog: Array> = []; + const pool = modelPool; + for (const model of pool) { + const key = `${effectiveProviderId}:${model.id}`; + if (!selectedIds.has(key)) continue; + if (localCustomKeys.has(key)) continue; // handled below + selectedFromCatalog.push({ + providerId: effectiveProviderId, + modelId: model.id, + displayName: model.name, + }); + } + // Preserve registered models that aren't currently in the pool (e.g. + // discovered list shrank, or filter chip hid them) — we never want a + // save to silently drop entries the user didn't touch. + for (const entry of existingEntries ?? []) { + const key = `${entry.providerId}:${entry.modelId}`; + if (!selectedIds.has(key)) continue; + if (selectedFromCatalog.some((e) => e.modelId === entry.modelId)) continue; + if (localCustomKeys.has(key)) continue; + selectedFromCatalog.push({ + providerId: effectiveProviderId, + modelId: entry.modelId, + displayName: entry.displayName, + }); + } + const localStillSelected = localCustomEntries.filter((e) => + selectedIds.has(`${effectiveProviderId}:${e.modelId}`) + ); + const selectedEntries = [...selectedFromCatalog, ...localStillSelected]; + + await onSave({ + providerId: effectiveProviderId, + providerConfig: finalProvider, + selectedEntries, + }); + onOpenChange(false); + }; + + const handleRemoveProvider = async (): Promise => { + if (state !== "edit" || !existingProvider || !onRemoveProvider) return; + await onRemoveProvider(existingProvider.id); + onOpenChange(false); + }; + + const dialogTitle = ( +
+ + {providerGlyph(displayName || builtinDisplayName || "??")} + + {titleForState(state, displayName, builtinDisplayName)} + {state === "edit" && verifyState.kind === "verified" && ( + + Verified + + )} +
+ ); + + const filteredSelectedCount = Array.from(selectedIds).filter((k) => + k.startsWith(`${effectiveProviderId}:`) + ).length; + + return ( + + + + {dialogTitle} + + Configure the connection and pick which models surface in your registry. + + + +
+ {state === "new-custom" && ( +
+ + setDisplayName(e.target.value)} + data-testid="configure-display-name" + /> + + +
+ {(["openai-compatible", "anthropic", "google"] as CustomProviderType[]).map( + (t) => ( + + ) + )} +
+
+
+ )} + +
+ + API key + {verifyState.kind === "verified" && ( + + ✓ + + )} + {verifyState.kind === "error" && ( + + ⚠ + + )} +
+ } + > +
+ { + setApiKey(e.target.value); + setApiKeyTouched(true); + clearVerify(); + }} + data-testid="configure-api-key" + /> + +
+ {verifyState.kind === "error" && ( +

+ + {verifyState.message} +

+ )} + {state === "edit" && + existingProvider?.lastVerifiedAt && + verifyState.kind === "idle" && ( +

+ Last verified {formatRelativeTime(existingProvider.lastVerifiedAt)} — re-test to + enable save. +

+ )} + + + +
+ { + setBaseUrl(e.target.value); + clearVerify(); + }} + data-testid="configure-base-url" + /> + {state === "new-custom" && customType === "openai-compatible" && ( + + )} +
+ {discoveryError && ( +

{discoveryError}

+ )} +
+ + { + setExtra(next); + clearVerify(); + }} + /> + + {/* No Availability row — explicitly removed per redesign. */} + + + {/* Models section */} +
+
+ +
+ +
+
+ +
+
+ +
+
+ + {effectiveProviderId ? ( + { + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(`${effectiveProviderId}:${modelId}`); + return next; + }); + }} + emptyMessage={ + state === "new-custom" && customType === "openai-compatible" + ? "Discover models via the [Discover] button after entering a base URL, or use [+ Add custom model]." + : undefined + } + /> + ) : ( +
+ Pick a provider to see its models. +
+ )} + + {localCustomEntries.length > 0 && ( +
+
+ Just added +
+ {localCustomEntries.map((entry) => ( +
+ {entry.displayName} + + custom + +
+ ))} +
+ )} +
+
+ + {/* Footer */} +
+ {state === "edit" ? ( + <> + +
+ + +
+ + ) : ( + <> +
+ {filteredSelectedCount} model{filteredSelectedCount === 1 ? "" : "s"} selected +
+
+ + +
+ + )} +
+ + {effectiveProviderId && ( + { + if (!onTestModel || !effectiveProviderId) return; + await onTestModel(effectiveProviderId, modelId); + }} + onAdd={handleAddCustomModel} + /> + )} + + + ); +}; + +/** + * Resolve the placeholder shown in the Base URL field. New-custom flows hint + * at a local endpoint; known built-ins surface their canonical URL so the + * user can see where requests will actually land. Unknown providers fall + * back to a generic "leave blank" cue. + */ +function baseUrlPlaceholder( + state: ConfigureProviderState, + providerId: ProviderId | undefined +): string { + if (state === "new-custom") return "http://localhost:11434/v1"; + const known = providerId ? defaultBaseUrl(providerId) : undefined; + return known ?? "Default endpoint (leave blank)"; +} + +interface ProviderExtrasFormProps { + providerType: ProviderConfig["type"]; + providerId: ProviderId | undefined; + extra: Record; + setExtra: (next: Record) => void; +} + +/** + * Typed inputs for the subset of `ProviderConfig.extra` fields that affect + * the verification probe / HTTP destination. Renders nothing for provider + * types whose adapters declare no extras (anthropic, google, …). + */ +const ProviderExtrasForm: React.FC = ({ + providerType, + providerId, + extra, + setExtra, +}) => { + const update = (key: string, value: string): void => { + const next = { ...extra }; + if (value.trim()) next[key] = value; + else delete next[key]; + setExtra(next); + }; + const read = (key: string): string => { + const v = extra[key]; + return typeof v === "string" ? v : ""; + }; + + if (providerType === "azure") { + return ( +
+ + update("azureInstanceName", e.target.value)} + data-testid="configure-extra-azure-instance" + /> + + + update("azureDeploymentName", e.target.value)} + data-testid="configure-extra-azure-deployment" + /> + + + update("azureApiVersion", e.target.value)} + data-testid="configure-extra-azure-version" + /> + +
+ ); + } + + if (providerType === "bedrock") { + return ( + + update("bedrockRegion", e.target.value)} + data-testid="configure-extra-bedrock-region" + /> + + ); + } + + if (providerType === "openai-compatible" && providerId === "openai") { + return ( + + update("openAIOrgId", e.target.value)} + data-testid="configure-extra-openai-org" + /> + + ); + } + + return null; +}; + +/** + * Compact relative-time formatter (e.g. "3 days ago"). Used only for the + * historic `lastVerifiedAt` helper text. Falls back to a localized date + * string for spans beyond 60 days. + */ +function formatRelativeTime(epochMillis: number): string { + const diffSec = (epochMillis - Date.now()) / 1000; + const abs = Math.abs(diffSec); + const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + if (abs < 60) return rtf.format(Math.round(diffSec), "second"); + if (abs < 60 * 60) return rtf.format(Math.round(diffSec / 60), "minute"); + if (abs < 60 * 60 * 24) return rtf.format(Math.round(diffSec / 3600), "hour"); + if (abs < 60 * 60 * 24 * 60) return rtf.format(Math.round(diffSec / 86_400), "day"); + return new Date(epochMillis).toLocaleDateString(); +} + +/** + * Resolve a built-in provider id → `ProviderConfig.type`. Keeps the dialog + * decoupled from the rest of the adapter registry. + */ +function deriveBuiltinType(providerId: ProviderId | undefined): ProviderConfig["type"] { + switch (providerId) { + case "anthropic": + return "anthropic"; + case "google": + return "google"; + case "azure": + return "azure"; + case "amazon-bedrock": + return "bedrock"; + case "github-copilot": + return "github-copilot"; + default: + return "openai-compatible"; + } +} + +/** Pretty-print the radio label — exposed as a function for testability. */ +function labelForCustomType(t: CustomProviderType): string { + switch (t) { + case "openai-compatible": + return "OpenAI-compatible"; + case "anthropic": + return "Anthropic"; + case "google": + return "Google"; + } +} + +/** Dialog title text per state. */ +function titleForState( + state: ConfigureProviderState, + displayName: string, + builtinName: string | undefined +): string { + const name = displayName || builtinName || "Provider"; + if (state === "new-byok") return `Add ${name}`; + if (state === "new-custom") return "Add custom provider"; + return `Configure ${name}`; +} + +ConfigureProviderDialog.displayName = "ConfigureProviderDialog"; diff --git a/src/modelManagement/ui/dialogs/__tests__/AddCustomModelDialog.test.tsx b/src/modelManagement/ui/dialogs/__tests__/AddCustomModelDialog.test.tsx new file mode 100644 index 00000000..abf6d480 --- /dev/null +++ b/src/modelManagement/ui/dialogs/__tests__/AddCustomModelDialog.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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, [string]>() + .mockRejectedValueOnce(new Error("404 model not found")) + .mockResolvedValueOnce(undefined); + + render( + + ); + + 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()); + }); +}); diff --git a/src/modelManagement/ui/dialogs/__tests__/AddProviderDialog.test.tsx b/src/modelManagement/ui/dialogs/__tests__/AddProviderDialog.test.tsx new file mode 100644 index 00000000..6e4d0d8b --- /dev/null +++ b/src/modelManagement/ui/dialogs/__tests__/AddProviderDialog.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.queryByTestId("add-provider-recommended")).toBeNull(); + // The More providers section still renders. + expect(screen.getByTestId("add-provider-more")).toBeTruthy(); + }); +}); diff --git a/src/modelManagement/ui/dialogs/__tests__/ConfigureProviderDialog.test.tsx b/src/modelManagement/ui/dialogs/__tests__/ConfigureProviderDialog.test.tsx new file mode 100644 index 00000000..0fc1a7c5 --- /dev/null +++ b/src/modelManagement/ui/dialogs/__tests__/ConfigureProviderDialog.test.tsx @@ -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> +): 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 +): React.ComponentProps["catalog"] { + return { + ensureLoaded: jest.fn().mockResolvedValue(undefined), + getProvider: (id: string) => byId[id], + }; +} + +function makeProvider(overrides: Partial = {}): ProviderConfig { + return { + id: "anthropic", + kind: "builtin", + displayName: "Anthropic", + type: "anthropic", + addedAt: 1, + ...overrides, + }; +} + +function makeEntry(overrides: Partial = {}): 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + // 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( + + ); + + 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( + + ); + + 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( + + ); + + await act(async () => { + fireEvent.click(screen.getByTestId("configure-remove-provider")); + }); + + expect(onRemoveProvider).toHaveBeenCalledWith("anthropic"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/modelManagement/ui/tabs/ByokPanel.tsx b/src/modelManagement/ui/tabs/ByokPanel.tsx new file mode 100644 index 00000000..fa6d7496 --- /dev/null +++ b/src/modelManagement/ui/tabs/ByokPanel.tsx @@ -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 = ({ 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(() => + providerRegistry.list().filter((p) => p.kind !== "system") + ); + const [registry, setRegistry] = useState(() => 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(() => { + 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 => { + 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["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 `/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 = { "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, + 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 ; + } + + const isMobile = Platform.isMobile; + + const headerActions = ( +
+ +
+ ); + + const configureDialog = configureState && ( + { + 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 = ( + <> + { + 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 ( +
+ +
+
+
No providers configured yet.
+ +
+
+ {dialogs} +
+ ); + } + + return ( +
+
+
+
BYOK
+ +
+ {headerActions} +
+ + + {dialogs} +
+ ); +}; + +const ByokDescription: React.FC = () => ( +
+ Bring your own providers and models to use in Copilot. +
+); + +/** Skeleton used while `ensureLoaded()` is in flight. */ +const ByokSkeleton: React.FC = () => ( +
+
+
+
+
+
+); + +interface FilterBarProps { + query: string; + setQuery: (q: string) => void; +} + +const FilterBar: React.FC = ({ query, setQuery }) => ( +
+
+ +
+
+); diff --git a/src/modelManagement/ui/tabs/__tests__/ByokPanel.test.tsx b/src/modelManagement/ui/tabs/__tests__/ByokPanel.test.tsx new file mode 100644 index 00000000..fbbc8813 --- /dev/null +++ b/src/modelManagement/ui/tabs/__tests__/ByokPanel.test.tsx @@ -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().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 { + return { + id: "anthropic", + kind: "builtin", + displayName: "Anthropic", + type: "anthropic", + addedAt: 1, + ...overrides, + }; +} + +function makeEntry(overrides: Partial = {}): 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(); + await waitFor(() => expect(ensureLoadedMock).toHaveBeenCalledTimes(1)); + }); + + it("renders the empty state when no providers are configured", async () => { + render(); + // 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(); + + 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(); + + 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(); + 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(); + }); +}); diff --git a/src/modelManagement/ui/utils/__tests__/formatModelMetadata.test.ts b/src/modelManagement/ui/utils/__tests__/formatModelMetadata.test.ts new file mode 100644 index 00000000..9e916f42 --- /dev/null +++ b/src/modelManagement/ui/utils/__tests__/formatModelMetadata.test.ts @@ -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"); + }); +}); diff --git a/src/modelManagement/ui/utils/formatModelMetadata.ts b/src/modelManagement/ui/utils/formatModelMetadata.ts new file mode 100644 index 00000000..d0d06a2f --- /dev/null +++ b/src/modelManagement/ui/utils/formatModelMetadata.ts @@ -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" }); +} diff --git a/src/search/v3/TieredLexicalRetriever.test.ts b/src/search/v3/TieredLexicalRetriever.test.ts index db34ce02..4c201d56 100644 --- a/src/search/v3/TieredLexicalRetriever.test.ts +++ b/src/search/v3/TieredLexicalRetriever.test.ts @@ -28,7 +28,7 @@ jest.mock("./SearchCore", () => ({ retrieve: retrieveMock, })), })); -jest.mock("@/LLMProviders/chatModelManager"); +jest.mock("@/LLMProviders/ChatModelManager"); jest.mock("@/utils", () => ({ extractNoteFiles: jest.fn().mockReturnValue([]), })); diff --git a/src/search/v3/TieredLexicalRetriever.ts b/src/search/v3/TieredLexicalRetriever.ts index 941d622a..b2639381 100644 --- a/src/search/v3/TieredLexicalRetriever.ts +++ b/src/search/v3/TieredLexicalRetriever.ts @@ -15,7 +15,7 @@ async function safeGetChatModel(): Promise { 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(); diff --git a/src/services/settingsPersistence.test.ts b/src/services/settingsPersistence.test.ts index 4e1b3685..f8c94be5 100644 --- a/src/services/settingsPersistence.test.ts +++ b/src/services/settingsPersistence.test.ts @@ -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"); }); diff --git a/src/services/settingsSecretTransforms.test.ts b/src/services/settingsSecretTransforms.test.ts index b850a644..f5b4808d 100644 --- a/src/services/settingsSecretTransforms.test.ts +++ b/src/services/settingsSecretTransforms.test.ts @@ -20,8 +20,16 @@ import { stripKeychainFields, } from "./settingsSecretTransforms"; -/** Create a lightweight settings object for transform tests. */ -function makeSettings(overrides: Partial = {}): CopilotSettings { +/** + * Create a lightweight settings object for transform tests. + * + * Accepts arbitrary keys via `Record` 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 = {}): CopilotSettings { return { activeModels: [], activeEmbeddingModels: [], @@ -29,6 +37,11 @@ function makeSettings(overrides: Partial = {}): CopilotSettings } as unknown as CopilotSettings; } +/** Coerce a settings-shaped object to `Record` for legacy-field assertions. */ +function asRecord(value: CopilotSettings): Record { + return value as unknown as Record; +} + /** JSON-safe clone helper for mutation assertions. */ function clone(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).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); + }); const result = cleanupLegacyFields(settings); const rec = result as unknown as Record; @@ -166,7 +179,7 @@ describe("cleanupLegacyFields", () => { it("migrates legacy _diskSecretsCleared → _keychainOnly", () => { const settings = makeSettings({ _diskSecretsCleared: true, - } as unknown as Partial); + }); const result = cleanupLegacyFields(settings); const rec = result as unknown as Record; @@ -180,7 +193,7 @@ describe("cleanupLegacyFields", () => { const settings = makeSettings({ _diskSecretsCleared: true, _keychainOnly: false, - } as unknown as Partial); + }); const result = cleanupLegacyFields(settings); const rec = result as unknown as Record; @@ -202,7 +215,7 @@ describe("cleanupLegacyFields", () => { _keychainOnly: true, _someFutureField: "future-value", anotherUnknownField: 42, - } as unknown as Partial); + }); const result = cleanupLegacyFields(settings); const rec = result as unknown as Record; diff --git a/src/settings/legacyApiKeyWrites.ts b/src/settings/legacyApiKeyWrites.ts new file mode 100644 index 00000000..a209cc7e --- /dev/null +++ b/src/settings/legacyApiKeyWrites.ts @@ -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> = { + [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 = { + 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 = { ...(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 = {}; + 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 : ""; +} diff --git a/src/settings/model.test.ts b/src/settings/model.test.ts index 92f73b53..9997edda 100644 --- a/src/settings/model.test.ts +++ b/src/settings/model.test.ts @@ -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, diff --git a/src/settings/v2/SettingsMainV2.tsx b/src/settings/v2/SettingsMainV2.tsx index 81bce564..f31ab001 100644 --- a/src/settings/v2/SettingsMainV2.tsx +++ b/src/settings/v2/SettingsMainV2.tsx @@ -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 = { basic: , - model: , + byok: , agent: , QA: , command: , @@ -32,11 +35,19 @@ const icons: Record = { advanced: , }; +interface SettingsTabComponentProps { + plugin: CopilotPlugin; + /** Switch the settings shell to a different tab. Wired by `SettingsContent`. */ + setSelectedTab: (id: TabId) => void; +} + // tab components -const components: Record = { +const components: Record> = { basic: () => , - model: () => , - agent: () => , + byok: ({ plugin }) => , + agent: ({ plugin, setSelectedTab }) => ( + setSelectedTab("byok")} /> + ), QA: () => , command: () => , skills: () => , @@ -45,13 +56,18 @@ const components: Record = { }; // 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 = { - 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 = ({ plugin }) => { const { selectedTab, setSelectedTab } = useTab(); return ( @@ -88,7 +108,7 @@ const SettingsContent: React.FC = () => { const Component = components[id]; return ( - + ); })} @@ -163,7 +183,7 @@ const SettingsMainV2: React.FC = ({ plugin }) => {
{/* Add the key prop to force re-render */} - +
diff --git a/src/settings/v2/components/AgentSettings.tsx b/src/settings/v2/components/AgentSettings.tsx deleted file mode 100644 index 3ef05338..00000000 --- a/src/settings/v2/components/AgentSettings.tsx +++ /dev/null @@ -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 ( -
-
Agents
-
- Agent Mode is desktop only. Open the desktop app to configure agents. -
-
- ); - } - - const allDescriptors = listBackendDescriptors(); - const orderedDescriptors = BACKEND_ORDER.map((id) => - allDescriptors.find((d) => d.id === id) - ).filter((d): d is BackendDescriptor => d !== undefined); - - return ( -
-
Agents (alpha)
-
- - setSettings((cur) => ({ agentMode: { ...cur.agentMode, enabled: checked } })) - } - /> - - {settings.agentMode.enabled && ( - - setSettings((cur) => ({ agentMode: { ...cur.agentMode, activeBackend: value } })) - } - options={orderedDescriptors.map((d) => ({ label: d.displayName, value: d.id }))} - /> - )} - - {settings.agentMode.enabled && } - - {settings.agentMode.enabled && - orderedDescriptors.map((descriptor) => ( - - ))} -
-
- ); -}; - -/** - * 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; -}> = ({ descriptor, plugin }) => { - const settings = useSettingsValue(); - const Panel = descriptor.SettingsPanel; - const manager = plugin.agentSessionManager; - - const [backendState, setBackendState] = React.useState( - () => 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 ( -
-
{descriptor.displayName}
- - {Panel && } - - {installState.kind === "ready" && ( - - )} -
- ); -}; - -/** - * 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 | undefined; -}> = ({ descriptor, backendState, overrides }) => { - const modelState = backendState?.model; - if (!modelState || modelState.availableModels.length === 0) { - return ( -
- No models reported yet — install the binary and reload, or open a chat session with this - agent. -
- ); - } - - const enabled = modelState.availableModels.filter((entry) => - isAgentModelEnabled(descriptor, { modelId: entry.baseModelId, name: entry.name }, overrides) - ); - - return ( -
- - - - - -
- ); -}; - -/** - * 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; - enabled: ReadonlyArray; -}> = ({ 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 ( - ({ 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 ( - ({ label: o.label, value: o.value ?? "" }))} - /> - ); -}; diff --git a/src/settings/v2/components/ApiKeyDialog.tsx b/src/settings/v2/components/ApiKeyDialog.tsx index fb60095d..82a34d5e 100644 --- a/src/settings/v2/components/ApiKeyDialog.tsx +++ b/src/settings/v2/components/ApiKeyDialog.tsx @@ -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); } }; diff --git a/src/settings/v2/components/EmbeddingModelsSection.tsx b/src/settings/v2/components/EmbeddingModelsSection.tsx new file mode 100644 index 00000000..6d4a2afe --- /dev/null +++ b/src/settings/v2/components/EmbeddingModelsSection.tsx @@ -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 ( +
+ setShowAddEmbeddingDialog(true)} + onUpdateModel={handleEmbeddingModelTableUpdate} + onReorderModels={handleEmbeddingModelReorder} + onRefresh={handleRefreshEmbeddingModels} + title="Embedding Models" + /> + + {/* Embedding model add dialog */} + { + const updatedModels = [...settings.activeEmbeddingModels, model]; + updateSetting("activeEmbeddingModels", updatedModels); + }} + isEmbeddingModel={true} + ping={(model) => EmbeddingManager.getInstance().ping(model)} + /> +
+ ); +}; diff --git a/src/settings/v2/components/ModelAddDialog.tsx b/src/settings/v2/components/ModelAddDialog.tsx index 722fafc2..97e7c613 100644 --- a/src/settings/v2/components/ModelAddDialog.tsx +++ b/src/settings/v2/components/ModelAddDialog.tsx @@ -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 = ({ isEmbeddingModel = false, }) => { const { modalContainer } = useTab(); - const settings = getSettings(); const defaultProvider = isEmbeddingModel ? EmbeddingModelProviders.OPENAI : ChatModelProviders.OPENROUTERAI; @@ -169,7 +168,7 @@ export const ModelAddDialog: React.FC = ({ 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 = ({ ...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, diff --git a/src/settings/v2/components/ModelEditDialog.tsx b/src/settings/v2/components/ModelEditDialog.tsx index bea5f7db..277d1784 100644 --- a/src/settings/v2/components/ModelEditDialog.tsx +++ b/src/settings/v2/components/ModelEditDialog.tsx @@ -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 = ({ 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 { ); diff --git a/src/settings/v2/components/QASettings.tsx b/src/settings/v2/components/QASettings.tsx index 15cf8b9c..dc297899 100644 --- a/src/settings/v2/components/QASettings.tsx +++ b/src/settings/v2/components/QASettings.tsx @@ -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 = () => { />
+ + {/* + * 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). + */} + ); }; diff --git a/src/settings/v2/components/__tests__/EmbeddingModelsSection.test.tsx b/src/settings/v2/components/__tests__/EmbeddingModelsSection.test.tsx new file mode 100644 index 00000000..764966f5 --- /dev/null +++ b/src/settings/v2/components/__tests__/EmbeddingModelsSection.test.tsx @@ -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, 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[] }) => ( +
+ ), +})); + +jest.mock("@/settings/v2/components/ModelAddDialog", () => ({ + ModelAddDialog: (props: { isEmbeddingModel?: boolean; open: boolean }) => ( +
+ ), +})); + +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(); + + 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(); + + 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"); + }); +}); diff --git a/src/settings/v2/components/__tests__/QASettings.test.tsx b/src/settings/v2/components/__tests__/QASettings.test.tsx new file mode 100644 index 00000000..0a19bc5b --- /dev/null +++ b/src/settings/v2/components/__tests__/QASettings.test.tsx @@ -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: () => , +})); + +jest.mock("@/components/ui/model-display", () => ({ + getModelDisplayWithIcons: (m: { name: string }) => m.name, +})); + +jest.mock("@/components/ui/setting-item", () => ({ + SettingItem: (props: { title: string }) => ( +
+ ), +})); + +jest.mock("@/settings/v2/components/PatternListEditor", () => ({ + PatternListEditor: () =>
, +})); + +// 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: () =>
, +})); + +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(); + expect(view.container.querySelector("section")).not.toBeNull(); + }); + + it("mounts the EmbeddingModelsSection at the bottom of the tab", () => { + const view = render(); + // 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(); + 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"); + }); +}); diff --git a/src/settings/v2/components/__tests__/SettingsMainV2.test.tsx b/src/settings/v2/components/__tests__/SettingsMainV2.test.tsx new file mode 100644 index 00000000..15d70ae8 --- /dev/null +++ b/src/settings/v2/components/__tests__/SettingsMainV2.test.tsx @@ -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"/); + }); +}); diff --git a/src/settings/v3/components/BackendModelPicker.tsx b/src/settings/v3/components/BackendModelPicker.tsx new file mode 100644 index 00000000..25bc0548 --- /dev/null +++ b/src/settings/v3/components/BackendModelPicker.tsx @@ -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 + * `` map back to `agentMode.backends..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..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 ( + + ); +}; + +/** + * Render the picker. Behaves as flat or sectioned based on which prop the + * caller provides. + */ +export const BackendModelPicker: React.FC = (props) => { + const { onToggle, onManageInByok, showManageInByokLink = true } = props; + const isSectioned = "sections" in props && props.sections !== undefined; + + return ( +
+
+
+
Models in this backend's picker
+
+ Tick which models show up when you switch model mid-session. +
+
+ {showManageInByokLink && onManageInByok && ( + + )} +
+ + {isSectioned ? ( + + ) : ( + + )} +
+ ); +}; + +/** + * 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 ( +
+ {emptyPlaceholder ?? "No models available yet."} +
+ ); + } + return ( +
+ {rows.map((row) => ( + + ))} +
+ ); +}; + +/** + * 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 ( +
+ {sections.map((section, idx) => { + const isEmpty = section.rows.length === 0; + if (isEmpty && !section.emptyPlaceholder) return null; + return ( +
0 && "copilot-divider-t tw-pt-3")} + > +
+ {section.title} +
+ {isEmpty ? ( +
+ {section.emptyPlaceholder} +
+ ) : ( +
+ {section.rows.map((row) => ( + + ))} +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/src/settings/v3/components/BackendSubtabs.tsx b/src/settings/v3/components/BackendSubtabs.tsx new file mode 100644 index 00000000..eb77c93e --- /dev/null +++ b/src/settings/v3/components/BackendSubtabs.tsx @@ -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 = [ + { 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 = ({ selectedTab, onSelectTab }) => { + return ( +
+ {AGENT_BACKEND_TAB_ORDER.map((tab) => { + const isSelected = tab.id === selectedTab; + return ( + + ); + })} +
+ ); +}; diff --git a/src/settings/v3/components/__tests__/BackendModelPicker.test.tsx b/src/settings/v3/components/__tests__/BackendModelPicker.test.tsx new file mode 100644 index 00000000..26b5a779 --- /dev/null +++ b/src/settings/v3/components/__tests__/BackendModelPicker.test.tsx @@ -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 `:` 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 { + 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( + {}} + /> + ); + 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( + {}} + /> + ); + 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( + + ); + 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( + {}} onManageInByok={onManage} /> + ); + const link = screen.getByTestId("manage-in-byok"); + expect(link).toBeTruthy(); + fireEvent.click(link); + expect(onManage).toHaveBeenCalledTimes(1); + + rerender( {}} />); + expect(screen.queryByTestId("manage-in-byok")).toBeNull(); + }); +}); + +describe("BackendModelPicker — sectioned", () => { + it("renders section titles + rows", () => { + render( + {}} + /> + ); + 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 `:` key", () => { + const onToggle = jest.fn(); + render( + + ); + 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( + {}} + /> + ); + expect(screen.queryByTestId("backend-model-section-Copilot Plus")).toBeNull(); + }); +}); diff --git a/src/settings/v3/components/__tests__/BackendSubtabs.test.tsx b/src/settings/v3/components/__tests__/BackendSubtabs.test.tsx new file mode 100644 index 00000000..8a73ad83 --- /dev/null +++ b/src/settings/v3/components/__tests__/BackendSubtabs.test.tsx @@ -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( {}} />); + 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( {}} />); + 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(); + fireEvent.click(screen.getByTestId("backend-subtab-quickChat")); + expect(onSelectTab).toHaveBeenCalledWith("quickChat"); + }); +}); diff --git a/src/settings/v3/components/backendOverrides.ts b/src/settings/v3/components/backendOverrides.ts new file mode 100644 index 00000000..c225fbc9 --- /dev/null +++ b/src/settings/v3/components/backendOverrides.ts @@ -0,0 +1,75 @@ +/** + * Lightweight read/write helpers for `agentMode.backends..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 | undefined { + const backends = settings.agentMode?.backends as + | Record } | 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 | undefined)?.[ + backendId + ] as { modelEnabledOverrides?: Record } | 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 | undefined, + key: string +): boolean { + if (!overrides) return true; + const value = overrides[key]; + return value !== false; +} diff --git a/src/settings/v3/components/backendPanelHelpers.ts b/src/settings/v3/components/backendPanelHelpers.ts new file mode 100644 index 00000000..a93c019b --- /dev/null +++ b/src/settings/v3/components/backendPanelHelpers.ts @@ -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 `:` 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); +} diff --git a/src/settings/v3/components/backends/ClaudeCodePanel.tsx b/src/settings/v3/components/backends/ClaudeCodePanel.tsx new file mode 100644 index 00000000..e71887d4 --- /dev/null +++ b/src/settings/v3/components/backends/ClaudeCodePanel.tsx @@ -0,0 +1,123 @@ +/** + * ClaudeCodePanel — Agent sub-panel for the Claude Code backend. + * + * Per §5.4.2: + * - Subscription card with "Authenticated as " + [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 = ({ 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 ( +
+ + + {descriptor?.SettingsPanel && } + + writeBackendOverride("claude", key, enabled)} + /> +
+ ); +}; + +/** + * 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 ( +
+
Not signed in
+
+ Install and sign in to the claude CLI to enable Claude Code. +
+
+ ); + } + return ( +
+
+ Authenticated via the local claude CLI. +
+
+ Re-authenticate by running claude /login in your shell. +
+
+ ); +}; diff --git a/src/settings/v3/components/backends/CodexPanel.tsx b/src/settings/v3/components/backends/CodexPanel.tsx new file mode 100644 index 00000000..7d733b8f --- /dev/null +++ b/src/settings/v3/components/backends/CodexPanel.tsx @@ -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 = ({ 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 ( +
+ + + {descriptor?.SettingsPanel && } + + writeBackendOverride("codex", key, enabled)} + /> +
+ ); +}; + +const SubscriptionCard: React.FC<{ installKind: "ready" | "absent" | "error" }> = ({ + installKind, +}) => { + if (installKind !== "ready") { + return ( +
+
Not signed in
+
+ Install codex-acp and sign in to enable Codex. +
+
+ ); + } + return ( +
+
Authenticated via the local Codex CLI.
+
+ Re-authenticate by running codex login in your shell. +
+
+ ); +}; diff --git a/src/settings/v3/components/backends/OpencodePanel.tsx b/src/settings/v3/components/backends/OpencodePanel.tsx new file mode 100644 index 00000000..71e7c4ff --- /dev/null +++ b/src/settings/v3/components/backends/OpencodePanel.tsx @@ -0,0 +1,235 @@ +/** + * OpencodePanel — Agent sub-panel for the OpenCode backend. + * + * Per §5.4.1 the picker section is a UNION of three sources: + * 1. OpenCode-bundled models (e.g. Big Pickle) + * 2. Copilot-Plus hosted models (when Plus active) + * 3. BYOK registry entries (anthropic, openai, openrouter, custom:…) + * + * After the M9 fix all three sources share **one** persistence key: the + * bare wire-form `baseModelId` opencode reports. The per-backend storage + * path (`agentMode.backends.opencode.modelEnabledOverrides`) already + * discriminates the backend, so the key never repeats it. Because opencode + * wire-form ids already carry the provider segment (`anthropic/claude-…`, + * `openrouter/anthropic/claude-…`), the same `modelId` from two providers + * resolves to two distinct keys — no collision risk. + * + * All three sections source from the OpenCode probe cache + * (`AgentSessionManager.getCachedBackendState("opencode").model.availableModels`) + * so the panel and the in-chat picker share one source of truth: whatever + * OpenCode itself reports. We classify into the three buckets via + * `listOpencodeBuckets` (leading-segment vs. registered BYOK provider). + * + * Never reads global `app` — receives it via props for popout safety. + */ +import { + listOpencodeBuckets, + listOpencodePlusModels, + type ModelEntry, + type OpencodeModelBuckets, + type OpencodePlusModel, +} from "@/agentMode"; +import { usePlugin } from "@/contexts/PluginContext"; +import { ModelRegistry, ProviderRegistry } from "@/modelManagement"; +import { useIsPlusUser } from "@/plusUtils"; +import { useSettingsValue } from "@/settings/model"; +import { + BackendModelPicker, + type BackendModelPickerRow, + type BackendModelPickerSection, +} 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 OpencodePanelProps { + app: App; + onManageInByok?: () => void; +} + +export const OpencodePanel: React.FC = ({ app, onManageInByok }) => { + const settings = useSettingsValue(); + const plugin = usePlugin(); + const manager = plugin.agentSessionManager; + // The bucket classifier consults `ModelRegistry`, so the panel must + // re-classify whenever the user picks/unpicks BYOK models. Use the + // settings-driven registry array (stable identity per render unless + // changed) as the trigger. + const registrySignal = settings.registry; + // Memoize the registry handle so the bucket-fetch `useEffect` doesn't + // re-fire every render. In production `getInstance()` returns a stable + // singleton, but test mocks frequently return a fresh object on every + // call, which would otherwise blow up the deps array on each render. + const providerRegistry = React.useMemo(() => ProviderRegistry.getInstance(), []); + const modelRegistry = React.useMemo(() => ModelRegistry.getInstance(), []); + + const descriptor = findDescriptor("opencode"); + + const installState = descriptor?.getInstallState(settings) ?? { kind: "absent" as const }; + const overrides = readBackendOverrides("opencode"); + + // Trigger a probe when ready but nothing's cached — same pattern as the + // legacy AgentSettings: catches binary-installed-after-load. + React.useEffect(() => { + if (!manager) return; + if (installState.kind !== "ready") return; + const cached = manager.getCachedBackendState("opencode"); + if (cached) return; + manager + .preloadModels("opencode") + .catch((e) => logError("[AgentMode] preload opencode failed", e)); + }, [manager, installState.kind]); + + // ---- Source #1+#3: probe-state buckets -------------------------------- + // + // `listOpencodeBuckets` returns null when no probe has populated the + // cache yet (binary missing, probe still running, mobile). We surface + // that as a per-section empty-state below. + const [buckets, setBuckets] = React.useState< + { status: "loading" } | { status: "ready"; value: OpencodeModelBuckets | null } + >({ status: "loading" }); + React.useEffect(() => { + let cancelled = false; + const run = async (): Promise => { + try { + const value = await listOpencodeBuckets(manager ?? null, providerRegistry, modelRegistry); + if (!cancelled) setBuckets({ status: "ready", value }); + } catch (err) { + logError("[AgentMode] OpencodePanel: listOpencodeBuckets failed", err); + if (!cancelled) setBuckets({ status: "ready", value: null }); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [manager, providerRegistry, modelRegistry, registrySignal]); + React.useEffect(() => { + if (!manager) return; + return manager.subscribeModelCache(() => { + void listOpencodeBuckets(manager, providerRegistry, modelRegistry).then( + (value) => setBuckets({ status: "ready", value }), + (err) => { + logError("[AgentMode] OpencodePanel: listOpencodeBuckets failed", err); + setBuckets({ status: "ready", value: null }); + } + ); + }); + }, [manager, providerRegistry, modelRegistry, registrySignal]); + + // ---- Source #2: Copilot-Plus hosted models ---------------------------- + // + // Gated by `useIsPlusUser`. When the user isn't Plus, the section is + // suppressed entirely — no empty-state copy, no row count. + const isPlusUser = useIsPlusUser(); + const [plusFallback, setPlusFallback] = React.useState([]); + React.useEffect(() => { + let cancelled = false; + const run = async (): Promise => { + try { + const rows = isPlusUser ? await listOpencodePlusModels() : []; + if (!cancelled) setPlusFallback(rows); + } catch (err) { + logError("[AgentMode] OpencodePanel: listPlusModels failed", err); + if (!cancelled) setPlusFallback([]); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [isPlusUser]); + + // ---- Compose the sectioned picker ------------------------------------- + const toRow = (entry: ModelEntry): BackendModelPickerRow => ({ + key: entry.baseModelId, + name: entry.name || entry.baseModelId, + providerLabel: entry.provider ?? undefined, + meta: entry.description, + enabled: isBackendModelEnabled(overrides, entry.baseModelId), + }); + + const bundledRows: BackendModelPickerRow[] = + buckets.status === "ready" && buckets.value ? buckets.value.bundled.map(toRow) : []; + const byokRows: BackendModelPickerRow[] = + buckets.status === "ready" && buckets.value ? buckets.value.byok.map(toRow) : []; + + // Plus rows: prefer the probe-state ones (their `baseModelId` matches + // exactly what the runtime sees). Fall back to the hard-coded + // `PLUS_MODELS` list only when the probe hasn't populated yet, so the + // section never blinks empty for a Plus user on a fresh load. + const plusFromProbe = + buckets.status === "ready" && buckets.value ? buckets.value.plus.map(toRow) : []; + const plusFromFallback: BackendModelPickerRow[] = plusFallback.map((m) => { + const key = `copilot-plus/${m.id}`; + return { + key, + name: m.displayName, + meta: m.contextWindow ? `${Math.round(m.contextWindow / 1000)}k ctx` : undefined, + enabled: isBackendModelEnabled(overrides, key), + }; + }); + const plusRows: BackendModelPickerRow[] = + plusFromProbe.length > 0 || buckets.status === "ready" ? plusFromProbe : plusFromFallback; + + /** + * Empty-state copy for the Bundled section depends on _why_ it's empty: + * - `loading`: probe still running — show a "Loading…" placeholder. + * - `ready` + `value === null`: OpenCode not installed / unreachable. + * - `ready` + non-null but no bundled rows: OpenCode responded but has + * nothing to surface beyond BYOK / Plus. + */ + let bundledPlaceholder: string; + if (buckets.status === "loading") { + bundledPlaceholder = "Loading OpenCode-bundled models…"; + } else if (buckets.value === null) { + bundledPlaceholder = "OpenCode is not installed. Install it below to see bundled models."; + } else { + bundledPlaceholder = "OpenCode reports no bundled models yet."; + } + + const byokPlaceholder = + buckets.status === "ready" && buckets.value !== null + ? "Add an agent-capable model in the BYOK tab to see it here once OpenCode reports it." + : "Install OpenCode to preview your BYOK models here."; + + const sections: BackendModelPickerSection[] = [ + { + title: "OpenCode-bundled", + rows: bundledRows, + emptyPlaceholder: bundledPlaceholder, + }, + ]; + if (isPlusUser) { + sections.push({ + title: "Copilot Plus", + rows: plusRows, + emptyPlaceholder: "Copilot Plus is active but no hosted models are available right now.", + }); + } + sections.push({ + title: "From BYOK", + rows: byokRows, + emptyPlaceholder: byokPlaceholder, + }); + + const Panel = descriptor?.SettingsPanel; + + return ( +
+ {Panel && } + + writeBackendOverride("opencode", key, enabled)} + /> +
+ ); +}; diff --git a/src/settings/v3/components/backends/QuickChatPanel.tsx b/src/settings/v3/components/backends/QuickChatPanel.tsx new file mode 100644 index 00000000..a652739e --- /dev/null +++ b/src/settings/v3/components/backends/QuickChatPanel.tsx @@ -0,0 +1,66 @@ +/** + * QuickChatPanel — UI skeleton for the Quick Chat agent backend's settings. + * + * Per spec §5.4.3, this is a SKELETON in M6: + * - Status card always says "Active — runs in the plugin" (no install). + * - BackendModelPicker sources chat-capable BYOK registry entries. + * - Persistence writes to `agentMode.backends.quickChat.modelEnabledOverrides`. + * + * No runtime routing wiring — clicking around saves settings but the chat + * input still goes through the legacy ChatModelManager path. The follow-up + * doc (`designdocs/QUICK_CHAT_AGENT_INTEGRATION.md`) will connect the wires. + * + * New session model + effort are inherited from the previous active session + * via `AgentSessionManager.getLastSelection`, not from a persisted default. + */ +import { ModelRegistry, ProviderRegistry } from "@/modelManagement"; +import { useSettingsValue } from "@/settings/model"; +import { + BackendModelPicker, + type BackendModelPickerRow, +} from "@/settings/v3/components/BackendModelPicker"; +import { + isBackendModelEnabled, + readBackendOverrides, + writeBackendOverride, +} from "@/settings/v3/components/backendOverrides"; +import { modelKey } from "@/settings/v3/components/backendPanelHelpers"; +import React from "react"; + +interface QuickChatPanelProps { + /** Callback to switch the settings shell to the BYOK tab. Optional. */ + onManageInByok?: () => void; +} + +export const QuickChatPanel: React.FC = ({ onManageInByok }) => { + // Subscribe so the toggle picker re-renders when settings mutate. + useSettingsValue(); + + const modelRegistry = ModelRegistry.getInstance(); + const providerRegistry = ProviderRegistry.getInstance(); + + const overrides = readBackendOverrides("quickChat"); + const entries = modelRegistry.list(); + + const rows: BackendModelPickerRow[] = entries.map((entry) => { + const provider = providerRegistry.get(entry.providerId); + const key = modelKey(entry.providerId, entry.modelId); + return { + key, + name: entry.displayName, + providerLabel: provider?.displayName ?? entry.providerId, + enabled: isBackendModelEnabled(overrides, key), + }; + }); + + return ( +
+ writeBackendOverride("quickChat", key, enabled)} + /> +
+ ); +}; diff --git a/src/settings/v3/components/backends/__tests__/ClaudeCodePanel.test.tsx b/src/settings/v3/components/backends/__tests__/ClaudeCodePanel.test.tsx new file mode 100644 index 00000000..f9be2b16 --- /dev/null +++ b/src/settings/v3/components/backends/__tests__/ClaudeCodePanel.test.tsx @@ -0,0 +1,107 @@ +/** + * `ClaudeCodePanel` rendering tests. + * + * Scope: + * - Subscription card surfaces (re-)authenticated vs not-signed-in copy. + * - Bundled model picker sources from + * `AgentSessionManager.getCachedBackendState("claude").model.availableModels`. + */ +import { 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; +}); + +interface BackendSlice { + modelEnabledOverrides?: Record; +} +interface MockSettings { + agentMode: { + activeBackend: string; + backends: Record; + }; +} +let mockSettings: MockSettings = { + agentMode: { + activeBackend: "claude", + backends: { claude: {} }, + }, +}; +jest.mock("@/settings/model", () => ({ + useSettingsValue: () => mockSettings, + getSettings: () => mockSettings, + setSettings: jest.fn(), +})); + +const cachedBackendState = { + model: { + current: { baseModelId: "claude-sonnet-4-5", effort: null }, + availableModels: [ + { + baseModelId: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + provider: "anthropic", + effortOptions: [], + }, + ], + }, + mode: null, +}; + +jest.mock("@/agentMode", () => ({ + listBackendDescriptors: () => [ + { + id: "claude", + displayName: "Claude", + getInstallState: () => ({ kind: "ready", source: "managed" }), + SettingsPanel: () =>
, + }, + ], +})); + +jest.mock("@/contexts/PluginContext", () => ({ + usePlugin: () => ({ + app: {}, + agentSessionManager: { + subscribeModelCache: () => () => {}, + getCachedBackendState: (id: string) => (id === "claude" ? cachedBackendState : null), + preloadModels: jest.fn().mockResolvedValue(undefined), + }, + }), +})); + +jest.mock("@/components/ui/setting-item", () => ({ + SettingItem: ({ title, children }: { title: string; children?: React.ReactNode }) => ( +
+ {title} + {children} +
+ ), +})); + +import { ClaudeCodePanel } from "@/settings/v3/components/backends/ClaudeCodePanel"; + +describe("ClaudeCodePanel", () => { + it("renders the subscription card when the CLI is ready", () => { + mockSettings = { + agentMode: { + activeBackend: "claude", + backends: { claude: {} }, + }, + }; + render(); + expect(screen.getByTestId("claude-subscription-card")).toBeTruthy(); + }); + + it("renders bundled rows from the cached backend state (key = bare baseModelId)", () => { + render(); + expect(screen.getByTestId("backend-model-row-claude-sonnet-4-5")).toBeTruthy(); + }); +}); diff --git a/src/settings/v3/components/backends/__tests__/CodexPanel.test.tsx b/src/settings/v3/components/backends/__tests__/CodexPanel.test.tsx new file mode 100644 index 00000000..5156c1e3 --- /dev/null +++ b/src/settings/v3/components/backends/__tests__/CodexPanel.test.tsx @@ -0,0 +1,96 @@ +/** + * `CodexPanel` rendering tests. + * + * Scope (M6): mirror the ClaudeCodePanel structural checks. Codex shares the + * same shape — the only difference is the descriptor + key prefix. + */ +import { 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; +}); + +interface MockSettings { + agentMode: { + activeBackend: string; + backends: Record>; + }; +} +let mockSettings: MockSettings = { + agentMode: { + activeBackend: "codex", + backends: { codex: {} }, + }, +}; +jest.mock("@/settings/model", () => ({ + useSettingsValue: () => mockSettings, + getSettings: () => mockSettings, + setSettings: jest.fn(), +})); + +const cachedBackendState = { + model: { + current: { baseModelId: "gpt-5.5", effort: null }, + availableModels: [ + { + baseModelId: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + effortOptions: [], + }, + ], + }, + mode: null, +}; + +jest.mock("@/agentMode", () => ({ + listBackendDescriptors: () => [ + { + id: "codex", + displayName: "Codex", + getInstallState: () => ({ kind: "ready", source: "managed" }), + SettingsPanel: () =>
, + }, + ], +})); + +jest.mock("@/contexts/PluginContext", () => ({ + usePlugin: () => ({ + app: {}, + agentSessionManager: { + subscribeModelCache: () => () => {}, + getCachedBackendState: (id: string) => (id === "codex" ? cachedBackendState : null), + preloadModels: jest.fn().mockResolvedValue(undefined), + }, + }), +})); + +jest.mock("@/components/ui/setting-item", () => ({ + SettingItem: ({ title, children }: { title: string; children?: React.ReactNode }) => ( +
+ {title} + {children} +
+ ), +})); + +import { CodexPanel } from "@/settings/v3/components/backends/CodexPanel"; + +describe("CodexPanel", () => { + it("renders the subscription card when the CLI is ready", () => { + render(); + expect(screen.getByTestId("codex-subscription-card")).toBeTruthy(); + }); + + it("renders bundled rows from the cached backend state (key = bare baseModelId)", () => { + render(); + expect(screen.getByTestId("backend-model-row-gpt-5.5")).toBeTruthy(); + }); +}); diff --git a/src/settings/v3/components/backends/__tests__/OpencodePanel.test.tsx b/src/settings/v3/components/backends/__tests__/OpencodePanel.test.tsx new file mode 100644 index 00000000..f9d4bce7 --- /dev/null +++ b/src/settings/v3/components/backends/__tests__/OpencodePanel.test.tsx @@ -0,0 +1,238 @@ +/** + * `OpencodePanel` rendering tests. + * + * Scope: + * - All three sections (Bundled / Plus / BYOK) source from + * `listOpencodeBuckets`, which classifies the OpenCode probe-cache + * `availableModels` list by leading wire-form segment. + * - Override-key shape is the bare wire-form `baseModelId` for every + * row — no per-section prefix. + * - When the probe cache is empty / null, the Bundled section shows the + * "OpenCode not installed" empty-state and the BYOK section shows the + * "Install OpenCode to preview" copy. + * - The Plus section is suppressed when `isPlusUser === false`. + * - Toggles in each section write `entry.baseModelId` keys via + * `writeBackendOverride("opencode", …)`. + */ +import { act, 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; +}); + +interface BackendSlice { + modelEnabledOverrides?: Record; +} +interface MockSettings { + agentMode: { + activeBackend: string; + backends: Record; + }; +} +let mockSettings: MockSettings = { + agentMode: { + activeBackend: "opencode", + backends: { + opencode: {}, + }, + }, +}; +const setSettingsMock = jest.fn( + (updater: ((cur: MockSettings) => Partial) | Partial) => { + const patch = typeof updater === "function" ? updater(mockSettings) : updater; + mockSettings = { ...mockSettings, ...patch }; + } +); +jest.mock("@/settings/model", () => ({ + useSettingsValue: () => mockSettings, + getSettings: () => mockSettings, + setSettings: (u: unknown) => setSettingsMock(u as never), +})); + +// Memoized singletons — `ProviderRegistry.getInstance()` must return the +// SAME object across renders, otherwise the panel's `[providerRegistry]` +// effect dep changes every render and the test's microtask flush races +// with the re-spawned fetches. +jest.mock("@/modelManagement", () => { + const providerRegistry = { + get: (id: string) => (id === "openai" ? { id, displayName: "OpenAI" } : undefined), + list: () => [] as never[], + }; + const modelRegistry = { list: () => [] as never[] }; + return { + ModelRegistry: { getInstance: () => modelRegistry }, + ProviderRegistry: { getInstance: () => providerRegistry }, + }; +}); + +interface BucketsValue { + bundled: Array<{ baseModelId: string; name: string }>; + byok: Array<{ baseModelId: string; name: string }>; + plus: Array<{ baseModelId: string; name: string }>; +} +let bucketsResult: BucketsValue | null = { + bundled: [{ baseModelId: "bigpickle/big-pickle", name: "Big Pickle" }], + byok: [{ baseModelId: "openai/gpt-5", name: "GPT-5" }], + plus: [{ baseModelId: "copilot-plus/copilot-plus-flash", name: "Copilot Plus Flash" }], +}; +const listBucketsMock = jest.fn(async () => bucketsResult); +let plusFallback: Array<{ id: string; displayName: string }> = []; +const listPlusMock = jest.fn(async () => plusFallback); + +let isPlusUserMock = true; +jest.mock("@/plusUtils", () => ({ + useIsPlusUser: () => isPlusUserMock, +})); + +jest.mock("@/agentMode", () => ({ + listBackendDescriptors: () => [ + { + id: "opencode", + displayName: "OpenCode", + getInstallState: () => ({ kind: "ready", source: "managed" }), + SettingsPanel: () =>
, + }, + ], + listOpencodeBuckets: () => listBucketsMock(), + listOpencodePlusModels: () => listPlusMock(), +})); + +// Stable plugin handle — recreating on every render would invalidate the +// `[manager]` dep of the panel's bucket-fetch effect and cause an infinite +// re-render loop in tests. +const mockPlugin = { + app: {}, + agentSessionManager: { + subscribeModelCache: () => () => {}, + getCachedBackendState: () => null, + preloadModels: jest.fn().mockResolvedValue(undefined), + }, +}; +jest.mock("@/contexts/PluginContext", () => ({ + usePlugin: () => mockPlugin, +})); + +jest.mock("@/components/ui/setting-item", () => ({ + SettingItem: ({ title, children }: { title: string; children?: React.ReactNode }) => ( +
+ {title} + {children} +
+ ), +})); + +import { OpencodePanel } from "@/settings/v3/components/backends/OpencodePanel"; + +/** + * Render the panel and let the async bucket / plus-fallback fetches resolve. + */ +async function renderPanel(): Promise { + render(); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("OpencodePanel", () => { + beforeEach(() => { + mockSettings = { + agentMode: { + activeBackend: "opencode", + backends: { + opencode: {}, + }, + }, + }; + setSettingsMock.mockClear(); + bucketsResult = { + bundled: [{ baseModelId: "bigpickle/big-pickle", name: "Big Pickle" }], + byok: [{ baseModelId: "openai/gpt-5", name: "GPT-5" }], + plus: [{ baseModelId: "copilot-plus/copilot-plus-flash", name: "Copilot Plus Flash" }], + }; + plusFallback = []; + isPlusUserMock = true; + listBucketsMock.mockClear(); + listPlusMock.mockClear(); + }); + + it("renders all three sections with bare baseModelId keys", async () => { + await renderPanel(); + expect(screen.getByTestId("backend-model-section-OpenCode-bundled")).toBeTruthy(); + expect(screen.getByTestId("backend-model-row-bigpickle/big-pickle")).toBeTruthy(); + expect(screen.getByTestId("backend-model-section-Copilot Plus")).toBeTruthy(); + expect(screen.getByTestId("backend-model-row-copilot-plus/copilot-plus-flash")).toBeTruthy(); + expect(screen.getByTestId("backend-model-section-From BYOK")).toBeTruthy(); + expect(screen.getByTestId("backend-model-row-openai/gpt-5")).toBeTruthy(); + }); + + it("renders the OpenCode-not-installed empty-state when listOpencodeBuckets returns null", async () => { + bucketsResult = null; + await renderPanel(); + expect(screen.getByTestId("backend-model-section-empty-OpenCode-bundled")).toBeTruthy(); + expect(screen.getByTestId("backend-model-section-empty-OpenCode-bundled").textContent).toMatch( + /not installed/i + ); + // BYOK section also shows its install-OpenCode placeholder. + expect(screen.getByTestId("backend-model-section-empty-From BYOK")).toBeTruthy(); + }); + + it("renders the OpenCode-not-installed empty-state when listOpencodeBuckets throws", async () => { + listBucketsMock.mockRejectedValueOnce(new Error("probe failed")); + await renderPanel(); + expect(screen.getByTestId("backend-model-section-empty-OpenCode-bundled")).toBeTruthy(); + expect(screen.getByTestId("backend-model-section-empty-From BYOK")).toBeTruthy(); + }); + + it("hides the Copilot Plus section when isPlusUser is false", async () => { + isPlusUserMock = false; + await renderPanel(); + expect(screen.queryByTestId("backend-model-section-Copilot Plus")).toBeNull(); + expect(screen.getByTestId("backend-model-section-OpenCode-bundled")).toBeTruthy(); + expect(screen.getByTestId("backend-model-section-From BYOK")).toBeTruthy(); + }); + + it("toggles a bundled row using the bare baseModelId as the override key", async () => { + await renderPanel(); + fireEvent.click(screen.getByTestId("backend-model-checkbox-bigpickle/big-pickle")); + const updater = setSettingsMock.mock.calls.at(-1)?.[0] as ( + cur: MockSettings + ) => Partial; + const patch = updater(mockSettings); + expect( + patch.agentMode?.backends?.opencode?.modelEnabledOverrides?.["bigpickle/big-pickle"] + ).toBe(false); + }); + + it("toggles a plus row using the bare baseModelId as the override key", async () => { + await renderPanel(); + fireEvent.click(screen.getByTestId("backend-model-checkbox-copilot-plus/copilot-plus-flash")); + const updater = setSettingsMock.mock.calls.at(-1)?.[0] as ( + cur: MockSettings + ) => Partial; + const patch = updater(mockSettings); + expect( + patch.agentMode?.backends?.opencode?.modelEnabledOverrides?.[ + "copilot-plus/copilot-plus-flash" + ] + ).toBe(false); + }); + + it("toggles a BYOK row using the bare baseModelId as the override key", async () => { + await renderPanel(); + fireEvent.click(screen.getByTestId("backend-model-checkbox-openai/gpt-5")); + const updater = setSettingsMock.mock.calls.at(-1)?.[0] as ( + cur: MockSettings + ) => Partial; + const patch = updater(mockSettings); + expect(patch.agentMode?.backends?.opencode?.modelEnabledOverrides?.["openai/gpt-5"]).toBe( + false + ); + }); +}); diff --git a/src/settings/v3/components/backends/__tests__/QuickChatPanel.test.tsx b/src/settings/v3/components/backends/__tests__/QuickChatPanel.test.tsx new file mode 100644 index 00000000..28bfbece --- /dev/null +++ b/src/settings/v3/components/backends/__tests__/QuickChatPanel.test.tsx @@ -0,0 +1,109 @@ +/** + * `QuickChatPanel` rendering tests. + * + * Scope: + * - Status card always shows the "Active" state (Quick Chat needs no install). + * - Picker rows persist via `writeBackendOverride("quickChat", …)`. + * + * The panel no longer surfaces a Default model dropdown — new sessions + * inherit (model, effort) from the previous active session via + * `AgentSessionManager.getLastSelection`. + */ +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; +}); + +interface BackendSlice { + modelEnabledOverrides?: Record; +} +interface MockSettings { + agentMode: { backends: Record }; +} +let mockSettings: MockSettings = { + agentMode: { + backends: { + quickChat: {}, + }, + }, +}; +const setSettingsMock = jest.fn( + (updater: ((cur: MockSettings) => Partial) | Partial) => { + const patch = typeof updater === "function" ? updater(mockSettings) : updater; + mockSettings = { ...mockSettings, ...patch }; + } +); +jest.mock("@/settings/model", () => ({ + useSettingsValue: () => mockSettings, + getSettings: () => mockSettings, + setSettings: (u: unknown) => setSettingsMock(u as never), +})); + +const chatModels = [ + { + providerId: "anthropic", + modelId: "claude-sonnet-4-5", + displayName: "Claude Sonnet 4.5", + addedAt: 1, + }, +]; +jest.mock("@/modelManagement", () => ({ + ModelRegistry: { + getInstance: () => ({ + list: () => chatModels, + }), + }, + ProviderRegistry: { + getInstance: () => ({ + get: (id: string) => (id === "anthropic" ? { displayName: "Anthropic" } : undefined), + list: () => [], + }), + }, +})); + +import { QuickChatPanel } from "@/settings/v3/components/backends/QuickChatPanel"; + +describe("QuickChatPanel", () => { + beforeEach(() => { + mockSettings = { + agentMode: { + backends: { + quickChat: {}, + }, + }, + }; + setSettingsMock.mockClear(); + }); + + it("lists chat-capable registry entries in the picker", () => { + render(); + expect(screen.getByTestId("backend-model-row-anthropic:claude-sonnet-4-5")).toBeTruthy(); + }); + + it("toggles overrides via writeBackendOverride", () => { + render(); + fireEvent.click(screen.getByTestId("backend-model-checkbox-anthropic:claude-sonnet-4-5")); + expect(setSettingsMock).toHaveBeenCalled(); + // Walk the latest set call's patch through to see the persisted override. + const updater = setSettingsMock.mock.calls.at(-1)?.[0] as ( + cur: MockSettings + ) => Partial; + const patch = updater(mockSettings); + const quickChat = patch.agentMode?.backends?.quickChat; + expect(quickChat?.modelEnabledOverrides?.["anthropic:claude-sonnet-4-5"]).toBe(false); + }); + + it("does not render a Default model or Default reasoning effort row", () => { + render(); + expect(screen.queryByTestId("setting-Default model")).toBeNull(); + expect(screen.queryByTestId("setting-Default reasoning effort")).toBeNull(); + }); +}); diff --git a/src/settings/v3/tabs/AgentPanel.tsx b/src/settings/v3/tabs/AgentPanel.tsx new file mode 100644 index 00000000..e0745863 --- /dev/null +++ b/src/settings/v3/tabs/AgentPanel.tsx @@ -0,0 +1,85 @@ +/** + * AgentPanel — top-level Agent tab (replaces the legacy AgentSettings). + * + * Lays out a four-way sub-tab strip (OpenCode · Claude Code · Codex · + * Quick chat — Quick chat LAST) and renders the matching sub-panel. + * + * Cross-tab navigation to the BYOK tab is exposed via the `onNavigateToByok` + * prop. When omitted, panels suppress the `Manage in BYOK →` link. + * + * Never reads global `app` — threaded via props (popout safety). + */ +import { McpServersPanel } from "@/agentMode"; +import { useSettingsValue } from "@/settings/model"; +import { + AGENT_BACKEND_TAB_ORDER, + BackendSubtabs, + type AgentBackendTabId, +} from "@/settings/v3/components/BackendSubtabs"; +import { ClaudeCodePanel } from "@/settings/v3/components/backends/ClaudeCodePanel"; +import { CodexPanel } from "@/settings/v3/components/backends/CodexPanel"; +import { OpencodePanel } from "@/settings/v3/components/backends/OpencodePanel"; +import { QuickChatPanel } from "@/settings/v3/components/backends/QuickChatPanel"; +import { App, Platform } from "obsidian"; +import React from "react"; + +interface AgentPanelProps { + app: App; + /** + * Switch the settings shell to the BYOK tab. Wired by `SettingsMainV2` + * via the TabContext. Optional — when missing, the per-backend panels + * hide the `Manage in BYOK →` link. + */ + onNavigateToByok?: () => void; +} + +/** Coerce an arbitrary `agentMode.activeBackend` string into a known sub-tab id. */ +function asAgentBackendTab(id: string | undefined): AgentBackendTabId | null { + if (id === "opencode" || id === "claude" || id === "codex" || id === "quickChat") { + return id; + } + return null; +} + +export const AgentPanel: React.FC = ({ app, onNavigateToByok }) => { + const settings = useSettingsValue(); + + // Default the selected sub-tab to whichever backend is active (when it + // maps to one of the four sub-tabs); otherwise the spec order kicks in + // and we land on OpenCode. + const initialTab = + asAgentBackendTab(settings.agentMode?.activeBackend) ?? AGENT_BACKEND_TAB_ORDER[0].id; + const [selectedTab, setSelectedTab] = React.useState(initialTab); + + if (Platform.isMobile) { + return ( +
+
Agents
+
+ Agent Mode is desktop only. Open the desktop app to configure agents. +
+
+ ); + } + + return ( +
+
Agents
+ + + +
+ {selectedTab === "opencode" && ( + + )} + {selectedTab === "claude" && } + {selectedTab === "codex" && } + {selectedTab === "quickChat" && } +
+ +
+ +
+
+ ); +}; diff --git a/src/settings/v3/tabs/__tests__/AgentPanel.test.tsx b/src/settings/v3/tabs/__tests__/AgentPanel.test.tsx new file mode 100644 index 00000000..f0927816 --- /dev/null +++ b/src/settings/v3/tabs/__tests__/AgentPanel.test.tsx @@ -0,0 +1,179 @@ +/** + * `AgentPanel` integration tests. + * + * Scope (M6): + * - Renders all four sub-tabs in the canonical order (Quick chat LAST). + * - Switching sub-tabs swaps the rendered sub-panel. + * - Each sub-tab preserves the panel-local state independently (i.e. + * clicking on Quick chat then back to OpenCode still shows OpenCode's + * BYOK section, not Quick Chat's). + * - Uses `agentMode.activeBackend` to drive the Active badge. + * + * Heavy descriptors / registries are mocked so the test focuses on layout + * + tab switching, not the full per-backend integration. + */ +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; +}); + +// Force desktop platform for these tests. The shared jest setup polyfills +// `obsidian` with `Platform.isMobile = false`, so we only need a partial +// re-mock here to keep the rest of the surface intact. +jest.mock("obsidian", () => { + const actual = jest.requireActual>("obsidian"); + const actualPlatform = (actual.Platform as Record | undefined) ?? {}; + return { + ...actual, + Platform: { ...actualPlatform, isMobile: false }, + }; +}); + +// Settings mock: starts on OpenCode as the active backend. +type Selection = { baseModelId: string; effort: string | null } | null; +interface BackendSlice { + defaultModel?: Selection; + modelEnabledOverrides?: Record; +} +interface MockSettings { + agentMode: { + activeBackend: string; + backends: Record; + }; +} +let mockSettings: MockSettings = { + agentMode: { + activeBackend: "opencode", + backends: { + opencode: { defaultModel: null }, + claude: { defaultModel: null }, + codex: { defaultModel: null }, + quickChat: { defaultModel: null }, + }, + }, +}; +const setSettingsMock = jest.fn( + (updater: ((cur: MockSettings) => Partial) | Partial) => { + const patch = typeof updater === "function" ? updater(mockSettings) : updater; + mockSettings = { ...mockSettings, ...patch }; + } +); +jest.mock("@/settings/model", () => ({ + useSettingsValue: () => mockSettings, + getSettings: () => mockSettings, + setSettings: (u: unknown) => setSettingsMock(u as never), +})); + +// `agentMode` barrel — only the symbols AgentPanel imports. +jest.mock("@/agentMode", () => ({ + McpServersPanel: () =>
, + listBackendDescriptors: () => [ + { + id: "opencode", + displayName: "OpenCode", + getInstallState: () => ({ kind: "absent" }), + SettingsPanel: () =>
, + }, + { + id: "claude", + displayName: "Claude", + getInstallState: () => ({ kind: "absent" }), + SettingsPanel: () =>
, + }, + { + id: "codex", + displayName: "Codex", + getInstallState: () => ({ kind: "absent" }), + SettingsPanel: () =>
, + }, + ], +})); + +// Plugin context: provide a stub plugin with a non-null sessionManager. +const fakeManager = { + subscribeModelCache: () => () => {}, + getCachedBackendState: () => null, + preloadModels: jest.fn().mockResolvedValue(undefined), +}; +jest.mock("@/contexts/PluginContext", () => ({ + usePlugin: () => ({ + app: {}, + agentSessionManager: fakeManager, + }), +})); + +// Mock the model management surface used by panels. +jest.mock("@/modelManagement", () => ({ + ModelRegistry: { getInstance: () => ({ list: () => [] }) }, + ProviderRegistry: { getInstance: () => ({ list: () => [], get: () => undefined }) }, +})); + +// Mock SettingItem so we don't drag in obsidian dialog primitives. +jest.mock("@/components/ui/setting-item", () => ({ + SettingItem: ({ title }: { title: string }) => ( +
{title}
+ ), +})); + +import { AgentPanel } from "@/settings/v3/tabs/AgentPanel"; + +describe("AgentPanel", () => { + beforeEach(() => { + mockSettings = { + agentMode: { + activeBackend: "opencode", + backends: { + opencode: { defaultModel: null }, + claude: { defaultModel: null }, + codex: { defaultModel: null }, + quickChat: { defaultModel: null }, + }, + }, + }; + setSettingsMock.mockClear(); + }); + + it("renders the four sub-tabs in the canonical order", () => { + render(); + expect(screen.getByTestId("backend-subtab-opencode")).toBeTruthy(); + expect(screen.getByTestId("backend-subtab-claude")).toBeTruthy(); + expect(screen.getByTestId("backend-subtab-codex")).toBeTruthy(); + expect(screen.getByTestId("backend-subtab-quickChat")).toBeTruthy(); + }); + + it("starts on the active backend's sub-panel", () => { + render(); + // OpenCode is the default initial sub-tab; its SettingsPanel mock + // renders the test id. + expect(screen.getByTestId("opencode-settings-panel")).toBeTruthy(); + expect(screen.queryByTestId("claude-settings-panel")).toBeNull(); + }); + + it("switches between sub-tabs and preserves per-tab content", () => { + render(); + fireEvent.click(screen.getByTestId("backend-subtab-claude")); + expect(screen.getByTestId("claude-settings-panel")).toBeTruthy(); + expect(screen.queryByTestId("opencode-settings-panel")).toBeNull(); + + // Switch to Quick chat — exposes the BYOK-sourced model picker. + fireEvent.click(screen.getByTestId("backend-subtab-quickChat")); + expect(screen.getByTestId("backend-model-picker")).toBeTruthy(); + + // Back to OpenCode — settings panel re-renders. + fireEvent.click(screen.getByTestId("backend-subtab-opencode")); + expect(screen.getByTestId("opencode-settings-panel")).toBeTruthy(); + }); + + // Mobile fallback is tested implicitly via the production code path — + // wiring a `jest.isolateModules` re-import would dual-register React, so we + // skip the assertion at this layer. The legacy AgentSettings has the same + // Platform.isMobile guard pattern already in production. +}); diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css index 31680654..e0f89603 100644 --- a/src/styles/tailwind.css +++ b/src/styles/tailwind.css @@ -26,6 +26,11 @@ If your plugin does not need CSS, delete this file. border-bottom: 1px solid var(--background-modifier-border); } +/* Top-only divider — same reason as .copilot-divider-b. */ +.copilot-divider-t { + border-top: 1px solid var(--background-modifier-border); +} + .button-container { display: flex; justify-content: space-between; diff --git a/src/tools/memoryTools.ts b/src/tools/memoryTools.ts index 101c9881..181ad6c3 100644 --- a/src/tools/memoryTools.ts +++ b/src/tools/memoryTools.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { createLangChainTool } from "./createLangChainTool"; import { UserMemoryManager } from "@/memory/UserMemoryManager"; import { logError } from "@/logger"; -import ChatModelManager from "@/LLMProviders/chatModelManager"; +import ChatModelManager from "@/LLMProviders/ChatModelManager"; // Define Zod schema for updateMemoryTool const memorySchema = z.object({ diff --git a/src/utils.test.ts b/src/utils.test.ts index ede34c3f..49b5d7ec 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -4,7 +4,6 @@ import { extractNoteFiles, extractTemplateNoteFiles, formatDateTime, - getModelInfo, getNotesFromPath, getNotesFromTags, getUtf8ByteLength, @@ -943,45 +942,8 @@ describe("stringToFormattedDateTime", () => { }); }); -describe("getModelInfo", () => { - it("flags claude-opus-4-7 as adaptive thinking", () => { - const info = getModelInfo("claude-opus-4-7"); - expect(info.isThinkingEnabled).toBe(true); - expect(info.usesAdaptiveThinking).toBe(true); - }); - - it("flags claude-opus-4-8 and higher as adaptive thinking", () => { - expect(getModelInfo("claude-opus-4-8").usesAdaptiveThinking).toBe(true); - expect(getModelInfo("claude-opus-4-12").usesAdaptiveThinking).toBe(true); - }); - - it("keeps claude-opus-4-6 and earlier on legacy thinking", () => { - const six = getModelInfo("claude-opus-4-6"); - expect(six.isThinkingEnabled).toBe(true); - expect(six.usesAdaptiveThinking).toBe(false); - - const zero = getModelInfo("claude-opus-4-0"); - expect(zero.isThinkingEnabled).toBe(true); - expect(zero.usesAdaptiveThinking).toBe(false); - }); - - it("does not affect other thinking-enabled families", () => { - expect(getModelInfo("claude-sonnet-4-5").usesAdaptiveThinking).toBe(false); - expect(getModelInfo("claude-3-7-sonnet-20250219").usesAdaptiveThinking).toBe(false); - }); - - it("does not match unversioned claude-opus-4 prefix", () => { - const bare = getModelInfo("claude-opus-4"); - expect(bare.isThinkingEnabled).toBe(true); - expect(bare.usesAdaptiveThinking).toBe(false); - }); - - it("does not treat dated snapshot IDs as adaptive thinking minors", () => { - // claude-opus-4-20250514 is the dated snapshot of Opus 4.0, not Opus 4.20250514. - expect(getModelInfo("claude-opus-4-20250514").usesAdaptiveThinking).toBe(false); - // claude-opus-4-1-20250805 is dated 4.1. - expect(getModelInfo("claude-opus-4-1-20250805").usesAdaptiveThinking).toBe(false); - // Dated 4.7 still matches because the minor is delimited by "-". - expect(getModelInfo("claude-opus-4-7-20260115").usesAdaptiveThinking).toBe(true); - }); -}); +// `getModelInfo` was removed in Task 8. The provider-specific id pattern +// helpers it returned (isOpenAIGPT5, isOpenAIOSeries, isAnthropicThinkingModel, +// isAnthropicAdaptiveThinkingModel) now live in +// `src/modelManagement/providers/adapters/adapterUtils.ts` and are tested by +// `adapterUtils.test.ts`. diff --git a/src/utils.ts b/src/utils.ts index 9415aca1..47cecbed 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1005,30 +1005,6 @@ export async function checkLatestVersion(): Promise<{ } } -// Note: LangChain 0.6.6+ handles O-series and GPT-5 models automatically -// These functions are kept for backward compatibility and specific checks -export function isOSeriesModel(model: BaseChatModel | string): boolean { - if (typeof model === "string") { - return model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o4"); - } - - // For BaseChatModel instances - const m = model as unknown as Record; - const modelName: string = (m.modelName as string) || (m.model as string) || ""; - return modelName.startsWith("o1") || modelName.startsWith("o3") || modelName.startsWith("o4"); -} - -function isGPT5Model(model: BaseChatModel | string): boolean { - if (typeof model === "string") { - return model.startsWith("gpt-5"); - } - - // For BaseChatModel instances - const m = model as unknown as Record; - const modelName: string = (m.modelName as string) || (m.model as string) || ""; - return modelName.startsWith("gpt-5"); -} - /** * Checks whether a model belongs to the Codex family. * Codex model identifiers consistently include the "codex" token. @@ -1062,49 +1038,26 @@ export function shouldUseGitHubCopilotResponsesApi( return isCodexModel(model.name); } -/** - * Utility for determining model characteristics - * Note: Most of this is handled by LangChain 0.6.6+ internally - */ -export interface ModelInfo { - isOSeries: boolean; - isGPT5: boolean; - isThinkingEnabled: boolean; - usesAdaptiveThinking: boolean; -} - -export function getModelInfo(model: BaseChatModel | string): ModelInfo { - const m = model as unknown as Record; - const modelName: string = - typeof model === "string" ? model : (m.modelName as string) || (m.model as string) || ""; - - const isOSeries = isOSeriesModel(modelName); - const isGPT5 = isGPT5Model(modelName); - const isThinkingEnabled = - modelName.startsWith("claude-3-7-sonnet") || - modelName.startsWith("claude-sonnet-4") || - modelName.startsWith("claude-opus-4"); - - // claude-opus-4-7 and later reject the legacy { type: "enabled", budget_tokens } shape - // with a 400 and require { type: "adaptive" }. Detect by minor version on the opus-4 line. - // Constrain the minor to 1-2 digits followed by a delimiter or end-of-string so dated - // snapshot IDs (e.g. "claude-opus-4-20250514") aren't misread as Opus 4.20250514. - const opusMinorMatch = modelName.match(/^claude-opus-4-(\d{1,2})(?:[-.]|$)/); - const usesAdaptiveThinking = opusMinorMatch ? parseInt(opusMinorMatch[1], 10) >= 7 : false; - - return { - isOSeries, - isGPT5, - isThinkingEnabled, - usesAdaptiveThinking, - }; -} - export function getMessageRole( model: BaseChatModel | string, defaultRole: "system" | "human" = "system" ): "system" | "human" { - return isOSeriesModel(model) ? "human" : defaultRole; + // OpenAI o-series models reject the `system` role on chat completions; flip + // to `human`. The provider-specific id pattern lives next to the OpenAI + // adapter, but inlining the prefix check here avoids a cross-boundary + // import from this generic shared file. The canonical helper is + // `isOpenAIOSeries` in `@/modelManagement/providers/adapters/adapterUtils`; + // keep the two in sync. + const modelName = + typeof model === "string" + ? model + : (((model as unknown as Record).modelName as string) ?? + ((model as unknown as Record).model as string) ?? + ""); + const isOSeries = + typeof modelName === "string" && + (modelName.startsWith("o1") || modelName.startsWith("o3") || modelName.startsWith("o4")); + return isOSeries ? "human" : defaultRole; } export function getNeedSetKeyProvider(): Provider[] { @@ -1131,16 +1084,16 @@ export function checkModelApiKey( } { const provider = model.provider as ChatModelProviders; if (provider === ChatModelProviders.AMAZON_BEDROCK) { - const apiKey = model.apiKey || settings.amazonBedrockApiKey; + // Sources the key from `ProviderRegistry` (post-M9). Region defaults to + // us-east-1, so the API key is the only required credential. + const apiKey = getApiKeyForProvider(provider, model); if (!apiKey) { return { hasApiKey: false, errorNotice: - "Amazon Bedrock API key is missing. Please add a key in Settings > API Keys or update the model configuration.", + "Amazon Bedrock API key is missing. Please add a key in Settings > BYOK or update the model configuration.", }; } - - // Region defaults to us-east-1 if not specified, so API key is the only required check return { hasApiKey: true }; } diff --git a/src/utils/curlCommand.ts b/src/utils/curlCommand.ts index a55532e2..9d7e10fc 100644 --- a/src/utils/curlCommand.ts +++ b/src/utils/curlCommand.ts @@ -358,7 +358,7 @@ async function buildAzureOpenAIRequestSpec( // When a base URL is provided, use it directly (new flow) if (model.baseUrl?.trim()) { - const { normalizeAzureUrl } = await import("@/LLMProviders/chatModelManager"); + const { normalizeAzureUrl } = await import("@/LLMProviders/ChatModelManager"); const { baseUrl, apiVersion } = normalizeAzureUrl(model.baseUrl.trim()); const version = apiVersion || model.azureOpenAIApiVersion?.trim() || "2024-05-01-preview"; const url = `${baseUrl}/${endpoint}?api-version=${encodeURIComponent(version)}`; diff --git a/src/utils/modelUtils.ts b/src/utils/modelUtils.ts index 17f1c57e..521a20a9 100644 --- a/src/utils/modelUtils.ts +++ b/src/utils/modelUtils.ts @@ -1,29 +1,49 @@ -import { - ChatModelProviders, - ChatModels, - ProviderSettingsKeyMap, - SettingKeyProviders, -} from "@/constants"; +import { ChatModelProviders, ChatModels, SettingKeyProviders } from "@/constants"; +import { getProviderApiKeySync } from "@/modelManagement"; import { getSettings } from "@/settings/model"; import { CustomModel } from "@/aiParams"; /** - * Get API key for a provider, with model-specific key taking precedence over global settings. + * Map `SettingKeyProviders` (legacy `ChatModelProviders` enum string values) + * to canonical `ProviderRegistry` provider ids. Copilot-Plus and GitHub + * Copilot are handled inline below — neither is a BYOK credential. + */ +const SETTING_PROVIDER_TO_REGISTRY_ID: Partial> = { + [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", +}; + +/** + * Get API key for a provider, with model-specific key taking precedence over + * `ProviderRegistry`. Post-M9 source of truth is `settings.providers[id]`; + * Copilot-Plus and GitHub Copilot read from their dedicated settings fields. * * @param provider - The provider to get the API key for - * @param model - Optional model instance; if provided and has apiKey, it will be used instead of global key - * @returns The API key (model-specific if available, otherwise global provider key, or empty string) - * - * @example - * // Get global API key for OpenAI - * const globalKey = getApiKeyForProvider(ChatModelProviders.OPENAI); - * - * // Get model-specific key (falls back to global if model.apiKey is empty) - * const modelKey = getApiKeyForProvider(ChatModelProviders.OPENAI, customModel); + * @param model - Optional model instance; if provided and has apiKey, it will be used instead of the registry value + * @returns The API key (model-specific if available, otherwise the registry value, or empty string) */ export function getApiKeyForProvider(provider: SettingKeyProviders, model?: CustomModel): string { - const settings = getSettings(); - return model?.apiKey || (settings[ProviderSettingsKeyMap[provider]] as string | undefined) || ""; + if (model?.apiKey) return model.apiKey; + if (provider === ChatModelProviders.COPILOT_PLUS) { + return getSettings().plusLicenseKey ?? ""; + } + if (provider === ChatModelProviders.GITHUB_COPILOT) { + const settings = getSettings(); + return settings.githubCopilotToken || settings.githubCopilotAccessToken || ""; + } + const registryId = SETTING_PROVIDER_TO_REGISTRY_ID[provider]; + if (!registryId) return ""; + return getProviderApiKeySync(registryId) ?? ""; } /**