mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Add BYOK settings - agent enablement
This commit is contained in:
parent
d554434020
commit
60185fc55a
61 changed files with 3739 additions and 82001 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
80316
.modelsCatalogCache.json
80316
.modelsCatalogCache.json
File diff suppressed because it is too large
Load diff
420
AGENTS.md
420
AGENTS.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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` (M1–M5, 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:**
|
||||
|
|
|
|||
8
designdocs/agents/DOCS_GUIDE.md
Normal file
8
designdocs/agents/DOCS_GUIDE.md
Normal 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.
|
||||
48
designdocs/agents/PLUGIN_DEV_GUIDE.md
Normal file
48
designdocs/agents/PLUGIN_DEV_GUIDE.md
Normal 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.
|
||||
52
designdocs/agents/PROCESS_GUIDE.md
Normal file
52
designdocs/agents/PROCESS_GUIDE.md
Normal 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
|
||||
```
|
||||
60
designdocs/agents/STYLE_GUIDE.md
Normal file
60
designdocs/agents/STYLE_GUIDE.md
Normal 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.
|
||||
|
|
@ -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
|
||||
26
designdocs/agents/VENDOR_GUIDE.md
Normal file
26
designdocs/agents/VENDOR_GUIDE.md
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" } } },
|
||||
],
|
||||
|
|
|
|||
453
src/agentMode/agentModelDiscovery.test.ts
Normal file
453
src/agentMode/agentModelDiscovery.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
221
src/agentMode/agentModelDiscovery.ts
Normal file
221
src/agentMode/agentModelDiscovery.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
162
src/agentMode/backends/opencode/opencodeModelResolve.test.ts
Normal file
162
src/agentMode/backends/opencode/opencodeModelResolve.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
83
src/agentMode/backends/opencode/opencodeModelResolve.ts
Normal file
83
src/agentMode/backends/opencode/opencodeModelResolve.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
40
src/agentMode/backends/opencode/opencodeProbePartition.ts
Normal file
40
src/agentMode/backends/opencode/opencodeProbePartition.ts
Normal 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;
|
||||
}
|
||||
82
src/agentMode/backends/shared/agentEnabledModels.test.ts
Normal file
82
src/agentMode/backends/shared/agentEnabledModels.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
43
src/agentMode/backends/shared/agentEnabledModels.ts
Normal file
43
src/agentMode/backends/shared/agentEnabledModels.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
src/agentMode/session/agentDefaultEnable.test.ts
Normal file
27
src/agentMode/session/agentDefaultEnable.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
27
src/agentMode/session/agentDefaultEnable.ts
Normal file
27
src/agentMode/session/agentDefaultEnable.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 “{query}”.
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
159
src/agentMode/ui/ModelEnableList.tsx
Normal file
159
src/agentMode/ui/ModelEnableList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 ----
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
src/main.ts
10
src/main.ts
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)!;
|
||||
|
|
|
|||
581
src/modelManagement/setup/AgentSetupApi.test.ts
Normal file
581
src/modelManagement/setup/AgentSetupApi.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? "" }))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
114
src/settings/v2/components/ConfiguredModelEnableList.tsx
Normal file
114
src/settings/v2/components/ConfiguredModelEnableList.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
282
src/settings/v2/components/configuredModelGrouping.test.ts
Normal file
282
src/settings/v2/components/configuredModelGrouping.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
144
src/settings/v2/components/configuredModelGrouping.ts
Normal file
144
src/settings/v2/components/configuredModelGrouping.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue