Add BYOK settings - agent enablement

This commit is contained in:
Zero Liu 2026-05-25 22:17:27 -07:00
parent d554434020
commit 60185fc55a
No known key found for this signature in database
61 changed files with 3739 additions and 82001 deletions

3
.gitignore vendored
View file

@ -36,3 +36,6 @@ TODO.md
# ACP full-frame debug log (vault sidecar, may contain prompt/tool data)
copilot/acp-frames.ndjson
copilot/acp-frames.old.ndjson
# Models.dev catalog cache (disposable runtime cache under .copilot/)
.copilot/model-catalog-cache.json

File diff suppressed because it is too large Load diff

420
AGENTS.md
View file

@ -4,377 +4,49 @@ This file provides guidance to any coding agent when working with code in this r
## Overview
Copilot for Obsidian is an AI-powered assistant plugin that integrates various LLM providers (OpenAI, Anthropic, Google, etc.) with Obsidian. It provides chat interfaces, autocomplete, semantic search, and various AI-powered commands for note-taking and knowledge management.
## Development Commands
### Build & Development
- **NEVER RUN `npm run dev`** - The user will handle all builds manually
- `npm run build` - Production build (TypeScript check + minified output)
- `npm run test:vault` - macOS only. Installs deps, builds, symlinks `main.js` / `manifest.json` / `styles.css` from the current worktree into `$COPILOT_TEST_VAULT_PATH/.obsidian/plugins/copilot/`, then reloads the plugin via the Obsidian CLI. Requires the user-level env var `COPILOT_TEST_VAULT_PATH` to be set to a vault that has been opened in Obsidian at least once. Use this when the user asks you to load the plugin into their test vault — it replaces manual build + copy + reload.
### Code Quality
- `npm run lint` - Run ESLint checks
- `npm run lint:fix` - Auto-fix ESLint issues
- `npm run format` - Format code with Prettier
- `npm run format:check` - Check formatting without changing files
- **Before PR:** Always run `npm run format && npm run lint`
### Testing
- `npm run test` - Run unit tests (excludes integration tests)
- `npm run test:integration` - Run integration tests (requires API keys)
- Run single test: `npm test -- -t "test name"`
### Obsidian CLI (Live Testing)
The Obsidian desktop app includes a CLI for plugin development at
`/Applications/Obsidian.app/Contents/MacOS/obsidian`. For end-to-end testing
workflows — multi-vault targeting, popout windows, build-and-deploy with
`npm run test:vault`, settings round-trips, screenshots, CDP-driven UI input,
performance metrics, and a sample smoke-test scaffold — see
[`OBSIDIAN_CLI_TEST.md`](./OBSIDIAN_CLI_TEST.md).
**The biggest gotcha is multi-vault targeting**: always pass `vault=<name>`
explicitly; the CLI's default routes to whichever window was touched last,
which silently changes as you (or another agent) work.
Run `obsidian help` for the full command list.
## High-Level Architecture
### Core Systems
1. **LLM Provider System** (`src/LLMProviders/`)
- Provider implementations for OpenAI, Anthropic, Google, Azure, local models
- `LLMProviderManager` handles provider lifecycle and switching
- Stream-based responses with error handling and rate limiting
- Custom model configuration support
2. **Chain Factory Pattern** (`src/chainFactory.ts`)
- Different chain types for various AI operations (chat, copilot, adhoc prompts)
- LangChain integration for complex workflows
- Memory management for conversation context
- Tool integration (search, file operations, time queries)
3. **Vector Store & Search** (`src/search/`)
- `VectorStoreManager` manages embeddings and semantic search
- `ChunkedStorage` for efficient large document handling
- Event-driven index updates via `IndexManager`
- Multiple embedding providers support
4. **UI Component System** (`src/components/`)
- React functional components with Radix UI primitives
- Tailwind CSS with class variance authority (CVA)
- Modal system for user interactions
- Chat interface with streaming support
- Settings UI with versioned components
5. **Message Management Architecture** (`src/core/`, `src/state/`)
- **MessageRepository** (`src/core/MessageRepository.ts`): Single source of truth for all messages
- Stores each message once with both `displayText` and `processedText`
- Provides computed views for UI display and LLM processing
- No complex dual-array synchronization
- **ChatManager** (`src/core/ChatManager.ts`): Central business logic coordinator
- Orchestrates MessageRepository, ContextManager, and LLM operations
- Handles message sending, editing, regeneration, and deletion
- Manages context processing and chain memory synchronization
- **Project Chat Isolation**: Maintains separate MessageRepository per project
- Automatically detects project switches via `getCurrentMessageRepo()`
- Each project has its own isolated message history
- Non-project chats use `defaultProjectKey` repository
- **ChatUIState** (`src/state/ChatUIState.ts`): Clean UI-only state manager
- Delegates all business logic to ChatManager
- Provides React integration with subscription mechanism
- Replaces legacy SharedState with minimal, focused approach
- **ContextManager** (`src/core/ContextManager.ts`): Handles context processing
- Processes message context (notes, URLs, selected text)
- Reprocesses context when messages are edited
6. **Settings Management**
- Jotai for atomic settings state management
- React contexts for feature-specific state
7. **Plugin Integration**
- Main entry: `src/main.ts` extends Obsidian Plugin
- Command registration system
- Event handling for Obsidian lifecycle
- Settings persistence and migration
- Chat history loading via pending message mechanism
### Key Patterns
- **Single Source of Truth**: MessageRepository stores each message once with computed views
- **Clean Architecture**: Repository → Manager → UIState → React Components
- **Context Reprocessing**: Automatic context updates when messages are edited
- **Computed Views**: Display messages for UI, LLM messages for AI processing
- **Project Isolation**: Each project maintains its own MessageRepository instance
- **Error Handling**: Custom error types with detailed interfaces
- **Async Operations**: Consistent async/await pattern with proper error boundaries
- **Caching**: Multi-layer caching for files, PDFs, and API responses
- **Streaming**: Real-time streaming for LLM responses
- **Testing**: Unit tests adjacent to implementation, integration tests for API calls
## Message Management Architecture
For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE.md`](./designdocs/MESSAGE_ARCHITECTURE.md).
### Core Classes and Flow
1. **MessageRepository** (`src/core/MessageRepository.ts`)
- Single source of truth for all messages
- Stores `StoredMessage` objects with both `displayText` and `processedText`
- Provides computed views via `getDisplayMessages()` and `getLLMMessages()`
- No complex dual-array synchronization or ID matching
2. **ChatManager** (`src/core/ChatManager.ts`)
- Central business logic coordinator
- Orchestrates MessageRepository, ContextManager, and LLM operations
- Handles all message CRUD operations with proper error handling
- Synchronizes with chain memory for conversation history
- **Project Chat Isolation Implementation**:
- Maintains `projectMessageRepos: Map<string, MessageRepository>` for project-specific storage
- `getCurrentMessageRepo()` automatically detects current project and returns correct repository
- Seamlessly switches between project repositories when project changes
- Creates new empty repository for each project (no message caching)
3. **ChatUIState** (`src/state/ChatUIState.ts`)
- Clean UI-only state manager
- Delegates all business logic to ChatManager
- Provides React integration with subscription mechanism
- Replaces legacy SharedState with minimal, focused approach
4. **ContextManager** (`src/core/ContextManager.ts`)
- Handles context processing (notes, URLs, selected text)
- Reprocesses context when messages are edited
- Ensures fresh context for LLM processing
5. **ChatPersistenceManager** (`src/core/ChatPersistenceManager.ts`)
- Handles saving and loading chat history to/from markdown files
- Project-aware file naming (prefixes with project ID)
- Parses and formats chat content for storage
- Integrated with ChatManager for seamless persistence
## Code Style Guidelines
### MAJOR PRINCIPLES
- **ALWAYS WRITE GENERALIZABLE SOLUTIONS**: Never add edge-case handling or hardcoded logic for specific scenarios (like "piano notes" or "daily notes"). Solutions must work for all cases.
- **NEVER MODIFY AI PROMPT CONTENT**: Do not update, edit, or change any AI prompts, system prompts, or model adapter prompts unless explicitly asked to do so by the user
- **Avoid hardcoding**: No hardcoded folder names, file patterns, or special-case logic
- **Configuration over convention**: If behavior needs to vary, make it configurable, not hardcoded
- **Universal patterns**: Solutions should work equally well for any folder structure, naming convention, or content type
- **Referential stability — reuse constants for empty values; cache and validate derived views.** Never return a freshly-allocated `[]` / `{}` for an "empty" slice — define a frozen module-level constant (`Object.freeze(...)`) and return it. For derived or filtered views over a settings slice, cache the result on the class/module and validate it by comparing the source-slice reference (`===`) against the live settings on every call; recompute only on miss. Reason: Jotai derived atoms and React memoization both short-circuit on referential equality, so fresh allocations invalidate every downstream cache on every read. Canonical examples: `EMPTY_PROVIDERS` / `EMPTY_CONFIGURED_MODELS` / `EMPTY_BACKENDS` in `src/settings/model.ts`
### TypeScript
- Strict mode enabled (no implicit any, strict null checks)
- Use absolute imports with `@/` prefix: `import { ChainType } from "@/chainFactory"`
- Prefer const assertions and type inference where appropriate
- Use interface for object shapes, type for unions/aliases
### React
- Functional components only (no class components)
- Custom hooks for reusable logic
- Props interfaces defined above components
- Avoid inline styles, use Tailwind classes
### General
- File naming: PascalCase for components, camelCase for utilities
- Async/await over promises
- Early returns for error conditions
- **Always add JSDoc comments** for all functions and methods
- Organize imports: React → external → internal
- **Avoid language-specific lists** (like stopwords or action verbs) - use language-agnostic approaches instead
### Logging
- **NEVER use console.log** - Use the logging utilities instead:
- `logInfo()` for informational messages
- `logWarn()` for warnings
- `logError()` for errors
- Import from logger: `import { logInfo, logWarn, logError } from "@/logger"`
### CSS & Styling
- **NEVER edit `styles.css` directly** - This is a generated file
- **Source file**: `src/styles/tailwind.css` - Edit this file for custom CSS
- **Build process**: `npm run build:tailwind` compiles `src/styles/tailwind.css``styles.css`
- **Tailwind classes**: Use Tailwind utility classes in components (see `tailwind.config.js` for available classes)
- **Custom CSS**: Add custom styles to `src/styles/tailwind.css` after the `@import` statements
- 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.
- **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
- Unit tests use Jest with TypeScript support
- Mock Obsidian API for plugin testing
- Integration tests require API keys in `.env.test`
- Test files adjacent to implementation (`.test.ts`)
- Use `@testing-library/react` for component testing
### Avoiding Deep Dependency Chains in Tests
This codebase has deep transitive import chains (e.g. a utility → cache → searchUtils → embeddingManager → brevilabsClient → plusUtils → Modal). Importing any module in this chain from a test requires mocking the entire tree, which is brittle and verbose.
**Rules for new code:**
1. **Pass data, not services** — If a function only needs a string (like `outputFolder`), accept it as a parameter. Don't give it access to the entire settings singleton.
2. **Singletons at the edges only**`getSettings()`, `PDFCache.getInstance()`, `BrevilabsClient.getInstance()` should only be called in top-level orchestration (constructors, main entry points). Inner functions receive what they need as parameters.
3. **Pure logic in leaf modules** — Extract testable logic into small files with minimal imports. The orchestration file (which has heavy imports) calls the leaf function and passes in the dependencies. See `src/tools/convertedDocOutput.ts` as an example.
4. **Litmus test before writing a function** — "Can I test this by calling it directly with plain arguments?" If the answer is no because of an import, that dependency should be a parameter instead.
## Development Session Planning
### Using TODO.md for Session Management
**IMPORTANT**: When working on a development session, maintain a comprehensive `TODO.md` file that serves as the central plan and tracker:
1. **Session Goal**: Define the high-level objective at the start
2. **Task Tracking**:
- List all completed tasks with [x] checkboxes
- Track pending tasks with [ ] checkboxes
- Group related tasks into logical sections
3. **Architecture Decisions**: Document key design choices and rationale
4. **Progress Updates**: Keep the TODO.md updated as tasks complete
5. **Testing Checklist**: Include verification steps for the session
The TODO.md should be:
- The single source of truth for session progress
- Updated frequently as work progresses
- Clear enough that another developer can understand what was done
- Comprehensive enough to serve as a migration guide
### Structure Example:
```markdown
# Development Session TODO
## Session Goal
[Clear statement of what this session aims to achieve]
## Completed Tasks ✅
- [x] Task description with key details
- [x] Another completed task
## Pending Tasks 📋
- [ ] Next task to work on
- [ ] Future enhancement
## Architecture Summary
[Key design decisions and rationale]
## Testing Checklist
- [ ] Functionality verification
- [ ] Performance checks
```
## Important Notes
- The plugin supports multiple LLM providers with custom endpoints
- Vector store requires rebuilding when switching embedding providers
- Settings are versioned - migrations may be needed
- Local model support available via Ollama/LM Studio
- Rate limiting is implemented for all API calls
- For technical debt and known issues, see [`TECHDEBT.md`](./designdocs/todo/TECHDEBT.md)
- For current development session planning, see [`TODO.md`](./TODO.md)
## User-Facing Documentation
- **When modifying user-facing behavior** (new features, changed settings, removed functionality), **update the corresponding doc in `docs/`**. The doc filenames match their topics (e.g., `llm-providers.md` for provider changes, `agent-mode-and-tools.md` for tool changes).
- Docs are written for non-technical users — no source code references, explain behavior and concepts.
- If a change affects multiple docs, update all of them.
- If you're unsure which doc to update, check `docs/index.md` for the full list with descriptions.
### AWS Bedrock Usage
**IMPORTANT**: When using AWS Bedrock, always use **cross-region inference profile IDs** for better reliability and availability:
- **Global** (recommended): `global.anthropic.claude-sonnet-4-5-20250929-v1:0`
- Routes to any commercial AWS region automatically
- Best for reliability and performance
- **US**: `us.anthropic.claude-sonnet-4-5-20250929-v1:0`
- **EU**: `eu.anthropic.claude-sonnet-4-5-20250929-v1:0`
- **APAC**: `apac.anthropic.claude-sonnet-4-5-20250929-v1:0`
**Avoid regional model IDs** (without prefix): `anthropic.claude-sonnet-4-5-20250929-v1:0`
- These only work in specific regions and often fail
- Not recommended for production use
**References:**
- [AWS Bedrock Cross-Region Inference](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html)
- [Supported Inference Profiles](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html)
### Obsidian Plugin Environment
#### Accessing the Obsidian `app`
Don't use the global `app`. It's a footgun in popout windows and hides dependencies from tests.
- **React components**`useApp()` from `@/context`. Don't add `app` as a new prop just to thread it down — call `useApp()` at the leaf.
- **Non-React modules** → take `app` (or just the slice you need, e.g. `app.vault`) as a parameter.
- **Plugin entry points**`this.app` on the `Plugin` instance.
Never write `declare const app: App;` in new code. A few legacy files do — don't follow them.
#### HTTP requests
Prefer Obsidian's `requestUrl` (from `obsidian`) over the global `fetch` for any plugin network call. `requestUrl` bypasses browser CORS, works on mobile and desktop, and matches the rest of the codebase. The standard wrapper is `safeFetch` in `src/utils.ts` — use it when you want a `Response`-shaped return. Reach for native `fetch` only when you need true streaming responses or AbortSignal cancellation; `requestUrl` supports neither.
### Picking the right `document` / `window` (popout-window safety)
Obsidian supports pop-out windows. The plugin loads in the main window but views can live in any window. Picking the wrong `Document` / `Window` produces stale references, off-screen popovers, listeners on the wrong window, or DOM nodes that never render. Use this decision order:
1. **`element.doc` / `element.win`** — preferred. Obsidian augments every `Node` with `.doc: Document` and `.win: Window` that always reflect the element's current owner. Use whenever you have any DOM node in scope (a ref, an event target, a component's container, a `Range`'s `startContainer`).
- `containerRef.current?.doc.addEventListener(...)`
- `range.startContainer.win.innerWidth`
- `editor.getRootElement()?.doc`
2. **`global activeDocument` / `activeWindow`** — fallback only. These point to whichever window is _focused right now_. Correct semantics for actions that follow user focus (e.g., the AddImageModal file picker; selectionchange registration at plugin load), but wrong when the action belongs to a specific view (a chat in a popout while the user clicks back to the main window).
3. **`document` / `window` globals** — almost always wrong. They are aliases for the main window even when the user is interacting with a popout. Avoid in new code. If you find yourself reaching for them, it's a sign the surrounding code should be taking a `Document`/`Window` parameter or deriving from a DOM ref.
4. **`element.ownerDocument`** — works (standard DOM), but prefer `.doc` for consistency with the codebase. They return the same `Document` for any mounted `HTMLElement`. `.doc` is shorter and typed non-nullable.
**Listeners that may outlive a window migration:** capture the `Document` / `Window` at registration and remove on the same one:
```ts
const doc = containerRef.current?.doc;
if (!doc) return;
doc.addEventListener("keydown", handler);
return () => doc.removeEventListener("keydown", handler);
```
Do **not** rely on `activeDocument` at registration _and_ removal — it can shift between the two calls if focus moves.
**View migrated to a new window:** for a view that owns React or other long-lived renderers, register `this.containerEl.onWindowMigrated((win) => { ... })` in `onOpen`. The callback fires when Obsidian reparents the element into a different window's document. Tear down and rebuild the renderer there so it captures the new window. Save the returned destroy function and call it in `onClose` to avoid leaks. `CopilotView` is the canonical example — it unmounts and recreates the React root on migration so Lexical re-binds to the popout's window.
**Cross-realm `instanceof`:** popout windows have their own `Element`, `MouseEvent`, etc., so standard `instanceof` checks fail across windows. Use Obsidian's `element.instanceOf(HTMLElement)` and `event.instanceOf(MouseEvent)` when checking type across realms.
**Tests (jsdom):** `jest.setup.js` polyfills `Node.doc` / `Node.win` so plugin code using these properties works under jsdom. Don't add `instanceof` guards that depend on the Obsidian-augmented globals without considering the test environment.
### Architecture Migration Notes
- **SharedState Removed**: The legacy `src/sharedState.ts` has been completely removed
- **Clean Architecture**: New architecture follows Repository → Manager → UIState → UI pattern
- **Single Source of Truth**: All messages stored once in MessageRepository with computed views
- **Context Always Fresh**: Context is reprocessed when messages are edited to ensure accuracy
- **Chat History Loading**: Uses pending message mechanism through CopilotView → Chat component props
- **Project Chat Isolation**: Each project now has completely isolated chat history
- Automatic detection of project switches via `ProjectManager.getCurrentProjectId()`
- Separate MessageRepository instances per project ID
- Non-project chats stored in default repository
- Backwards compatible - loads existing messages from ProjectManager cache
- Zero configuration required - works automatically
- Check @tailwind.config.js to understand what tailwind css classnames are available
Copilot for Obsidian is an AI-powered assistant plugin that integrates various LLM providers (OpenAI, Anthropic, Google, etc.) and coding agents (claude code, codex, opencode) with Obsidian. It provides chat interfaces, semantic search, and various AI-powered commands for note-taking and knowledge management.
## Commands
- **NEVER RUN `npm run dev`** — the user handles all builds manually.
- `npm run build` — production build (TypeScript check + minified output).
- `npm run lint` / `npm run lint:fix` — ESLint check / autofix.
- `npm run format` / `npm run format:check` — Prettier write / check.
- **Before PR: always run `npm run format && npm run lint`.**
- `npm run test` — unit tests. `npm run test:integration` — integration (needs API keys). Single test: `npm test -- -t "test name"`.
- `npm run test:vault` — macOS-only build-and-deploy into `$COPILOT_TEST_VAULT_PATH`; see [`TESTING_GUIDE.md`](./designdocs/agents/TESTING_GUIDE.md).
## Core principles (apply to every change)
- **Always write generalizable solutions.** No hardcoded folder names, file patterns, or special-case logic (no "piano notes" / "daily notes" branches). Make varying behavior configurable, not hardcoded.
- **Never modify AI prompt content** — system prompts, model adapter prompts, etc. — unless the user explicitly asks.
- **Referential stability.** Never return a freshly-allocated `[]` / `{}` for an "empty" slice; return a frozen module-level constant (canonical examples: `EMPTY_PROVIDERS` / `EMPTY_CONFIGURED_MODELS` / `EMPTY_BACKENDS` in `src/settings/model.ts`).
- **Never use `console.log`** — use `logInfo()` / `logWarn()` / `logError()` from `@/logger`.
- **Comment the why, not the what;** minimal comments, no milestone/plan-step refs. → [`STYLE_GUIDE.md`](./designdocs/agents/STYLE_GUIDE.md)
- **Never edit `styles.css`** (generated); edit `src/styles/tailwind.css`, no inline `style`, no arbitrary font sizes, wrap class strings in `cn()`. → [`STYLE_GUIDE.md`](./designdocs/agents/STYLE_GUIDE.md)
- **TypeScript:** `@/` absolute imports; `interface` for shapes, `type` for unions. **React:** custom hooks, props interfaces above components. → [`STYLE_GUIDE.md`](./designdocs/agents/STYLE_GUIDE.md)
- **Never use the global `app`** (footgun in popouts, hides dependencies); thread it via `useApp()` or a parameter. → [`PLUGIN_DEV_GUIDE.md`](./designdocs/agents/PLUGIN_DEV_GUIDE.md)
## Task-specific guides
Read the matching guide when your task touches that area — they aren't loaded by default.
| When you're… | Read |
| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| writing or altering tests, or doing E2E via the Obsidian CLI | [`designdocs/agents/TESTING_GUIDE.md`](./designdocs/agents/TESTING_GUIDE.md) |
| writing code: DI/structure, TypeScript, React, comments, CSS/Tailwind | [`designdocs/agents/STYLE_GUIDE.md`](./designdocs/agents/STYLE_GUIDE.md) |
| touching plugin runtime: the `app`, network requests, popout windows | [`designdocs/agents/PLUGIN_DEV_GUIDE.md`](./designdocs/agents/PLUGIN_DEV_GUIDE.md) |
| using a specific LLM provider (e.g. AWS Bedrock) | [`designdocs/agents/VENDOR_GUIDE.md`](./designdocs/agents/VENDOR_GUIDE.md) |
| running a multi-step dev session | [`designdocs/agents/PROCESS_GUIDE.md`](./designdocs/agents/PROCESS_GUIDE.md) |
| changing user-facing behavior | [`designdocs/agents/DOCS_GUIDE.md`](./designdocs/agents/DOCS_GUIDE.md) |
## Important notes
- The plugin supports multiple LLM providers with custom endpoints.
- Vector store requires rebuilding when switching embedding providers.
- Settings are versioned — migrations may be needed.
- Local model support via Ollama / LM Studio.
- Rate limiting is implemented for all API calls.
- Message & chat architecture (Repository → Manager → UIState → UI; single `MessageRepository`; per-project isolation) → [`designdocs/MESSAGE_ARCHITECTURE.md`](./designdocs/MESSAGE_ARCHITECTURE.md).
- Tech debt and known issues → [`designdocs/todo/TECHDEBT.md`](./designdocs/todo/TECHDEBT.md). Current session plan → [`TODO.md`](./TODO.md).
- Available Tailwind tokens/classes → [`tailwind.config.js`](./tailwind.config.js).

View file

@ -946,6 +946,17 @@ The BYOK tab's empty state (one big `[+ Add provider]` CTA per §5.1) is the onl
### M8 — BYOK → OpenCode bridge + OpenCode panel model sources
> **SUPERSEDED / LANDED.** The _intent_ of M8 (BYOK providers usable in agent
> mode; opencode panel unions BYOK + bundled + Plus sources) shipped via
> `agent_model_curation_migration.md` (M1M5, landed), but the **mechanics below
> are superseded**: the shipped code uses `origin`-based `Provider` +
> `ConfiguredModel` + `BackendConfigRegistry` + `backends.opencode.enabledModels`,
> not the `ProviderInstance` / `instanceId` / `EnrollmentRegistry` /
> `consumers[...]` / `source`-tagged refs named here (those never existed in the
> codebase). Read the curation-migration doc for the shipped design; the file
> names below (`byokBridge.ts`, `bundledModels.ts`, `plusModels.ts`,
> `OpencodePanel.tsx`) did not land as written.
**Goal:** BYOK providers usable in agent mode, and the OpenCode sub-tab's three-source picker (OpenCode-bundled ⊕ Copilot Plus ⊕ BYOK). **OpenCode-bundled and Copilot Plus models stay out of the BYOK panel entirely.**
**Deliverables:**
@ -974,6 +985,14 @@ The BYOK tab's empty state (one big `[+ Add provider]` CTA per §5.1) is the onl
### M9 — Cleanup + final removals
> **PARTIALLY LANDED.** The agent-curation slice of cleanup landed via
> `agent_model_curation_migration.md` M5: the legacy `activeModels` /
> `plusLicenseKey` / `modelEnabledOverrides` agent paths are removed (the last is
> retained only as a deprecated, migration-only read field that drains after a
> one-time seed). The **chat-mode** removals listed below (`ModelSettings.tsx`,
> `BasicSettings.tsx` picker portion, etc.) remain future work — tracked in
> `models_management_redesign_cleanup.md`.
**Goal:** Delete legacy code paths, finalize tab labels, update docs.
**Deliverables:**

View file

@ -0,0 +1,8 @@
# Documentation Guide
When and how to update the user-facing docs in `docs/`.
- **When modifying user-facing behavior** (new features, changed settings, removed functionality), **update the corresponding doc in `docs/`**. The doc filenames match their topics (e.g., `llm-providers.md` for provider changes, `agent-mode-and-tools.md` for tool changes).
- Docs are written for non-technical users — no source code references, explain behavior and concepts.
- If a change affects multiple docs, update all of them.
- If you're unsure which doc to update, check `docs/index.md` for the full list with descriptions.

View file

@ -0,0 +1,48 @@
# Plugin Development Guide
Obsidian-specific runtime concerns: how to reach the `app`, how to make network
requests, and how to stay correct across pop-out windows. Read this when
touching plugin runtime code rather than pure logic.
## Accessing the Obsidian `app`
Don't use the global `app`. It's a footgun in popout windows and hides dependencies from tests.
- **React components**`useApp()` from `@/context`. Don't add `app` as a new prop just to thread it down — call `useApp()` at the leaf.
- **Non-React modules** → take `app` (or just the slice you need, e.g. `app.vault`) as a parameter.
- **Plugin entry points**`this.app` on the `Plugin` instance.
Never write `declare const app: App;` in new code. A few legacy files do — don't follow them.
## HTTP requests
Prefer Obsidian's `requestUrl` (from `obsidian`) over the global `fetch` for any plugin network call. `requestUrl` bypasses browser CORS, works on mobile and desktop, and matches the rest of the codebase. The standard wrapper is `safeFetch` in `src/utils.ts` — use it when you want a `Response`-shaped return. Reach for native `fetch` only when you need true streaming responses or AbortSignal cancellation; `requestUrl` supports neither.
## Picking the right `document` / `window` (popout-window safety)
Obsidian supports pop-out windows. The plugin loads in the main window but views can live in any window. Picking the wrong `Document` / `Window` produces stale references, off-screen popovers, listeners on the wrong window, or DOM nodes that never render. Use this decision order:
1. **`element.doc` / `element.win`** — preferred. Obsidian augments every `Node` with `.doc: Document` and `.win: Window` that always reflect the element's current owner. Use whenever you have any DOM node in scope (a ref, an event target, a component's container, a `Range`'s `startContainer`).
- `containerRef.current?.doc.addEventListener(...)`
- `range.startContainer.win.innerWidth`
- `editor.getRootElement()?.doc`
2. **`global activeDocument` / `activeWindow`** — fallback only. These point to whichever window is _focused right now_. Correct semantics for actions that follow user focus (e.g., the AddImageModal file picker; selectionchange registration at plugin load), but wrong when the action belongs to a specific view (a chat in a popout while the user clicks back to the main window).
3. **`document` / `window` globals** — almost always wrong. They are aliases for the main window even when the user is interacting with a popout. Avoid in new code. If you find yourself reaching for them, it's a sign the surrounding code should be taking a `Document`/`Window` parameter or deriving from a DOM ref.
4. **`element.ownerDocument`** — works (standard DOM), but prefer `.doc` for consistency with the codebase. They return the same `Document` for any mounted `HTMLElement`. `.doc` is shorter and typed non-nullable.
**Listeners that may outlive a window migration:** capture the `Document` / `Window` at registration and remove on the same one:
```ts
const doc = containerRef.current?.doc;
if (!doc) return;
doc.addEventListener("keydown", handler);
return () => doc.removeEventListener("keydown", handler);
```
Do **not** rely on `activeDocument` at registration _and_ removal — it can shift between the two calls if focus moves.
**View migrated to a new window:** for a view that owns React or other long-lived renderers, register `this.containerEl.onWindowMigrated((win) => { ... })` in `onOpen`. The callback fires when Obsidian reparents the element into a different window's document. Tear down and rebuild the renderer there so it captures the new window. Save the returned destroy function and call it in `onClose` to avoid leaks. `CopilotView` is the canonical example — it unmounts and recreates the React root on migration so Lexical re-binds to the popout's window.
**Cross-realm `instanceof`:** popout windows have their own `Element`, `MouseEvent`, etc., so standard `instanceof` checks fail across windows. Use Obsidian's `element.instanceOf(HTMLElement)` and `event.instanceOf(MouseEvent)` when checking type across realms.
**Tests (jsdom):** `jest.setup.js` polyfills `Node.doc` / `Node.win` so plugin code using these properties works under jsdom. Don't add `instanceof` guards that depend on the Obsidian-augmented globals without considering the test environment.

View file

@ -0,0 +1,52 @@
# Process Guide
Conventions for running a multi-step development session in this repo.
## Using TODO.md for session management
**IMPORTANT**: When working on a development session, maintain a comprehensive `TODO.md` file that serves as the central plan and tracker:
1. **Session Goal**: Define the high-level objective at the start
2. **Task Tracking**:
- List all completed tasks with [x] checkboxes
- Track pending tasks with [ ] checkboxes
- Group related tasks into logical sections
3. **Architecture Decisions**: Document key design choices and rationale
4. **Progress Updates**: Keep the TODO.md updated as tasks complete
5. **Testing Checklist**: Include verification steps for the session
The TODO.md should be:
- The single source of truth for session progress
- Updated frequently as work progresses
- Clear enough that another developer can understand what was done
- Comprehensive enough to serve as a migration guide
### Structure example
```markdown
# Development Session TODO
## Session Goal
[Clear statement of what this session aims to achieve]
## Completed Tasks ✅
- [x] Task description with key details
- [x] Another completed task
## Pending Tasks 📋
- [ ] Next task to work on
- [ ] Future enhancement
## Architecture Summary
[Key design decisions and rationale]
## Testing Checklist
- [ ] Functionality verification
- [ ] Performance checks
```

View file

@ -0,0 +1,60 @@
# Style & Code Guide
Detailed coding conventions for this repo. The cross-cutting principles in
`AGENTS.md` (generalizable solutions, referential stability, comment-the-why,
no `console.log`) always apply; this guide carries the full detail behind the
language, comment, styling, and code-structure rules.
## TypeScript
- Use absolute imports with `@/` prefix: `import { ChainType } from "@/chainFactory"`
- Prefer const assertions and type inference where appropriate
- Use interface for object shapes, type for unions/aliases
## React
- Custom hooks for reusable logic
- Props interfaces defined above components
## Comments
The code is the source of truth for **what** the code does. Comments exist to
carry the **why** — the things a reader cannot recover by reading the code.
- **Comment the why, not the what.** Document non-obvious constraints,
invariants, gotchas, and "why this exists / why not the obvious alternative".
If a comment only restates what the next line plainly says, delete it.
- **Default to minimal comments — JSDoc is not required on every function.** A
function with a clear name and signature needs no doc block. Add one only when
there's a why worth recording. When you do write a "what", keep it to one
short line.
- **Drop redundant `@param`/`@returns`.** Keep a tag only when it adds
information the type and name don't already convey (e.g. "`null` means the
agent is CLI-managed, so no key is stored"). Don't write a `@param` line that
just echoes the parameter name and type.
- **No milestone or plan-step references in code.** Never write `M1`/`M3`,
`§4.3`, "step 3 of the plan", "after milestone X lands", or similar. These are
scaffolding for whoever is _writing_ a branch and are meaningless to whoever _reviews or maintains_ the code later.
- **No comments that rot.** Avoid "added for feature X" or "used by caller Y" —
those go stale as the code moves and belong in the PR description, not the
source.
## CSS & Styling
- **NEVER edit `styles.css` directly** - This is a generated file
- **Source file**: `src/styles/tailwind.css` - Edit this file for custom CSS
- **Build process**: `npm run build:tailwind` compiles `src/styles/tailwind.css``styles.css`
- **Tailwind classes**: Use Tailwind utility classes in components (see `tailwind.config.js` for available classes)
- **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).
- **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`). 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")`.
## Writing testable code (dependency injection)
Structure new code so it can be tested by calling it directly with plain
arguments — no singleton or import has to be live for the test to run.
1. **Pass data, not services** — If a function only needs a string (like `outputFolder`), accept it as a parameter. Don't give it access to the entire settings singleton.
2. **Singletons at the edges only**`getSettings()`, `PDFCache.getInstance()`, `BrevilabsClient.getInstance()` should only be called in top-level orchestration (constructors, main entry points). Inner functions receive what they need as parameters.
3. **Pure logic in leaf modules** — Extract testable logic into small files with minimal imports. The orchestration file (which has heavy imports) calls the leaf function and passes in the dependencies.
4. **Litmus test before writing a function** — "Can I test this by calling it directly with plain arguments?" If the answer is no because of an import, that dependency should be a parameter instead.

View file

@ -1,4 +1,36 @@
# Obsidian CLI E2E Testing Guide
# Testing Guide
How to test the Copilot plugin across three layers — unit, integration, and
end-to-end. Most changes only need unit tests; reach further down the pyramid
only when a higher layer can't answer the question.
## Test pyramid
| Layer | Command | When to use |
| ----------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unit | `npm run test` | Pure logic. Fast. Mocks the Obsidian API. Default for any code change that doesn't touch Obsidian itself. |
| Integration | `npm run test:integration` | LLM-provider HTTP calls. Requires API keys in `.env.test`. |
| E2E | `obsidian` CLI against a real vault | Anything that needs the live React tree, the real Obsidian DOM, or actual settings persistence — UI regressions, settings round-trips, plugin lifecycle, perf checks. |
## Unit tests
- Jest with TypeScript support. `npm run test` runs the unit suite (excludes
integration tests); run a single test with `npm test -- -t "test name"`.
- Mock the Obsidian API for plugin testing.
- Test files live adjacent to the implementation (`.test.ts`).
- Use `@testing-library/react` for component testing.
- For how to structure code so it's unit-testable — dependency injection, pure
leaf modules, the litmus test — see [`STYLE_GUIDE.md`](./STYLE_GUIDE.md).
## Integration tests
`npm run test:integration` exercises real LLM-provider HTTP calls and requires
API keys in `.env.test`.
## End-to-end testing (Obsidian CLI)
E2E via the CLI is the slowest and most fragile layer — reach for it only when
the unit/integration layers can't answer the question.
A field guide for coding agents driving the Copilot plugin through the Obsidian
desktop CLI. Everything here was validated against Obsidian `1.12.7` with the
@ -6,18 +38,6 @@ plugin loaded into a real vault. The CLI lives at
`/Applications/Obsidian.app/Contents/MacOS/obsidian` — use the full path; the
`obsidian` shim is not always on `PATH`.
## Where this fits in the test pyramid
| Layer | Command | When to use |
| -------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unit | `npm run test` | Pure logic. Fast. Mocks the Obsidian API. Default for any code change that doesn't touch Obsidian itself. |
| Integration | `npm run test:integration` | LLM-provider HTTP calls. Requires API keys in `.env.test`. |
| E2E (this doc) | `obsidian` CLI against a real vault | Anything that needs the live React tree, the real Obsidian DOM, or actual settings persistence — UI regressions, settings round-trips, plugin lifecycle, perf checks. |
E2E via the CLI is the slowest and most fragile layer — reach for it only when
the unit/integration layers can't answer the question. See AGENTS.md for the
unit-test rules (deep dependency chains, leaf modules).
## Get a fresh build into the test vault
```bash

View file

@ -0,0 +1,26 @@
# Vendor Guide
Provider-specific quirks and configuration that don't generalize across LLM
vendors. Read the relevant section when wiring up or debugging a specific
provider.
## AWS Bedrock
**IMPORTANT**: When using AWS Bedrock, always use **cross-region inference profile IDs** for better reliability and availability:
- **Global** (recommended): `global.anthropic.claude-sonnet-4-5-20250929-v1:0`
- Routes to any commercial AWS region automatically
- Best for reliability and performance
- **US**: `us.anthropic.claude-sonnet-4-5-20250929-v1:0`
- **EU**: `eu.anthropic.claude-sonnet-4-5-20250929-v1:0`
- **APAC**: `apac.anthropic.claude-sonnet-4-5-20250929-v1:0`
**Avoid regional model IDs** (without prefix): `anthropic.claude-sonnet-4-5-20250929-v1:0`
- These only work in specific regions and often fail
- Not recommended for production use
**References:**
- [AWS Bedrock Cross-Region Inference](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html)
- [Supported Inference Profiles](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html)

View file

@ -5,7 +5,7 @@
- [x] Save chat history to notes
- [x] P0: Thoroughly assess whether migrating to Anthropic agent SDK is worth it.
- [ ] P0: Test Windows devices
- [ ] P0: Make sure bash command shows what command it runs (visibility)
- [x] P0: Make sure bash command shows what command it runs (visibility)
- [ ] P0: Provide copilot specific system prompt
- [ ] Allow users to share system prompt
- [ ] Do a quick spike on the concrete behavior
@ -19,20 +19,22 @@
- [x] support self host model
- [x] remove built-in models
- [ ] P0: Test opencode works well with local models
- [ ] P0: Fix broken legacy agent mode
- [ ] Can we only support basic chat - not now but eventually yes
- [ ] P0: 非migrate的skill最好能直接引用,同名的skill如果内容一样migrate可以做得更好
- [x] P0: Auto-save chat history controls
- [x] P0: Support image context
- [x] P1: [BUG] Check active note path. Sometimes the agent will start from a path that does not exist
- [x] P1: [Performance] Updating skills settings is laggy
- [ ] P1: Make askuserquestion tool show questions inline in the chat.
- [ ] P1: Improve binary detection
- [ ] P1: MCP
- Basic functionality is ready
- [ ] P1: Surface externally-managed MCP servers (claude.ai remote, plugin-provided) — see [MCP_EXTERNALLY_MANAGED_SERVERS.md](./MCP_EXTERNALLY_MANAGED_SERVERS.md)
- [ ] P1: Support oauth for MCP servers (the one example that I tested didn't work)
- [ ] P1: Support setting MCP by copy pasting JSON blobs
- [ ] P1: Fix broken legacy agent mode
- [ ] Can we only support basic chat - not now but eventually yes
- [x] P1: [BUG] Check active note path. Sometimes the agent will start from a path that does not exist
- [x] P1: [Performance] Updating skills settings is laggy
- [ ] P1: Make askuserquestion tool show questions inline in the chat.
- [ ] P1: Improve binary detection
- [ ] P1: Citation
- [ ] a tool to let agent call citation
- [ ] P1: Content type support (image, audio) - https://agentclientprotocol.com/protocol/content
- [ ] P1: Edit diff UI - https://agentclientprotocol.com/protocol/tool-calls#diffs
- The edit diff should be based on well rendered markdown, not raw markdown file. For example, table is impossible to understand the diff with the raw format
@ -87,6 +89,7 @@
- [ ] P2: Claude code / Codex authentication
- [ ] P2: Steering conversation (instead of queue)
- [ ] P2: Rollback everything to the state of previous message
- [ ] P2: In-product QA agent to debug for users
- [ ] P3: Rerun agent response
- [ ] P3: Agent todo list
- [ ] make sure in case it renders, it won't be buggy

View file

@ -28,6 +28,13 @@ for now — the picker will be migrated to the new registry in a later PR.
Until then, providers/models added via the BYOK tab won't appear in the
default-chat-model dropdown.
> **Status (still open):** This item is about the **chat-mode** picker only.
> The **agent-backend** curation (opencode / claude-code / codex) has been
> fully migrated off the legacy `activeModels` + `modelEnabledOverrides` path
> to the new `backends.<agentType>.enabledModels` registry. The chat-mode `BasicSettings`
> / `chatModelManager` migration remains future work and is unchanged by that
> effort.
## Legacy → new migration (incl. `catalogProviderId` backfill)
The new registry (`Provider` / `ConfiguredModel`) is populated only when a

View file

@ -272,7 +272,7 @@ export default [
{
from: { type: "backend" },
allow: [
{ to: { type: ["acp", "sdk", "session", "skills", "host"] } },
{ to: { type: ["acp", "sdk", "session", "skills", "modelmgmt", "host"] } },
{ to: { type: "backend", captured: { name: "{{from.captured.name}}" } } },
{ to: { type: "backend", captured: { name: "shared" } } },
],

View file

@ -0,0 +1,453 @@
/**
* Tests for the M3 probe-settle discovery orchestrator.
*
* The Agent Mode barrel (`@/agentMode`) and the settings module are mocked so
* the test exercises `wireAgentModelDiscovery` / `enrollBackend` against
* controllable fakes without dragging in the React/Obsidian dependency tree.
* The barrel helpers (`partitionOpencodeOnlyWireIds`, `computeDefaultEnabledIds`,
* `mapProviderToOpencodeId`) are re-implemented thinly in the mock to keep the
* orchestration assertions independent of their unit tests;
* `buildManagedOpencodeProviderIds` now lives in this module and is tested for
* real below.
*/
import type { ModelManagementApi, Provider, ProviderOrigin } from "@/modelManagement";
import type CopilotPlugin from "@/main";
import type { AgentSessionManager, BackendDescriptor, BackendState } from "@/agentMode";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
let mockDescriptors: BackendDescriptor[] = [];
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
jest.mock("@/agentMode", () => ({
listBackendDescriptors: () => mockDescriptors,
// Real-equivalent pure helpers.
partitionOpencodeOnlyWireIds: (reported: string[], managed: Set<string>) =>
reported.filter((w) => !managed.has(w.split("/")[0])),
mapProviderToOpencodeId: (provider: {
providerId: string;
origin: { kind: string; catalogProviderId?: string };
}) => {
switch (provider.origin.kind) {
case "byok":
return provider.origin.catalogProviderId
? { id: provider.origin.catalogProviderId, native: false }
: null;
case "copilot-plus":
return { id: "copilot-plus", native: false };
case "agent":
return { id: provider.providerId, native: true };
default:
return null;
}
},
computeDefaultEnabledIds: (
enrolled: Array<{ configuredModelId: string; wireModelId: string }>,
currentWireId: string | undefined
) => {
if (enrolled.length === 0) return [];
const current = enrolled.find((e) => e.wireModelId === currentWireId);
return [(current ?? enrolled[0]).configuredModelId];
},
}));
// Import AFTER mocks so the module under test binds to them.
import { buildManagedOpencodeProviderIds, wireAgentModelDiscovery } from "./agentModelDiscovery";
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
const identityWire = {
encode: (s: { baseModelId: string }) => s.baseModelId,
decode: (wireId: string) => ({
selection: { baseModelId: wireId, effort: null as string | null },
provider: null as string | null,
}),
};
function makeDescriptor(partial: Partial<BackendDescriptor>): BackendDescriptor {
return {
id: "codex",
displayName: "Codex",
wire: identityWire,
...partial,
} as unknown as BackendDescriptor;
}
/**
* State with a settled model list of the given wire baseModelIds. The agent's
* current selection defaults to the first model unless `currentBaseId` is given.
*/
function stateWithModels(baseIds: string[], currentBaseId?: string): BackendState {
return {
model: {
current: { baseModelId: currentBaseId ?? baseIds[0] ?? "", effort: null },
availableModels: baseIds.map((baseModelId) => ({
baseModelId,
name: baseModelId,
provider: null,
effortOptions: [],
})),
},
mode: null,
};
}
interface ApiFake {
api: ModelManagementApi;
agentProviders: Array<{ providerId: string; origin: { kind: string; agentType: string } }>;
byok: Array<{ origin: { kind: string; catalogProviderId?: string } }>;
plus: Array<{ origin: { kind: string } }>;
registerAgentProvider: jest.Mock;
syncAgentModels: jest.Mock;
setEnabledModels: jest.Mock;
}
function makeApiFake(): ApiFake {
const agentProviders: ApiFake["agentProviders"] = [];
const byok: ApiFake["byok"] = [];
const plus: ApiFake["plus"] = [];
// registerAgentProvider returns configuredModelIds in wireModelIds order and
// records an agent provider so the next probe takes the sync branch.
const registerAgentProvider = jest.fn(
async (input: { agentType: string; wireModelIds: string[] }) => {
const providerId = `prov-${input.agentType}`;
agentProviders.push({ providerId, origin: { kind: "agent", agentType: input.agentType } });
return {
providerId,
configuredModelIds: input.wireModelIds.map((_w, i) => `cm-${input.agentType}-${i}`),
};
}
);
const syncAgentModels = jest.fn(async () => ({ added: [], removed: [] }));
const setEnabledModels = jest.fn(async () => {});
const api = {
providerRegistry: {
listByOrigin: (kind: string) => {
if (kind === "agent") return agentProviders;
if (kind === "byok") return byok;
if (kind === "copilot-plus") return plus;
return [];
},
},
setup: { agent: { registerAgentProvider, syncAgentModels } },
backendConfigRegistry: { setEnabledModels },
} as unknown as ModelManagementApi;
return {
api,
agentProviders,
byok,
plus,
registerAgentProvider,
syncAgentModels,
setEnabledModels,
};
}
interface ManagerFake {
manager: AgentSessionManager;
setState: (backendId: string, state: BackendState | null) => void;
emit: () => void;
}
function makeManagerFake(): ManagerFake {
const states = new Map<string, BackendState | null>();
let listener: (() => void) | null = null;
const manager = {
getCachedBackendState: (id: string) => states.get(id) ?? null,
subscribeModelCache: (cb: () => void) => {
listener = cb;
return () => {
listener = null;
};
},
} as unknown as AgentSessionManager;
return {
manager,
setState: (id, state) => states.set(id, state),
emit: () => listener?.(),
};
}
function makePlugin(api: ModelManagementApi): CopilotPlugin {
return { modelManagement: api } as unknown as CopilotPlugin;
}
/** Let queued microtask chains (per-backend serialized runs) settle. */
async function flush(): Promise<void> {
// The orchestrator chains: prior → enrollBackend (which awaits the async
// register/sync) → lastEnrolled.set → finally. Several layers of awaited
// promises, so drain generously.
for (let i = 0; i < 20; i++) {
await Promise.resolve();
}
}
beforeEach(() => {
mockDescriptors = [];
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("wireAgentModelDiscovery", () => {
it("first enrollment registers a provider and enrolls reported models", async () => {
mockDescriptors = [makeDescriptor({ id: "codex" })];
const m = makeManagerFake();
const a = makeApiFake();
m.setState("codex", stateWithModels(["gpt-5", "gpt-5.5"]));
const unsub = wireAgentModelDiscovery(makePlugin(a.api), m.manager);
await flush();
expect(a.registerAgentProvider).toHaveBeenCalledTimes(1);
expect(a.registerAgentProvider.mock.calls[0][0]).toMatchObject({
agentType: "codex",
providerType: "openai-compatible",
wireModelIds: ["gpt-5", "gpt-5.5"],
});
expect(a.syncAgentModels).not.toHaveBeenCalled();
unsub();
});
it("seeds enabledModels to the agent's current model on first enrollment", async () => {
mockDescriptors = [makeDescriptor({ id: "codex" })];
const m = makeManagerFake();
const a = makeApiFake();
// Agent reports gpt-5.5 as current (not the first reported model).
m.setState("codex", stateWithModels(["gpt-5", "gpt-5.5"], "gpt-5.5"));
const unsub = wireAgentModelDiscovery(makePlugin(a.api), m.manager);
await flush();
// registerAgentProvider auto-enrolled both; seeding narrows to the current
// model only (gpt-5.5 → cm-codex-1).
expect(a.setEnabledModels).toHaveBeenCalledTimes(1);
expect(a.setEnabledModels.mock.calls[0]).toEqual(["codex", ["cm-codex-1"]]);
unsub();
});
it("falls back to the first model when the current one isn't enrolled", async () => {
// opencode suppresses the agent's current model (a BYOK-managed anthropic
// model); seeding falls back to the first opencode-only model.
mockDescriptors = [makeDescriptor({ id: "opencode" })];
const m = makeManagerFake();
const a = makeApiFake();
a.byok.push({ origin: { kind: "byok", catalogProviderId: "anthropic" } });
m.setState(
"opencode",
stateWithModels(
["anthropic/claude-sonnet-4-5", "opencode/big-pickle", "opencode/small-gherkin"],
"anthropic/claude-sonnet-4-5"
)
);
const unsub = wireAgentModelDiscovery(makePlugin(a.api), m.manager);
await flush();
// anthropic/* suppressed; current not enrolled → first enrolled (big-pickle).
expect(a.setEnabledModels).toHaveBeenCalledTimes(1);
expect(a.setEnabledModels.mock.calls[0]).toEqual(["opencode", ["cm-opencode-0"]]);
unsub();
});
it("does NOT call setEnabledModels when a single model is reported (no churn)", async () => {
mockDescriptors = [makeDescriptor({ id: "codex" })];
const m = makeManagerFake();
const a = makeApiFake();
// One reported model is already the entire enabled set → no narrowing.
m.setState("codex", stateWithModels(["gpt-5"]));
const unsub = wireAgentModelDiscovery(makePlugin(a.api), m.manager);
await flush();
expect(a.setEnabledModels).not.toHaveBeenCalled();
unsub();
});
it("recurring probe (provider already exists) takes the sync branch, no register, no re-seed", async () => {
mockDescriptors = [makeDescriptor({ id: "codex" })];
const m = makeManagerFake();
const a = makeApiFake();
m.setState("codex", stateWithModels(["gpt-5"]));
const unsub = wireAgentModelDiscovery(makePlugin(a.api), m.manager);
await flush(); // first enrollment → register
// A new probe reports a changed list; provider now exists → sync only.
m.setState("codex", stateWithModels(["gpt-5", "gpt-5.5"]));
m.emit();
await flush();
expect(a.registerAgentProvider).toHaveBeenCalledTimes(1);
expect(a.syncAgentModels).toHaveBeenCalledTimes(1);
expect(a.syncAgentModels.mock.calls[0][0]).toEqual({
agentType: "codex",
wireModelIds: ["gpt-5", "gpt-5.5"],
});
// Seeding never re-runs on the sync branch.
expect(a.setEnabledModels).not.toHaveBeenCalled();
unsub();
});
it("re-probe with an unchanged list is a no-op (no register, no sync)", async () => {
mockDescriptors = [makeDescriptor({ id: "codex" })];
const m = makeManagerFake();
const a = makeApiFake();
m.setState("codex", stateWithModels(["gpt-5"]));
const unsub = wireAgentModelDiscovery(makePlugin(a.api), m.manager);
await flush();
a.syncAgentModels.mockClear();
// Same list re-reported.
m.emit();
await flush();
expect(a.syncAgentModels).not.toHaveBeenCalled();
unsub();
});
it("opencode suppresses BYOK-managed models and enrolls only opencode-only ids", async () => {
mockDescriptors = [makeDescriptor({ id: "opencode" })];
const m = makeManagerFake();
const a = makeApiFake();
a.byok.push({ origin: { kind: "byok", catalogProviderId: "anthropic" } });
m.setState("opencode", stateWithModels(["anthropic/claude-sonnet-4-5", "opencode/big-pickle"]));
const unsub = wireAgentModelDiscovery(makePlugin(a.api), m.manager);
await flush();
// anthropic/* is suppressed (BYOK-managed); only opencode/* enrolls.
expect(a.registerAgentProvider.mock.calls[0][0].wireModelIds).toEqual(["opencode/big-pickle"]);
unsub();
});
it("does NOT register/sync on a settled-but-empty probe (transient/degraded)", async () => {
mockDescriptors = [makeDescriptor({ id: "codex" })];
const m = makeManagerFake();
const a = makeApiFake();
// A settled state that reports zero models (distinct from null/no-state).
m.setState("codex", stateWithModels([]));
const unsub = wireAgentModelDiscovery(makePlugin(a.api), m.manager);
await flush();
expect(a.registerAgentProvider).not.toHaveBeenCalled();
expect(a.syncAgentModels).not.toHaveBeenCalled();
unsub();
});
it("does NOT cascade-remove when opencode's reported list fully suppresses to empty", async () => {
// Regression: a BYOK-only user whose opencode probe reports only
// BYOK-managed (anthropic/*) ids. After suppression the list is empty;
// running syncAgentModels({ wireModelIds: [] }) on the existing provider
// would cascade-REMOVE every prior opencode agent model. Guard against it.
mockDescriptors = [makeDescriptor({ id: "opencode" })];
const m = makeManagerFake();
const a = makeApiFake();
// An opencode agent provider already exists (prior enrollment).
a.agentProviders.push({
providerId: "prov-opencode",
origin: { kind: "agent", agentType: "opencode" },
});
a.byok.push({ origin: { kind: "byok", catalogProviderId: "anthropic" } });
m.setState("opencode", stateWithModels(["anthropic/claude-sonnet-4-5"]));
const unsub = wireAgentModelDiscovery(makePlugin(a.api), m.manager);
await flush();
// All reported ids are suppressed → empty list → no destructive sync.
expect(a.syncAgentModels).not.toHaveBeenCalled();
expect(a.registerAgentProvider).not.toHaveBeenCalled();
unsub();
});
it("ignores a backend that has not reported a model state yet", async () => {
mockDescriptors = [makeDescriptor({ id: "codex" })];
const m = makeManagerFake();
const a = makeApiFake();
m.setState("codex", null);
const unsub = wireAgentModelDiscovery(makePlugin(a.api), m.manager);
await flush();
expect(a.registerAgentProvider).not.toHaveBeenCalled();
expect(a.syncAgentModels).not.toHaveBeenCalled();
unsub();
});
it("after unsubscribe, further cache emits do nothing", async () => {
mockDescriptors = [makeDescriptor({ id: "codex" })];
const m = makeManagerFake();
const a = makeApiFake();
const unsub = wireAgentModelDiscovery(makePlugin(a.api), m.manager);
await flush();
unsub();
m.setState("codex", stateWithModels(["gpt-5"]));
m.emit();
await flush();
expect(a.registerAgentProvider).not.toHaveBeenCalled();
});
});
describe("buildManagedOpencodeProviderIds", () => {
/** Build a minimal `Provider` row for a given origin. */
function makeProvider(providerId: string, origin: ProviderOrigin): Provider {
return {
providerId,
providerType: "anthropic",
displayName: providerId,
origin,
addedAt: 0,
};
}
it("maps BYOK providers through their catalog provider id", () => {
const managed = buildManagedOpencodeProviderIds([
makeProvider("p1", { kind: "byok", catalogProviderId: "anthropic" }),
makeProvider("p2", { kind: "byok", catalogProviderId: "openai" }),
]);
expect(managed).toEqual(new Set(["anthropic", "openai"]));
});
it("maps copilot-plus to the reserved opencode provider id", () => {
const managed = buildManagedOpencodeProviderIds([makeProvider("p1", { kind: "copilot-plus" })]);
expect(managed).toEqual(new Set(["copilot-plus"]));
});
it("excludes unroutable BYOK providers (no catalog id → null mapping)", () => {
const managed = buildManagedOpencodeProviderIds([
makeProvider("p1", { kind: "byok" }), // custom endpoint, no catalogProviderId
makeProvider("p2", { kind: "byok", catalogProviderId: "google" }),
]);
expect(managed).toEqual(new Set(["google"]));
});
it("excludes agent-origin providers so they never suppress themselves", () => {
const managed = buildManagedOpencodeProviderIds([
makeProvider("opencode-agent", { kind: "agent", agentType: "opencode" }),
makeProvider("p1", { kind: "byok", catalogProviderId: "anthropic" }),
]);
expect(managed).toEqual(new Set(["anthropic"]));
});
it("returns an empty set for no providers", () => {
expect(buildManagedOpencodeProviderIds([])).toEqual(new Set());
});
});

View file

@ -0,0 +1,221 @@
/**
* Enrolls each agent backend's reported model catalog into the
* model-management data model on probe settle: every reported model becomes an
* `origin: "agent"` `ConfiguredModel` so the curation UI and chat picker can
* show the agent-discovered set.
*
* First enrollment of an `agentType` creates the provider and seeds the enabled
* set to the model the agent reports as current; every later probe only
* reconciles the model list (no provider-row write), so re-probes don't churn
* settings.
*
* opencode enrolls all its models under a single agent provider, each
* `ConfiguredModel.info.id` keeping the full prefixed wire form (e.g.
* `opencode/big-pickle`) so ids stay globally unique and UI sub-grouping can be
* derived from the wire-id prefix.
*/
import { logError, logInfo } from "@/logger";
import type CopilotPlugin from "@/main";
import type { AgentType, ModelManagementApi, Provider, ProviderType } from "@/modelManagement";
import {
computeDefaultEnabledIds,
listBackendDescriptors,
mapProviderToOpencodeId,
partitionOpencodeOnlyWireIds,
type AgentSessionManager,
type BackendDescriptor,
type BackendId,
type BackendState,
} from "@/agentMode";
/**
* opencode's providerType is bookkeeping only its models are hosted by
* opencode and never built by `ChatModelFactory`, so any neutral bucket works.
*/
const PROVIDER_TYPE_BY_AGENT: Record<AgentType, ProviderType> = {
claude: "anthropic",
codex: "openai-compatible",
opencode: "openai-compatible",
};
/**
* Subscribe (for the plugin's lifetime) to the model cache and enroll each
* backend's reported models on settle, whether or not the settings tab is open.
* Returns an unsubscribe function for the host to call on unload.
*
* Each fire only enrolls a backend whose reported wire-id set actually changed
* since its last enrollment, so a settle that notifies repeatedly enrolls at
* most once. Enrollment runs are serialized per backend so concurrent settles
* never interleave register/sync writes.
*/
export function wireAgentModelDiscovery(
plugin: CopilotPlugin,
manager: AgentSessionManager
): () => void {
const lastEnrolled = new Map<BackendId, string>();
const inFlight = new Map<BackendId, Promise<void>>();
let disposed = false;
const runForBackend = (descriptor: BackendDescriptor): void => {
const state = manager.getCachedBackendState(descriptor.id);
const reported = reportedWireIds(state);
if (reported === null) return; // No model state yet — agent hasn't settled.
const signature = reported.join("\n");
if (lastEnrolled.get(descriptor.id) === signature) return; // Unchanged — no-op.
// The model the agent currently has selected — the one first enrollment
// seeds as the sole enabled model.
const currentWireId = state?.model?.current.baseModelId;
// Chain behind any in-flight run for this backend so two settles can't
// interleave register/sync writes.
const prior = inFlight.get(descriptor.id) ?? Promise.resolve();
const run = prior
.catch(() => undefined)
.then(async () => {
if (disposed) return;
await enrollBackend(plugin.modelManagement, descriptor, reported, currentWireId);
// Record the signature only after a successful enroll so a failed
// run retries on the next settle.
lastEnrolled.set(descriptor.id, signature);
})
.catch((err) => {
logError(`[AgentMode] model discovery enroll failed for ${descriptor.id}`, err);
})
.finally(() => {
if (inFlight.get(descriptor.id) === run) inFlight.delete(descriptor.id);
});
inFlight.set(descriptor.id, run);
};
const onCacheUpdate = (): void => {
if (disposed) return;
for (const descriptor of listBackendDescriptors()) {
runForBackend(descriptor);
}
};
const unsubscribe = manager.subscribeModelCache(onCacheUpdate);
// Run once for any backend whose probe already settled before we
// subscribed (load-time preload may resolve before this wiring runs).
onCacheUpdate();
return () => {
disposed = true;
unsubscribe();
};
}
/**
* Enroll one backend's reported wire ids through `AgentSetupApi`: first
* enrollment registers the provider and seeds the enabled set to the agent's
* current model; later enrollments only reconcile the model list. opencode
* first drops models it shares with a Copilot-managed provider.
*/
async function enrollBackend(
api: ModelManagementApi,
descriptor: BackendDescriptor,
reported: readonly string[],
currentWireId: string | undefined
): Promise<void> {
// A backend's id doubles as its model-management AgentType.
const agentType = descriptor.id as AgentType;
const wireModelIds =
descriptor.id === "opencode" ? suppressManagedOpencode(api, reported) : reported;
// An empty list means a transient/degraded probe (zero models settled, or —
// for opencode — every model was suppressed as Copilot-managed), NOT "the
// user removed everything". Syncing it would cascade-remove every enrolled
// model, so skip and let a later non-empty probe re-enroll.
if (wireModelIds.length === 0) {
logInfo(
`[AgentMode] model discovery: empty model list for ${agentType}` +
`skipping enroll/sync (transient or fully-suppressed probe)`
);
return;
}
const existing = api.providerRegistry
.listByOrigin("agent")
.find((p) => p.origin.kind === "agent" && p.origin.agentType === agentType);
if (existing) {
await api.setup.agent.syncAgentModels({ agentType, wireModelIds });
return;
}
// First enrollment: register the provider (auto-enrolls every model), then
// narrow the enabled set to the agent's current model.
const result = await api.setup.agent.registerAgentProvider({
agentType,
providerType: PROVIDER_TYPE_BY_AGENT[agentType],
displayName: descriptor.displayName,
// No Copilot-side key: claude/codex are CLI-managed and opencode hosts its
// own models, so the keychain id stays null.
apiKey: null,
wireModelIds,
});
// `configuredModelIds` come back in `wireModelIds` order, so zip them to
// recover each model's wire id for the current-model lookup.
const enrolled = result.configuredModelIds.map((configuredModelId, i) => ({
configuredModelId,
wireModelId: wireModelIds[i],
}));
const seeded = computeDefaultEnabledIds(enrolled, currentWireId);
if (seeded.length !== result.configuredModelIds.length) {
await api.backendConfigRegistry.setEnabledModels(agentType, seeded);
}
logInfo(
`[AgentMode] model discovery: first enrollment for ${agentType}` +
`${result.configuredModelIds.length} model(s) configured, ${seeded.length} enabled`
);
}
/**
* Drop opencode wire ids hosted by a Copilot-managed (BYOK / Plus) provider,
* keeping only the opencode-only ids. Builds the managed-provider-id set from
* the registry here (impure) and delegates the filtering to a pure function.
*/
function suppressManagedOpencode(api: ModelManagementApi, reported: readonly string[]): string[] {
const byokAndPlus = [
...api.providerRegistry.listByOrigin("byok"),
...api.providerRegistry.listByOrigin("copilot-plus"),
];
const managed = buildManagedOpencodeProviderIds(byokAndPlus);
return partitionOpencodeOnlyWireIds(reported, managed);
}
/**
* The opencode provider ids Copilot already manages via the user's BYOK / Plus
* providers, used to suppress those models from opencode's reported catalog.
* Agent-origin providers are excluded they ARE the opencode-only models we
* want to enroll, so they must never suppress themselves. Unroutable providers
* map to `null` and contribute nothing.
*/
export function buildManagedOpencodeProviderIds(
byokAndPlusProviders: readonly Provider[]
): Set<string> {
const managed = new Set<string>();
for (const provider of byokAndPlusProviders) {
if (provider.origin.kind === "agent") continue;
const mapping = mapProviderToOpencodeId(provider);
if (!mapping) continue;
managed.add(mapping.id);
}
return managed;
}
/**
* The reported `baseModelId`s from a cached `BackendState`, or `null` when the
* backend hasn't reported a model state yet distinct from an empty array (a
* settled state with zero models), which callers treat differently.
*/
function reportedWireIds(state: BackendState | null): string[] | null {
if (!state?.model) return null;
return state.model.availableModels.map((m) => m.baseModelId);
}

View file

@ -13,6 +13,7 @@ import {
import type { AgentSession } from "@/agentMode/session/AgentSession";
import { MethodUnsupportedError } from "@/agentMode/session/errors";
import { resolveClaudeBinary } from "./claudeBinaryResolver";
import { agentOriginEnabledWireIds } from "@/agentMode/backends/shared/agentEnabledModels";
import { ClaudeSdkBackendProcess } from "@/agentMode/sdk/ClaudeSdkBackendProcess";
import { getCachedSdkCatalog, synthesizeEffortConfigOption } from "@/agentMode/sdk/effortOption";
import {
@ -133,6 +134,11 @@ export const ClaudeBackendDescriptor: BackendDescriptor = {
restartOnManagedSkillsChange: false,
wire: claudeWire,
getEnabledBaseModelIds(settings: CopilotSettings): ReadonlySet<string> {
// All Claude Code models are agent-origin.
return agentOriginEnabledWireIds(settings, "claude", (wireId) => claudeWire.decode(wireId));
},
getInstallState(settings: CopilotSettings): InstallState {
const path = resolveClaudeCliPath(settings);
if (!path) return { kind: "absent" };

View file

@ -1,31 +0,0 @@
import { CodexBackendDescriptor } from "./descriptor";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
describe("CodexBackendDescriptor.isModelEnabledByDefault", () => {
const fn = CodexBackendDescriptor.isModelEnabledByDefault!;
it("matches gpt-5.5 family by modelId", () => {
expect(fn({ modelId: "gpt-5.5", name: "GPT-5.5" })).toBe(true);
expect(fn({ modelId: "gpt-5.5/high", name: "GPT-5.5 (high)" })).toBe(true);
});
it("matches gpt-5.5 family by display name", () => {
expect(fn({ modelId: "some-internal-id", name: "GPT-5.5" })).toBe(true);
});
it("does not match version numbers that merely contain 5.5", () => {
expect(fn({ modelId: "model-15.5-x", name: "Model 15.5x" })).toBe(false);
expect(fn({ modelId: "model-5.50", name: "Model 5.50" })).toBe(false);
});
it("rejects unrelated models", () => {
expect(fn({ modelId: "gpt-5", name: "GPT-5" })).toBe(false);
expect(fn({ modelId: "gpt-5-codex/high", name: "GPT-5 Codex (high)" })).toBe(false);
expect(fn({ modelId: "o3", name: "o3" })).toBe(false);
});
});

View file

@ -10,6 +10,7 @@ import { CodexInstallModal } from "./CodexInstallModal";
import CodexLogo from "./logo.svg";
import { CodexSettingsPanel } from "./CodexSettingsPanel";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import { agentOriginEnabledWireIds } from "@/agentMode/backends/shared/agentEnabledModels";
import {
binaryPathInstallState,
simpleBinaryBackendProcess,
@ -57,9 +58,10 @@ const codexWire: ModelWireCodec = {
/**
* Codex backend wraps `@zed-industries/codex-acp`, which inherits auth
* from the local `codex` CLI login. Independent of Copilot's
* `activeModels` / BYOK keys, so the picker is fed entirely by live
* `availableModels` (active session or preloader cache).
* from the local `codex` CLI login. Auth is CLI-owned (no Copilot-side keys),
* so the candidate models come entirely from the CLI's live `availableModels`
* (active session or preloader cache); curation is the model-management
* `backends.codex.enabledModels` set surfaced via `getEnabledBaseModelIds`.
*
* Effort is surfaced via opencode-style model-id parsing codex-acp
* advertises one model per (base × effort) combination, and we collapse
@ -74,6 +76,11 @@ export const CodexBackendDescriptor: BackendDescriptor = {
restartOnManagedSkillsChange: false,
wire: codexWire,
getEnabledBaseModelIds(settings: CopilotSettings): ReadonlySet<string> {
// All Codex models are agent-origin.
return agentOriginEnabledWireIds(settings, "codex", (wireId) => codexWire.decode(wireId));
},
getInstallState(settings: CopilotSettings): InstallState {
return binaryPathInstallState(settings.agentMode?.backends?.codex?.binaryPath);
},
@ -106,13 +113,6 @@ export const CodexBackendDescriptor: BackendDescriptor = {
SettingsPanel: CodexSettingsPanel,
isModelEnabledByDefault(model) {
// Default-enable only gpt-5.5; digit-boundary on each side avoids
// matching `15.5` or `5.50`. Users widen via the Agents tab toggles.
const re = /(^|[^0-9])5\.5([^0-9]|$)/;
return re.test(model.name) || re.test(model.modelId);
},
/**
* Codex exposes sandbox/approval presets via ACP setMode: `read-only`,
* `auto`, and `full-access`. We surface all three:

View file

@ -1,7 +1,20 @@
import { ChatModelProviders } from "@/constants";
import { resetSettings, setSettings, updateSetting } from "@/settings/model";
import { getSettings, resetSettings, setSettings, updateSetting } from "@/settings/model";
import type {
BackendConfigRegistry,
ConfiguredModel,
EnabledBackendEntry,
Provider,
ProviderOrigin,
ProviderRegistry,
} from "@/modelManagement";
import type { Skill } from "@/agentMode/skills";
import { buildOpencodeConfig, OPENCODE_PROVIDER_MAP, OpencodeBackend } from "./OpencodeBackend";
import {
buildOpencodeConfig,
OPENCODE_PROVIDER_MAP,
OpencodeBackend,
type OpencodeModelDeps,
} from "./OpencodeBackend";
import { COPILOT_PROMPT_BASE, selectCopilotPrompt } from "./prompts";
jest.mock("@/logger", () => ({
@ -48,214 +61,190 @@ 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: [] });
// ---------------------------------------------------------------------------
// Registry mocks — `buildOpencodeConfig` only calls
// `backendConfigRegistry.resolveEnabled("opencode")` and
// `providerRegistry.getApiKey(providerId)`.
// ---------------------------------------------------------------------------
function makeProvider(providerId: string, origin: ProviderOrigin): Provider {
return {
providerId,
providerType: "anthropic",
displayName: providerId,
origin,
addedAt: 0,
};
}
describe("buildOpencodeConfig", () => {
function makeModel(providerId: string, wireId: string): ConfiguredModel {
return {
configuredModelId: `cm-${providerId}-${wireId}`,
providerId,
info: { id: wireId, displayName: wireId },
configuredAt: 0,
};
}
/** Build an `EnabledBackendEntry` in the `"ok"` state. */
function okEntry(provider: Provider, model: ConfiguredModel): EnabledBackendEntry {
return {
configuredModelId: model.configuredModelId,
state: "ok",
configuredModel: model,
provider,
};
}
/**
* Construct the registry deps `buildOpencodeConfig` needs from a seeded list
* of resolved entries and a key map keyed by `providerId`.
*/
function makeDeps(args: {
resolved: EnabledBackendEntry[];
keys?: Record<string, string | null>;
}): OpencodeModelDeps {
const keys = args.keys ?? {};
return {
backendConfigRegistry: {
resolveEnabled: (backend: string) => (backend === "opencode" ? args.resolved : []),
} as unknown as BackendConfigRegistry,
providerRegistry: {
getApiKey: async (providerId: string) => keys[providerId] ?? null,
} as unknown as ProviderRegistry,
};
}
const NO_MODELS_DEPS = makeDeps({ resolved: [] });
describe("buildOpencodeConfig — provider/model injection", () => {
beforeEach(() => {
resetSettings();
clearActiveModels();
seedSkills([]);
});
it("emits provider entries only for non-empty keys", async () => {
updateSetting("anthropicApiKey", "anth-123");
updateSetting("openAIApiKey", "");
updateSetting("googleApiKey", "g-456");
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
expect(Object.keys(cfg.provider).sort()).toEqual(["anthropic", "google"]);
expect(cfg.provider.anthropic).toEqual({ options: { apiKey: "anth-123" } });
expect(cfg.provider.google).toEqual({ options: { apiKey: "g-456" } });
});
it("returns empty provider map when no keys are set", async () => {
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
expect(cfg.provider).toEqual({});
});
it("injects enabled active models under their provider's `models` map", async () => {
updateSetting("anthropicApiKey", "anth-123");
setSettings({
activeModels: [
{
name: "claude-sonnet-4-6",
provider: ChatModelProviders.ANTHROPIC,
enabled: true,
},
{
name: "claude-haiku",
provider: ChatModelProviders.ANTHROPIC,
enabled: false, // disabled — should NOT inject
},
],
it("registers a BYOK provider with its keychain key and injects the model", async () => {
const provider = makeProvider("p-anthropic", {
kind: "byok",
catalogProviderId: "anthropic",
});
const cfg = (await buildOpencodeConfig()) as {
provider: Record<string, { options?: unknown; models?: Record<string, unknown> }>;
const model = makeModel("p-anthropic", "claude-sonnet-4-6");
const deps = makeDeps({
resolved: [okEntry(provider, model)],
keys: { "p-anthropic": "anth-123" },
});
const cfg = (await buildOpencodeConfig(getSettings(), deps)) as {
provider: Record<string, { options?: { apiKey?: string }; models?: Record<string, unknown> }>;
};
expect(cfg.provider.anthropic.options).toEqual({ apiKey: "anth-123" });
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,
},
],
it("injects multiple models under the same provider", async () => {
const provider = makeProvider("p-anthropic", {
kind: "byok",
catalogProviderId: "anthropic",
});
const deps = makeDeps({
resolved: [
okEntry(provider, makeModel("p-anthropic", "claude-sonnet-4-6")),
okEntry(provider, makeModel("p-anthropic", "claude-haiku")),
],
keys: { "p-anthropic": "anth-123" },
});
const cfg = (await buildOpencodeConfig(getSettings(), deps)) as {
provider: Record<string, { models?: Record<string, unknown> }>;
};
expect(cfg.provider.anthropic.models).toEqual({
"claude-sonnet-4-6": {},
"claude-haiku": {},
});
// Neither openAIApiKey nor model.apiKey configured
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
expect(cfg.provider).toEqual({});
});
it("falls back to per-model apiKey when top-level provider key is missing", async () => {
setSettings({
activeModels: [
{
name: "gpt-5",
provider: ChatModelProviders.OPENAI,
enabled: true,
apiKey: "per-model-key",
},
],
it("registers two distinct providers", async () => {
const anthropic = makeProvider("p-anthropic", {
kind: "byok",
catalogProviderId: "anthropic",
});
// No openAIApiKey configured globally, but the model carries its own key.
const cfg = (await buildOpencodeConfig()) as {
const openai = makeProvider("p-openai", { kind: "byok", catalogProviderId: "openai" });
const deps = makeDeps({
resolved: [
okEntry(anthropic, makeModel("p-anthropic", "claude-sonnet-4-6")),
okEntry(openai, makeModel("p-openai", "gpt-5")),
],
keys: { "p-anthropic": "anth-123", "p-openai": "oai-456" },
});
const cfg = (await buildOpencodeConfig(getSettings(), deps)) as {
provider: Record<string, { options?: { apiKey?: string }; models?: Record<string, unknown> }>;
};
expect(cfg.provider.openai.options).toEqual({ apiKey: "per-model-key" });
expect(Object.keys(cfg.provider).sort()).toEqual(["anthropic", "openai"]);
expect(cfg.provider.openai.options).toEqual({ apiKey: "oai-456" });
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",
},
],
it("skips a model when the provider has no key in the keychain", async () => {
const provider = makeProvider("p-openai", { kind: "byok", catalogProviderId: "openai" });
const deps = makeDeps({
resolved: [okEntry(provider, makeModel("p-openai", "gpt-5"))],
keys: { "p-openai": null },
});
const cfg = (await buildOpencodeConfig()) as {
provider: Record<string, { options?: { apiKey?: string } }>;
const cfg = (await buildOpencodeConfig(getSettings(), deps)) as {
provider: Record<string, unknown>;
};
// Top-level wins because the provider entry is built before the
// per-model fallback runs — keeps the historical behaviour.
expect(cfg.provider.openai.options).toEqual({ apiKey: "global-key" });
});
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,
},
],
});
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
expect(cfg.provider).toEqual({});
});
it("skips embedding models", async () => {
updateSetting("openAIApiKey", "key");
setSettings({
activeModels: [
{
name: "text-embedding-3-large",
provider: ChatModelProviders.OPENAI,
enabled: true,
isEmbeddingModel: true,
},
{
name: "gpt-test",
provider: ChatModelProviders.OPENAI,
enabled: true,
},
],
});
const cfg = (await buildOpencodeConfig()) as {
provider: Record<string, { models?: Record<string, unknown> }>;
it("returns an empty provider map when no models are enabled", async () => {
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as {
provider: Record<string, unknown>;
};
// gpt-test is injected; the embedding model is not, even though both have
// the same provider and enabled flag.
expect(cfg.provider.openai.models).toHaveProperty("gpt-test");
expect(cfg.provider.openai.models).not.toHaveProperty("text-embedding-3-large");
expect(cfg.provider).toEqual({});
});
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 },
},
},
},
it("ignores broken resolved entries", async () => {
const deps = makeDeps({
resolved: [{ configuredModelId: "gone", state: "broken" }],
});
const cfg = (await buildOpencodeConfig()) as { model?: string };
expect(cfg.model).toBe("anthropic/claude-sonnet-4-6");
const cfg = (await buildOpencodeConfig(getSettings(), deps)) as {
provider: Record<string, unknown>;
};
expect(cfg.provider).toEqual({});
});
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" },
},
},
},
it("does not inject native (agent-origin) providers — opencode hosts them", async () => {
const provider = makeProvider("opencode-provider", {
kind: "agent",
agentType: "opencode",
});
const cfg = (await buildOpencodeConfig()) as { model?: string };
expect(cfg.model).toBe("anthropic/claude-sonnet-4-6/high");
const deps = makeDeps({
resolved: [okEntry(provider, makeModel("opencode-provider", "opencode/big-pickle"))],
keys: { "opencode-provider": "should-not-be-read" },
});
const cfg = (await buildOpencodeConfig(getSettings(), deps)) as {
provider: Record<string, unknown>;
};
expect(cfg.provider).toEqual({});
});
it("registers a custom copilot-plus provider when plusLicenseKey is set", async () => {
updateSetting("plusLicenseKey", "plus-token-123");
setSettings({
activeModels: [
{
name: "copilot-plus-flash",
provider: ChatModelProviders.COPILOT_PLUS,
enabled: true,
},
],
it("skips unroutable providers (BYOK without a catalog id, e.g. azure/bedrock/custom)", async () => {
const provider = makeProvider("p-azure", { kind: "byok" });
const deps = makeDeps({
resolved: [okEntry(provider, makeModel("p-azure", "my-azure-deploy"))],
keys: { "p-azure": "azure-key" },
});
const cfg = (await buildOpencodeConfig()) as {
const cfg = (await buildOpencodeConfig(getSettings(), deps)) as {
provider: Record<string, unknown>;
};
expect(cfg.provider).toEqual({});
});
it("registers Copilot Plus as a custom openai-compatible provider", async () => {
const provider = makeProvider("p-plus", { kind: "copilot-plus" });
const deps = makeDeps({
resolved: [okEntry(provider, makeModel("p-plus", "copilot-plus-flash"))],
keys: { "p-plus": "plus-token-123" },
});
const cfg = (await buildOpencodeConfig(getSettings(), deps)) as {
provider: Record<
string,
{
@ -273,31 +262,16 @@ describe("buildOpencodeConfig", () => {
expect(cp.options?.apiKey).toBe("plus-token-123");
expect(cp.models).toEqual({ "copilot-plus-flash": {} });
});
});
it("does not register copilot-plus provider when plusLicenseKey is empty", async () => {
setSettings({
activeModels: [
{
name: "copilot-plus-flash",
provider: ChatModelProviders.COPILOT_PLUS,
enabled: true,
},
],
});
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
expect(cfg.provider["copilot-plus"]).toBeUndefined();
describe("buildOpencodeConfig — agent/prompt/mode/skills blocks (preserved)", () => {
beforeEach(() => {
resetSettings();
seedSkills([]);
});
it("uses a Copilot-Plus-shaped defaultModel.baseModelId verbatim", async () => {
updateSetting("plusLicenseKey", "plus-token-123");
it("sets top-level model from the persisted defaultModel.baseModelId", async () => {
setSettings({
activeModels: [
{
name: "copilot-plus-flash",
provider: ChatModelProviders.COPILOT_PLUS,
enabled: true,
},
],
agentMode: {
enabled: true,
byok: {},
@ -308,22 +282,63 @@ describe("buildOpencodeConfig", () => {
backends: {
opencode: {
binaryPath: "/x",
defaultModel: { baseModelId: "copilot-plus/copilot-plus-flash", effort: null },
defaultModel: { baseModelId: "anthropic/claude-sonnet-4-6", effort: null },
},
},
},
});
const cfg = (await buildOpencodeConfig()) as { model?: string };
expect(cfg.model).toBe("copilot-plus/copilot-plus-flash");
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as { model?: string };
expect(cfg.model).toBe("anthropic/claude-sonnet-4-6");
});
it("appends effort suffix when defaultModel.effort is set", async () => {
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(getSettings(), NO_MODELS_DEPS)) as { model?: string };
expect(cfg.model).toBe("anthropic/claude-sonnet-4-6/high");
});
it("omits cfg.model when no defaultModel is set", async () => {
setSettings({
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: { binaryPath: "/x" },
},
},
});
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as { model?: string };
expect(cfg.model).toBeUndefined();
});
it("always spawns with canonical default agent (copilot-build)", async () => {
const cfg = (await buildOpencodeConfig()) as { default_agent?: string };
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as {
default_agent?: string;
};
expect(cfg.default_agent).toBe("copilot-build");
});
it("overrides system prompt on both build and copilot-build agents", async () => {
const cfg = (await buildOpencodeConfig()) as {
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as {
agent: Record<string, { prompt?: string; permission?: unknown; mode?: string }>;
};
expect(cfg.agent["copilot-build"].prompt?.startsWith(COPILOT_PROMPT_BASE)).toBe(true);
@ -354,7 +369,7 @@ describe("buildOpencodeConfig", () => {
backends: {},
},
});
const cfg = (await buildOpencodeConfig()) as {
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as {
agent: Record<string, { prompt?: string }>;
};
expect(cfg.agent["copilot-build"].prompt).toContain("<vault>/team-skills/<name>/SKILL.md");
@ -363,7 +378,7 @@ describe("buildOpencodeConfig", () => {
it("denies a skill enabled for Claude only (cross-discovered, not enabled for opencode)", async () => {
seedSkills([makeSkill("foo", ["claude"])]);
const cfg = (await buildOpencodeConfig()) as {
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as {
permission?: { skill?: Record<string, string> };
};
expect(cfg.permission?.skill?.foo).toBe("deny");
@ -371,7 +386,7 @@ describe("buildOpencodeConfig", () => {
it("does not deny a skill enabled for both Claude and OpenCode", async () => {
seedSkills([makeSkill("foo", ["claude", "opencode"])]);
const cfg = (await buildOpencodeConfig()) as {
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as {
permission?: { skill?: Record<string, string> };
};
expect(cfg.permission?.skill?.foo).toBeUndefined();
@ -379,7 +394,7 @@ describe("buildOpencodeConfig", () => {
it("does not emit a permission.skill block when no skills need denying", async () => {
seedSkills([makeSkill("foo", ["opencode"])]);
const cfg = (await buildOpencodeConfig()) as {
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as {
permission?: { skill?: Record<string, string> };
};
expect(cfg.permission).toBeUndefined();
@ -387,7 +402,9 @@ describe("buildOpencodeConfig", () => {
it("does not emit a permission.skill block when there are no skills at all", async () => {
seedSkills([]);
const cfg = (await buildOpencodeConfig()) as { permission?: unknown };
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as {
permission?: unknown;
};
expect(cfg.permission).toBeUndefined();
});
@ -399,7 +416,7 @@ describe("buildOpencodeConfig", () => {
makeSkill("d", ["opencode"]),
makeSkill("e", ["codex"]),
]);
const cfg = (await buildOpencodeConfig()) as {
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as {
permission?: { skill?: Record<string, string> };
};
// a is claude-only → denied. e is codex-only → denied (codex also
@ -408,48 +425,29 @@ describe("buildOpencodeConfig", () => {
});
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 };
const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as {
permission?: unknown;
};
expect(cfg.permission).toBeUndefined();
});
it("omits cfg.model when no defaultModel is set", async () => {
updateSetting("anthropicApiKey", "anth-123");
setSettings({
activeModels: [],
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: { binaryPath: "/x" },
},
},
});
const cfg = (await buildOpencodeConfig()) as { model?: string };
expect(cfg.model).toBeUndefined();
});
});
describe("OpencodeBackend.buildSpawnDescriptor", () => {
beforeEach(() => {
resetSettings();
clearActiveModels();
seedSkills([]);
});
it("throws if no binary is installed", async () => {
const backend = new OpencodeBackend();
const backend = new OpencodeBackend(NO_MODELS_DEPS);
await expect(backend.buildSpawnDescriptor({ vaultBasePath: "/vault" })).rejects.toThrow(
/binary not installed/
);
});
it("uses agentMode.backends.opencode.binaryPath as command and passes cwd in args", async () => {
it("uses agentMode.backends.opencode.binaryPath as command and passes cwd in args, injecting enabled models", async () => {
updateSetting("agentMode", {
enabled: true,
byok: {},
@ -465,14 +463,22 @@ describe("OpencodeBackend.buildSpawnDescriptor", () => {
},
},
});
updateSetting("anthropicApiKey", "anth-xyz");
const backend = new OpencodeBackend();
const provider = makeProvider("p-anthropic", {
kind: "byok",
catalogProviderId: "anthropic",
});
const deps = makeDeps({
resolved: [okEntry(provider, makeModel("p-anthropic", "claude-sonnet-4-6"))],
keys: { "p-anthropic": "anth-xyz" },
});
const backend = new OpencodeBackend(deps);
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.models).toEqual({ "claude-sonnet-4-6": {} });
});
});
@ -493,8 +499,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/);
@ -507,19 +511,10 @@ describe("COPILOT_PROMPT_BASE", () => {
});
describe("OPENCODE_PROVIDER_MAP", () => {
it("includes the eight BYOK-mapped providers plus Copilot Plus", () => {
expect(Object.keys(OPENCODE_PROVIDER_MAP).sort()).toEqual(
[
ChatModelProviders.ANTHROPIC,
ChatModelProviders.COPILOT_PLUS,
ChatModelProviders.DEEPSEEK,
ChatModelProviders.GOOGLE,
ChatModelProviders.GROQ,
ChatModelProviders.MISTRAL,
ChatModelProviders.OPENAI,
ChatModelProviders.OPENROUTERAI,
ChatModelProviders.XAI,
].sort()
);
it("maps the BYOK provider ids plus Copilot Plus to opencode provider ids", () => {
expect(OPENCODE_PROVIDER_MAP[ChatModelProviders.ANTHROPIC]).toBe("anthropic");
expect(OPENCODE_PROVIDER_MAP[ChatModelProviders.OPENAI]).toBe("openai");
expect(OPENCODE_PROVIDER_MAP[ChatModelProviders.OPENROUTERAI]).toBe("openrouter");
expect(OPENCODE_PROVIDER_MAP[ChatModelProviders.COPILOT_PLUS]).toBe("copilot-plus");
});
});

View file

@ -1,7 +1,8 @@
import { BREVILABS_MODELS_BASE_URL, ChatModelProviders } from "@/constants";
import { getDecryptedKey } from "@/encryptionService";
import { logInfo, logWarn } from "@/logger";
import { logInfo } from "@/logger";
import { getSettings } from "@/settings/model";
import type { CopilotSettings } from "@/settings/model";
import type { BackendConfigRegistry, ProviderRegistry } from "@/modelManagement";
import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
import type { CopilotMode } from "@/agentMode/session/types";
import {
@ -13,18 +14,13 @@ import {
SkillManager,
} from "@/agentMode/skills";
import { OpencodeBackendDescriptor } from "./descriptor";
import { COPILOT_PLUS_OPENCODE_PROVIDER_ID, mapProviderToOpencodeId } from "./opencodeModelResolve";
import { selectCopilotPrompt } from "./prompts";
/**
* Map from Copilot's `ChatModelProviders` enum value (as stored in
* `CustomModel.provider`) to OpenCode's provider id (as it appears in
* OpenCode's `availableModels` and config). Only providers in this map are
* routable through OpenCode; everything else (Azure, Bedrock, Ollama,
* LM Studio, GitHub Copilot, etc.) is filtered out of the picker.
*
* Copilot Plus is handled separately because it isn't a built-in OpenCode
* provider we register it as a custom `@ai-sdk/openai-compatible` entry
* pointing at brevilabs and authed via the user's `plusLicenseKey`.
* Maps Copilot's `ChatModelProviders` to OpenCode's provider id. Used for the
* picker's wire-codec provider-grouping; config injection derives provider ids
* from the data model via `mapProviderToOpencodeId` instead.
*/
export const OPENCODE_PROVIDER_MAP: Partial<Record<ChatModelProviders, string>> = {
[ChatModelProviders.ANTHROPIC]: "anthropic",
@ -38,9 +34,6 @@ export const OPENCODE_PROVIDER_MAP: Partial<Record<ChatModelProviders, string>>
[ChatModelProviders.COPILOT_PLUS]: "copilot-plus",
};
/** OpenCode provider id reserved for Copilot Plus's brevilabs proxy. */
const COPILOT_PLUS_PROVIDER_ID = "copilot-plus";
/**
* Custom OpenCode agent id provisioned via `OPENCODE_CONFIG_CONTENT`. Maps
* to Copilot's canonical `default` mode (writes/exec allowed, but the user
@ -64,17 +57,27 @@ export const OPENCODE_CANONICAL_MODE_AGENT_IDS: Partial<Record<CopilotMode, stri
auto: OPENCODE_BUILTIN_BUILD_AGENT_ID,
};
/** Registries `buildOpencodeConfig` needs; injected so it stays unit-testable with plain mocks. */
export interface OpencodeModelDeps {
providerRegistry: ProviderRegistry;
backendConfigRegistry: BackendConfigRegistry;
}
/**
* Spawns `opencode acp --cwd <vault>` with `OPENCODE_CONFIG_CONTENT`
* containing decrypted BYOK keys pulled from the existing Copilot settings.
*
* Reuses Copilot's top-level `*ApiKey` fields so users don't have to re-enter
* them in an Agent Mode-specific settings panel.
* Spawns `opencode acp --cwd <vault>` with an `OPENCODE_CONFIG_CONTENT` payload
* built from the user's enabled BYOK models. The registries are injected by the
* descriptor from `plugin.modelManagement`.
*/
export class OpencodeBackend implements AcpBackend {
readonly id = "opencode" as const;
readonly displayName = "opencode";
readonly #deps: OpencodeModelDeps;
constructor(deps: OpencodeModelDeps) {
this.#deps = deps;
}
async buildSpawnDescriptor(ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor> {
const binaryPath = getSettings().agentMode?.backends?.opencode?.binaryPath;
if (!binaryPath) {
@ -83,7 +86,7 @@ export class OpencodeBackend implements AcpBackend {
);
}
const config = await buildOpencodeConfig();
const config = await buildOpencodeConfig(getSettings(), this.#deps);
const envOverrides = getSettings().agentMode?.backends?.opencode?.envOverrides ?? {};
return {
@ -100,102 +103,66 @@ export class OpencodeBackend implements AcpBackend {
}
}
/** Mutable opencode provider config entry built into `OPENCODE_CONFIG_CONTENT`. */
type ProviderConfig = {
npm?: string;
name?: string;
options?: { apiKey?: string; baseURL?: string; headers?: Record<string, string> };
models?: Record<string, Record<string, unknown>>;
};
/**
* Build the `OPENCODE_CONFIG_CONTENT` payload from current Copilot settings.
* Build the `OPENCODE_CONFIG_CONTENT` payload from the enabled opencode models.
* Each non-native (BYOK / Plus) provider is registered with its keychain key
* and its models; native (agent-origin) providers are skipped since opencode
* already hosts them. The top-level `model` field carries the user's sticky
* preference so a fresh session boots with the right default.
*
* - Per-provider `options.apiKey` is set for any BYOK key configured in
* Copilot, decrypted in-process.
* - Each enabled `activeModel` whose provider is in `OPENCODE_PROVIDER_MAP`
* is registered under `provider.<id>.models.<modelName>` so OpenCode
* reports it in `NewSessionResponse.models.availableModels`. Built-in
* providers (anthropic, openai, ) carry their own models.dev snapshot
* so this is largely additive there; for the custom Copilot Plus
* provider and for OpenRouter models the snapshot doesn't cover
* the registration is what makes the model visible at all. The
* Agents-tab catalog modal then curates from opencode's reported
* `availableModels`.
* - 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.
* Takes settings + registries as parameters (no singletons) so it stays
* unit-testable.
*/
export async function buildOpencodeConfig(): Promise<Record<string, unknown>> {
const s = getSettings();
export async function buildOpencodeConfig(
s: CopilotSettings,
deps: OpencodeModelDeps
): Promise<Record<string, unknown>> {
const { providerRegistry, backendConfigRegistry } = deps;
type Mapping = { providerId: string; settingsKey: keyof typeof s };
const mappings: Mapping[] = [
{ providerId: "anthropic", settingsKey: "anthropicApiKey" },
{ providerId: "openai", settingsKey: "openAIApiKey" },
{ providerId: "google", settingsKey: "googleApiKey" },
{ providerId: "groq", settingsKey: "groqApiKey" },
{ providerId: "mistral", settingsKey: "mistralApiKey" },
{ providerId: "deepseek", settingsKey: "deepseekApiKey" },
{ providerId: "openrouter", settingsKey: "openRouterAiApiKey" },
{ providerId: "xai", settingsKey: "xaiApiKey" },
];
const decrypted = await Promise.all(
mappings.map(async (m) => {
const raw = s[m.settingsKey];
if (typeof raw !== "string" || !raw) return null;
const apiKey = await getDecryptedKey(raw);
if (!apiKey) return null;
return { providerId: m.providerId, apiKey };
})
);
type ProviderConfig = {
npm?: string;
name?: string;
options?: { apiKey?: string; baseURL?: string; headers?: Record<string, string> };
models?: Record<string, Record<string, unknown>>;
};
const provider: Record<string, ProviderConfig> = {};
for (const entry of decrypted) {
if (entry) provider[entry.providerId] = { options: { apiKey: entry.apiKey } };
}
// 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`.
if (typeof s.plusLicenseKey === "string" && s.plusLicenseKey) {
const licenseKey = await getDecryptedKey(s.plusLicenseKey);
if (licenseKey) {
provider[COPILOT_PLUS_PROVIDER_ID] = {
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)`
for (const entry of backendConfigRegistry.resolveEnabled("opencode")) {
if (entry.state !== "ok") continue;
const mapping = mapProviderToOpencodeId(entry.provider);
if (!mapping) continue;
// opencode hosts native (agent-origin) providers itself, so never register them.
if (mapping.native) continue;
let providerConfig = provider[mapping.id];
if (!providerConfig) {
const apiKey = await providerRegistry.getApiKey(entry.provider.providerId);
if (!apiKey) {
logInfo(
`[AgentMode] skipping ${mapping.id}/${entry.configuredModel.info.id}: no API key in keychain`
);
continue;
}
provider[providerId] = { options: { apiKey: perModel } };
if (mapping.id === COPILOT_PLUS_OPENCODE_PROVIDER_ID) {
// Copilot Plus speaks OpenAI's wire format but isn't a built-in opencode
// provider, so register it as a custom `@ai-sdk/openai-compatible` entry.
providerConfig = {
npm: "@ai-sdk/openai-compatible",
name: "Copilot Plus",
options: { baseURL: BREVILABS_MODELS_BASE_URL, apiKey },
};
} else {
providerConfig = { options: { apiKey } };
}
provider[mapping.id] = providerConfig;
}
if (!provider[providerId].models) provider[providerId].models = {};
provider[providerId].models[model.name] = {};
injected.push(`${providerId}/${model.name}`);
if (!providerConfig.models) providerConfig.models = {};
providerConfig.models[entry.configuredModel.info.id] = {};
injected.push(`${mapping.id}/${entry.configuredModel.info.id}`);
}
if (injected.length > 0) {
@ -204,7 +171,7 @@ export async function buildOpencodeConfig(): Promise<Record<string, unknown>> {
);
} 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 enabled BYOK models found; opencode will rely on its own auth. Add and enable models for opencode in Copilot settings to use Agent Mode end-to-end."
);
}

View file

@ -129,23 +129,3 @@ describe("OpencodeBackendDescriptor.wire.encode", () => {
}
});
});
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);
});
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);
});
it("returns false for unrelated models", () => {
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);
});
});

View file

@ -12,6 +12,7 @@ import {
OPENCODE_PROVIDER_MAP,
} from "./OpencodeBackend";
import { computeInstallState, OpencodeBinaryManager } from "./OpencodeBinaryManager";
import { opencodeEnabledWireIds } from "./opencodeModelResolve";
import { OpencodeSettingsPanel } from "./OpencodeSettingsPanel";
import { mapNodeArch, mapNodePlatform } from "./platformResolver";
import type { AgentSession } from "@/agentMode/session/AgentSession";
@ -95,6 +96,10 @@ export const OpencodeBackendDescriptor: BackendDescriptor = {
restartOnManagedSkillsChange: true,
wire: opencodeWire,
getEnabledBaseModelIds(settings: CopilotSettings): ReadonlySet<string> {
return opencodeEnabledWireIds(settings);
},
getInstallState(settings: CopilotSettings): InstallState {
const raw = computeInstallState(settings.agentMode?.backends?.opencode);
if (raw.kind === "absent") return { kind: "absent" };
@ -121,7 +126,11 @@ export const OpencodeBackendDescriptor: BackendDescriptor = {
},
createBackendProcess(args): BackendProcess {
return simpleBinaryBackendProcess(args, new OpencodeBackend());
const { providerRegistry, backendConfigRegistry } = args.plugin.modelManagement;
return simpleBinaryBackendProcess(
args,
new OpencodeBackend({ providerRegistry, backendConfigRegistry })
);
},
SettingsPanel: OpencodeSettingsPanel,
@ -130,13 +139,6 @@ export const OpencodeBackendDescriptor: BackendDescriptor = {
await getOpencodeBinaryManager(plugin).refreshInstallState();
},
isModelEnabledByDefault(model) {
// Default-enable only "Big Pickle"; users widen the catalog via the
// per-model toggles in the Agents tab.
const re = /big[\s_-]*pickle/i;
return re.test(model.name) || re.test(model.modelId);
},
getProbeSessionId(settings: CopilotSettings): string | undefined {
const id = settings.agentMode?.backends?.opencode?.probeSessionId;
return id && id.length > 0 ? id : undefined;

View file

@ -0,0 +1,162 @@
import type { CopilotSettings } from "@/settings/model";
import type { ConfiguredModel, Provider, ProviderOrigin } from "@/modelManagement";
import { mapProviderToOpencodeId, opencodeEnabledWireIds } from "./opencodeModelResolve";
/** Build a minimal `Provider` row for a given origin + type. */
function makeProvider(providerId: string, origin: ProviderOrigin): Provider {
return {
providerId,
providerType: "anthropic",
displayName: providerId,
origin,
addedAt: 0,
};
}
/** Build a minimal `ConfiguredModel` row. */
function makeModel(configuredModelId: string, providerId: string, wireId: string): ConfiguredModel {
return {
configuredModelId,
providerId,
info: { id: wireId, displayName: wireId },
configuredAt: 0,
};
}
/**
* Assemble a `CopilotSettings`-shaped object with only the slices
* `opencodeEnabledWireIds` reads. Cast through `unknown` since the resolver
* touches just `backends` / `configuredModels` / `providers`.
*/
function makeSettings(args: {
enabledModels?: string[];
configuredModels?: ConfiguredModel[];
providers?: Record<string, Provider>;
}): CopilotSettings {
return {
backends:
args.enabledModels === undefined
? {}
: { opencode: { enabledModels: args.enabledModels, defaultModel: null } },
configuredModels: args.configuredModels ?? [],
providers: args.providers ?? {},
} as unknown as CopilotSettings;
}
describe("mapProviderToOpencodeId", () => {
it("maps a BYOK provider with a catalog id to that id, non-native", () => {
const provider = makeProvider("p1", { kind: "byok", catalogProviderId: "anthropic" });
expect(mapProviderToOpencodeId(provider)).toEqual({ id: "anthropic", native: false });
});
it("maps BYOK openrouter to openrouter, non-native", () => {
const provider = makeProvider("p1", { kind: "byok", catalogProviderId: "openrouter" });
expect(mapProviderToOpencodeId(provider)).toEqual({ id: "openrouter", native: false });
});
it("returns null for a BYOK provider without a catalog id (custom endpoint)", () => {
const provider = makeProvider("p1", { kind: "byok" });
expect(mapProviderToOpencodeId(provider)).toBeNull();
});
it("maps copilot-plus origin to the reserved copilot-plus id, non-native", () => {
const provider = makeProvider("p1", { kind: "copilot-plus" });
expect(mapProviderToOpencodeId(provider)).toEqual({ id: "copilot-plus", native: false });
});
it("maps an agent-origin provider to its providerId, native", () => {
const provider = makeProvider("opencode-provider", { kind: "agent", agentType: "opencode" });
expect(mapProviderToOpencodeId(provider)).toEqual({
id: "opencode-provider",
native: true,
});
});
});
describe("opencodeEnabledWireIds", () => {
it("returns the shared frozen empty set when no models are enabled", () => {
const first = opencodeEnabledWireIds(makeSettings({ enabledModels: [] }));
const second = opencodeEnabledWireIds(makeSettings({ enabledModels: [] }));
expect(first.size).toBe(0);
// Referential stability: the same frozen constant on every empty call.
expect(first).toBe(second);
});
it("returns the shared frozen empty set when the opencode backend is absent", () => {
const result = opencodeEnabledWireIds(makeSettings({}));
expect(result.size).toBe(0);
});
it("builds `<provider>/<model>` wire ids for BYOK models", () => {
const settings = makeSettings({
enabledModels: ["cm1"],
providers: { p1: makeProvider("p1", { kind: "byok", catalogProviderId: "anthropic" }) },
configuredModels: [makeModel("cm1", "p1", "claude-sonnet-4-6")],
});
const result = opencodeEnabledWireIds(settings);
expect([...result]).toEqual(["anthropic/claude-sonnet-4-6"]);
});
it("builds `<provider>/<model>` wire ids for copilot-plus models", () => {
const settings = makeSettings({
enabledModels: ["cm1"],
providers: { p1: makeProvider("p1", { kind: "copilot-plus" }) },
configuredModels: [makeModel("cm1", "p1", "copilot-plus-flash")],
});
const result = opencodeEnabledWireIds(settings);
expect([...result]).toEqual(["copilot-plus/copilot-plus-flash"]);
});
it("uses the verbatim info.id for agent-origin models (already full wire form)", () => {
const settings = makeSettings({
enabledModels: ["cm1"],
providers: { p1: makeProvider("p1", { kind: "agent", agentType: "opencode" }) },
configuredModels: [makeModel("cm1", "p1", "opencode/big-pickle")],
});
const result = opencodeEnabledWireIds(settings);
expect([...result]).toEqual(["opencode/big-pickle"]);
});
it("skips models whose provider row is missing", () => {
const settings = makeSettings({
enabledModels: ["cm1"],
providers: {},
configuredModels: [makeModel("cm1", "p1", "claude-sonnet-4-6")],
});
expect(opencodeEnabledWireIds(settings).size).toBe(0);
});
it("skips models whose configured-model row is missing", () => {
const settings = makeSettings({
enabledModels: ["missing"],
providers: { p1: makeProvider("p1", { kind: "byok", catalogProviderId: "anthropic" }) },
configuredModels: [],
});
expect(opencodeEnabledWireIds(settings).size).toBe(0);
});
it("skips models on unroutable providers (BYOK without catalog id)", () => {
const settings = makeSettings({
enabledModels: ["cm1"],
providers: { p1: makeProvider("p1", { kind: "byok" }) },
configuredModels: [makeModel("cm1", "p1", "some-azure-model")],
});
expect(opencodeEnabledWireIds(settings).size).toBe(0);
});
it("mixes BYOK and agent-origin models with the correct wire shapes", () => {
const settings = makeSettings({
enabledModels: ["cm-byok", "cm-agent"],
providers: {
byok: makeProvider("byok", { kind: "byok", catalogProviderId: "openai" }),
agent: makeProvider("agent", { kind: "agent", agentType: "opencode" }),
},
configuredModels: [
makeModel("cm-byok", "byok", "gpt-5"),
makeModel("cm-agent", "agent", "opencode/big-pickle"),
],
});
const result = opencodeEnabledWireIds(settings);
expect([...result].sort()).toEqual(["openai/gpt-5", "opencode/big-pickle"].sort());
});
});

View file

@ -0,0 +1,83 @@
import type { CopilotSettings } from "@/settings/model";
import type { ConfiguredModel, Provider } from "@/modelManagement";
export interface OpencodeProviderMapping {
/** The opencode provider id — leading segment of `<provider>/<model>`. */
id: string;
/**
* `true` when opencode hosts the provider itself (an agent-origin provider it
* discovered): it carries its own auth + model snapshot, so the runtime
* config must NOT re-register it or inject a key.
*/
native: boolean;
}
/** opencode provider id reserved for the Copilot Plus brevilabs proxy. */
export const COPILOT_PLUS_OPENCODE_PROVIDER_ID = "copilot-plus";
/** See AGENTS.md → "Referential stability". */
const EMPTY_WIRE_IDS: ReadonlySet<string> = Object.freeze(new Set<string>());
/**
* Map a Copilot `Provider` onto its opencode provider id, or `null` when
* opencode can't route it (so callers skip it). BYOK maps to its
* `catalogProviderId` (identical to opencode's provider id) and is unroutable
* without one (custom-endpoint / azure / bedrock / self-hosted).
*/
export function mapProviderToOpencodeId(provider: Provider): OpencodeProviderMapping | null {
switch (provider.origin.kind) {
case "byok": {
const catalogProviderId = provider.origin.catalogProviderId;
if (!catalogProviderId) return null;
return { id: catalogProviderId, native: false };
}
case "copilot-plus":
return { id: COPILOT_PLUS_OPENCODE_PROVIDER_ID, native: false };
case "agent":
// An opencode-discovered provider's id is opencode's own provider id, and
// opencode hosts the models — native, so no key/registration.
return { id: provider.providerId, native: true };
default:
return null;
}
}
/**
* The opencode wire ids for the backend's enabled models, joining
* `backends.opencode.enabledModels` to the configured-model + provider state.
* BYOK / Plus models become `${opencodeProviderId}/${info.id}`; agent-origin
* models use `info.id` verbatim (already the full wire form). Unroutable or
* missing entries are skipped.
*
* Shared by `buildOpencodeConfig` (injection) and the descriptor's picker
* filter so the injected / enabled / shown sets agree.
*/
export function opencodeEnabledWireIds(settings: CopilotSettings): ReadonlySet<string> {
const enabledIds = settings.backends.opencode?.enabledModels ?? [];
if (enabledIds.length === 0) return EMPTY_WIRE_IDS;
const modelsById = new Map<string, ConfiguredModel>();
for (const model of settings.configuredModels) {
modelsById.set(model.configuredModelId, model);
}
const wireIds = new Set<string>();
for (const configuredModelId of enabledIds) {
const configuredModel = modelsById.get(configuredModelId);
if (!configuredModel) continue;
const provider = settings.providers[configuredModel.providerId];
if (!provider) continue;
const mapping = mapProviderToOpencodeId(provider);
if (!mapping) continue;
if (mapping.native) {
// Agent-origin: `info.id` is already the full opencode wire form.
wireIds.add(configuredModel.info.id);
} else {
wireIds.add(`${mapping.id}/${configuredModel.info.id}`);
}
}
if (wireIds.size === 0) return EMPTY_WIRE_IDS;
return wireIds;
}

View file

@ -0,0 +1,80 @@
import { partitionOpencodeOnlyWireIds } from "./opencodeProbePartition";
describe("partitionOpencodeOnlyWireIds", () => {
it("drops wire ids whose provider id is in the managed set", () => {
const managed = new Set(["anthropic", "openai"]);
const result = partitionOpencodeOnlyWireIds(
["anthropic/claude-sonnet-4-5", "openai/gpt-5", "opencode/big-pickle"],
managed
);
// Only the opencode-only id survives; the BYOK-managed ones are suppressed.
expect(result).toEqual(["opencode/big-pickle"]);
});
it("keeps every opencode-only id when the managed set is empty", () => {
const result = partitionOpencodeOnlyWireIds(
["opencode/big-pickle", "opencode/small-gherkin"],
new Set()
);
expect(result).toEqual(["opencode/big-pickle", "opencode/small-gherkin"]);
});
it("treats the FIRST segment as the provider id for multi-segment wire ids", () => {
const managed = new Set(["openrouter"]);
const result = partitionOpencodeOnlyWireIds(
// openrouter is managed → dropped; mistral (first segment) is not → kept.
["openrouter/anthropic/claude-3.5-haiku", "mistral/large/latest"],
managed
);
expect(result).toEqual(["mistral/large/latest"]);
});
it("suppresses a copilot-plus managed id", () => {
const managed = new Set(["copilot-plus"]);
const result = partitionOpencodeOnlyWireIds(
["copilot-plus/some-model", "opencode/big-pickle"],
managed
);
expect(result).toEqual(["opencode/big-pickle"]);
});
it("keeps a wire id with no slash (no provider segment to attribute)", () => {
const result = partitionOpencodeOnlyWireIds(["bare-model"], new Set(["anthropic"]));
expect(result).toEqual(["bare-model"]);
});
it("de-duplicates repeated wire ids", () => {
const result = partitionOpencodeOnlyWireIds(
["opencode/big-pickle", "opencode/big-pickle"],
new Set()
);
expect(result).toEqual(["opencode/big-pickle"]);
});
it("preserves report order of the kept ids", () => {
const result = partitionOpencodeOnlyWireIds(
["opencode/c", "opencode/a", "opencode/b"],
new Set()
);
expect(result).toEqual(["opencode/c", "opencode/a", "opencode/b"]);
});
it("returns a frozen empty array (referential stability) for empty input", () => {
const a = partitionOpencodeOnlyWireIds([], new Set());
const b = partitionOpencodeOnlyWireIds([], new Set(["anthropic"]));
expect(a).toEqual([]);
expect(Object.isFrozen(a)).toBe(true);
// Same frozen constant returned for every empty case.
expect(a).toBe(b);
});
it("returns the frozen empty array when every reported id is suppressed", () => {
const empty = partitionOpencodeOnlyWireIds([], new Set());
const result = partitionOpencodeOnlyWireIds(
["anthropic/claude-sonnet-4-5"],
new Set(["anthropic"])
);
expect(result).toEqual([]);
expect(result).toBe(empty);
});
});

View file

@ -0,0 +1,40 @@
/**
* opencode bundles a full models.dev snapshot for every provider it holds a key
* for, so its reported catalog floods with models Copilot already curates on
* the BYOK / Plus tabs. These pure helpers keep only the "opencode-only" wire
* ids those hosted by a provider Copilot does NOT manage (opencode Zen
* `opencode/*`, or a provider the user authed directly). The managed-provider
* set is built by `buildManagedOpencodeProviderIds` and passed in.
*/
/** See AGENTS.md → "Referential stability". */
const EMPTY_OPENCODE_ONLY: readonly string[] = Object.freeze([] as string[]);
function opencodeProviderIdOf(wireId: string): string {
const slash = wireId.indexOf("/");
return slash === -1 ? wireId : wireId.slice(0, slash);
}
/**
* Keep only wire ids whose provider isn't in `managedOpencodeIds`. Ids hosted
* by a Copilot-managed provider are dropped (curated on the BYOK tab, must not
* be re-enrolled as agent-origin). Order follows the input; duplicates are
* dropped so a flooded catalog never enrolls a model twice.
*/
export function partitionOpencodeOnlyWireIds(
reportedWireIds: readonly string[],
managedOpencodeIds: ReadonlySet<string>
): string[] {
if (reportedWireIds.length === 0) return EMPTY_OPENCODE_ONLY as string[];
const seen = new Set<string>();
const opencodeOnly: string[] = [];
for (const wireId of reportedWireIds) {
if (seen.has(wireId)) continue;
const providerId = opencodeProviderIdOf(wireId);
if (managedOpencodeIds.has(providerId)) continue;
seen.add(wireId);
opencodeOnly.push(wireId);
}
if (opencodeOnly.length === 0) return EMPTY_OPENCODE_ONLY as string[];
return opencodeOnly;
}

View file

@ -0,0 +1,82 @@
import { agentOriginEnabledWireIds } from "./agentEnabledModels";
import type { CopilotSettings } from "@/settings/model";
import type { ConfiguredModel } from "@/modelManagement";
/** Bare descriptor-style decode (claude): the wire id IS the baseModelId. */
const bareDecode = (wireId: string): { selection: { baseModelId: string } } => ({
selection: { baseModelId: wireId },
});
/** Suffix-style decode (codex): `<base>/<effort>` strips a known effort. */
const KNOWN_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh"]);
const suffixDecode = (wireId: string): { selection: { baseModelId: string } } => {
const segments = wireId.split("/");
if (segments.length === 2 && KNOWN_EFFORTS.has(segments[1])) {
return { selection: { baseModelId: segments[0] } };
}
return { selection: { baseModelId: wireId } };
};
function model(configuredModelId: string, infoId: string): ConfiguredModel {
return {
configuredModelId,
providerId: "p1",
info: { id: infoId, displayName: infoId },
configuredAt: 0,
};
}
function settingsWith(
agentType: "claude" | "codex",
enabledModels: string[],
configuredModels: ConfiguredModel[]
): CopilotSettings {
return {
backends: { [agentType]: { enabledModels, defaultModel: null } },
configuredModels,
} as unknown as CopilotSettings;
}
describe("agentOriginEnabledWireIds", () => {
it("returns the shared frozen empty set when nothing is enabled", () => {
const a = agentOriginEnabledWireIds(settingsWith("claude", [], []), "claude", bareDecode);
const b = agentOriginEnabledWireIds(settingsWith("codex", [], []), "codex", suffixDecode);
expect(a.size).toBe(0);
// Frozen empty constant — same reference across calls (referential stability).
expect(a).toBe(b);
});
it("claude: maps enabled configured-model ids to their bare info.id baseModelId", () => {
const settings = settingsWith(
"claude",
["cm1", "cm2"],
[model("cm1", "claude-sonnet-4-5"), model("cm2", "claude-opus-4-1")]
);
const set = agentOriginEnabledWireIds(settings, "claude", bareDecode);
expect([...set].sort()).toEqual(["claude-opus-4-1", "claude-sonnet-4-5"]);
});
it("codex: strips the effort suffix to the base model id", () => {
const settings = settingsWith("codex", ["cm1"], [model("cm1", "gpt-5/high")]);
const set = agentOriginEnabledWireIds(settings, "codex", suffixDecode);
expect([...set]).toEqual(["gpt-5"]);
});
it("skips enabled ids with no matching configured-model row", () => {
const settings = settingsWith("claude", ["cm1", "ghost"], [model("cm1", "claude-sonnet-4-5")]);
const set = agentOriginEnabledWireIds(settings, "claude", bareDecode);
expect([...set]).toEqual(["claude-sonnet-4-5"]);
});
it("only reads the requested agentType's enabledModels", () => {
const settings = {
backends: {
claude: { enabledModels: ["cm1"], defaultModel: null },
codex: { enabledModels: ["cm2"], defaultModel: null },
},
configuredModels: [model("cm1", "claude-sonnet-4-5"), model("cm2", "gpt-5")],
} as unknown as CopilotSettings;
const claude = agentOriginEnabledWireIds(settings, "claude", bareDecode);
expect([...claude]).toEqual(["claude-sonnet-4-5"]);
});
});

View file

@ -0,0 +1,43 @@
import type { CopilotSettings } from "@/settings/model";
import type { ConfiguredModel } from "@/modelManagement";
/** See AGENTS.md → "Referential stability". */
const EMPTY_WIRE_IDS: ReadonlySet<string> = Object.freeze(new Set<string>());
/** A descriptor's own `wire.decode`, accepted as a parameter so this backend-layer helper needn't import the session-domain codec type. */
export type WireDecode = (wireId: string) => { selection: { baseModelId: string } };
/**
* The wire baseModelIds for a claude / codex backend's enabled models
* each enabled `ConfiguredModel.info.id` decoded via the descriptor's
* `wire.decode`. Used by the descriptor's `getEnabledBaseModelIds` so the
* enabled and picker-shown sets agree.
*
* Only for all-agent-origin backends; opencode mixes in BYOK/Plus models and
* uses `opencodeEnabledWireIds` instead.
*/
export function agentOriginEnabledWireIds(
settings: CopilotSettings,
agentType: "claude" | "codex",
wireDecode: WireDecode
): ReadonlySet<string> {
const enabledIds = settings.backends[agentType]?.enabledModels ?? [];
if (enabledIds.length === 0) return EMPTY_WIRE_IDS;
const modelsById = new Map<string, ConfiguredModel>();
for (const model of settings.configuredModels) {
modelsById.set(model.configuredModelId, model);
}
const wireIds = new Set<string>();
for (const configuredModelId of enabledIds) {
const configuredModel = modelsById.get(configuredModelId);
if (!configuredModel) continue;
// `info.id` is the agent-reported wire id; decode it to the baseModelId
// the picker compares against `ModelEntry.baseModelId`.
wireIds.add(wireDecode(configuredModel.info.id).selection.baseModelId);
}
if (wireIds.size === 0) return EMPTY_WIRE_IDS;
return wireIds;
}

View file

@ -23,8 +23,12 @@ export { useAgentModePicker } from "./ui/useAgentModePicker";
export type { AgentModePickerOverride } from "./ui/useAgentModePicker";
export type { AgentSessionManager } from "./session/AgentSessionManager";
export type { AgentBrand, BackendDescriptor, BackendId, InstallState } from "./session/types";
export { isAgentModelEnabled, writeAgentModelOverride } from "./session/modelEnable";
export { getBackendModelOverrides } from "./session/backendSettingsAccess";
// First-enrollment default-enable rule (enable the agent's current model).
export { computeDefaultEnabledIds } from "./session/agentDefaultEnable";
export type { EnrolledModelRef } from "./session/agentDefaultEnable";
export { partitionOpencodeOnlyWireIds } from "./backends/opencode/opencodeProbePartition";
export { mapProviderToOpencodeId } from "./backends/opencode/opencodeModelResolve";
export type { OpencodeProviderMapping } from "./backends/opencode/opencodeModelResolve";
export type {
BackendState,
CopilotMode,
@ -36,7 +40,8 @@ export type {
export type { StoredMcpServer, McpTransport } from "./session/mcpResolver";
export { sanitizeStoredMcpServers } from "./session/mcpResolver";
export { McpServersPanel } from "./ui/McpServersPanel";
export { SelectedModelsList } from "./ui/SelectedModelsList";
export { ModelEnableList } from "./ui/ModelEnableList";
export type { ModelEnableGroup, ModelEnableRow } from "./ui/ModelEnableList";
export { PlanPreviewView, PLAN_PREVIEW_VIEW_TYPE } from "./ui/PlanPreviewView";
export type { PlanPreviewViewState } from "./ui/PlanPreviewView";
export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/registry";

View file

@ -112,12 +112,12 @@ describe("AgentChatPersistenceManager", () => {
it("round-trips messages, backendId, and label", async () => {
const messages = [makeMessage(USER_SENDER, "hello world"), makeMessage(AI_SENDER, "hi back")];
const saved = await manager.saveSession(messages, "claude-code", { label: "My chat" });
const saved = await manager.saveSession(messages, "claude", { label: "My chat" });
expect(saved).not.toBeNull();
const file = app.files.get(saved!.path)!;
const loaded = await manager.loadFile(file as unknown as TFile);
expect(loaded.backendId).toBe("claude-code");
expect(loaded.backendId).toBe("claude");
expect(loaded.label).toBe("My chat");
expect(loaded.messages).toHaveLength(2);
expect(loaded.messages[0].sender).toBe(USER_SENDER);
@ -164,7 +164,7 @@ describe("AgentChatPersistenceManager", () => {
makeMessage(USER_SENDER, "first", 1700000000000),
makeMessage(AI_SENDER, "second", 1700000000001),
];
const saved = await manager.saveSession(messages, "claude-code");
const saved = await manager.saveSession(messages, "claude");
const file = app.files.get(saved!.path)!;
const loadedA = await manager.loadFile(file as unknown as TFile);

View file

@ -855,7 +855,7 @@ describe("AgentSession.setConfigOption", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
await session.setConfigOption("effort", "high");
expect(mock.setSessionConfigOption).toHaveBeenCalledWith({
@ -871,7 +871,7 @@ describe("AgentSession.setConfigOption", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const onModelChanged = jest.fn();
session.subscribe({
@ -892,7 +892,7 @@ describe("AgentSession.setConfigOption", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const onModelChanged = jest.fn();
session.subscribe({
@ -915,7 +915,7 @@ describe("AgentSession.setMode", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
await session.setMode("plan");
expect(mock.setSessionMode).toHaveBeenCalledWith({ sessionId: "acp-1", modeId: "plan" });
@ -928,7 +928,7 @@ describe("AgentSession.setMode", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
await expect(session.setMode("plan")).rejects.toBeInstanceOf(MethodUnsupportedError);
});
@ -939,7 +939,7 @@ describe("AgentSession.setMode", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const onModelChanged = jest.fn();
session.subscribe({
@ -959,7 +959,7 @@ describe("AgentSession state_changed event", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const onModelChanged = jest.fn();
session.subscribe({
@ -1443,7 +1443,7 @@ describe("AgentSession plan proposal lifecycle", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const { turn } = session.sendPrompt("plan something");
@ -1498,7 +1498,7 @@ describe("AgentSession plan proposal lifecycle", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const { turn } = session.sendPrompt("plan something");
@ -1556,7 +1556,7 @@ describe("AgentSession plan proposal lifecycle", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
initialState: {
model: null,
mode: {
@ -1600,7 +1600,7 @@ describe("AgentSession plan proposal lifecycle", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const decisionPromise = session.handlePlanProposalPermission({
@ -1643,7 +1643,7 @@ describe("AgentSession plan proposal lifecycle", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const statusChanges: string[] = [];
session.subscribe({
@ -1695,7 +1695,7 @@ describe("AgentSession plan proposal lifecycle", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const { turn } = session.sendPrompt("edit a file");
@ -1735,7 +1735,7 @@ describe("AgentSession plan proposal lifecycle", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const { turn } = session.sendPrompt("plan something");
@ -1776,7 +1776,7 @@ describe("AgentSession plan proposal lifecycle", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const { turn } = session.sendPrompt("plan something");
@ -1839,7 +1839,7 @@ describe("AgentSession status derivation", () => {
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
backendId: "claude",
});
const statusChanges: string[] = [];
session.subscribe({

View file

@ -439,7 +439,7 @@ export class AgentSession {
}
/**
* Switch the active session mode (claude-code permission mode, codex
* Switch the active session mode (claude permission mode, codex
* sandbox preset, etc.). On success, replaces the cached state and
* notifies `onModelChanged` listeners.
*

View file

@ -758,7 +758,7 @@ describe("AgentSessionManager.applySelection", () => {
// Active session is on `opencode`; asking for a different backend
// must refuse so a stray cross-backend apply can't slip through.
await expect(
mgr.applySelection({ effort: "high" }, { expectBackendId: "claude-code" })
mgr.applySelection({ effort: "high" }, { expectBackendId: "claude" })
).resolves.toBeUndefined();
});

View file

@ -261,7 +261,7 @@ export class AgentSessionManager {
this.notify();
// Once the ACP session is ready, apply backend-specific persisted state
// (claude-code's effort, future config-option preferences) and clear the
// (claude's effort, future config-option preferences) and clear the
// "starting" pill. On failure, capture into `lastError` so the status
// surface and retry handler can react. The session itself transitions to
// status "error" inside its own `initialize`. For warm-adopted sessions

View file

@ -0,0 +1,27 @@
import { computeDefaultEnabledIds, type EnrolledModelRef } from "./agentDefaultEnable";
function refs(...pairs: Array<[string, string]>): EnrolledModelRef[] {
return pairs.map(([configuredModelId, wireModelId]) => ({ configuredModelId, wireModelId }));
}
describe("computeDefaultEnabledIds", () => {
it("enables the model matching the agent's current wire id", () => {
const enrolled = refs(["cm-1", "gpt-5"], ["cm-2", "gpt-5.5"]);
expect(computeDefaultEnabledIds(enrolled, "gpt-5.5")).toEqual(["cm-2"]);
});
it("falls back to the first enrolled model when the current id isn't enrolled", () => {
const enrolled = refs(["cm-1", "gpt-5"], ["cm-2", "gpt-5.5"]);
// e.g. the current model was suppressed as a Copilot-managed opencode model.
expect(computeDefaultEnabledIds(enrolled, "anthropic/claude-sonnet-4-5")).toEqual(["cm-1"]);
});
it("falls back to the first enrolled model when there is no current id", () => {
const enrolled = refs(["cm-1", "gpt-5"], ["cm-2", "gpt-5.5"]);
expect(computeDefaultEnabledIds(enrolled, undefined)).toEqual(["cm-1"]);
});
it("returns an empty list when nothing is enrolled", () => {
expect(computeDefaultEnabledIds([], "gpt-5")).toEqual([]);
});
});

View file

@ -0,0 +1,27 @@
/**
* On a backend's first enrollment, `AgentSetupApi` auto-enrolls every reported
* model. We don't want a fresh enrollment to flood the picker with a backend's
* whole catalog, so the discovery wiring narrows the enabled set to a single
* model: the one the agent reports as currently active.
*/
/** A freshly-enrolled model: its assigned `configuredModelId` and the wire id it came from. */
export interface EnrolledModelRef {
configuredModelId: string;
wireModelId: string;
}
/**
* The single enrolled id to enable on first enrollment: the model the agent
* reports as current, falling back to the first enrolled model when the current
* one isn't enrolled (e.g. it was suppressed as a Copilot-managed opencode
* model). Empty when nothing enrolled.
*/
export function computeDefaultEnabledIds(
enrolled: readonly EnrolledModelRef[],
currentWireId: string | undefined
): string[] {
if (enrolled.length === 0) return [];
const current = enrolled.find((e) => e.wireModelId === currentWireId);
return [(current ?? enrolled[0]).configuredModelId];
}

View file

@ -1,20 +0,0 @@
import type { CopilotSettings } from "@/settings/model";
import type { BackendId } from "./types";
interface BackendSliceWithOverrides {
modelEnabledOverrides?: Record<string, boolean>;
}
/**
* `undefined` means "no overrides written" callers should fall back to the
* descriptor's default policy.
*/
export function getBackendModelOverrides(
settings: CopilotSettings,
backendId: BackendId
): Record<string, boolean> | undefined {
const backends = settings.agentMode?.backends as
| Record<string, BackendSliceWithOverrides | undefined>
| undefined;
return backends?.[backendId]?.modelEnabledOverrides;
}

View file

@ -6,7 +6,6 @@ import type { AgentSession } from "@/agentMode/session/AgentSession";
import type {
BackendConfigOption,
BackendId,
BackendModelInfo,
BackendProcess,
ModelSelection,
ModelWireCodec,
@ -28,6 +27,11 @@ export type InstallState =
* edits to session or UI.
*/
export interface BackendDescriptor {
/**
* Stable backend identifier. Doubles as the model-management `AgentType`
* for agent backends every agent-discovered model enrolls under this id,
* and `agentModelDiscovery` narrows it to `AgentType` at that seam.
*/
readonly id: BackendId;
readonly displayName: string;
@ -155,18 +159,6 @@ export interface BackendDescriptor {
*/
isPlanModePlanFilePath?(absolutePath: string, cwd: string | null | undefined): boolean;
/**
* Optional: default enable/disable policy for an agent-reported model when
* the user has no explicit `modelEnabledOverrides` entry. Returning `true`
* surfaces the model in the chat picker and the settings tab; `false`
* hides it. Omit to default-enable every agent-reported model.
*
* Used as a no-config curation knob Codex and Opencode advertise large
* catalogs and we ship with one-model defaults; Claude Code defaults to
* showing all reported models.
*/
isModelEnabledByDefault?(model: BackendModelInfo): boolean;
/**
* Optional: previously-stored sessionId of the backend's dedicated
* "probe session", used by `AgentModelPreloader` to enumerate live models
@ -175,6 +167,15 @@ export interface BackendDescriptor {
*/
getProbeSessionId?(settings: CopilotSettings): string | undefined;
/**
* Optional: the backend's enabled set as wire baseModelIds, which the chat
* picker matches against the reported catalog. The signature is limited to
* `CopilotSettings` so `session/` stays free of `@/modelManagement` the
* backend implements the join. `null` opts out: the picker then keeps only
* the active session's selection.
*/
getEnabledBaseModelIds?(settings: CopilotSettings): ReadonlySet<string> | null;
/**
* Optional: persist the probe sessionId returned by a successful
* `session/new` probe so the next plugin load can reuse it via

View file

@ -1,77 +0,0 @@
import { isAgentModelEnabled, isAgentModelEnabledOrKept } from "./modelEnable";
import type { BackendDescriptor, BackendModelInfo } from "./types";
const baseDescriptor = {
id: "test",
displayName: "Test",
} as unknown as BackendDescriptor;
const model = (modelId: string, name = modelId): BackendModelInfo => ({ modelId, name });
describe("isAgentModelEnabled", () => {
it("returns true when no override and no descriptor policy", () => {
expect(isAgentModelEnabled(baseDescriptor, model("foo"), undefined)).toBe(true);
});
it("respects an explicit override of true", () => {
expect(isAgentModelEnabled(baseDescriptor, model("foo"), { foo: true })).toBe(true);
});
it("respects an explicit override of false", () => {
const descriptor = {
...baseDescriptor,
isModelEnabledByDefault: () => true,
} as BackendDescriptor;
expect(isAgentModelEnabled(descriptor, model("foo"), { foo: false })).toBe(false);
});
it("falls back to descriptor policy when no override exists", () => {
const descriptor = {
...baseDescriptor,
isModelEnabledByDefault: (m: BackendModelInfo) => m.modelId === "wanted",
} as BackendDescriptor;
expect(isAgentModelEnabled(descriptor, model("wanted"), undefined)).toBe(true);
expect(isAgentModelEnabled(descriptor, model("other"), undefined)).toBe(false);
});
it("override wins even when descriptor policy disagrees", () => {
const descriptor = {
...baseDescriptor,
isModelEnabledByDefault: () => false,
} as BackendDescriptor;
expect(isAgentModelEnabled(descriptor, model("foo"), { foo: true })).toBe(true);
});
it("treats missing override key as 'no override'", () => {
const descriptor = {
...baseDescriptor,
isModelEnabledByDefault: () => false,
} as BackendDescriptor;
expect(isAgentModelEnabled(descriptor, model("foo"), { bar: true })).toBe(false);
});
});
describe("isAgentModelEnabledOrKept", () => {
const restrictive = {
...baseDescriptor,
isModelEnabledByDefault: () => false,
} as BackendDescriptor;
it("force-enables the kept model even when policy disables it", () => {
expect(isAgentModelEnabledOrKept(restrictive, model("kept"), undefined, "kept")).toBe(true);
});
it("force-enables the kept model even when an override disables it", () => {
expect(isAgentModelEnabledOrKept(restrictive, model("kept"), { kept: false }, "kept")).toBe(
true
);
});
it("falls through to normal resolution for non-kept models", () => {
expect(isAgentModelEnabledOrKept(restrictive, model("other"), undefined, "kept")).toBe(false);
});
it("treats null keepModelId as 'no carve-out'", () => {
expect(isAgentModelEnabledOrKept(restrictive, model("foo"), undefined, null)).toBe(false);
});
});

View file

@ -1,49 +0,0 @@
import { getBackendModelOverrides } from "@/agentMode/session/backendSettingsAccess";
import type { BackendDescriptor, BackendId, BackendModelInfo } from "@/agentMode/session/types";
import { getSettings, updateAgentModeBackendFields, type CopilotSettings } from "@/settings/model";
/**
* User overrides win; absent overrides fall through to the descriptor's
* default policy; absent policy = default-enabled.
*/
export function isAgentModelEnabled(
descriptor: BackendDescriptor,
model: BackendModelInfo,
overrides: Record<string, boolean> | undefined
): boolean {
const override = overrides?.[model.modelId];
if (typeof override === "boolean") return override;
return descriptor.isModelEnabledByDefault?.(model) ?? true;
}
/**
* `keepModelId` carves out the user's current selection so curation never
* strands it.
*/
export function isAgentModelEnabledOrKept(
descriptor: BackendDescriptor,
model: BackendModelInfo,
overrides: Record<string, boolean> | undefined,
keepModelId: string | null
): boolean {
if (keepModelId && model.modelId === keepModelId) return true;
return isAgentModelEnabled(descriptor, model, overrides);
}
/**
* Atomic per-backend slice update for a single model toggle. Composes the
* next `modelEnabledOverrides` map by reading the previous one off current
* settings; `updateAgentModeBackendFields` merges it back in. Shared by
* the Agents-tab selected list and the catalog modal so both surfaces
* agree on the persistence path.
*/
export function writeAgentModelOverride(
backendId: BackendId,
baseModelId: string,
enabled: boolean
): void {
const prev = getBackendModelOverrides(getSettings(), backendId) ?? {};
const next: Record<string, boolean> = { ...prev, [baseModelId]: enabled };
type BackendKey = keyof CopilotSettings["agentMode"]["backends"];
updateAgentModeBackendFields(backendId as BackendKey, { modelEnabledOverrides: next });
}

View file

@ -207,7 +207,7 @@ export interface BackendState {
/**
* One model entry as reported by a backend. Mirrors ACP `ModelInfo` shape
* but is owned by `session/` so backends and descriptors share a single
* vocabulary. Used by `isModelEnabledByDefault`.
* vocabulary.
*/
export interface BackendModelInfo {
modelId: string;
@ -244,7 +244,7 @@ export interface RawModeState {
}
/**
* One configuration option a backend exposes (e.g. claude-code's "effort"
* One configuration option a backend exposes (e.g. claude's "effort"
* select). Structurally mirrors ACP `SessionConfigOption`. Used as input
* to `descriptor.getModeMapping` (configOption-mode style) and produced
* by `ModelWireCodec.effortConfigFor` for descriptor-style effort.

View file

@ -1,228 +0,0 @@
import { ReactModal } from "@/components/modals/ReactModal";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { SettingSwitch } from "@/components/ui/setting-switch";
import { ProviderInfo } from "@/constants";
import { getBackendModelOverrides } from "@/agentMode/session/backendSettingsAccess";
import { isAgentModelEnabled, writeAgentModelOverride } from "@/agentMode/session/modelEnable";
import type { BackendDescriptor, ModelEntry } from "@/agentMode/session/types";
import { useSettingsValue } from "@/settings/model";
import { ChevronDown, ChevronRight } from "lucide-react";
import { App } from "obsidian";
import React from "react";
interface AgentModelCatalogContentProps {
descriptor: BackendDescriptor;
availableModels: ReadonlyArray<ModelEntry>;
onClose: () => void;
}
interface ProviderGroup {
/** Stable key — Copilot provider enum value, or "__other__" for null. */
key: string;
/** Human-readable group label (e.g. "OpenRouter"). */
label: string;
entries: ModelEntry[];
}
const OTHER_GROUP_KEY = "__other__";
/**
* Groups at or below this size auto-expand on first open. Large catalogs
* (e.g. OpenRouter's models.dev snapshot) stay collapsed so they don't
* drown out smaller curated providers the user expands them on demand.
*/
const AUTO_EXPAND_GROUP_SIZE = 25;
/** Resolve a Copilot provider id to its human-readable label. */
function providerLabel(provider: string | null): string {
if (!provider) return "Other";
// ProviderInfo is keyed by ChatModelProviders enum *value* (e.g. "openrouterai").
const meta = (ProviderInfo as Record<string, { label: string } | undefined>)[provider];
return meta?.label ?? provider;
}
/** Group catalog entries by provider, preserving first-seen order. */
function groupByProvider(entries: ReadonlyArray<ModelEntry>): ProviderGroup[] {
const groups = new Map<string, ProviderGroup>();
for (const entry of entries) {
const key = entry.provider ?? OTHER_GROUP_KEY;
const existing = groups.get(key);
if (existing) {
existing.entries.push(entry);
} else {
groups.set(key, {
key,
label: providerLabel(entry.provider),
entries: [entry],
});
}
}
// Sort: real providers alphabetically, "Other" last.
return Array.from(groups.values()).sort((a, b) => {
if (a.key === OTHER_GROUP_KEY) return 1;
if (b.key === OTHER_GROUP_KEY) return -1;
return a.label.localeCompare(b.label);
});
}
/**
* Filter a group's entries by a search query (case-insensitive across
* `name` and `baseModelId`). Returns the original array reference when
* the query is empty so the caller can fast-path "no filtering."
*/
function filterEntries(entries: ReadonlyArray<ModelEntry>, query: string): ModelEntry[] {
if (!query) return entries as ModelEntry[];
const q = query.toLowerCase();
return entries.filter(
(e) => e.name.toLowerCase().includes(q) || e.baseModelId.toLowerCase().includes(q)
);
}
const AgentModelCatalogContent: React.FC<AgentModelCatalogContentProps> = ({
descriptor,
availableModels,
onClose,
}) => {
// Subscribe to settings so toggle flips re-render rows live.
const settings = useSettingsValue();
const overrides = getBackendModelOverrides(settings, descriptor.id);
const [query, setQuery] = React.useState("");
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
const groups = React.useMemo(() => groupByProvider(availableModels), [availableModels]);
const filteredGroups = React.useMemo(
() =>
groups
.map((g) => ({ ...g, entries: filterEntries(g.entries, query) }))
.filter((g) => g.entries.length > 0),
[groups, query]
);
const isExpanded = (group: ProviderGroup): boolean => {
// While searching, auto-expand any group with matches so results are
// visible without an extra click.
if (query) return true;
// Explicit user toggle wins.
const userToggle = expanded[group.key];
if (typeof userToggle === "boolean") return userToggle;
// Default: small catalogs (claude, codex, anthropic in opencode) open
// immediately; oversized ones (OpenRouter) stay collapsed until asked.
return group.entries.length <= AUTO_EXPAND_GROUP_SIZE;
};
const toggleExpanded = (group: ProviderGroup): void => {
const currentlyOpen = isExpanded(group);
setExpanded((prev) => ({ ...prev, [group.key]: !currentlyOpen }));
};
return (
<div className="tw-flex tw-flex-col tw-gap-3" style={{ minHeight: 480 }}>
<Input
type="text"
placeholder="Search models by name or id…"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
<div className="tw-flex-1 tw-overflow-y-auto tw-pr-1" style={{ maxHeight: "60vh" }}>
{filteredGroups.length === 0 ? (
<div className="tw-py-8 tw-text-center tw-text-sm tw-text-muted">
No models match &ldquo;{query}&rdquo;.
</div>
) : (
<div className="tw-space-y-2">
{filteredGroups.map((group) => (
<Collapsible
key={group.key}
open={isExpanded(group)}
onOpenChange={() => toggleExpanded(group)}
>
<CollapsibleTrigger
className="tw-flex tw-w-full tw-items-center tw-justify-between tw-rounded tw-px-2 tw-py-1.5 tw-text-left hover:tw-bg-modifier-hover"
type="button"
>
<div className="tw-flex tw-items-center tw-gap-2">
{isExpanded(group) ? (
<ChevronDown className="tw-size-4 tw-text-muted" />
) : (
<ChevronRight className="tw-size-4 tw-text-muted" />
)}
<span className="tw-font-medium">{group.label}</span>
</div>
<span className="tw-text-xs tw-text-muted">{group.entries.length}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="tw-mt-1 tw-space-y-1 tw-pl-6">
{group.entries.map((entry) => {
const enabled = isAgentModelEnabled(
descriptor,
{ modelId: entry.baseModelId, name: entry.name },
overrides
);
return (
<div
key={entry.baseModelId}
className="tw-flex tw-items-center tw-justify-between tw-rounded tw-px-2 tw-py-1 hover:tw-bg-modifier-hover"
>
<div className="tw-min-w-0">
<div className="tw-truncate">{entry.name || entry.baseModelId}</div>
{entry.description && (
<div className="tw-truncate tw-text-xs tw-text-muted">
{entry.description}
</div>
)}
</div>
<SettingSwitch
checked={enabled}
onCheckedChange={(next) =>
writeAgentModelOverride(descriptor.id, entry.baseModelId, next)
}
/>
</div>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
))}
</div>
)}
</div>
<div className="tw-flex tw-justify-end">
<Button variant="default" onClick={onClose}>
Done
</Button>
</div>
</div>
);
};
/**
* Obsidian-native modal listing every model in a backend's
* `availableModels` catalog, grouped by provider with a sticky search
* input. Toggles persist immediately via `writeAgentModelOverride`; no
* separate save step.
*/
export class AgentModelCatalogModal extends ReactModal {
constructor(
app: App,
private readonly descriptor: BackendDescriptor,
private readonly availableModels: ReadonlyArray<ModelEntry>
) {
super(app, `Available ${descriptor.displayName} models`);
}
protected renderContent(close: () => void): React.ReactElement {
return (
<AgentModelCatalogContent
descriptor={this.descriptor}
availableModels={this.availableModels}
onClose={close}
/>
);
}
}

View file

@ -0,0 +1,159 @@
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { SearchBar } from "@/components/ui/SearchBar";
import { SettingSwitch } from "@/components/ui/setting-switch";
import { cn } from "@/lib/utils";
import { ChevronDown, ChevronRight } from "lucide-react";
import React from "react";
/** A single toggleable model row. */
export interface ModelEnableRow {
/** Stable identity used for the toggle callback (a `configuredModelId`). */
id: string;
/** Primary label shown to the user. */
label: string;
/** Optional secondary line (e.g. wire id / description). */
description?: string;
/** Whether the model is currently enabled. */
enabled: boolean;
}
/** A provider-display-name-grouped section of model rows. */
export interface ModelEnableGroup {
/** Stable key used for collapse state + React keys. */
key: string;
/** Group heading — a provider display name (no glyphs/avatars). */
label: string;
/**
* When `true`, the group renders inside a `Collapsible` with a count and
* starts collapsed by default. When `false`/omitted the rows render
* directly (small, always-visible groups).
*/
collapsible?: boolean;
rows: ModelEnableRow[];
}
interface ModelEnableListProps {
/** Provider-grouped rows to render. Already filtered/derived by the caller. */
groups: ModelEnableGroup[];
/** Toggle handler — `enabled` is the next desired state. */
onToggle: (id: string, enabled: boolean) => void;
/** Search query (controlled). */
query: string;
onQueryChange: (next: string) => void;
/** Placeholder for the search box. */
searchPlaceholder?: string;
/** Rendered when there are no groups/rows to show (after filtering). */
emptyState?: React.ReactNode;
}
/**
* Presentational toggle list for agent model curation: provider-grouped rows
* (optionally collapsible), a search box, and a switch per row. Owns no
* registry/atom access the container passes grouped data and `onToggle`.
* Group headings show the provider display name only (no glyphs/avatars).
*/
export const ModelEnableList: React.FC<ModelEnableListProps> = ({
groups,
onToggle,
query,
onQueryChange,
searchPlaceholder = "Search models…",
emptyState,
}) => {
// Per-group user-controlled expand state. While searching every group with
// matches auto-expands so results are visible without an extra click.
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
const searching = query.trim().length > 0;
const isExpanded = (group: ModelEnableGroup): boolean => {
if (!group.collapsible) return true;
if (searching) return true;
const userToggle = expanded[group.key];
if (typeof userToggle === "boolean") return userToggle;
// Collapsible groups default to collapsed.
return false;
};
const toggleExpanded = (group: ModelEnableGroup): void => {
const currentlyOpen = isExpanded(group);
setExpanded((prev) => ({ ...prev, [group.key]: !currentlyOpen }));
};
const renderRows = (rows: ModelEnableRow[]): React.ReactNode => (
<div className="tw-space-y-1">
{rows.map((row) => (
<div
key={row.id}
className={cn(
"tw-flex tw-items-center tw-justify-between tw-gap-2 tw-rounded tw-px-2 tw-py-1",
"hover:tw-bg-modifier-hover"
)}
>
<div className="tw-min-w-0">
<div className="tw-truncate">{row.label}</div>
{row.description && (
<div className="tw-truncate tw-text-xs tw-text-muted">{row.description}</div>
)}
</div>
<SettingSwitch checked={row.enabled} onCheckedChange={(next) => onToggle(row.id, next)} />
</div>
))}
</div>
);
const hasRows = groups.some((g) => g.rows.length > 0);
return (
<div className="tw-flex tw-flex-col tw-gap-2">
<SearchBar value={query} onChange={onQueryChange} placeholder={searchPlaceholder} />
<div className="tw-max-h-80 tw-overflow-y-auto tw-pr-1">
{!hasRows ? (
<div className="tw-py-6 tw-text-center tw-text-sm tw-text-muted">
{emptyState ?? (searching ? `No models match “${query.trim()}”.` : "No models.")}
</div>
) : (
<div className="tw-space-y-2">
{groups
.filter((g) => g.rows.length > 0)
.map((group) =>
group.collapsible ? (
<Collapsible
key={group.key}
open={isExpanded(group)}
onOpenChange={() => toggleExpanded(group)}
>
<CollapsibleTrigger
className={cn(
"tw-flex tw-w-full tw-items-center tw-justify-between tw-rounded tw-px-2 tw-py-1.5 tw-text-left",
"hover:tw-bg-modifier-hover"
)}
type="button"
>
<div className="tw-flex tw-items-center tw-gap-2">
{isExpanded(group) ? (
<ChevronDown className="tw-size-4 tw-text-muted" />
) : (
<ChevronRight className="tw-size-4 tw-text-muted" />
)}
<span className="tw-font-medium">{group.label}</span>
</div>
<span className="tw-text-xs tw-text-muted">{group.rows.length}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="tw-mt-1 tw-pl-6">{renderRows(group.rows)}</div>
</CollapsibleContent>
</Collapsible>
) : (
<div key={group.key}>
<div className="tw-px-2 tw-py-1.5 tw-font-medium">{group.label}</div>
<div className="tw-pl-2">{renderRows(group.rows)}</div>
</div>
)
)}
</div>
)}
</div>
</div>
);
};

View file

@ -1,134 +0,0 @@
import { AgentModelCatalogModal } from "@/agentMode/ui/AgentModelCatalogModal";
import { Button } from "@/components/ui/button";
import { isAgentModelEnabled, writeAgentModelOverride } from "@/agentMode/session/modelEnable";
import type { BackendDescriptor, ModelEntry } from "@/agentMode/session/types";
import { usePlugin } from "@/contexts/PluginContext";
import { Plus, X } from "lucide-react";
import React from "react";
interface SelectedModelsListProps {
descriptor: BackendDescriptor;
availableModels: ReadonlyArray<ModelEntry>;
overrides: Record<string, boolean> | undefined;
}
interface SelectedRow {
baseModelId: string;
name: string;
description?: string;
/** True when the catalog no longer reports this model. */
unavailable: boolean;
}
/**
* Compute the rows to display: every catalog entry that resolves to
* enabled, plus any override-enabled id missing from the catalog
* (surfaced with an "unavailable" flag so the user can see and remove
* stale picks).
*/
function computeSelected(
descriptor: BackendDescriptor,
availableModels: ReadonlyArray<ModelEntry>,
overrides: Record<string, boolean> | undefined
): SelectedRow[] {
const seen = new Set<string>();
const rows: SelectedRow[] = [];
for (const entry of availableModels) {
const enabled = isAgentModelEnabled(
descriptor,
{ modelId: entry.baseModelId, name: entry.name },
overrides
);
if (!enabled) continue;
seen.add(entry.baseModelId);
rows.push({
baseModelId: entry.baseModelId,
name: entry.name || entry.baseModelId,
description: entry.description,
unavailable: false,
});
}
if (overrides) {
for (const [id, value] of Object.entries(overrides)) {
if (!value) continue;
if (seen.has(id)) continue;
rows.push({
baseModelId: id,
name: id,
unavailable: true,
});
}
}
return rows;
}
/**
* Curation surface for a backend's enabled model set. Replaces the long
* toggle list by default only the user's currently-enabled models are
* visible; the full catalog is one click away in `AgentModelCatalogModal`.
*/
export const SelectedModelsList: React.FC<SelectedModelsListProps> = ({
descriptor,
availableModels,
overrides,
}) => {
const plugin = usePlugin();
const rows = React.useMemo(
() => computeSelected(descriptor, availableModels, overrides),
[descriptor, availableModels, overrides]
);
const openCatalog = (): void => {
new AgentModelCatalogModal(plugin.app, descriptor, availableModels).open();
};
return (
<div>
<div className="tw-mb-2 tw-flex tw-items-center tw-justify-between">
<div className="tw-text-sm tw-font-medium">Selected models</div>
<Button variant="secondary" size="sm" onClick={openCatalog}>
<Plus className="tw-mr-1 tw-size-4" />
Add models
</Button>
</div>
{rows.length === 0 ? (
<div className="tw-rounded tw-border tw-border-dashed tw-border-border tw-px-3 tw-py-4 tw-text-center tw-text-sm tw-text-muted">
No models selected yet. Click <span className="tw-font-medium">Add models</span> to pick
from the {descriptor.displayName} catalog.
</div>
) : (
<div className="tw-space-y-1">
{rows.map((row) => (
<div
key={row.baseModelId}
className="tw-flex tw-items-center tw-justify-between tw-rounded tw-px-2 tw-py-1 hover:tw-bg-modifier-hover"
>
<div className="tw-min-w-0">
<div className="tw-flex tw-items-center tw-gap-2 tw-truncate">
<span className="tw-truncate">{row.name}</span>
{row.unavailable && (
<span className="tw-text-xs tw-text-muted">(unavailable)</span>
)}
</div>
{row.description && (
<div className="tw-truncate tw-text-xs tw-text-muted">{row.description}</div>
)}
</div>
<Button
variant="ghost"
size="icon"
aria-label={`Remove ${row.name}`}
onClick={() => writeAgentModelOverride(descriptor.id, row.baseModelId, false)}
>
<X className="tw-size-4" />
</Button>
</div>
))}
</div>
)}
</div>
);
};

View file

@ -227,6 +227,89 @@ describe("buildPickerEntries", () => {
expect(entries).toHaveLength(1);
expect(entries[0].name).toBe("gpt-5");
});
it("filters by getEnabledBaseModelIds when the descriptor implements it (new path)", () => {
const enabled = makeModelEntry("anthropic/claude-sonnet-4-6");
const disabled = makeModelEntry("anthropic/claude-haiku");
// Opencode-style descriptor exposing the new enabled-set hook. Only the
// first model is enabled; the second must be dropped from the picker.
const opencode = {
...makeDescriptor("opencode"),
getEnabledBaseModelIds: () => new Set(["anthropic/claude-sonnet-4-6"]),
} as unknown as BackendDescriptor;
const manager = makeManager({
cachedStateById: {
opencode: {
model: makeModelState("anthropic/claude-sonnet-4-6", [enabled, disabled]),
mode: null,
},
},
});
const ctx: ModelActiveContext = {
activeSession: null,
activeChatUIState: null,
activeBackendId: null,
activeDescriptor: undefined,
activeSessionHasHistory: false,
activeModelState: null,
activeCurrentEntry: undefined,
};
const { entries } = buildPickerEntries(manager, [opencode], ctx, emptySettings);
expect(entries.map((e) => e.name)).toEqual(["anthropic/claude-sonnet-4-6"]);
});
it("drops every model when the descriptor has no getEnabledBaseModelIds (no legacy branch) except the kept one", () => {
// With the legacy override branch removed, a descriptor that doesn't
// implement getEnabledBaseModelIds yields an empty enabled set, so only
// keepBaseModelId survives. `dropped` is neither enabled nor kept.
const kept = makeModelEntry("kept-model");
const dropped = makeModelEntry("dropped-model");
const opencode = makeDescriptor("opencode");
const manager = makeManager({
cachedStateById: {
opencode: { model: makeModelState("kept-model", [kept, dropped]), mode: null },
},
});
const ctx: ModelActiveContext = {
activeSession: { backendId: "opencode" } as unknown as AgentSession,
activeChatUIState: null,
activeBackendId: "opencode",
activeDescriptor: opencode,
activeSessionHasHistory: false,
activeModelState: makeModelState("kept-model", [kept, dropped]),
activeCurrentEntry: kept,
};
const { entries } = buildPickerEntries(manager, [opencode], ctx, emptySettings);
expect(entries.map((e) => e.name)).toEqual(["kept-model"]);
});
it("keeps the sticky active model even when getEnabledBaseModelIds excludes it", () => {
// The active (sticky) model is no longer in the enabled set, but
// keepBaseModelId must preserve it so curation never strands the
// running selection.
const sticky = makeModelEntry("anthropic/claude-haiku");
const opencode = {
...makeDescriptor("opencode"),
// Empty enabled set — nothing curated in.
getEnabledBaseModelIds: () => new Set<string>(),
} as unknown as BackendDescriptor;
const manager = makeManager({
cachedStateById: {
opencode: { model: makeModelState("anthropic/claude-haiku", [sticky]), mode: null },
},
});
const ctx: ModelActiveContext = {
activeSession: { backendId: "opencode" } as unknown as AgentSession,
activeChatUIState: null,
activeBackendId: "opencode",
activeDescriptor: opencode,
activeSessionHasHistory: false,
activeModelState: makeModelState("anthropic/claude-haiku", [sticky]),
activeCurrentEntry: sticky,
};
const { entries } = buildPickerEntries(manager, [opencode], ctx, emptySettings);
expect(entries.map((e) => e.name)).toEqual(["anthropic/claude-haiku"]);
});
});
// ---- buildEffortSibling ----

View file

@ -1,13 +1,11 @@
import { Notice } from "obsidian";
import { logError } from "@/logger";
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 { MethodUnsupportedError } from "@/agentMode/session/errors";
import { backendRegistry } from "@/agentMode/backends/registry";
import { getBackendModelOverrides } from "@/agentMode/session/backendSettingsAccess";
import { getModelKeyFromModel } from "@/settings/model";
import type { CopilotSettings } from "@/settings/model";
import type {
@ -55,10 +53,10 @@ export function handlePickerSwitchError(err: unknown, action: "model" | "effort"
export const AGENT_PROVIDER = "agent";
/**
* Append one backend's section to the picker. Entries are synthesized from
* the backend's catalog (`backendModels`) gated by the user's per-model
* overrides; the active session's selection is preserved via
* `keepBaseModelId` so curation never strands it.
* Append one backend's section to the picker: its reported catalog filtered to
* the backend's enabled set (`getEnabledBaseModelIds`). The active session's
* selection is always kept via `keepBaseModelId` so curation never strands it.
* A descriptor returning `null` is treated as an empty enabled set.
*/
export function appendBackendSection(
entries: ModelSelectorEntry[],
@ -66,19 +64,17 @@ export function appendBackendSection(
ctx: {
/** Translator-produced entries from `state.model.availableModels`. */
backendModels: ReadonlyArray<ModelEntry> | null;
overrides: Record<string, boolean> | undefined;
/** baseModelId of the active session — never filtered out. */
keepBaseModelId: string | null;
/** Current settings — read by `getEnabledBaseModelIds`. */
settings: CopilotSettings;
}
): void {
if (!ctx.backendModels) return;
const filtered = ctx.backendModels.filter((entry) =>
isAgentModelEnabledOrKept(
descriptor,
{ modelId: entry.baseModelId, name: entry.name },
ctx.overrides,
ctx.keepBaseModelId
)
const enabledSet = descriptor.getEnabledBaseModelIds?.(ctx.settings) ?? null;
const filtered = ctx.backendModels.filter(
(entry) =>
enabledSet?.has(entry.baseModelId) === true || entry.baseModelId === ctx.keepBaseModelId
);
for (const m of filtered) {
entries.push(synthesizeAgentEntry(m.baseModelId, m.name, descriptor));
@ -191,8 +187,8 @@ export function buildPickerEntries(
const backendModels = cached?.model?.availableModels ?? null;
appendBackendSection(entries, descriptor, {
backendModels,
overrides: getBackendModelOverrides(settings, descriptor.id),
keepBaseModelId,
settings,
});
// No catalog cached yet (and not because the agent intentionally
// omitted a model state). Show a per-backend loading / failure row so

View file

@ -13,6 +13,7 @@ import {
SkillManager,
type AgentSessionManager,
} from "@/agentMode";
import { wireAgentModelDiscovery } from "@/agentMode/agentModelDiscovery";
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import ProjectManager from "@/LLMProviders/projectManager";
import {
@ -119,6 +120,7 @@ export default class CopilotPlugin extends Plugin {
settingsUnsubscriber?: () => void;
chatUIState: ChatManagerChatUIState;
agentSessionManager?: AgentSessionManager;
private agentModelDiscoveryUnsubscriber?: () => void;
modelManagement!: ModelManagementApi;
private ribbonIconEl?: HTMLElement;
userMemoryManager: UserMemoryManager;
@ -145,7 +147,6 @@ export default class CopilotPlugin extends Plugin {
await this.loadSettings();
this.modelManagement = createModelManagement({
app: this.app,
pluginDir: this.manifest.dir ?? "",
});
this.settingsUnsubscriber = subscribeToSettingsChange((prev, next) => {
void (async () => {
@ -186,6 +187,12 @@ export default class CopilotPlugin extends Plugin {
// Initialize Agent Mode coordinator (desktop only — ACP needs subprocess support).
if (!Platform.isMobile) {
this.agentSessionManager = createAgentSessionManager(this.app, this);
// Enroll agent-reported models on probe settle, even when the settings
// tab is closed. See `agentModelDiscovery.ts`.
this.agentModelDiscoveryUnsubscriber = wireAgentModelDiscovery(
this,
this.agentSessionManager
);
}
// Always construct VectorStoreManager; it internally no-ops when semantic search is disabled
@ -334,6 +341,7 @@ export default class CopilotPlugin extends Plugin {
this.projectManager.onunload();
}
this.agentModelDiscoveryUnsubscriber?.();
await this.agentSessionManager?.shutdown();
// Cleanup VaultDataManager event listeners

View file

@ -14,7 +14,7 @@ embedding a `ModelInfo` snapshot copied from the catalog (or
hand-typed for self-hosted endpoints). The plugin keeps working
when the catalog is unreachable because every runtime-relevant field
lives in `ConfiguredModel.info`. The four backends that curate model
selection — `chat`, `opencode`, `claude-code`, `codex` — each persist a
selection — `chat`, `opencode`, `claude`, `codex` — each persist a
`BackendConfig` listing `configuredModelId`s they expose in their
picker. The BYOK settings tab filters `providers` by
`origin.kind === "byok"`; the chat-model factory dispatches purely on

View file

@ -22,6 +22,9 @@ const mockedRequestUrl = requestUrl as unknown as jest.Mock<
[RequestUrlParam]
>;
const CACHE_DIR = ".copilot";
const CACHE_PATH = `${CACHE_DIR}/model-catalog-cache.json`;
const FIXTURE: WireCatalog = {
anthropic: {
id: "anthropic",
@ -58,6 +61,7 @@ type AdapterMock = {
read: jest.Mock<Promise<string>, [string]>;
write: jest.Mock<Promise<void>, [string, string]>;
exists: jest.Mock<Promise<boolean>, [string]>;
mkdir: jest.Mock<Promise<void>, [string]>;
};
function buildFakeApp(adapter: AdapterMock): App {
@ -66,8 +70,11 @@ function buildFakeApp(adapter: AdapterMock): App {
function buildAdapter(initial?: { fetchedAt: number; data: WireCatalog }): AdapterMock {
let stored = initial ? JSON.stringify(initial) : null;
const exists: AdapterMock["exists"] = jest.fn((_path: string) =>
Promise.resolve(stored !== null)
let dirExists = false;
// Path-aware: the `.copilot` dir existence is tracked separately from
// the cache file, which keys off whether anything has been written.
const exists: AdapterMock["exists"] = jest.fn((path: string) =>
Promise.resolve(path === CACHE_DIR ? dirExists : stored !== null)
);
const read: AdapterMock["read"] = jest.fn((_path: string) => {
if (stored === null) throw new Error("not found");
@ -77,7 +84,11 @@ function buildAdapter(initial?: { fetchedAt: number; data: WireCatalog }): Adapt
stored = contents;
return Promise.resolve();
});
return { exists, read, write };
const mkdir: AdapterMock["mkdir"] = jest.fn((_path: string) => {
dirExists = true;
return Promise.resolve();
});
return { exists, read, write, mkdir };
}
function okResponse(json: unknown): RequestUrlResponse {
@ -96,10 +107,6 @@ function freezeNow(timestamp: number): jest.SpyInstance<number, []> {
}
describe("CatalogDownloadService", () => {
// Stand-in for `this.manifest.dir`; the real value is derived at
// runtime from `Vault#configDir`.
const PLUGIN_DIR = "plugin-root/copilot";
const CACHE_PATH = `${PLUGIN_DIR}/.modelsCatalogCache.json`;
const FIXED_NOW = 1_700_000_000_000;
let nowSpy: jest.SpyInstance<number, []> | null = null;
@ -117,7 +124,7 @@ describe("CatalogDownloadService", () => {
it("loads from disk without fetching when cache is fresh (<24h)", async () => {
nowSpy = freezeNow(FIXED_NOW);
const adapter = buildAdapter({ fetchedAt: FIXED_NOW - 60 * 60 * 1000, data: FIXTURE });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
await svc.ensureLoaded();
@ -132,7 +139,7 @@ describe("CatalogDownloadService", () => {
const STALE_BY = 25 * 60 * 60 * 1000;
const adapter = buildAdapter({ fetchedAt: FIXED_NOW - STALE_BY, data: { foo: { id: "foo" } } });
mockedRequestUrl.mockResolvedValue(okResponse(FIXTURE));
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
await svc.ensureLoaded();
@ -155,7 +162,7 @@ describe("CatalogDownloadService", () => {
const STALE_BY = 48 * 60 * 60 * 1000;
const adapter = buildAdapter({ fetchedAt: FIXED_NOW - STALE_BY, data: FIXTURE });
mockedRequestUrl.mockResolvedValue({ ...okResponse(null), status: 500, json: undefined });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
await svc.ensureLoaded();
@ -168,7 +175,7 @@ describe("CatalogDownloadService", () => {
nowSpy = freezeNow(FIXED_NOW);
const adapter = buildAdapter();
mockedRequestUrl.mockResolvedValue(okResponse(FIXTURE));
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
await svc.ensureLoaded();
@ -181,7 +188,7 @@ describe("CatalogDownloadService", () => {
nowSpy = freezeNow(FIXED_NOW);
const adapter = buildAdapter();
mockedRequestUrl.mockResolvedValue({ ...okResponse(null), status: 500, json: undefined });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
await svc.ensureLoaded();
@ -193,7 +200,7 @@ describe("CatalogDownloadService", () => {
nowSpy = freezeNow(FIXED_NOW);
const adapter = buildAdapter();
mockedRequestUrl.mockResolvedValue({ ...okResponse(null), status: 500, json: undefined });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
await svc.ensureLoaded();
await svc.ensureLoaded();
@ -223,7 +230,7 @@ describe("CatalogDownloadService", () => {
mockedRequestUrl.mockResolvedValueOnce({ ...okResponse(null), status: 500, json: undefined });
// Second attempt: success.
mockedRequestUrl.mockResolvedValueOnce(okResponse(FIXTURE));
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
await svc.ensureLoaded();
expect(svc.getAllProviders()).toEqual([]);
@ -236,7 +243,7 @@ describe("CatalogDownloadService", () => {
it("deduplicates concurrent ensureLoaded() calls", async () => {
nowSpy = freezeNow(FIXED_NOW);
const adapter = buildAdapter({ fetchedAt: FIXED_NOW - 60 * 1000, data: FIXTURE });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
await Promise.all([svc.ensureLoaded(), svc.ensureLoaded(), svc.ensureLoaded()]);
@ -254,7 +261,7 @@ describe("CatalogDownloadService", () => {
resolveFetch = resolve;
})
);
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
const pending = Promise.all([svc.refresh(), svc.refresh(), svc.refresh()]);
// All three share the same in-flight fetch.
@ -271,7 +278,7 @@ describe("CatalogDownloadService", () => {
nowSpy = freezeNow(FIXED_NOW);
const adapter = buildAdapter();
mockedRequestUrl.mockResolvedValue({ ...okResponse(null), status: 500 });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
const result = await svc.refresh();
@ -284,7 +291,7 @@ describe("CatalogDownloadService", () => {
nowSpy = freezeNow(FIXED_NOW);
const adapter = buildAdapter();
mockedRequestUrl.mockResolvedValue(okResponse([1, 2, 3]));
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
const result = await svc.refresh();
@ -302,7 +309,7 @@ describe("CatalogDownloadService", () => {
arrayBuffer: new ArrayBuffer(0),
headers: {},
});
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
const result = await svc.refresh();
@ -314,7 +321,7 @@ describe("CatalogDownloadService", () => {
nowSpy = freezeNow(FIXED_NOW);
const adapter = buildAdapter();
mockedRequestUrl.mockReturnValue(new Promise(() => {}));
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
const pending = svc.refresh();
jest.advanceTimersByTime(5000);
@ -329,7 +336,7 @@ describe("CatalogDownloadService", () => {
nowSpy = freezeNow(FIXED_NOW);
const adapter = buildAdapter();
mockedRequestUrl.mockResolvedValue(okResponse(FIXTURE));
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
const subscribed = jest.fn();
const unsubscribedAt = jest.fn();
@ -355,7 +362,7 @@ describe("CatalogDownloadService", () => {
fetchedAt: FIXED_NOW - 60 * 1000,
data: {},
});
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
// Five fresh-disk loads — none should touch the network.
await svc.ensureLoaded();
@ -407,7 +414,7 @@ describe("CatalogDownloadService", () => {
})
);
mockedRequestUrl.mockResolvedValue(okResponse(FIXTURE));
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
// Kick off ensureLoaded — it blocks on the disk read.
const ensurePending = svc.ensureLoaded();
@ -426,7 +433,7 @@ describe("CatalogDownloadService", () => {
it("getAllProviders() returns a copy so caller-side mutation can't corrupt internal state", async () => {
nowSpy = freezeNow(FIXED_NOW);
const adapter = buildAdapter({ fetchedAt: FIXED_NOW - 60 * 1000, data: FIXTURE });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
await svc.ensureLoaded();
@ -451,7 +458,7 @@ describe("CatalogDownloadService", () => {
try {
const adapter = buildAdapter();
mockedRequestUrl.mockResolvedValue(okResponse(FIXTURE));
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter), pluginDir: PLUGIN_DIR });
const svc = new CatalogDownloadService({ app: buildFakeApp(adapter) });
await svc.ensureLoaded();

View file

@ -3,7 +3,8 @@
*
* Two-tier read path:
* 1. Memory cache (populated by `ensureLoaded` or `refresh`).
* 2. Disk cache at `<pluginDir>/.modelsCatalogCache.json`.
* 2. Disk cache at `<vault>/.copilot/model-catalog-cache.json`,
* alongside the plugin's other disposable runtime caches.
*
* `ensureLoaded` reads disk first. If the cached payload is younger
* than 24h it's used as-is no network call. Otherwise (stale or
@ -26,7 +27,10 @@ import type { CatalogProvider } from "@/modelManagement/types/catalog";
import { transformWireToCatalog } from "./catalogTransform";
import { isPlainObject } from "./modelsDevWire";
const CACHE_FILENAME = ".modelsCatalogCache.json";
// Sits with the other disposable caches under `.copilot/`
// (see fileCache / pdfCache / projectContextCache).
const CACHE_DIR = ".copilot";
const CACHE_FILENAME = "model-catalog-cache.json";
const MODELS_DEV_URL = "https://models.dev/api.json";
const FETCH_TIMEOUT_MS = 5000;
const STALE_AFTER_MS = 24 * 60 * 60 * 1000;
@ -45,8 +49,6 @@ interface DiskPayload {
export interface CatalogDownloadDeps {
app: App;
/** Plugin directory, e.g. `this.manifest.dir`. */
pluginDir: string;
}
type Listener = () => void;
@ -64,7 +66,7 @@ export class CatalogDownloadService {
constructor(deps: CatalogDownloadDeps) {
this.app = deps.app;
this.cachePath = normalizePath(`${deps.pluginDir}/${CACHE_FILENAME}`);
this.cachePath = normalizePath(`${CACHE_DIR}/${CACHE_FILENAME}`);
}
/**
@ -156,6 +158,9 @@ export class CatalogDownloadService {
const fetchedAt = Date.now();
try {
if (!(await this.app.vault.adapter.exists(CACHE_DIR))) {
await this.app.vault.adapter.mkdir(CACHE_DIR);
}
const payload: DiskPayload = { fetchedAt, data: parsed };
await this.app.vault.adapter.write(this.cachePath, JSON.stringify(payload));
} catch (err) {

View file

@ -125,3 +125,71 @@ describe("ModelManagementCoordinator.removeProvider", () => {
expect(getSettings().backends).toEqual({});
});
});
describe("ModelManagementCoordinator.removeConfiguredModel", () => {
let coordinator: ModelManagementCoordinator;
let providers: ProviderRegistry;
let models: ConfiguredModelRegistry;
let backends: BackendConfigRegistry;
beforeEach(() => {
resetSettings();
KeychainService.resetInstance();
const app = makeFakeApp();
KeychainService.getInstance(app);
providers = new ProviderRegistry(app, new ProviderAdapterRegistry());
models = new ConfiguredModelRegistry();
backends = new BackendConfigRegistry(providers, models);
coordinator = new ModelManagementCoordinator(providers, models, backends);
});
it("removes the model row and drops its refs from every backend", async () => {
const providerId = await providers.add({
providerType: "anthropic",
displayName: "Anthropic",
origin: { kind: "byok" },
});
const id1 = await models.add({
providerId,
info: { id: "claude-sonnet-4-5", displayName: "Sonnet" },
});
const id2 = await models.add({
providerId,
info: { id: "claude-opus-4-5", displayName: "Opus" },
});
await backends.setEnabledModels("chat", [id1, id2]);
await backends.setDefaultModel("chat", id1);
await backends.setEnabledModels("opencode", [id1]);
await coordinator.removeConfiguredModel(id1);
// The target row is gone; the sibling row survives.
expect(models.get(id1)).toBeUndefined();
expect(models.get(id2)).toBeDefined();
// Refs dropped from every backend; the default that pointed at it
// becomes null.
expect(backends.get("chat").enabledModels).toEqual([id2]);
expect(backends.get("chat").defaultModel).toBeNull();
expect(backends.get("opencode").enabledModels).toEqual([]);
// The provider row is untouched (per-model removal, not per-provider).
expect(providers.get(providerId)).toBeDefined();
});
it("is a no-op for an id that doesn't exist", async () => {
const providerId = await providers.add({
providerType: "google",
displayName: "G",
origin: { kind: "byok" },
});
const id = await models.add({
providerId,
info: { id: "gemini", displayName: "Gemini" },
});
await backends.setEnabledModels("chat", [id]);
await coordinator.removeConfiguredModel("does-not-exist");
expect(models.get(id)).toBeDefined();
expect(backends.get("chat").enabledModels).toEqual([id]);
});
});

View file

@ -1,7 +1,7 @@
/**
* Top-level factory + cross-slice coordinator.
*
* Host code calls `createModelManagement({ app, pluginDir })` exactly
* Host code calls `createModelManagement({ app })` exactly
* once (in `Plugin.onload`), stores the returned `ModelManagementApi`
* on the plugin instance, threads it into React via
* `<ModelManagementProvider>`, and passes it to non-React modules
@ -37,9 +37,6 @@ import { CopilotPlusSetupApi } from "@/modelManagement/setup/CopilotPlusSetupApi
export interface CreateModelManagementInput {
app: App;
/** From `Plugin.manifest.dir`. Used by `CatalogDownloadService` to
* locate its on-disk JSON cache. */
pluginDir: string;
}
export interface ModelManagementApi {
@ -104,16 +101,13 @@ export class ModelManagementCoordinator {
}
/**
* Cascade:
* 1. Drop the id from every BackendConfig.
* 2. Remove the ConfiguredModel row.
*
* TODO(byok): implemented when the UI surfaces per-model removal.
* Drop a configured model and its backend refs. Refs are cleared first so
* `resolveEnabled` never briefly surfaces the model as broken. Idempotent
* removing an unknown id is a no-op.
*/
removeConfiguredModel(configuredModelId: string): Promise<void> {
throw new Error(
"[modelManagement] ModelManagementCoordinator.removeConfiguredModel not implemented yet"
);
async removeConfiguredModel(configuredModelId: string): Promise<void> {
await this.#backends.removeRefs([configuredModelId]);
await this.#models.remove(configuredModelId);
}
}
@ -127,11 +121,11 @@ export class ModelManagementCoordinator {
* singleton, no reset helpers needed.
*/
export function createModelManagement(input: CreateModelManagementInput): ModelManagementApi {
const { app, pluginDir } = input;
const { app } = input;
const adapters = createDefaultAdapterRegistry();
const catalogService = new CatalogDownloadService({ app, pluginDir });
const catalogService = new CatalogDownloadService({ app });
const providerRegistry = new ProviderRegistry(app, adapters);
const configuredModelRegistry = new ConfiguredModelRegistry();
const backendConfigRegistry = new BackendConfigRegistry(

View file

@ -106,7 +106,7 @@ describe("ProviderRegistry", () => {
await registry.add({
providerType: "anthropic",
displayName: "B",
origin: { kind: "agent", agentType: "claude-code" },
origin: { kind: "agent", agentType: "claude" },
});
const list1 = registry.list();
const list2 = registry.list();
@ -150,7 +150,7 @@ describe("ProviderRegistry", () => {
providerId: "hacked",
addedAt: 1,
providerType: "openai",
origin: { kind: "agent", agentType: "claude-code" },
origin: { kind: "agent", agentType: "claude" },
} as Record<string, unknown>),
});
const row = registry.get(id)!;

View file

@ -0,0 +1,581 @@
/**
* Tests for `AgentSetupApi` (M2 agent-origin provider/model enrollment).
*
* The 5 injected deps are mocked as plain in-memory fakes / jest.fns
* no real settings store. Each fake holds just enough state to exercise
* the create / idempotent-update / diff-reconcile / auto-enroll / cascade
* paths and to assert the observable effects.
*/
import { AgentSetupApi } from "./AgentSetupApi";
import type { CatalogDownloadService } from "@/modelManagement/catalog/CatalogDownloadService";
import type { ModelManagementCoordinator } from "@/modelManagement/createModelManagement";
import type { CatalogProvider, ModelInfo } from "@/modelManagement/types/catalog";
import type { BackendType, ConfiguredModel, Provider } from "@/modelManagement/types/persisted";
import type { BackendConfigRegistry } from "@/modelManagement/backends/BackendConfigRegistry";
import type { ConfiguredModelRegistry } from "@/modelManagement/models/ConfiguredModelRegistry";
import type { ProviderRegistry } from "@/modelManagement/providers/ProviderRegistry";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
// ---------------------------------------------------------------------------
// In-memory fakes
// ---------------------------------------------------------------------------
let idCounter = 0;
function nextId(prefix: string): string {
idCounter += 1;
return `${prefix}-${idCounter}`;
}
/** Minimal ProviderRegistry fake — Map-backed, tracks setApiKey calls. */
class FakeProviderRegistry {
rows = new Map<string, Provider>();
setApiKey = jest.fn(async (providerId: string, apiKey: string) => {
const row = this.rows.get(providerId);
if (row) {
row.apiKeyKeychainId = `keychain-${providerId}`;
void apiKey;
}
});
add = jest.fn(async (input: Omit<Provider, "providerId" | "addedAt" | "apiKeyKeychainId">) => {
const providerId = nextId("provider");
this.rows.set(providerId, {
...input,
providerId,
addedAt: Date.now(),
apiKeyKeychainId: null,
});
return providerId;
});
update = jest.fn(async (providerId: string, patch: Partial<Provider>) => {
const existing = this.rows.get(providerId);
if (!existing) throw new Error(`unknown providerId ${providerId}`);
// Mirror the real registry: providerType/origin/keychain id immutable.
const safe = { ...patch };
delete (safe as Record<string, unknown>).providerType;
delete (safe as Record<string, unknown>).origin;
delete (safe as Record<string, unknown>).apiKeyKeychainId;
this.rows.set(providerId, { ...existing, ...safe });
});
listByOrigin = jest.fn((kind: Provider["origin"]["kind"]) =>
Array.from(this.rows.values()).filter((p) => p.origin.kind === kind)
);
get(providerId: string): Provider | undefined {
return this.rows.get(providerId);
}
}
/** Minimal ConfiguredModelRegistry fake — array-backed. */
class FakeConfiguredModelRegistry {
rows: ConfiguredModel[] = [];
add = jest.fn(async (input: { providerId: string; info: ModelInfo }) => {
if (this.rows.some((m) => m.providerId === input.providerId && m.info.id === input.info.id)) {
throw new Error(`duplicate (${input.providerId}, ${input.info.id})`);
}
const configuredModelId = nextId("model");
this.rows.push({ ...input, configuredModelId, configuredAt: Date.now() });
return configuredModelId;
});
remove = jest.fn(async (configuredModelId: string) => {
this.rows = this.rows.filter((m) => m.configuredModelId !== configuredModelId);
});
listByProvider = jest.fn((providerId: string) =>
this.rows.filter((m) => m.providerId === providerId)
);
getByWireId = jest.fn((providerId: string, wireModelId: string) =>
this.rows.find((m) => m.providerId === providerId && m.info.id === wireModelId)
);
get(configuredModelId: string): ConfiguredModel | undefined {
return this.rows.find((m) => m.configuredModelId === configuredModelId);
}
}
/** Minimal BackendConfigRegistry fake — per-backend enabled-id sets. */
class FakeBackendConfigRegistry {
enabled = new Map<BackendType, string[]>();
enableModel = jest.fn(async (backend: BackendType, configuredModelId: string) => {
const ids = this.enabled.get(backend) ?? [];
if (!ids.includes(configuredModelId)) ids.push(configuredModelId);
this.enabled.set(backend, ids);
});
removeRefs = jest.fn(async (configuredModelIds: readonly string[]) => {
const drop = new Set(configuredModelIds);
for (const [backend, ids] of this.enabled) {
this.enabled.set(
backend,
ids.filter((id) => !drop.has(id))
);
}
});
enabledFor(backend: BackendType): string[] {
return this.enabled.get(backend) ?? [];
}
}
/** Coordinator fake mirroring the real cascade: refs then row. */
class FakeCoordinator {
constructor(
private readonly backends: FakeBackendConfigRegistry,
private readonly models: FakeConfiguredModelRegistry
) {}
removeConfiguredModel = jest.fn(async (configuredModelId: string) => {
await this.backends.removeRefs([configuredModelId]);
await this.models.remove(configuredModelId);
});
}
/** Catalog fake — returns a fixed provider list keyed by providerType. */
function makeCatalog(providers: CatalogProvider[]): {
service: CatalogDownloadService;
ensureLoaded: jest.Mock;
} {
const ensureLoaded = jest.fn(async () => {});
const service = {
ensureLoaded,
getAllProviders: () => providers,
} as unknown as CatalogDownloadService;
return { service, ensureLoaded };
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const CLAUDE_CATALOG: CatalogProvider = {
id: "anthropic",
displayName: "Anthropic",
providerType: "anthropic",
models: {
"claude-sonnet-4-5": {
id: "claude-sonnet-4-5",
displayName: "Claude Sonnet 4.5",
reasoning: true,
},
"claude-opus-4-5": { id: "claude-opus-4-5", displayName: "Claude Opus 4.5" },
},
};
const GOOGLE_CATALOG: CatalogProvider = {
id: "google",
displayName: "Google",
providerType: "google",
models: {
"gemini-2-5-pro": { id: "gemini-2-5-pro", displayName: "Gemini 2.5 Pro" },
"gemini-2-5-flash": { id: "gemini-2-5-flash", displayName: "Gemini 2.5 Flash" },
},
};
interface Harness {
api: AgentSetupApi;
providers: FakeProviderRegistry;
models: FakeConfiguredModelRegistry;
backends: FakeBackendConfigRegistry;
coordinator: FakeCoordinator;
ensureLoaded: jest.Mock;
}
function makeHarness(catalogProviders: CatalogProvider[] = [CLAUDE_CATALOG]): Harness {
const providers = new FakeProviderRegistry();
const models = new FakeConfiguredModelRegistry();
const backends = new FakeBackendConfigRegistry();
const coordinator = new FakeCoordinator(backends, models);
const { service, ensureLoaded } = makeCatalog(catalogProviders);
const api = new AgentSetupApi(
providers as unknown as ProviderRegistry,
models as unknown as ConfiguredModelRegistry,
backends as unknown as BackendConfigRegistry,
service,
coordinator as unknown as ModelManagementCoordinator
);
return { api, providers, models, backends, coordinator, ensureLoaded };
}
beforeEach(() => {
idCounter = 0;
});
// ---------------------------------------------------------------------------
// registerAgentProvider
// ---------------------------------------------------------------------------
describe("AgentSetupApi.registerAgentProvider", () => {
it("creates exactly one agent-origin provider, N models, all enrolled into the agent backend only", async () => {
const h = makeHarness();
const result = await h.api.registerAgentProvider({
agentType: "claude",
providerType: "anthropic",
displayName: "Claude Code",
apiKey: null,
wireModelIds: ["claude-sonnet-4-5", "claude-opus-4-5"],
});
// One provider, correct origin.
expect(h.providers.rows.size).toBe(1);
const provider = h.providers.get(result.providerId)!;
expect(provider.origin).toEqual({ kind: "agent", agentType: "claude" });
expect(provider.providerType).toBe("anthropic");
expect(provider.displayName).toBe("Claude Code");
// Two ConfiguredModels.
expect(result.configuredModelIds).toHaveLength(2);
expect(
h.models
.listByProvider(result.providerId)
.map((m) => m.info.id)
.sort()
).toEqual(["claude-opus-4-5", "claude-sonnet-4-5"]);
// Enrolled into backends["claude"] only — not chat or other agents.
expect(h.backends.enabledFor("claude").sort()).toEqual([...result.configuredModelIds].sort());
expect(h.backends.enabledFor("chat")).toEqual([]);
expect(h.backends.enabledFor("opencode")).toEqual([]);
expect(h.backends.enabledFor("codex")).toEqual([]);
});
it("is idempotent on (agentType, providerType): re-running updates in place, no duplicate provider", async () => {
const h = makeHarness();
const first = await h.api.registerAgentProvider({
agentType: "codex",
providerType: "openai-compatible",
displayName: "Codex",
apiKey: null,
wireModelIds: ["gpt-5"],
fallbackDisplayNames: { "gpt-5": "GPT-5" },
});
// Clear the model-mutation spies so the second run's call counts
// reflect only what the identical re-register triggers.
h.models.add.mockClear();
h.backends.enableModel.mockClear();
h.coordinator.removeConfiguredModel.mockClear();
const second = await h.api.registerAgentProvider({
agentType: "codex",
providerType: "openai-compatible",
displayName: "Codex CLI", // changed label
apiKey: null,
wireModelIds: ["gpt-5"],
fallbackDisplayNames: { "gpt-5": "GPT-5" },
});
expect(second.providerId).toBe(first.providerId);
expect(h.providers.rows.size).toBe(1);
expect(h.providers.add).toHaveBeenCalledTimes(1);
expect(h.providers.update).toHaveBeenCalledTimes(1);
expect(h.providers.get(first.providerId)!.displayName).toBe("Codex CLI");
// Same single model, same id preserved.
expect(second.configuredModelIds).toEqual(first.configuredModelIds);
// Idempotent model reconcile: an unchanged wire-id list is a pure
// no-op — no add, no re-enroll, no cascade-remove ("only real
// add/remove deltas write settings", per the risk table).
expect(h.models.add).not.toHaveBeenCalled();
expect(h.backends.enableModel).not.toHaveBeenCalled();
expect(h.coordinator.removeConfiguredModel).not.toHaveBeenCalled();
});
it("adds a newly-reported wire id (new model enrolled) and preserves existing rows' ids + enable state", async () => {
const h = makeHarness();
const first = await h.api.registerAgentProvider({
agentType: "claude",
providerType: "anthropic",
displayName: "Claude Code",
apiKey: null,
wireModelIds: ["claude-sonnet-4-5"],
});
const sonnetId = first.configuredModelIds[0];
// Simulate user disabling/curating: leave enable state as-is and re-run
// with an extra reported model.
const second = await h.api.registerAgentProvider({
agentType: "claude",
providerType: "anthropic",
displayName: "Claude Code",
apiKey: null,
wireModelIds: ["claude-sonnet-4-5", "claude-opus-4-5"],
});
// Existing row kept its id.
expect(second.configuredModelIds).toContain(sonnetId);
expect(h.models.getByWireId(first.providerId, "claude-sonnet-4-5")!.configuredModelId).toBe(
sonnetId
);
// New row added + enrolled.
const opusId = h.models.getByWireId(first.providerId, "claude-opus-4-5")!.configuredModelId;
expect(opusId).toBeDefined();
expect(h.backends.enabledFor("claude")).toContain(opusId);
// Only the genuinely-new id triggered an add.
expect(h.models.add).toHaveBeenCalledTimes(2);
});
it("cascade-removes a vanished wire id (model removed + backend refs dropped via coordinator)", async () => {
const h = makeHarness();
const first = await h.api.registerAgentProvider({
agentType: "claude",
providerType: "anthropic",
displayName: "Claude Code",
apiKey: null,
wireModelIds: ["claude-sonnet-4-5", "claude-opus-4-5"],
});
const opusId = h.models.getByWireId(first.providerId, "claude-opus-4-5")!.configuredModelId;
await h.api.registerAgentProvider({
agentType: "claude",
providerType: "anthropic",
displayName: "Claude Code",
apiKey: null,
wireModelIds: ["claude-sonnet-4-5"], // opus vanished
});
expect(h.coordinator.removeConfiguredModel).toHaveBeenCalledWith(opusId);
expect(h.models.getByWireId(first.providerId, "claude-opus-4-5")).toBeUndefined();
expect(h.backends.enabledFor("claude")).not.toContain(opusId);
// Surviving model still enrolled.
const sonnetId = h.models.getByWireId(first.providerId, "claude-sonnet-4-5")!.configuredModelId;
expect(h.backends.enabledFor("claude")).toContain(sonnetId);
});
it("does not call setApiKey for CLI-managed agents (apiKey: null) — keychain id stays null", async () => {
const h = makeHarness();
const result = await h.api.registerAgentProvider({
agentType: "claude",
providerType: "anthropic",
displayName: "Claude Code",
apiKey: null,
wireModelIds: ["claude-sonnet-4-5"],
});
expect(h.providers.setApiKey).not.toHaveBeenCalled();
expect(h.providers.get(result.providerId)!.apiKeyKeychainId).toBeNull();
});
it("calls setApiKey when a key is supplied (subscription-style agent)", async () => {
const h = makeHarness();
const result = await h.api.registerAgentProvider({
agentType: "opencode",
providerType: "anthropic",
displayName: "OpenCode",
apiKey: "sk-agent",
wireModelIds: ["claude-sonnet-4-5"],
});
expect(h.providers.setApiKey).toHaveBeenCalledWith(result.providerId, "sk-agent");
expect(h.providers.get(result.providerId)!.apiKeyKeychainId).toBeTruthy();
});
it("enriches info from the catalog on a hit and falls back on a miss", async () => {
const h = makeHarness();
const result = await h.api.registerAgentProvider({
agentType: "claude",
providerType: "anthropic",
displayName: "Claude Code",
apiKey: null,
wireModelIds: ["claude-sonnet-4-5", "unknown-model"],
fallbackDisplayNames: { "unknown-model": "Unknown Model" },
});
const hit = h.models.getByWireId(result.providerId, "claude-sonnet-4-5")!;
// Full catalog metadata copied onto the snapshot.
expect(hit.info.displayName).toBe("Claude Sonnet 4.5");
expect(hit.info.reasoning).toBe(true);
const miss = h.models.getByWireId(result.providerId, "unknown-model")!;
expect(miss.info).toEqual({ id: "unknown-model", displayName: "Unknown Model" });
});
it("falls back to the bare wire id when there is no catalog hit and no fallback name", async () => {
const h = makeHarness();
const result = await h.api.registerAgentProvider({
agentType: "codex",
providerType: "openai-compatible",
displayName: "Codex",
apiKey: null,
wireModelIds: ["o4-mini"],
});
const row = h.models.getByWireId(result.providerId, "o4-mini")!;
expect(row.info).toEqual({ id: "o4-mini", displayName: "o4-mini" });
});
it("still snapshots every wire id when the catalog is unreachable", async () => {
// Unreachable catalog: ensureLoaded throws AND memory is empty (no
// providers ever loaded), so enrichment must fall back per wire id.
const h = makeHarness([]);
h.ensureLoaded.mockRejectedValueOnce(new Error("offline"));
const result = await h.api.registerAgentProvider({
agentType: "claude",
providerType: "anthropic",
displayName: "Claude Code",
apiKey: null,
wireModelIds: ["claude-sonnet-4-5"],
fallbackDisplayNames: { "claude-sonnet-4-5": "Sonnet (fallback)" },
});
// Catalog enrichment failed → fallback used, but the model still exists.
const row = h.models.getByWireId(result.providerId, "claude-sonnet-4-5")!;
expect(row.info).toEqual({ id: "claude-sonnet-4-5", displayName: "Sonnet (fallback)" });
expect(h.backends.enabledFor("claude")).toContain(row.configuredModelId);
});
});
// ---------------------------------------------------------------------------
// syncAgentModels
// ---------------------------------------------------------------------------
describe("AgentSetupApi.syncAgentModels", () => {
it("no-ops when no agent provider exists for the agentType", async () => {
const h = makeHarness();
const result = await h.api.syncAgentModels({
agentType: "claude",
wireModelIds: ["claude-sonnet-4-5"],
});
expect(result).toEqual({ added: [], removed: [] });
expect(h.models.add).not.toHaveBeenCalled();
expect(h.providers.add).not.toHaveBeenCalled();
});
it("reconciles models without touching the provider row", async () => {
const h = makeHarness();
const reg = await h.api.registerAgentProvider({
agentType: "claude",
providerType: "anthropic",
displayName: "Claude Code",
apiKey: null,
wireModelIds: ["claude-sonnet-4-5"],
});
const sonnetId = reg.configuredModelIds[0];
h.providers.add.mockClear();
h.providers.update.mockClear();
const result = await h.api.syncAgentModels({
agentType: "claude",
wireModelIds: ["claude-sonnet-4-5", "claude-opus-4-5"],
});
// Provider row untouched.
expect(h.providers.add).not.toHaveBeenCalled();
expect(h.providers.update).not.toHaveBeenCalled();
// New model added + enrolled; existing one preserved.
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(0);
const opusId = h.models.getByWireId(reg.providerId, "claude-opus-4-5")!.configuredModelId;
expect(result.added).toEqual([opusId]);
expect(h.backends.enabledFor("claude")).toEqual(expect.arrayContaining([sonnetId, opusId]));
});
it("cascade-removes a vanished model on sync", async () => {
const h = makeHarness();
const reg = await h.api.registerAgentProvider({
agentType: "claude",
providerType: "anthropic",
displayName: "Claude Code",
apiKey: null,
wireModelIds: ["claude-sonnet-4-5", "claude-opus-4-5"],
});
const opusId = h.models.getByWireId(reg.providerId, "claude-opus-4-5")!.configuredModelId;
const result = await h.api.syncAgentModels({
agentType: "claude",
wireModelIds: ["claude-sonnet-4-5"],
});
expect(result.removed).toEqual([opusId]);
expect(h.coordinator.removeConfiguredModel).toHaveBeenCalledWith(opusId);
expect(h.models.getByWireId(reg.providerId, "claude-opus-4-5")).toBeUndefined();
});
it("is a pure no-op when the reported list is unchanged", async () => {
const h = makeHarness();
await h.api.registerAgentProvider({
agentType: "claude",
providerType: "anthropic",
displayName: "Claude Code",
apiKey: null,
wireModelIds: ["claude-sonnet-4-5"],
});
h.models.add.mockClear();
h.coordinator.removeConfiguredModel.mockClear();
const result = await h.api.syncAgentModels({
agentType: "claude",
wireModelIds: ["claude-sonnet-4-5"],
});
expect(result).toEqual({ added: [], removed: [] });
expect(h.models.add).not.toHaveBeenCalled();
expect(h.coordinator.removeConfiguredModel).not.toHaveBeenCalled();
});
it("multi-provider: partitions wire ids per owning provider without corrupting another provider's list", async () => {
// opencode with two agent providers (anthropic + google). A later
// sync drops one model from each provider's owned set and reports an
// unowned wire id; the unowned id must NOT be added (no providerType
// to resolve it), and neither provider's surviving model is touched.
const h = makeHarness([CLAUDE_CATALOG, GOOGLE_CATALOG]);
const anthropic = await h.api.registerAgentProvider({
agentType: "opencode",
providerType: "anthropic",
displayName: "OpenCode (Anthropic)",
apiKey: "sk-a",
wireModelIds: ["claude-sonnet-4-5", "claude-opus-4-5"],
});
const google = await h.api.registerAgentProvider({
agentType: "opencode",
providerType: "google",
displayName: "OpenCode (Google)",
apiKey: "sk-g",
wireModelIds: ["gemini-2-5-pro", "gemini-2-5-flash"],
});
const opusId = h.models.getByWireId(anthropic.providerId, "claude-opus-4-5")!.configuredModelId;
const flashId = h.models.getByWireId(google.providerId, "gemini-2-5-flash")!.configuredModelId;
const sonnetId = h.models.getByWireId(
anthropic.providerId,
"claude-sonnet-4-5"
)!.configuredModelId;
const proId = h.models.getByWireId(google.providerId, "gemini-2-5-pro")!.configuredModelId;
h.models.add.mockClear();
// Report: each provider keeps one model; opus + flash vanish; an
// unowned wire id ("mystery-model") is reported but belongs to no
// existing provider.
const result = await h.api.syncAgentModels({
agentType: "opencode",
wireModelIds: ["claude-sonnet-4-5", "gemini-2-5-pro", "mystery-model"],
});
// Both vanished models cascade-removed, scoped to their own provider.
expect(result.removed.sort()).toEqual([opusId, flashId].sort());
expect(h.coordinator.removeConfiguredModel).toHaveBeenCalledWith(opusId);
expect(h.coordinator.removeConfiguredModel).toHaveBeenCalledWith(flashId);
// Unowned wire id is NOT enrolled (no providerType to place it).
expect(result.added).toEqual([]);
expect(h.models.add).not.toHaveBeenCalled();
// Each surviving model is preserved on its own provider; no cross-
// provider corruption.
expect(h.models.getByWireId(anthropic.providerId, "claude-sonnet-4-5")!.configuredModelId).toBe(
sonnetId
);
expect(h.models.getByWireId(google.providerId, "gemini-2-5-pro")!.configuredModelId).toBe(
proId
);
expect(h.backends.enabledFor("opencode").sort()).toEqual([sonnetId, proId].sort());
});
});

View file

@ -1,25 +1,21 @@
/**
* Agent setup workflow. Called by an agent's own setup flow (Claude
* Code installer, Codex CLI installer, OpenCode installer) when the
* agent reports its supported model list.
* Enrolls an agent's reported model list into the data model. Creates one
* `Provider` per `(agentType, providerType)` with `origin: "agent"`, snapshots
* a `ConfiguredModel` per `wireModelId`, and auto-enrolls each into
* `backends[agentType]` only, so agent-owned models stay exclusive to their
* agent's picker.
*
* Creates one `Provider` row per `(agentType, providerType)` pair
* with `origin: { kind: "agent", agentType }`, snapshots
* `ConfiguredModel` rows for each `wireModelId`, and auto-enrolls
* them into `backends[agentType]` only agent-owned models stay
* exclusive to their agent's picker.
*
* `registerAgentProvider` is idempotent on
* `(agentType, providerType)` re-running upgrades the model list
* (adds new wire ids, drops vanished ones). `syncAgentModels` is the
* narrower variant for callers that only need the model-list refresh
* without provider metadata changes.
* `registerAgentProvider` is idempotent on `(agentType, providerType)`:
* re-running reconciles the model list. `syncAgentModels` is the narrower
* variant that refreshes models without touching provider metadata.
*/
import { logWarn } from "@/logger";
import type { CatalogDownloadService } from "@/modelManagement/catalog/CatalogDownloadService";
import type { ModelManagementCoordinator } from "@/modelManagement/createModelManagement";
import type { ProviderType } from "@/modelManagement/types/catalog";
import type { AgentType } from "@/modelManagement/types/persisted";
import type { ModelInfo, ProviderType } from "@/modelManagement/types/catalog";
import type { AgentType, Provider } from "@/modelManagement/types/persisted";
import type { BackendConfigRegistry } from "@/modelManagement/backends/BackendConfigRegistry";
import type { ConfiguredModelRegistry } from "@/modelManagement/models/ConfiguredModelRegistry";
import type { ProviderRegistry } from "@/modelManagement/providers/ProviderRegistry";
@ -29,19 +25,15 @@ export interface RegisterAgentProviderInput {
providerType: ProviderType;
displayName: string;
baseUrl?: string;
/** Agents that use CLI-managed credentials (Claude Code, Codex)
* pass `null`. The Provider row's `apiKeyKeychainId` stays
* `null`; the chat-model factory's `getApiKey()` returns `null`
* and the adapter must tolerate that. */
/** `null` for CLI-managed agents (Claude Code, Codex): no key is stored, so
* the chat-model factory's `getApiKey()` returns `null` and the adapter
* must tolerate that. */
apiKey?: string | null;
/** Per-providerType payload — see `Provider.extras`. */
extras?: Record<string, unknown>;
/** Full set of wire ids the agent reports as supported. The
* existing ConfiguredModel set is diffed against this list. */
/** Full set of wire ids the agent reports; the existing model set is diffed
* against this list. */
wireModelIds: readonly string[];
/** Fallback for wire ids the catalog doesn't know used when the
* agent supports a model that hasn't been added to `models.dev`
* yet. Keyed by wire id. */
/** Display names for wire ids the catalog doesn't know yet. Keyed by wire id. */
fallbackDisplayNames?: Record<string, string>;
}
@ -61,39 +53,299 @@ export interface AgentSyncResult {
}
export class AgentSetupApi {
readonly #providers: ProviderRegistry;
readonly #models: ConfiguredModelRegistry;
readonly #backends: BackendConfigRegistry;
readonly #catalog: CatalogDownloadService;
readonly #coordinator: ModelManagementCoordinator;
constructor(
providerRegistry: ProviderRegistry,
configuredModelRegistry: ConfiguredModelRegistry,
backendConfigRegistry: BackendConfigRegistry,
catalogService: CatalogDownloadService,
/** Required for the diff-reconcile cascade: when a wire id vanishes
* on agent upgrade, the orphan ConfiguredModel and its backend
* refs are removed via `coordinator.removeConfiguredModel`. */
/** Used to cascade-remove an orphaned ConfiguredModel (and its backend
* refs) when a wire id vanishes from the agent's reported list. */
coordinator: ModelManagementCoordinator
) {}
/**
* Idempotent on `(agentType, providerType)`. First call creates;
* subsequent calls update the Provider row in place and reconcile
* the configured-model list (add new wire ids, drop vanished
* ones). Each new ConfiguredModel is auto-enrolled into
* `backends[agentType]`.
*
* Catalog lookups (via `catalogService`) enrich each
* `ConfiguredModel.info` snapshot where the wire id matches a
* catalog entry; unmatched wire ids fall back to
* `fallbackDisplayNames[wireId] ?? wireId`.
*/
registerAgentProvider(input: RegisterAgentProviderInput): Promise<AgentSetupResult> {
throw new Error("[modelManagement] AgentSetupApi.registerAgentProvider not implemented yet");
) {
this.#providers = providerRegistry;
this.#models = configuredModelRegistry;
this.#backends = backendConfigRegistry;
this.#catalog = catalogService;
this.#coordinator = coordinator;
}
/**
* Reconcile the configured-model list for an existing agent-owned
* provider. Same diff logic as `registerAgentProvider` but doesn't
* touch the Provider row.
* Idempotent on `(agentType, providerType)`: the first call creates the
* provider, later calls update it in place and reconcile its model list.
* Catalog metadata enriches each model snapshot where the wire id matches;
* unmatched ids fall back to `fallbackDisplayNames[wireId] ?? wireId`.
*
* Writes happen in order provider row, then key (only if non-null), then
* models and a failed later step leaves earlier writes in place for
* `coordinator.removeProvider` to clean up.
*/
syncAgentModels(input: SyncAgentModelsInput): Promise<AgentSyncResult> {
throw new Error("[modelManagement] AgentSetupApi.syncAgentModels not implemented yet");
async registerAgentProvider(input: RegisterAgentProviderInput): Promise<AgentSetupResult> {
const existing = this.#findAgentProvider(input.agentType, input.providerType);
let providerId: string;
if (existing) {
providerId = existing.providerId;
// Reuse the existing row so re-running never spawns a second provider for
// the same `(agentType, providerType)`. providerType/origin/keychain id
// are immutable through `update`.
await this.#providers.update(providerId, {
displayName: input.displayName,
baseUrl: input.baseUrl,
extras: input.extras,
});
} else {
providerId = await this.#providers.add({
providerType: input.providerType,
displayName: input.displayName,
baseUrl: input.baseUrl,
origin: { kind: "agent", agentType: input.agentType },
extras: input.extras,
});
}
if (input.apiKey != null) {
await this.#providers.setApiKey(providerId, input.apiKey);
}
const infos = await this.#resolveModelInfos(
input.providerType,
input.wireModelIds,
input.fallbackDisplayNames
);
const { added, removed } = await this.#reconcileModels(input.agentType, providerId, infos);
// Return the configured-model ids in wire-id order, joining freshly-added
// ids with the surviving ones.
const addedByWireId = new Map(added.map((a) => [a.wireId, a.configuredModelId]));
const configuredModelIds: string[] = [];
for (const info of infos) {
const fromAdd = addedByWireId.get(info.id);
if (fromAdd) {
configuredModelIds.push(fromAdd);
continue;
}
const surviving = this.#models.getByWireId(providerId, info.id);
if (surviving) configuredModelIds.push(surviving.configuredModelId);
}
// `#reconcileModels` already applied the removals; the delta isn't surfaced
// here (callers that need it use `syncAgentModels`).
void removed;
return { providerId, configuredModelIds };
}
/**
* Reconcile an existing agent provider's model list without touching the
* Provider row. No-op when no agent provider exists yet (the caller must run
* `registerAgentProvider` first).
*
* The single-provider case (claude / codex, and opencode as enrolled
* today) reconciles directly. When several agent providers share one
* `agentType`, wire ids are partitioned by their owning provider so each only
* reconciles its own never corrupting another provider's list.
*/
async syncAgentModels(input: SyncAgentModelsInput): Promise<AgentSyncResult> {
const providers = this.#listAgentProviders(input.agentType);
if (providers.length === 0) {
return { added: [], removed: [] };
}
if (providers.length === 1) {
const provider = providers[0];
// Every reported wire id belongs to this one provider.
const infos = await this.#resolveModelInfosForProvider(provider, input.wireModelIds);
const { added, removed } = await this.#reconcileModels(
input.agentType,
provider.providerId,
infos
);
return {
added: added.map((a) => a.configuredModelId),
removed: removed.map((r) => r.configuredModelId),
};
}
// Partition reported wire ids by their owning provider. Ids owned by no
// provider yet can't be placed without a providerType, so they're left for
// `registerAgentProvider` (which carries one) rather than guessing.
const addedAll: string[] = [];
const removedAll: string[] = [];
const wireIdSet = new Set(input.wireModelIds);
for (const provider of providers) {
const ownedWireIds: string[] = [];
for (const wireId of wireIdSet) {
if (this.#models.getByWireId(provider.providerId, wireId)) {
ownedWireIds.push(wireId);
}
}
// Reconcile only owned ids: a dropped id is one this provider owned and
// the agent no longer reports. Unowned ids aren't added here (no
// providerType to resolve their catalog metadata).
const infos = await this.#resolveModelInfosForProvider(provider, ownedWireIds);
const { added, removed } = await this.#reconcileModels(
input.agentType,
provider.providerId,
infos
);
for (const a of added) addedAll.push(a.configuredModelId);
for (const r of removed) removedAll.push(r.configuredModelId);
}
return { added: addedAll, removed: removedAll };
}
// -------------------------------------------------------------------------
// Internals
// -------------------------------------------------------------------------
/**
* Find the single agent-owned provider for `(agentType, providerType)`.
* Throws if more than one matches the `(agentType, providerType)`
* idempotency invariant is violated and a silent pick would corrupt
* state.
*/
#findAgentProvider(agentType: AgentType, providerType: ProviderType): Provider | undefined {
const matches = this.#providers
.listByOrigin("agent")
.filter(
(p) =>
p.origin.kind === "agent" &&
p.origin.agentType === agentType &&
p.providerType === providerType
);
if (matches.length > 1) {
throw new Error(
`[modelManagement] AgentSetupApi: ${matches.length} providers found for ` +
`(${agentType}, ${providerType}); the (agentType, providerType) ` +
`idempotency invariant is violated`
);
}
return matches[0];
}
/** All agent-owned providers for an `agentType` (origin filter). */
#listAgentProviders(agentType: AgentType): readonly Provider[] {
return this.#providers
.listByOrigin("agent")
.filter((p) => p.origin.kind === "agent" && p.origin.agentType === agentType);
}
/**
* Catalog-enrich each wire id under one `providerType`, falling back to
* `fallbackDisplayNames[id] ?? id` on a miss (so correctness never depends on
* the catalog being reachable).
*
* The input carries only `providerType`, which maps to many catalog
* providers, so the lookup scans them and takes the first match. A wire id
* colliding across two same-type providers resolves to whichever comes first
* fine, since this only affects display metadata.
*/
async #resolveModelInfos(
providerType: ProviderType,
wireModelIds: readonly string[],
fallbackDisplayNames?: Record<string, string>
): Promise<ModelInfo[]> {
const wireToInfo = await this.#buildCatalogLookup(providerType);
return this.#snapshotInfos(wireModelIds, wireToInfo, fallbackDisplayNames);
}
/**
* Like `#resolveModelInfos` but scoped to one provider: on a catalog miss it
* reuses the existing `ConfiguredModel.info`, so a re-sync never downgrades an
* already-enriched row to a bare fallback.
*/
async #resolveModelInfosForProvider(
provider: Provider,
wireModelIds: readonly string[]
): Promise<ModelInfo[]> {
const wireToInfo = await this.#buildCatalogLookup(provider.providerType);
return wireModelIds.map((wireId) => {
const fromCatalog = wireToInfo.get(wireId);
if (fromCatalog) return fromCatalog;
const existing = this.#models.getByWireId(provider.providerId, wireId);
if (existing) return existing.info;
return { id: wireId, displayName: wireId };
});
}
/** Pure wire-id → `ModelInfo` mapping, split from the catalog IO so it stays testable. */
#snapshotInfos(
wireModelIds: readonly string[],
wireToInfo: ReadonlyMap<string, ModelInfo>,
fallbackDisplayNames?: Record<string, string>
): ModelInfo[] {
return wireModelIds.map((wireId) => {
const fromCatalog = wireToInfo.get(wireId);
if (fromCatalog) return fromCatalog;
return { id: wireId, displayName: fallbackDisplayNames?.[wireId] ?? wireId };
});
}
/**
* Build a `wireId → ModelInfo` map across every catalog provider whose
* `providerType` matches. Best-effort: `ensureLoaded` failure is
* logged and an empty map is returned so the fallback path takes over.
*/
async #buildCatalogLookup(providerType: ProviderType): Promise<ReadonlyMap<string, ModelInfo>> {
try {
await this.#catalog.ensureLoaded();
} catch (err) {
logWarn(
"[modelManagement] AgentSetupApi: catalog ensureLoaded failed; falling back to wire-id metadata",
err
);
}
const lookup = new Map<string, ModelInfo>();
for (const catalogProvider of this.#catalog.getAllProviders()) {
if (catalogProvider.providerType !== providerType) continue;
for (const [wireId, info] of Object.entries(catalogProvider.models)) {
if (!lookup.has(wireId)) lookup.set(wireId, info);
}
}
return lookup;
}
/**
* Diff-reconcile one provider's ConfiguredModel set against `infos`: add new
* wire ids (auto-enrolling each into `backends[agentType]` only),
* cascade-remove vanished ones, leave unchanged ids untouched. Only real
* deltas write, so re-syncing an unchanged list is a no-op that never resets
* user curation.
*/
async #reconcileModels(
agentType: AgentType,
providerId: string,
infos: readonly ModelInfo[]
): Promise<{
added: Array<{ wireId: string; configuredModelId: string }>;
removed: Array<{ wireId: string; configuredModelId: string }>;
}> {
const existing = this.#models.listByProvider(providerId);
const existingByWireId = new Map(existing.map((m) => [m.info.id, m]));
const desiredWireIds = new Set(infos.map((info) => info.id));
const added: Array<{ wireId: string; configuredModelId: string }> = [];
for (const info of infos) {
if (existingByWireId.has(info.id)) continue;
const configuredModelId = await this.#models.add({ providerId, info });
// Enroll into this agent's backend only — agent models never leak into
// chat or another agent's picker.
await this.#backends.enableModel(agentType, configuredModelId);
added.push({ wireId: info.id, configuredModelId });
}
const removed: Array<{ wireId: string; configuredModelId: string }> = [];
for (const model of existing) {
if (desiredWireIds.has(model.info.id)) continue;
await this.#coordinator.removeConfiguredModel(model.configuredModelId);
removed.push({ wireId: model.info.id, configuredModelId: model.configuredModelId });
}
return { added, removed };
}
}

View file

@ -10,7 +10,7 @@ import type { ModelInfo, ProviderType } from "./catalog";
* The three agent backends. Each can own its own `Provider`(s) and
* reports a runtime model inventory.
*/
export type AgentType = "opencode" | "claude-code" | "codex";
export type AgentType = "opencode" | "claude" | "codex";
/**
* The broader set used for per-backend model curation: the three agents
@ -19,7 +19,7 @@ export type AgentType = "opencode" | "claude-code" | "codex";
*
* "chat" Simple Chat picker
* "opencode" OpenCode agent picker
* "claude-code" Claude Code agent picker
* "claude" Claude Code agent picker
* "codex" Codex agent picker
*
* Everything else either has no curated selection (vault-qa / project /

View file

@ -293,12 +293,6 @@ export interface CopilotSettings {
export interface ClaudeBackendSettings {
/** Sticky model preference — `{ baseModelId, effort }`. Unset = use the agent's default. */
defaultModel?: ModelSelection | null;
/**
* Sparse user overrides for which agent-reported models should appear in
* the model picker. Keyed by SDK model id. Absent fall back to the
* descriptor's `isModelEnabledByDefault` policy.
*/
modelEnabledOverrides?: Record<string, boolean>;
/**
* Opt-in: pass `thinking: { type: "enabled" }` to the SDK so the agent
* surfaces reasoning chunks. Off by default (matches SDK default).
@ -319,8 +313,6 @@ export interface CodexBackendSettings {
binaryPath?: string;
/** Sticky model preference — `{ baseModelId, effort }`. Unset = use the agent's default. */
defaultModel?: ModelSelection | null;
/** Sparse user overrides; see `ClaudeBackendSettings.modelEnabledOverrides`. */
modelEnabledOverrides?: Record<string, boolean>;
/** See `ClaudeBackendSettings.envOverrides`. Applied to the spawned `codex-acp` subprocess. */
envOverrides?: Record<string, string>;
}
@ -349,8 +341,6 @@ export interface OpencodeBackendSettings {
* surfaced in the Copilot tab strip or chat history.
*/
probeSessionId?: string;
/** Sparse user overrides; see `ClaudeBackendSettings.modelEnabledOverrides`. */
modelEnabledOverrides?: Record<string, boolean>;
/** See `ClaudeBackendSettings.envOverrides`. Applied to the spawned `opencode` subprocess. */
envOverrides?: Record<string, string>;
}
@ -1000,17 +990,6 @@ function nonEmptyString(v: unknown): string | undefined {
return typeof v === "string" && v ? v : undefined;
}
function sanitizeModelEnabledOverrides(raw: unknown): Record<string, boolean> | undefined {
if (!raw || typeof raw !== "object") return undefined;
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
if (typeof k === "string" && k.length > 0 && k.length <= 256 && typeof v === "boolean") {
out[k] = v;
}
}
return Object.keys(out).length > 0 ? out : undefined;
}
function sanitizeDefaultModel(raw: unknown): ModelSelection | undefined {
if (!raw || typeof raw !== "object") return undefined;
const r = raw as Record<string, unknown>;
@ -1053,7 +1032,6 @@ function sanitizeClaudeBackendSettings(raw: unknown): ClaudeBackendSettings {
const r = raw as Record<string, unknown>;
return {
defaultModel: sanitizeDefaultModel(r.defaultModel),
modelEnabledOverrides: sanitizeModelEnabledOverrides(r.modelEnabledOverrides),
enableThinking: typeof r.enableThinking === "boolean" ? r.enableThinking : undefined,
envOverrides: sanitizeEnvOverrides(r.envOverrides),
};
@ -1065,7 +1043,6 @@ function sanitizeCodexBackendSettings(raw: unknown): CodexBackendSettings {
return {
binaryPath: nonEmptyString(r.binaryPath),
defaultModel: sanitizeDefaultModel(r.defaultModel),
modelEnabledOverrides: sanitizeModelEnabledOverrides(r.modelEnabledOverrides),
envOverrides: sanitizeEnvOverrides(r.envOverrides),
};
}
@ -1088,7 +1065,6 @@ function sanitizeOpencodeBackendSettings(raw: unknown): OpencodeBackendSettings
binarySource,
defaultModel: sanitizeDefaultModel(r.defaultModel),
probeSessionId: nonEmptyString(r.probeSessionId),
modelEnabledOverrides: sanitizeModelEnabledOverrides(r.modelEnabledOverrides),
envOverrides: sanitizeEnvOverrides(r.envOverrides),
};
}

View file

@ -1,13 +1,8 @@
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";
@ -15,6 +10,7 @@ import { logError } from "@/logger";
import { setSettings, useSettingsValue } from "@/settings/model";
import { Platform } from "obsidian";
import React from "react";
import { ConfiguredModelEnableList } from "./ConfiguredModelEnableList";
/**
* Explicit ordering for backend sections. Keeps Opencode Claude Codex
@ -25,7 +21,7 @@ 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).
* (binary path + model curation).
*/
export const AgentSettings: React.FC = () => {
const settings = useSettingsValue();
@ -86,10 +82,10 @@ export const AgentSettings: React.FC = () => {
};
/**
* 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.
* One per-backend block: heading, binary install panel, and the model enable
* list. If the backend is installed but no catalog is cached yet, it kicks a
* probe so discovery enrolls the reported models, which then populate the list
* (the list reads the model-management registry, not the probe state).
*/
const BackendSection: React.FC<{
descriptor: BackendDescriptor;
@ -99,32 +95,18 @@ const BackendSection: React.FC<{
const Panel = descriptor.SettingsPanel;
const manager = plugin.agentSessionManager;
const [backendState, setBackendState] = React.useState<BackendState | null>(
() => manager?.getCachedBackendState(descriptor.id) ?? null
);
React.useEffect(() => {
if (!manager) return;
return manager.subscribeModelCache(() => {
setBackendState(manager.getCachedBackendState(descriptor.id) ?? null);
});
}, [manager, descriptor.id]);
const cachedModel = backendState?.model ?? null;
const installState = descriptor.getInstallState(settings);
// Trigger a probe when the install is ready but no cache has arrived — the
// load-time preload may have skipped this backend (binary installed after
// plugin start).
// Probe when ready but uncached — 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;
if (manager.getCachedBackendState(descriptor.id)?.model) 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);
}, [manager, descriptor.id, installState.kind]);
return (
<div className="tw-space-y-3 tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-3">
@ -132,157 +114,7 @@ const BackendSection: React.FC<{
{Panel && <Panel plugin={plugin} app={plugin.app} />}
{installState.kind === "ready" && (
<ModelCurationBlock
descriptor={descriptor}
backendState={backendState}
overrides={overrides}
/>
)}
{installState.kind === "ready" && <ConfiguredModelEnableList descriptor={descriptor} />}
</div>
);
};
/**
* Renders the "Available models" toggle list plus the default model and
* default effort dropdowns. Hidden when the preloader hasn't returned a
* model list yet (still probing or agent reports nothing).
*/
const ModelCurationBlock: React.FC<{
descriptor: BackendDescriptor;
backendState: BackendState | null;
overrides: Record<string, boolean> | undefined;
}> = ({ descriptor, backendState, overrides }) => {
const modelState = backendState?.model;
if (!modelState || modelState.availableModels.length === 0) {
return (
<div className="tw-text-sm tw-text-muted">
No models reported yet install the binary and reload, or open a chat session with this
agent.
</div>
);
}
const enabled = modelState.availableModels.filter((entry) =>
isAgentModelEnabled(descriptor, { modelId: entry.baseModelId, name: entry.name }, overrides)
);
return (
<div className="tw-space-y-3">
<SelectedModelsList
descriptor={descriptor}
availableModels={modelState.availableModels}
overrides={overrides}
/>
<DefaultModelPicker
descriptor={descriptor}
availableModels={modelState.availableModels}
enabled={enabled}
/>
<DefaultEffortPicker descriptor={descriptor} backendState={backendState} />
</div>
);
};
/**
* Default-model dropdown limited to enabled models. Reads/writes the
* normalized `{ baseModelId, effort }` preference through the session
* manager; the picker UI never sees wire format.
*/
const DefaultModelPicker: React.FC<{
descriptor: BackendDescriptor;
availableModels: ReadonlyArray<ModelEntry>;
enabled: ReadonlyArray<ModelEntry>;
}> = ({ descriptor, availableModels, enabled }) => {
// useSettingsValue() subscribes the component to settings changes — without
// it, manager.getDefaultSelection (which reads getSettings synchronously)
// wouldn't trigger a re-render after persistDefaultSelection.
useSettingsValue();
const plugin = usePlugin();
const manager = plugin.agentSessionManager;
const defaultSelection = manager?.getDefaultSelection(descriptor.id) ?? null;
const currentBaseId = defaultSelection?.baseModelId ?? "";
// If the persisted default is currently disabled by override/policy, keep
// it visible in the dropdown so the user isn't stranded.
const currentEntry =
currentBaseId && !enabled.some((m) => m.baseModelId === currentBaseId)
? availableModels.find((m) => m.baseModelId === currentBaseId)
: undefined;
const dropdownEntries = currentEntry ? [currentEntry, ...enabled] : enabled;
if (dropdownEntries.length === 0) return null;
const handleChange = (newBaseId: string): void => {
if (!newBaseId || !manager) return;
manager
.persistDefaultSelection(descriptor.id, {
baseModelId: newBaseId,
effort: defaultSelection?.effort ?? null,
})
.catch((e) => logError(`[AgentMode] persist default model for ${descriptor.id} failed`, e));
};
return (
<SettingItem
type="select"
title="Default model"
description="Used when starting a new session with this agent."
value={currentBaseId}
onChange={handleChange}
options={[
{ label: "Use agent default", value: "" },
...dropdownEntries.map((m) => ({ label: m.name || m.baseModelId, value: m.baseModelId })),
]}
/>
);
};
/**
* Default-effort dropdown sources `effortOptions` from the catalog entry
* for the persisted default model, falling back to the agent's catalog-
* declared default (`availableModels[0]`) when no preference is set. Never
* reads `modelState.current.*` so the settings UI doesn't drift with mid-
* session model switches. Hidden when the target model has no effort
* dimension or the catalog hasn't loaded yet.
*/
const DefaultEffortPicker: React.FC<{
descriptor: BackendDescriptor;
backendState: BackendState | null;
}> = ({ descriptor, backendState }) => {
// See DefaultModelPicker for why useSettingsValue() is called for its
// re-render side effect rather than its return value.
useSettingsValue();
const plugin = usePlugin();
const manager = plugin.agentSessionManager;
const modelState = backendState?.model;
if (!modelState) return null;
const defaultSelection = manager?.getDefaultSelection(descriptor.id) ?? null;
const targetBaseId =
defaultSelection?.baseModelId ?? manager?.getDefaultBaseModelId(descriptor.id);
if (!targetBaseId) return null;
const targetEntry = modelState.availableModels.find((e) => e.baseModelId === targetBaseId);
if (!targetEntry || targetEntry.effortOptions.length === 0) return null;
const domValue = defaultSelection?.effort ?? "";
const handleChange = (raw: string): void => {
if (!manager) return;
const value = raw === "" ? null : raw;
manager
.persistDefaultSelection(descriptor.id, { baseModelId: targetBaseId, effort: value })
.catch((e) => logError(`[AgentMode] persist default effort for ${descriptor.id} failed`, e));
};
return (
<SettingItem
type="select"
title="Default effort"
description="Reasoning effort applied when starting a new session."
value={domValue}
onChange={handleChange}
options={targetEntry.effortOptions.map((o) => ({ label: o.label, value: o.value ?? "" }))}
/>
);
};

View file

@ -0,0 +1,114 @@
import {
mapProviderToOpencodeId,
ModelEnableList,
type BackendDescriptor,
type ModelEnableGroup,
} from "@/agentMode";
import { logError } from "@/logger";
import {
backendsAtom,
configuredModelsAtom,
providersAtom,
useModelManagement,
type AgentType,
} from "@/modelManagement";
import { settingsStore } from "@/settings/model";
import { useAtomValue } from "jotai";
import React from "react";
import { buildModelEnableGroups, partitionCandidates } from "./configuredModelGrouping";
interface ConfiguredModelEnableListProps {
descriptor: BackendDescriptor;
}
/** Frozen empty fallback so an untouched backend's enabled set is a stable reference. */
const EMPTY_ENABLED: readonly string[] = Object.freeze([]);
/**
* Hoisted to module scope to stay referentially stable across renders (an
* inline arrow would invalidate the partition memo every render).
*/
const isOpencodeRoutableProvider = (
provider: Parameters<typeof mapProviderToOpencodeId>[0]
): boolean => mapProviderToOpencodeId(provider) !== null;
/**
* Renders the shared `ModelEnableList` for one agent backend, sourcing
* candidates from the `configuredModels` registry and toggling through
* `BackendConfigRegistry`. opencode shows BYOK/Plus models plus its own
* agent-origin models; claude/codex show only their agent-origin models.
* Disabled rows stay visible.
*/
export const ConfiguredModelEnableList: React.FC<ConfiguredModelEnableListProps> = ({
descriptor,
}) => {
const api = useModelManagement();
// A backend's id doubles as its model-management AgentType.
const agentType = descriptor.id as AgentType;
const configuredModels = useAtomValue(configuredModelsAtom, { store: settingsStore });
const providers = useAtomValue(providersAtom, { store: settingsStore });
const backends = useAtomValue(backendsAtom, { store: settingsStore });
const [query, setQuery] = React.useState("");
const enabledIds = React.useMemo(() => {
const list = backends[agentType]?.enabledModels ?? EMPTY_ENABLED;
return new Set(list);
}, [backends, agentType]);
const isOpencode = descriptor.id === "opencode";
const partition = React.useMemo(
() =>
partitionCandidates(
configuredModels,
providers,
enabledIds,
agentType,
isOpencode,
isOpencodeRoutableProvider
),
[configuredModels, providers, enabledIds, agentType, isOpencode]
);
const groups = React.useMemo<ModelEnableGroup[]>(
() => buildModelEnableGroups(partition, isOpencode, query),
[partition, isOpencode, query]
);
const handleToggle = React.useCallback(
(id: string, enabled: boolean) => {
const run = enabled
? api.backendConfigRegistry.enableModel(agentType, id)
: api.backendConfigRegistry.disableModel(agentType, id);
run.catch((err) => logError(`[AgentMode] toggle model ${id} for ${agentType} failed`, err));
},
[api, agentType]
);
const emptyState =
descriptor.id === "opencode" ? (
<span>
No models configured yet. Add a provider on the{" "}
<span className="tw-font-medium">Models (BYOK)</span> tab, or sign in to an opencode
subscription, to curate models here.
</span>
) : (
<span>
No models reported yet. Sign in / install the {descriptor.displayName} CLI and reload, or
open a chat session with this agent.
</span>
);
return (
<ModelEnableList
groups={groups}
onToggle={handleToggle}
query={query}
onQueryChange={setQuery}
searchPlaceholder={`Search ${descriptor.displayName} models…`}
emptyState={emptyState}
/>
);
};

View file

@ -0,0 +1,282 @@
import {
buildModelEnableGroups,
opencodeOnlySubGroupLabel,
partitionCandidates,
toRow,
type Candidate,
} from "./configuredModelGrouping";
import type { ConfiguredModel, Provider } from "@/modelManagement";
function byokProvider(id: string, displayName: string): Provider {
return {
providerId: id,
providerType: "anthropic",
displayName,
origin: { kind: "byok", catalogProviderId: "anthropic" },
addedAt: 0,
};
}
function agentProvider(
id: string,
agentType: "opencode" | "claude" | "codex",
displayName = id
): Provider {
return {
providerId: id,
providerType: "openai-compatible",
displayName,
origin: { kind: "agent", agentType },
addedAt: 0,
};
}
function model(configuredModelId: string, providerId: string, infoId: string): ConfiguredModel {
return {
configuredModelId,
providerId,
info: { id: infoId, displayName: infoId },
configuredAt: 0,
};
}
describe("partitionCandidates", () => {
const byok = byokProvider("byok-1", "Anthropic");
const ocAgent = agentProvider("oc-agent", "opencode", "opencode");
const codexAgent = agentProvider("codex-agent", "codex", "Codex");
const providers = {
[byok.providerId]: byok,
[ocAgent.providerId]: ocAgent,
[codexAgent.providerId]: codexAgent,
};
const models = [
model("m-byok", "byok-1", "claude-sonnet-4-5"),
model("m-oc", "oc-agent", "opencode/big-pickle"),
model("m-codex", "codex-agent", "gpt-5"),
];
it("opencode: BYOK rows + opencode agent-origin rows; excludes other agents", () => {
const { byokPlusCandidates, agentOriginCandidates } = partitionCandidates(
models,
providers,
new Set(),
"opencode",
true
);
expect(byokPlusCandidates.map((c) => c.configuredModel.configuredModelId)).toEqual(["m-byok"]);
expect(agentOriginCandidates.map((c) => c.configuredModel.configuredModelId)).toEqual(["m-oc"]);
});
it("codex: only this agent's agent-origin rows, no BYOK", () => {
const { byokPlusCandidates, agentOriginCandidates } = partitionCandidates(
models,
providers,
new Set(),
"codex",
false
);
expect(byokPlusCandidates).toHaveLength(0);
expect(agentOriginCandidates.map((c) => c.configuredModel.configuredModelId)).toEqual([
"m-codex",
]);
});
it("reflects enabled state from the enabled-id set", () => {
const { agentOriginCandidates } = partitionCandidates(
models,
providers,
new Set(["m-codex"]),
"codex",
false
);
expect(agentOriginCandidates[0].enabled).toBe(true);
});
it("opencode: drops BYOK/Plus providers the routability predicate rejects (dead-toggle guard)", () => {
// An azure/bedrock-style BYOK provider with no catalog back-reference is
// unroutable by opencode; the predicate rejects it so it never renders.
const unroutable: Provider = {
providerId: "byok-azure",
providerType: "azure",
displayName: "Azure",
origin: { kind: "byok" }, // no catalogProviderId → unroutable
addedAt: 0,
};
const withUnroutable = {
...providers,
[unroutable.providerId]: unroutable,
};
const allModels = [...models, model("m-azure", "byok-azure", "gpt-4o")];
const isRoutable = (p: Provider): boolean =>
p.origin.kind === "byok" ? Boolean(p.origin.catalogProviderId) : true;
const { byokPlusCandidates } = partitionCandidates(
allModels,
withUnroutable,
new Set(),
"opencode",
true,
isRoutable
);
// Only the routable BYOK provider survives; the azure row is dropped.
expect(byokPlusCandidates.map((c) => c.configuredModel.configuredModelId)).toEqual(["m-byok"]);
});
it("opencode: keeps every BYOK/Plus provider when no routability predicate is given", () => {
const unroutable: Provider = {
providerId: "byok-azure",
providerType: "azure",
displayName: "Azure",
origin: { kind: "byok" },
addedAt: 0,
};
const allModels = [...models, model("m-azure", "byok-azure", "gpt-4o")];
const { byokPlusCandidates } = partitionCandidates(
allModels,
{ ...providers, [unroutable.providerId]: unroutable },
new Set(),
"opencode",
true
);
expect(byokPlusCandidates.map((c) => c.configuredModel.configuredModelId).sort()).toEqual([
"m-azure",
"m-byok",
]);
});
it("skips models whose provider row is missing", () => {
const orphan = [model("orphan", "missing-provider", "x")];
const { byokPlusCandidates, agentOriginCandidates } = partitionCandidates(
orphan,
providers,
new Set(),
"opencode",
true
);
expect(byokPlusCandidates).toHaveLength(0);
expect(agentOriginCandidates).toHaveLength(0);
});
});
describe("opencodeOnlySubGroupLabel", () => {
const provider = agentProvider("oc-agent", "opencode", "opencode");
it("derives the label from the wire-id prefix (first /-segment)", () => {
expect(opencodeOnlySubGroupLabel(model("a", "oc-agent", "opencode/big-pickle"), provider)).toBe(
"opencode"
);
expect(
opencodeOnlySubGroupLabel(model("b", "oc-agent", "openrouter/anthropic/claude"), provider)
).toBe("openrouter");
});
it("falls back to the provider display name when the id has no prefix", () => {
expect(opencodeOnlySubGroupLabel(model("c", "oc-agent", "bare-model"), provider)).toBe(
"opencode"
);
});
});
describe("toRow", () => {
it("surfaces the wire id as a description only when it differs from the label", () => {
const provider = byokProvider("p", "Anthropic");
const withName: Candidate = {
configuredModel: {
configuredModelId: "cm",
providerId: "p",
info: { id: "claude-sonnet-4-5", displayName: "Claude Sonnet 4.5" },
configuredAt: 0,
},
provider,
enabled: true,
};
const row = toRow(withName);
expect(row.label).toBe("Claude Sonnet 4.5");
expect(row.description).toBe("claude-sonnet-4-5");
const sameAsId: Candidate = {
configuredModel: model("cm2", "p", "raw-id"),
provider,
enabled: false,
};
expect(toRow(sameAsId).description).toBeUndefined();
});
});
describe("buildModelEnableGroups", () => {
const byok = byokProvider("byok-1", "Anthropic");
const ocAgent = agentProvider("oc-agent", "opencode", "opencode");
it("opencode: BYOK group is always-visible; opencode-only sub-groups derive from wire prefix and collapse", () => {
const partition = {
byokPlusCandidates: [
{
configuredModel: model("m-byok", "byok-1", "claude-sonnet-4-5"),
provider: byok,
enabled: true,
},
],
agentOriginCandidates: [
{
configuredModel: model("m-oc1", "oc-agent", "opencode/big-pickle"),
provider: ocAgent,
enabled: false,
},
{
configuredModel: model("m-oc2", "oc-agent", "openrouter/x"),
provider: ocAgent,
enabled: false,
},
],
};
const groups = buildModelEnableGroups(partition, true, "");
const byokGroup = groups.find((g) => g.key === "byok:byok-1");
expect(byokGroup?.collapsible).toBe(false);
expect(byokGroup?.label).toBe("Anthropic");
const openGroup = groups.find((g) => g.label === "opencode");
const routerGroup = groups.find((g) => g.label === "openrouter");
expect(openGroup?.collapsible).toBe(true);
expect(routerGroup?.collapsible).toBe(true);
expect(openGroup?.rows.map((r) => r.id)).toEqual(["m-oc1"]);
expect(routerGroup?.rows.map((r) => r.id)).toEqual(["m-oc2"]);
});
it("filters rows by the search query and drops empty groups", () => {
const partition = {
byokPlusCandidates: [],
agentOriginCandidates: [
{
configuredModel: model("m-oc1", "oc-agent", "opencode/big-pickle"),
provider: ocAgent,
enabled: false,
},
{
configuredModel: model("m-oc2", "oc-agent", "openrouter/x"),
provider: ocAgent,
enabled: false,
},
],
};
const groups = buildModelEnableGroups(partition, true, "pickle");
expect(groups.map((g) => g.label)).toEqual(["opencode"]);
expect(groups[0].rows.map((r) => r.id)).toEqual(["m-oc1"]);
});
it("claude/codex: agent-origin rows render as an always-visible provider group", () => {
const codexAgent = agentProvider("codex-agent", "codex", "Codex");
const partition = {
byokPlusCandidates: [],
agentOriginCandidates: [
{
configuredModel: model("m-codex", "codex-agent", "gpt-5"),
provider: codexAgent,
enabled: true,
},
],
};
const groups = buildModelEnableGroups(partition, false, "");
expect(groups).toHaveLength(1);
expect(groups[0].label).toBe("Codex");
expect(groups[0].collapsible).toBe(false);
});
});

View file

@ -0,0 +1,144 @@
/** Pure grouping logic for `ConfiguredModelEnableList`, split from the React container so it's testable with plain data. */
import type { ModelEnableGroup, ModelEnableRow } from "@/agentMode";
import type { ConfiguredModel, Provider } from "@/modelManagement";
/** One candidate model joined to its provider, plus current enabled state. */
export interface Candidate {
configuredModel: ConfiguredModel;
provider: Provider;
enabled: boolean;
}
/** The two candidate buckets a backend's configured models partition into. */
export interface CandidatePartition {
/** BYOK/Plus configured models (opencode candidates only). */
byokPlusCandidates: Candidate[];
/** This agent's own agent-origin models. */
agentOriginCandidates: Candidate[];
}
/**
* Split configured models into this backend's candidate buckets: agent-origin
* models matching `agentType`, plus BYOK/Plus models for opencode only.
*
* For opencode, a BYOK/Plus provider it can't route (`isOpencodeRoutable`
* false azure / bedrock / self-hosted) is dropped to avoid a dead toggle:
* `opencodeEnabledWireIds` skips unroutable providers at injection and
* picker-filter time, so enabling one would never reach the agent or picker.
*/
export function partitionCandidates(
configuredModels: readonly ConfiguredModel[],
providers: Readonly<Record<string, Provider>>,
enabledIds: ReadonlySet<string>,
agentType: string,
isOpencode: boolean,
isOpencodeRoutable?: (provider: Provider) => boolean
): CandidatePartition {
const byokPlusCandidates: Candidate[] = [];
const agentOriginCandidates: Candidate[] = [];
for (const configuredModel of configuredModels) {
const provider = providers[configuredModel.providerId];
if (!provider) continue;
const candidate: Candidate = {
configuredModel,
provider,
enabled: enabledIds.has(configuredModel.configuredModelId),
};
const origin = provider.origin;
if (origin.kind === "agent") {
// Agent-origin models are exclusive to their agent's picker.
if (origin.agentType === agentType) agentOriginCandidates.push(candidate);
continue;
}
// BYOK / Plus rows are candidates for opencode only — and only when
// opencode can actually route the provider (no dead toggles).
if (isOpencode && (origin.kind === "byok" || origin.kind === "copilot-plus")) {
if (isOpencodeRoutable && !isOpencodeRoutable(provider)) continue;
byokPlusCandidates.push(candidate);
}
}
return { byokPlusCandidates, agentOriginCandidates };
}
/**
* The opencode-only sub-group label: the wire-id prefix (e.g.
* `opencode/big-pickle` `opencode`). All opencode-only models live under one
* agent provider with full-prefixed ids, so sub-grouping comes from the prefix,
* not separate Provider rows. Falls back to the provider name when unprefixed.
*/
export function opencodeOnlySubGroupLabel(model: ConfiguredModel, provider: Provider): string {
const slash = model.info.id.indexOf("/");
if (slash > 0) return model.info.id.slice(0, slash);
return provider.displayName;
}
/** A `ModelEnableRow` from a candidate, surfacing the wire id as a secondary line when it differs from the label. */
export function toRow(candidate: Candidate): ModelEnableRow {
const { configuredModel, enabled } = candidate;
const label = configuredModel.info.displayName || configuredModel.info.id;
return {
id: configuredModel.configuredModelId,
label,
description: label === configuredModel.info.id ? undefined : configuredModel.info.id,
enabled,
};
}
/** Case-insensitive match of a row against a (lowercased) search query. */
export function rowMatches(row: ModelEnableRow, q: string): boolean {
if (!q) return true;
return (
row.label.toLowerCase().includes(q) ||
row.id.toLowerCase().includes(q) ||
(row.description?.toLowerCase().includes(q) ?? false)
);
}
/**
* Provider-grouped rows for the shared list. BYOK/Plus candidates get one
* always-visible group per provider; agent-origin candidates get collapsible
* wire-prefix sub-groups for opencode (its catalog floods) or a per-provider
* group for claude/codex. Groups emptied by `query` are dropped.
*/
export function buildModelEnableGroups(
partition: CandidatePartition,
isOpencode: boolean,
query: string
): ModelEnableGroup[] {
const q = query.trim().toLowerCase();
const out: ModelEnableGroup[] = [];
// BYOK/Plus providers — one group per provider, always visible.
const byProvider = new Map<string, { label: string; rows: ModelEnableRow[] }>();
for (const candidate of partition.byokPlusCandidates) {
const row = toRow(candidate);
if (!rowMatches(row, q)) continue;
const key = candidate.provider.providerId;
const bucket = byProvider.get(key);
if (bucket) bucket.rows.push(row);
else byProvider.set(key, { label: candidate.provider.displayName, rows: [row] });
}
for (const [key, { label, rows }] of byProvider) {
out.push({ key: `byok:${key}`, label, collapsible: false, rows });
}
// Agent-origin models — opencode-only sub-groups (by wire prefix) or a
// provider group for claude/codex.
const bySubGroup = new Map<string, { label: string; rows: ModelEnableRow[] }>();
for (const candidate of partition.agentOriginCandidates) {
const label = isOpencode
? opencodeOnlySubGroupLabel(candidate.configuredModel, candidate.provider)
: candidate.provider.displayName;
const row = toRow(candidate);
if (!rowMatches(row, q)) continue;
const bucket = bySubGroup.get(label);
if (bucket) bucket.rows.push(row);
else bySubGroup.set(label, { label, rows: [row] });
}
for (const [label, { rows }] of bySubGroup) {
out.push({ key: `agent:${label}`, label, collapsible: isOpencode, rows });
}
return out;
}