mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-29 07:25:33 +08:00
feat: add opencode support (#762)
* feat: add opencode support
* fix: stabilize opencode session startup
* fix: /models
* fix: improveUI for commands
* fix: format commands.js
* feat: load models through provider adapters
Provider model selection had outgrown a single hardcoded service.
The old service mixed shared caching with provider catalogs and CLI lookup details.
That made stale model lists more likely as providers changed on separate schedules.
Move model discovery behind each provider so lookup lives next to the integration.
The shared service now focuses on provider resolution, caching, persistence, and dedupe.
Return cache metadata and add bypassCache because model availability changes outside the app.
The UI and /models command can show freshness and let users force a provider refresh.
Surface model descriptions while keeping fallback catalogs for unavailable CLIs or SDKs.
* feat(models): resolve active session models through provider adapters
The model inventory command was showing a mix of catalog defaults and
composer-local state instead of the model that is actually active for a
real provider session. That made /models, /cost, and /status
misleading once a session had already started, especially for providers
whose effective runtime model can differ from the optimistic model value
held in the UI.
Introduce an explicit getCurrentActiveModel() contract on
IProviderModels so model resolution lives next to each provider's
catalog logic and uses the provider-native source of truth:
- Claude reads the init event from a resumed stream-json run
- Codex reads model from ~/.codex/config.toml
- Cursor reads lastUsedModel from the chat store.db
- OpenCode reads the persisted session model from opencode.db
- Gemini intentionally returns its default because the CLI does not
provide a reliable active-session lookup
Keep the returned shape intentionally minimal ({ model }). The goal is
to expose only what downstream command consumers need and avoid leaking
provider-specific metadata into a shared transport shape that would
create extra UI coupling and future cleanup cost.
Also make command behavior session-aware: when there is no concrete
session id, do not spawn provider processes or inspect provider session
storage just to answer /models, /cost, or /status. In a new-session
view the correct answer is simply the provider default, and doing more
work there adds latency and unnecessary side effects for no user value.
As part of this, centralize two supporting concerns:
- add a shared helper for building the default current-model result from
a provider catalog so fallbacks stay aligned with DEFAULT
- move leaf-directory validation into shared utils so Cursor session
readers and model lookup code enforce the same path-safety rule
Tests were expanded to cover both the new service delegation path and
the sessionless command behavior, while keeping cache-sensitive tests
isolated from persisted host cache state.
Why this change:
- command output should reflect the model actually driving a session
- new-session views should stay fast and side-effect free
- provider-specific active-model lookup should not be scattered across
routes or UI code
- fallback behavior should be explicit, consistent, and limited to the
provider default when no true active model can be resolved
* feat: support session-scoped model overrides
Model selection was acting like a provider-level preference.
That made resumed sessions drift back to a default or request-time model.
Users expect /models changes made inside a conversation to affect that session.
Store explicit session choices in app-owned ~/.cloudcli state.
This avoids editing provider transcripts or native provider config.
Resolve the effective model before launching each provider runtime.
Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices.
Expose a backend active-model change endpoint for existing sessions.
The models modal can now distinguish default changes from session overrides.
It also shows when a selected model will apply on the next response.
For Claude, stop probing active model state by resuming with a dummy prompt.
Read the indexed JSONL transcript from the end instead.
This preserves provider history while honoring /model stdout or model fields.
Add service tests for adapter delegation and resume-model precedence.
The tests keep cache state, override state, and requested fallback separate.
* feat: make command modal more compact
* fix: preserve opencode session creation events
OpenCode emits the real session id asynchronously on its first JSON output. The runner
registered that id from a helper that could not see the spawned process because
the process reference was scoped inside the model-resolution callback. That
ReferenceError was swallowed by the generic JSON parse fallback, so the client
never received session_created. Without that event, a new OpenCode chat stayed
on / and the assistant stream was not attached to the new session view.
Keep the process reference in the outer spawn scope so registration can update
the active-process map and websocket writer as soon as OpenCode announces the
session id. Split JSON parsing from event processing so malformed non-JSON
output can still stream as raw text, while registration or adapter failures are
surfaced as real errors instead of being hidden as assistant content.
Add a fake opencode executable regression test to lock in the expected lifecycle
ordering: session_created must be sent before live assistant messages, and the
same session id must carry through stream_end and complete.
* fix: clarify model refresh and onboarding providers
OpenCode is now a supported chat provider, but first-run onboarding still only offered
Claude, Cursor, Codex, and Gemini. That made OpenCode harder to discover and
forced users to finish setup before finding the provider in settings or chat.
Adding it to onboarding keeps first-run setup aligned with the providers the
application already supports elsewhere.
The model refresh control was also doing too much visual work. In the new chat
model picker, the previous Hard Refresh label looked like the dialog heading,
which made the primary task unclear. Users open that dialog to choose a model;
refreshing catalogs is only a secondary maintenance action for stale cached
provider model lists.
Rename and reposition the refresh affordance so the model picker reads as a
model picker first. The copy now explains why catalogs are cached, when a refresh
is useful, and that the refresh checks every provider. The /models modal gets the
same clarification so both model-selection surfaces describe the cache behavior
consistently.
* fix: format opencode model catalog labels
OpenCode returns provider-prefixed ids directly from the CLI. Passing those ids through as
labels made the model picker hard to scan: users saw values like
anthropic/claude-3-5-sonnet-20241022 or lowercased, hyphen-split text instead
of readable model names.
Keep the exact OpenCode id as the option value because that is what the CLI
expects, but derive a presentation label for the frontend. The formatter is
intentionally generic rather than a catalog of known providers. It handles common
identifier structure such as provider/model, hyphen-delimited words, v-prefixed
versions, adjacent numeric version tokens, and 8-digit date suffixes.
This keeps OpenCode usable as its model list expands across many upstream
providers without requiring code changes for every new provider or model family.
The description keeps the raw provider-prefixed id visible so users can still
confirm the precise model being selected.
* feat: add more fallback models for cursor
* docs: move model catalog out of shared
The model catalog is no longer a frontend/backend runtime contract.
Keeping it under shared made ownership misleading. It implied the catalog was
application code shared by runtime consumers, even though it now only supports
README links and public API documentation.
Move the catalog into public so it lives beside the docs surfaces that need it.
This gives the API docs a stable, served module and gives README readers a
linkable source without suggesting frontend or backend runtime dependency.
Render the API docs model list from the exported provider registry instead of a
hardcoded Claude/Cursor/Codex subset. That keeps Gemini and OpenCode visible and
makes future provider documentation changes flow through one docs-specific file.
Update README links, provider maintenance notes, and package files so published
artifacts include the standalone docs page and model catalog without relying on
the old shared path.
* fix: simplify empty-state model selector
Keep the provider empty state focused on the setup action users need there:
choosing a model.
The refresh control, cache timestamp, and refresh explanation made the dialog feel
like a cache-management surface.
That extra action is out of place in the empty state, where the goal is to start
a chat with the selected provider and model.
Remove the refresh-specific UI from ProviderSelectionEmptyState and drop the
now-unused refresh/cache props from the ChatMessagesPane pass-through.
Refresh behavior remains available in the dedicated command result flow.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export { initializeDatabase } from '@/modules/database/init-db.js';
|
||||
export { closeConnection, getConnection, getDatabasePath } from '@/modules/database/connection.js';
|
||||
export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
|
||||
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
||||
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
||||
|
||||
@@ -33,6 +33,7 @@ type ProjectApiView = {
|
||||
cursorSessions: [];
|
||||
codexSessions: [];
|
||||
geminiSessions: [];
|
||||
opencodeSessions: [];
|
||||
sessionMeta: {
|
||||
hasMore: false;
|
||||
total: 0;
|
||||
@@ -84,6 +85,7 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
|
||||
cursorSessions: [],
|
||||
codexSessions: [],
|
||||
geminiSessions: [],
|
||||
opencodeSessions: [],
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0,
|
||||
|
||||
@@ -14,7 +14,7 @@ type SessionSummary = {
|
||||
lastActivity: string;
|
||||
};
|
||||
|
||||
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini', SessionSummary[]>;
|
||||
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
|
||||
|
||||
type SessionRepositoryRow = {
|
||||
provider: string;
|
||||
@@ -34,6 +34,7 @@ export type ProjectListItem = {
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -74,6 +75,7 @@ export type ProjectSessionsPageApiView = {
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -139,6 +141,7 @@ function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByPr
|
||||
cursor: [],
|
||||
codex: [],
|
||||
gemini: [],
|
||||
opencode: [],
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -253,6 +256,7 @@ export async function getProjectsWithSessions(
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
@@ -309,6 +313,7 @@ export async function getArchivedProjectsWithSessions(
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
@@ -341,6 +346,7 @@ export async function getProjectSessionsPage(
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
|
||||
@@ -37,6 +37,7 @@ Current provider ids in this repo are:
|
||||
- `codex`
|
||||
- `cursor`
|
||||
- `gemini`
|
||||
- `opencode`
|
||||
|
||||
Those ids are mirrored in backend unions and frontend provider constants. If
|
||||
adding a new provider, update every place that hardcodes this list.
|
||||
@@ -55,7 +56,8 @@ server/modules/providers/list/<provider>/
|
||||
<provider>-session-synchronizer.provider.ts
|
||||
```
|
||||
|
||||
The existing provider folders are `claude`, `codex`, `cursor`, and `gemini`.
|
||||
The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
|
||||
`opencode`.
|
||||
|
||||
## What Each Facet Does
|
||||
|
||||
@@ -81,7 +83,7 @@ The existing provider folders are `claude`, `codex`, `cursor`, and `gemini`.
|
||||
- Update `server/modules/providers/provider.routes.ts`.
|
||||
- Update `server/routes/agent.js` if the provider is launchable from the agent runtime.
|
||||
- Update `server/index.js` if the provider needs runtime boot or shutdown wiring.
|
||||
- Update `shared/modelConstants.js` if the provider appears in UI provider pickers.
|
||||
- Update `public/modelConstants.js` if the provider appears in README or public API docs.
|
||||
- Update `src/components/chat/hooks/useChatProviderState.ts` and
|
||||
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
|
||||
the provider should be selectable in chat.
|
||||
@@ -122,6 +124,7 @@ Current MCP formats in this repo are:
|
||||
| Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` |
|
||||
| Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` |
|
||||
| Gemini | `.gemini/settings.json` | `user`, `project` | `stdio`, `http` |
|
||||
| OpenCode | `~/.config/opencode/opencode.json` or `<workspace>/opencode.json` (`.jsonc` is read when present) | `user`, `project` | `stdio`, `http` |
|
||||
|
||||
5. Implement skills.
|
||||
|
||||
@@ -142,6 +145,7 @@ Current skill discovery roots are:
|
||||
| Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `<workspace>/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. |
|
||||
| Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
|
||||
| Gemini | `~/.gemini/skills`, `~/.agents/skills` | `<workspace>/.gemini/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
|
||||
| OpenCode | `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | Cwd-to-topmost-git-root `.opencode/skills`, `.claude/skills`, and `.agents/skills` | `/` | Reuses OpenCode, Claude, and Agents skill locations. Overlapping roots are deduplicated before scanning. |
|
||||
|
||||
Command forms currently used by the providers are:
|
||||
|
||||
@@ -150,6 +154,7 @@ Command forms currently used by the providers are:
|
||||
- Codex skills: `$skill-name`
|
||||
- Cursor skills: `/skill-name`
|
||||
- Gemini skills: `/skill-name`
|
||||
- OpenCode skills: `/skill-name`
|
||||
|
||||
6. Implement sessions.
|
||||
|
||||
@@ -187,6 +192,7 @@ Current session sync roots are:
|
||||
| Codex | `~/.codex/sessions/**/*.jsonl` | Uses `~/.codex/session_index.jsonl` for title lookup and the last `task_complete` message for a fallback title. |
|
||||
| Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. |
|
||||
| Gemini | `~/.gemini/tmp/**/*.jsonl` | Current full scans only index temp JSONL chat artifacts. Single-file sync also accepts legacy `.json` files. |
|
||||
| OpenCode | `~/.local/share/opencode/opencode.db` | Reads active sessions/messages/parts from OpenCode's shared SQLite database and stores `jsonl_path` as `null` so deleting one app session cannot remove the shared DB. |
|
||||
|
||||
8. Register the provider.
|
||||
|
||||
@@ -203,10 +209,11 @@ If the provider can run live chat sessions, update the runtime entrypoints too:
|
||||
|
||||
If the provider is visible in the UI, update:
|
||||
|
||||
- `shared/modelConstants.js`
|
||||
- provider model fallback files under `server/modules/providers/list/<provider>/`
|
||||
- `src/components/chat/hooks/useChatProviderState.ts`
|
||||
- `src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx`
|
||||
- `src/components/provider-auth/view/ProviderLoginModal.tsx`
|
||||
- `src/components/mcp/constants.ts`
|
||||
|
||||
## Minimal Wrapper Template
|
||||
|
||||
@@ -324,6 +331,7 @@ Useful tests in this repo:
|
||||
|
||||
- `server/modules/providers/tests/mcp.test.ts`
|
||||
- `server/modules/providers/tests/skills.test.ts`
|
||||
- `server/modules/providers/tests/opencode-sessions.test.ts`
|
||||
|
||||
If you touch sessions or session synchronization, add or update focused tests
|
||||
alongside the implementation.
|
||||
|
||||
230
server/modules/providers/list/claude/claude-models.provider.ts
Normal file
230
server/modules/providers/list/claude/claude-models.provider.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { query, type ModelInfo, type Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelOption,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'default', label: 'Default (recommended)' },
|
||||
{ value: 'sonnet[1m]', label: 'Sonnet (1M context)' },
|
||||
{ value: 'opus', label: 'Opus' },
|
||||
{ value: 'opus[1m]', label: 'Opus (1M context)' },
|
||||
{ value: 'haiku', label: 'Haiku' },
|
||||
{ value: 'sonnet', label: 'sonnet' },
|
||||
],
|
||||
DEFAULT: 'default',
|
||||
};
|
||||
|
||||
type ClaudeModelQueryOptions = Pick<Options, 'env' | 'pathToClaudeCodeExecutable' | 'permissionMode'>;
|
||||
type ClaudeInitEvent = {
|
||||
sessionId?: string;
|
||||
session_id?: string;
|
||||
type?: string;
|
||||
subtype?: string;
|
||||
model?: string;
|
||||
message?: {
|
||||
content?: unknown;
|
||||
model?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const ANSI_PATTERN = new RegExp(
|
||||
'[\\u001B\\u009B][[\\]()#;?]*(?:'
|
||||
+ '(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]'
|
||||
+ '|(?:[\\dA-PR-TZcf-ntqry=><~]))',
|
||||
'g',
|
||||
);
|
||||
|
||||
const buildClaudeQueryOptions = (): ClaudeModelQueryOptions => ({
|
||||
env: { ...process.env },
|
||||
pathToClaudeCodeExecutable: resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH),
|
||||
permissionMode: 'default',
|
||||
});
|
||||
|
||||
const mapClaudeModel = (model: ModelInfo): ProviderModelOption => ({
|
||||
value: model.value,
|
||||
label: model.displayName || model.value,
|
||||
description: model.description || undefined,
|
||||
});
|
||||
|
||||
const buildClaudeModelsDefinition = (models: ModelInfo[]): ProviderModelsDefinition => {
|
||||
const options: ProviderModelOption[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
for (const model of models) {
|
||||
const mappedModel = mapClaudeModel(model);
|
||||
if (seenValues.has(mappedModel.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(mappedModel.value);
|
||||
options.push(mappedModel);
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return CLAUDE_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
const defaultValue = options.find((option) => option.value === 'default')?.value
|
||||
?? options[0]?.value
|
||||
?? CLAUDE_FALLBACK_MODELS.DEFAULT;
|
||||
|
||||
return {
|
||||
OPTIONS: options,
|
||||
DEFAULT: defaultValue,
|
||||
};
|
||||
};
|
||||
|
||||
const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => {
|
||||
const eventSessionId = event.sessionId ?? event.session_id;
|
||||
if (eventSessionId && eventSessionId !== sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentModel = extractClaudeModelFromMessageContent(event.message?.content);
|
||||
if (contentModel) {
|
||||
return contentModel;
|
||||
}
|
||||
|
||||
const directModel = event.model?.trim();
|
||||
if (directModel) {
|
||||
return directModel;
|
||||
}
|
||||
|
||||
const messageModel = event.message?.model?.trim();
|
||||
return messageModel || null;
|
||||
};
|
||||
|
||||
const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, '');
|
||||
|
||||
const extractTaggedContent = (content: string, tagName: string): string | null => {
|
||||
const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const extractClaudeModelFromTextContent = (content: string): string | null => {
|
||||
const localCommandStdout = extractTaggedContent(content, 'local-command-stdout');
|
||||
if (localCommandStdout !== null) {
|
||||
const cleanedStdout = stripAnsi(localCommandStdout).replace(/\s+/g, ' ').trim();
|
||||
const changedModel = /(?:set|changed|switched)\s+model\s+to\s+(.+?)\.?$/i.exec(cleanedStdout);
|
||||
if (changedModel?.[1]?.trim()) {
|
||||
return changedModel[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
const modelTag = extractTaggedContent(content, 'model')?.trim();
|
||||
return modelTag || null;
|
||||
};
|
||||
|
||||
const extractClaudeModelFromMessageContent = (content: unknown): string | null => {
|
||||
if (typeof content === 'string') {
|
||||
return extractClaudeModelFromTextContent(content);
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object' || !('text' in part) || typeof part.text !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const model = extractClaudeModelFromTextContent(part.text);
|
||||
if (model) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const readClaudeSessionModelFromJsonl = async (
|
||||
sessionId: string,
|
||||
jsonlPath: string,
|
||||
): Promise<ProviderCurrentActiveModel | null> => {
|
||||
const content = await readFile(jsonlPath, 'utf8');
|
||||
const lines = content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
try {
|
||||
const event = JSON.parse(lines[index]) as ClaudeInitEvent;
|
||||
const model = extractClaudeEventModel(event, sessionId);
|
||||
if (model) {
|
||||
return { model };
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed JSONL lines that can happen during concurrent writes.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export class ClaudeProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
let queryInstance: ReturnType<typeof query> | null = null;
|
||||
|
||||
try {
|
||||
// The SDK exposes its runtime model catalog on the initialized query
|
||||
// instance, so we create a lightweight query and immediately close it
|
||||
// after reading the control-plane metadata.
|
||||
queryInstance = query({
|
||||
prompt: 'Get supported models',
|
||||
options: buildClaudeQueryOptions(),
|
||||
});
|
||||
|
||||
const supportedModels = await queryInstance.supportedModels();
|
||||
|
||||
return buildClaudeModelsDefinition(supportedModels);
|
||||
} catch {
|
||||
return CLAUDE_FALLBACK_MODELS;
|
||||
} finally {
|
||||
queryInstance?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
|
||||
if (!sessionId?.trim()) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonlPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
const activeModel = jsonlPath
|
||||
? await readClaudeSessionModelFromJsonl(sessionId, jsonlPath)
|
||||
: null;
|
||||
if (activeModel?.model) {
|
||||
return activeModel;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the provider default when the session-backed lookup fails.
|
||||
}
|
||||
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('claude', input);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js';
|
||||
import { ClaudeProviderModels } from '@/modules/providers/list/claude/claude-models.provider.js';
|
||||
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
|
||||
import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js';
|
||||
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
|
||||
import { ClaudeSkillsProvider } from '@/modules/providers/list/claude/claude-skills.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class ClaudeProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new ClaudeProviderModels();
|
||||
readonly mcp = new ClaudeMcpProvider();
|
||||
readonly auth: IProviderAuth = new ClaudeProviderAuth();
|
||||
readonly skills: IProviderSkills = new ClaudeSkillsProvider();
|
||||
|
||||
125
server/modules/providers/list/codex/codex-models.provider.ts
Normal file
125
server/modules/providers/list/codex/codex-models.provider.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import TOML from '@iarna/toml';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelOption,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'gpt-5.5', label: 'gpt-5.5' },
|
||||
{ value: 'gpt-5.4', label: 'gpt-5.4' },
|
||||
{ value: 'gpt-5.4-mini', label: 'gpt-5.4-mini' },
|
||||
{ value: 'gpt-5.3-codex', label: 'gpt-5.3-codex' },
|
||||
{ value: 'gpt-5.2', label: 'gpt-5.2' },
|
||||
],
|
||||
DEFAULT: 'gpt-5.4',
|
||||
};
|
||||
|
||||
type CodexCachedModel = {
|
||||
slug?: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
visibility?: string;
|
||||
supported_in_api?: boolean;
|
||||
};
|
||||
|
||||
const CODEX_MODELS_CACHE_PATH = path.join(os.homedir(), '.codex', 'models_cache.json');
|
||||
const CODEX_CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
|
||||
const isCodexCachedModel = (value: unknown): value is CodexCachedModel => {
|
||||
const record = readObjectRecord(value);
|
||||
return Boolean(record && readOptionalString(record.slug));
|
||||
};
|
||||
|
||||
const readCodexPriority = (value: unknown): number => (
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER
|
||||
);
|
||||
|
||||
const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({
|
||||
value: model.slug as string,
|
||||
label: readOptionalString(model.display_name) ?? (model.slug as string),
|
||||
description: readOptionalString(model.description),
|
||||
});
|
||||
|
||||
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
|
||||
const sortedModels = [...models]
|
||||
.filter((model) => model.visibility !== 'hidden' && model.supported_in_api !== false)
|
||||
.sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority));
|
||||
|
||||
const options: ProviderModelOption[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
for (const model of sortedModels) {
|
||||
const mappedModel = mapCodexModel(model);
|
||||
if (seenValues.has(mappedModel.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(mappedModel.value);
|
||||
options.push(mappedModel);
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return CODEX_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
return {
|
||||
OPTIONS: options,
|
||||
DEFAULT: options[0]?.value ?? CODEX_FALLBACK_MODELS.DEFAULT,
|
||||
};
|
||||
};
|
||||
|
||||
export class CodexProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
try {
|
||||
const raw = await readFile(CODEX_MODELS_CACHE_PATH, 'utf8');
|
||||
const parsed = readObjectRecord(JSON.parse(raw));
|
||||
const models = Array.isArray(parsed?.models)
|
||||
? parsed.models.filter(isCodexCachedModel)
|
||||
: [];
|
||||
|
||||
return buildCodexModelsDefinition(models);
|
||||
} catch {
|
||||
return CODEX_FALLBACK_MODELS;
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||
try {
|
||||
const raw = await readFile(CODEX_CONFIG_PATH, 'utf8');
|
||||
const parsed = readObjectRecord(TOML.parse(raw));
|
||||
const model = readOptionalString(parsed?.model);
|
||||
if (!model) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
};
|
||||
} catch {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('codex', input);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,12 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||
|
||||
const hasGitMarker = async (dirPath: string): Promise<boolean> => {
|
||||
try {
|
||||
const gitMarkerStats = await fs.stat(path.join(dirPath, '.git'));
|
||||
return gitMarkerStats.isDirectory() || gitMarkerStats.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const findTopmostGitRoot = async (startPath: string): Promise<string | null> => {
|
||||
let currentPath = path.resolve(startPath);
|
||||
let topmostGitRoot: string | null = null;
|
||||
|
||||
while (true) {
|
||||
if (await hasGitMarker(currentPath)) {
|
||||
topmostGitRoot = currentPath;
|
||||
}
|
||||
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentPath = parentPath;
|
||||
}
|
||||
|
||||
return topmostGitRoot;
|
||||
};
|
||||
|
||||
const addUniqueSource = (
|
||||
sources: ProviderSkillSource[],
|
||||
seenRootDirs: Set<string>,
|
||||
source: ProviderSkillSource,
|
||||
): void => {
|
||||
const normalizedRootDir = path.resolve(source.rootDir);
|
||||
if (seenRootDirs.has(normalizedRootDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seenRootDirs.add(normalizedRootDir);
|
||||
sources.push({ ...source, rootDir: normalizedRootDir });
|
||||
};
|
||||
import {
|
||||
addUniqueProviderSkillSource,
|
||||
findTopmostGitRoot,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export class CodexSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
@@ -58,7 +18,7 @@ export class CodexSkillsProvider extends SkillsProvider {
|
||||
const seenRootDirs = new Set<string>();
|
||||
const repoRoot = await findTopmostGitRoot(workspacePath);
|
||||
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
@@ -67,29 +27,29 @@ export class CodexSkillsProvider extends SkillsProvider {
|
||||
if (repoRoot) {
|
||||
// Codex checks repository skills at the launch folder, one folder above it,
|
||||
// and the topmost git root; these can collapse to the same directory.
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(path.dirname(workspacePath), '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(repoRoot, '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
}
|
||||
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'admin',
|
||||
rootDir: path.join('/etc', 'codex', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'system',
|
||||
rootDir: path.join(os.homedir(), '.codex', 'skills', '.system'),
|
||||
commandPrefix: '$',
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js';
|
||||
import { CodexProviderModels } from '@/modules/providers/list/codex/codex-models.provider.js';
|
||||
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
|
||||
import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js';
|
||||
import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js';
|
||||
import { CodexSkillsProvider } from '@/modules/providers/list/codex/codex-skills.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class CodexProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new CodexProviderModels();
|
||||
readonly mcp = new CodexMcpProvider();
|
||||
readonly auth: IProviderAuth = new CodexProviderAuth();
|
||||
readonly skills: IProviderSkills = new CodexSkillsProvider();
|
||||
|
||||
283
server/modules/providers/list/cursor/cursor-models.provider.ts
Normal file
283
server/modules/providers/list/cursor/cursor-models.provider.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { access, readdir } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelOption,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
sanitizeLeafDirectoryName,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: "opus-4.6-thinking", label: "Claude 4.6 Opus (Thinking)" },
|
||||
{ value: "gpt-5.3-codex", label: "GPT-5.3" },
|
||||
{ value: "gpt-5.2-high", label: "GPT-5.2 High" },
|
||||
{ value: "gemini-3-pro", label: "Gemini 3 Pro" },
|
||||
{ value: "opus-4.5-thinking", label: "Claude 4.5 Opus (Thinking)" },
|
||||
{ value: "gpt-5.2", label: "GPT-5.2" },
|
||||
{ value: "gpt-5.1", label: "GPT-5.1" },
|
||||
{ value: "gpt-5.1-high", label: "GPT-5.1 High" },
|
||||
{ value: "composer-1", label: "Composer 1" },
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "sonnet-4.5", label: "Claude 4.5 Sonnet" },
|
||||
{ value: "sonnet-4.5-thinking", label: "Claude 4.5 Sonnet (Thinking)" },
|
||||
{ value: "opus-4.5", label: "Claude 4.5 Opus" },
|
||||
{ value: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
|
||||
{ value: "gpt-5.1-codex-high", label: "GPT-5.1 Codex High" },
|
||||
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
||||
{ value: "gpt-5.1-codex-max-high", label: "GPT-5.1 Codex Max High" },
|
||||
{ value: "opus-4.1", label: "Claude 4.1 Opus" },
|
||||
{ value: "grok", label: "Grok" },
|
||||
],
|
||||
DEFAULT: 'composer-2-fast',
|
||||
};
|
||||
|
||||
type CursorModelRow = {
|
||||
name: string;
|
||||
description: string;
|
||||
current: boolean;
|
||||
default: boolean;
|
||||
};
|
||||
|
||||
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
|
||||
const CURSOR_CHATS_ROOT = path.join(os.homedir(), '.cursor', 'chats');
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
const ANSI_PATTERN = new RegExp(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
'[\\u001B\\u009B][[\\]()#;?]*(?:'
|
||||
+ '(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]'
|
||||
+ '|(?:[\\dA-PR-TZcf-ntqry=><~]))',
|
||||
'g',
|
||||
);
|
||||
|
||||
const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, '');
|
||||
|
||||
const parseModelLine = (line: string): CursorModelRow | null => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (
|
||||
!trimmed
|
||||
|| trimmed === 'Available models'
|
||||
|| trimmed.startsWith('Loading models')
|
||||
|| trimmed.startsWith('Tip:')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^(.+?)\s+-\s+(.+)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = match[1].trim();
|
||||
let description = match[2].trim();
|
||||
const current = /\(current\)/i.test(description);
|
||||
const defaultModel = /\(default\)/i.test(description);
|
||||
|
||||
description = description.replace(/\s*\((current|default)\)/gi, '').replace(/\s{2,}/g, ' ').trim();
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
current,
|
||||
default: defaultModel,
|
||||
};
|
||||
};
|
||||
|
||||
const parseModelsOutput = (text: string): CursorModelRow[] => {
|
||||
const models: CursorModelRow[] = [];
|
||||
|
||||
for (const line of stripAnsi(text).split(/\r?\n/)) {
|
||||
const parsed = parseModelLine(line);
|
||||
if (parsed) {
|
||||
models.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
};
|
||||
|
||||
const runCursorListModels = (): Promise<string> => new Promise((resolve, reject) => {
|
||||
const cursorProcess = spawnFunction('cursor-agent', ['--list-models'], {
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cursorProcess.kill('SIGTERM');
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error('cursor-agent --list-models timed out'));
|
||||
}
|
||||
}, CURSOR_MODELS_TIMEOUT_MS);
|
||||
|
||||
const finish = (error: Error | null, output: string) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(output);
|
||||
};
|
||||
|
||||
cursorProcess.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
cursorProcess.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
cursorProcess.on('error', (error) => {
|
||||
finish(error instanceof Error ? error : new Error(String(error)), '');
|
||||
});
|
||||
|
||||
cursorProcess.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
finish(new Error(stderr.trim() || `cursor-agent --list-models exited with code ${code}`), '');
|
||||
return;
|
||||
}
|
||||
|
||||
finish(null, stdout);
|
||||
});
|
||||
});
|
||||
|
||||
const buildCursorModelsDefinition = (models: CursorModelRow[]): ProviderModelsDefinition => {
|
||||
const options: ProviderModelOption[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
for (const model of models) {
|
||||
if (seenValues.has(model.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(model.name);
|
||||
options.push({
|
||||
value: model.name,
|
||||
label: model.name,
|
||||
description: model.description || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return CURSOR_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
const defaultValue = models.find((model) => model.default)?.name
|
||||
?? models.find((model) => model.current)?.name
|
||||
?? options[0]?.value
|
||||
?? CURSOR_FALLBACK_MODELS.DEFAULT;
|
||||
|
||||
return {
|
||||
OPTIONS: options,
|
||||
DEFAULT: defaultValue,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveCursorSessionStorePath = async (sessionId: string): Promise<string | null> => {
|
||||
const safeSessionId = sanitizeLeafDirectoryName(sessionId, 'cursor session id');
|
||||
|
||||
try {
|
||||
const workspaceEntries = await readdir(CURSOR_CHATS_ROOT, { withFileTypes: true });
|
||||
for (const workspaceEntry of workspaceEntries) {
|
||||
if (!workspaceEntry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const storeDbPath = path.join(CURSOR_CHATS_ROOT, workspaceEntry.name, safeSessionId, 'store.db');
|
||||
try {
|
||||
await access(storeDbPath);
|
||||
return storeDbPath;
|
||||
} catch {
|
||||
// Keep scanning sibling workspaces until the matching session directory is found.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export class CursorProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
try {
|
||||
const stdout = await runCursorListModels();
|
||||
const models = parseModelsOutput(stdout);
|
||||
return buildCursorModelsDefinition(models);
|
||||
} catch {
|
||||
return CURSOR_FALLBACK_MODELS;
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
|
||||
if (!sessionId?.trim()) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
try {
|
||||
const storeDbPath = await resolveCursorSessionStorePath(sessionId);
|
||||
if (!storeDbPath) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
try {
|
||||
const row = db.prepare(`SELECT value FROM meta WHERE key='0' LIMIT 1;`).get() as {
|
||||
value?: Buffer | string;
|
||||
} | undefined;
|
||||
const metadataText = Buffer.isBuffer(row?.value)
|
||||
? row.value.toString('utf8')
|
||||
: typeof row?.value === 'string' && row.value.trim()
|
||||
? Buffer.from(row.value.trim(), 'hex').toString('utf8')
|
||||
: '';
|
||||
if (!metadataText) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(metadataText) as { lastUsedModel?: string };
|
||||
if (typeof metadata.lastUsedModel === 'string' && metadata.lastUsedModel.trim()) {
|
||||
return {
|
||||
model: metadata.lastUsedModel.trim(),
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the provider default when Cursor metadata cannot be read.
|
||||
}
|
||||
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('cursor', input);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import path from 'node:path';
|
||||
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
import {
|
||||
createNormalizedMessage,
|
||||
generateMessageId,
|
||||
readObjectRecord,
|
||||
sanitizeLeafDirectoryName,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'cursor';
|
||||
|
||||
@@ -186,24 +191,6 @@ function normalizeCursorToolInput(toolName: string, rawInput: unknown): unknown
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sanitizeCursorSessionId(sessionId: string): string {
|
||||
const normalized = sessionId.trim();
|
||||
if (!normalized) {
|
||||
throw new Error('Cursor session id is required.');
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('..')
|
||||
|| normalized.includes(path.posix.sep)
|
||||
|| normalized.includes(path.win32.sep)
|
||||
|| normalized !== path.basename(normalized)
|
||||
) {
|
||||
throw new Error(`Invalid cursor session id "${sessionId}".`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export class CursorSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation
|
||||
@@ -214,7 +201,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const safeSessionId = sanitizeCursorSessionId(sessionId);
|
||||
const safeSessionId = sanitizeLeafDirectoryName(sessionId, 'cursor session id');
|
||||
const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
||||
const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db');
|
||||
const resolvedBaseChatsPath = path.resolve(baseChatsPath);
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
|
||||
import { CursorProviderModels } from '@/modules/providers/list/cursor/cursor-models.provider.js';
|
||||
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
|
||||
import { CursorSessionSynchronizer } from '@/modules/providers/list/cursor/cursor-session-synchronizer.provider.js';
|
||||
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
|
||||
import { CursorSkillsProvider } from '@/modules/providers/list/cursor/cursor-skills.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class CursorProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new CursorProviderModels();
|
||||
readonly mcp = new CursorMcpProvider();
|
||||
readonly auth: IProviderAuth = new CursorProviderAuth();
|
||||
readonly skills: IProviderSkills = new CursorSkillsProvider();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
|
||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
|
||||
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' },
|
||||
],
|
||||
DEFAULT: 'gemini-3.1-pro-preview',
|
||||
};
|
||||
|
||||
export class GeminiProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
return GEMINI_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||
return buildDefaultProviderCurrentActiveModel(GEMINI_FALLBACK_MODELS);
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('gemini', input);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
|
||||
import { GeminiProviderModels } from '@/modules/providers/list/gemini/gemini-models.provider.js';
|
||||
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
|
||||
import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js';
|
||||
import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
|
||||
import { GeminiSkillsProvider } from '@/modules/providers/list/gemini/gemini-skills.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class GeminiProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new GeminiProviderModels();
|
||||
readonly mcp = new GeminiMcpProvider();
|
||||
readonly auth: IProviderAuth = new GeminiProviderAuth();
|
||||
readonly skills: IProviderSkills = new GeminiSkillsProvider();
|
||||
|
||||
111
server/modules/providers/list/opencode/opencode-auth.provider.ts
Normal file
111
server/modules/providers/list/opencode/opencode-auth.provider.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type OpenCodeCredentialsStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const OPENCODE_ENV_CREDENTIAL_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GROQ_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
];
|
||||
|
||||
export class OpenCodeProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Checks whether the OpenCode CLI is available to the server process.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
try {
|
||||
const result = spawn.sync('opencode', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return !result.error && result.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns OpenCode CLI installation and credential status.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
const credentials = await this.checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'opencode',
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads OpenCode's auth store or falls back to provider API key environment variables.
|
||||
*/
|
||||
private async checkCredentials(): Promise<OpenCodeCredentialsStatus> {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
|
||||
const content = await readFile(authPath, 'utf8');
|
||||
const auth = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
|
||||
for (const [providerId, providerAuth] of Object.entries(auth)) {
|
||||
const providerRecord = readObjectRecord(providerAuth);
|
||||
if (!providerRecord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasCredential = Object.values(providerRecord).some(
|
||||
(value) => readOptionalString(value) !== undefined || Boolean(readObjectRecord(value)),
|
||||
);
|
||||
if (hasCredential) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: `${providerId} credentials`,
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: error instanceof Error ? error.message : 'Failed to read OpenCode auth',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const envCredential = OPENCODE_ENV_CREDENTIAL_KEYS.find((key) => process.env[key]?.trim());
|
||||
if (envCredential) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: envCredential,
|
||||
method: 'environment',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'OpenCode not configured',
|
||||
};
|
||||
}
|
||||
}
|
||||
228
server/modules/providers/list/opencode/opencode-mcp.provider.ts
Normal file
228
server/modules/providers/list/opencode/opencode-mcp.provider.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
type OpenCodeConfigPath = {
|
||||
filePath: string;
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
const fileExists = async (filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes JSONC comments without touching comment-like text inside strings.
|
||||
*/
|
||||
const stripJsonComments = (content: string): string => {
|
||||
let output = '';
|
||||
let inString = false;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const char = content[index];
|
||||
const next = content[index + 1];
|
||||
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === quote) {
|
||||
inString = false;
|
||||
quote = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === '\'') {
|
||||
inString = true;
|
||||
quote = char;
|
||||
output += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '/') {
|
||||
while (index < content.length && content[index] !== '\n') {
|
||||
index += 1;
|
||||
}
|
||||
output += '\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '*') {
|
||||
index += 2;
|
||||
while (index < content.length && !(content[index] === '*' && content[index + 1] === '/')) {
|
||||
index += 1;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += char;
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
const stripTrailingCommas = (content: string): string =>
|
||||
content.replace(/,\s*([}\]])/g, '$1');
|
||||
|
||||
const readOpenCodeConfig = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(stripTrailingCommas(stripJsonComments(content))) as unknown;
|
||||
return readObjectRecord(parsed) ?? {};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeOpenCodeConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
const resolveOpenCodeConfigPath = async (scope: McpScope, workspacePath: string): Promise<OpenCodeConfigPath> => {
|
||||
const root = scope === 'user'
|
||||
? path.join(os.homedir(), '.config', 'opencode')
|
||||
: workspacePath;
|
||||
const jsonPath = path.join(root, 'opencode.json');
|
||||
const jsoncPath = path.join(root, 'opencode.jsonc');
|
||||
|
||||
if (await fileExists(jsonPath)) {
|
||||
return { filePath: jsonPath, exists: true };
|
||||
}
|
||||
|
||||
if (await fileExists(jsoncPath)) {
|
||||
return { filePath: jsoncPath, exists: true };
|
||||
}
|
||||
|
||||
return { filePath: jsonPath, exists: false };
|
||||
};
|
||||
|
||||
export class OpenCodeMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('opencode', ['user', 'project'], ['stdio', 'http']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const { filePath } = await resolveOpenCodeConfigPath(scope, workspacePath);
|
||||
const config = await readOpenCodeConfig(filePath);
|
||||
return readObjectRecord(config.mcp) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const { filePath } = await resolveOpenCodeConfigPath(scope, workspacePath);
|
||||
const config = await readOpenCodeConfig(filePath);
|
||||
config.mcp = servers;
|
||||
await writeOpenCodeConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'local',
|
||||
command: [input.command, ...(input.args ?? [])],
|
||||
enabled: true,
|
||||
environment: input.env ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'remote',
|
||||
url: input.url,
|
||||
enabled: true,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
const config = readObjectRecord(rawConfig);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.type === 'local' || config.command !== undefined) {
|
||||
const commandParts = typeof config.command === 'string'
|
||||
? [config.command, ...(readStringArray(config.args) ?? [])]
|
||||
: readStringArray(config.command);
|
||||
const command = commandParts?.[0];
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'opencode',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command,
|
||||
args: commandParts.slice(1),
|
||||
env: readStringRecord(config.environment) ?? readStringRecord(config.env),
|
||||
};
|
||||
}
|
||||
|
||||
if (config.type === 'remote' || typeof config.url === 'string') {
|
||||
const url = readOptionalString(config.url);
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'opencode',
|
||||
name,
|
||||
scope,
|
||||
transport: 'http',
|
||||
url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelOption,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
getOpenCodeDatabasePath,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: 'anthropic/claude-sonnet-4-5',
|
||||
label: 'Claude Sonnet 4.5',
|
||||
description: 'anthropic - anthropic/claude-sonnet-4-5',
|
||||
},
|
||||
{
|
||||
value: 'anthropic/claude-opus-4-1',
|
||||
label: 'Claude Opus 4.1',
|
||||
description: 'anthropic - anthropic/claude-opus-4-1',
|
||||
},
|
||||
{
|
||||
value: 'anthropic/claude-haiku-4-5',
|
||||
label: 'Claude Haiku 4.5',
|
||||
description: 'anthropic - anthropic/claude-haiku-4-5',
|
||||
},
|
||||
{
|
||||
value: 'openai/gpt-5.1',
|
||||
label: 'GPT-5.1',
|
||||
description: 'openai - openai/gpt-5.1',
|
||||
},
|
||||
{
|
||||
value: 'openai/gpt-5.1-codex',
|
||||
label: 'GPT-5.1 Codex',
|
||||
description: 'openai - openai/gpt-5.1-codex',
|
||||
},
|
||||
{
|
||||
value: 'openai/gpt-5.4-mini',
|
||||
label: 'GPT-5.4 Mini',
|
||||
description: 'openai - openai/gpt-5.4-mini',
|
||||
},
|
||||
{
|
||||
value: 'google/gemini-2.5-pro',
|
||||
label: 'Gemini 2.5 Pro',
|
||||
description: 'google - google/gemini-2.5-pro',
|
||||
},
|
||||
{
|
||||
value: 'google/gemini-2.5-flash',
|
||||
label: 'Gemini 2.5 Flash',
|
||||
description: 'google - google/gemini-2.5-flash',
|
||||
},
|
||||
],
|
||||
DEFAULT: 'anthropic/claude-sonnet-4-5',
|
||||
};
|
||||
|
||||
const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000;
|
||||
const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
const DATE_TOKEN = /^\d{8}$/;
|
||||
const SIMPLE_NUMBER_TOKEN = /^\d$/;
|
||||
const VERSION_TOKEN = /^[a-z]\d+$/i;
|
||||
const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/;
|
||||
const SHORT_ACRONYM_TOKEN = /^[a-z]{2,3}$/;
|
||||
|
||||
export const parseOpenCodeModelsStdout = (stdout: string): string[] => {
|
||||
const ids: string[] = [];
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('{') || line.startsWith('[')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (MODEL_ID_LINE.test(line)) {
|
||||
ids.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(ids)];
|
||||
};
|
||||
|
||||
const formatDateToken = (token: string): string => (
|
||||
`${token.slice(0, 4)}-${token.slice(4, 6)}-${token.slice(6, 8)}`
|
||||
);
|
||||
|
||||
const formatModelToken = (token: string, nextToken?: string): string => {
|
||||
const lower = token.toLowerCase();
|
||||
|
||||
if (VERSION_TOKEN.test(token)) {
|
||||
return token.toUpperCase();
|
||||
}
|
||||
|
||||
if (SHORT_ACRONYM_TOKEN.test(lower) && nextToken && NUMERIC_TOKEN.test(nextToken)) {
|
||||
return token.toUpperCase();
|
||||
}
|
||||
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
};
|
||||
|
||||
const formatOpenCodeModelSlug = (slug: string): string => {
|
||||
const labelParts: string[] = [];
|
||||
const dateParts: string[] = [];
|
||||
const tokens = slug.split('-').filter(Boolean);
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
const nextToken = tokens[index + 1];
|
||||
|
||||
if (DATE_TOKEN.test(token)) {
|
||||
dateParts.push(formatDateToken(token));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SIMPLE_NUMBER_TOKEN.test(token) && nextToken && SIMPLE_NUMBER_TOKEN.test(nextToken)) {
|
||||
labelParts.push(`${token}.${nextToken}`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
labelParts.push(formatModelToken(token, nextToken));
|
||||
}
|
||||
|
||||
const label = (labelParts.join(' ').trim() || slug).replace(/^GPT\s+/, 'GPT-');
|
||||
if (dateParts.length === 0) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `${label} (${dateParts.join(', ')})`;
|
||||
};
|
||||
|
||||
const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: string } => {
|
||||
const separatorIndex = id.indexOf('/');
|
||||
if (separatorIndex < 0) {
|
||||
return {
|
||||
upstreamProvider: '',
|
||||
slug: id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
upstreamProvider: id.slice(0, separatorIndex),
|
||||
slug: id.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const labelForOpenCodeModelId = (id: string): string => {
|
||||
const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label;
|
||||
if (fallbackLabel) {
|
||||
return fallbackLabel;
|
||||
}
|
||||
|
||||
const { slug } = readOpenCodeModelParts(id);
|
||||
return formatOpenCodeModelSlug(slug);
|
||||
};
|
||||
|
||||
const descriptionForOpenCodeModelId = (id: string): string => {
|
||||
const { upstreamProvider } = readOpenCodeModelParts(id);
|
||||
return upstreamProvider ? `${upstreamProvider} - ${id}` : id;
|
||||
};
|
||||
|
||||
export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
|
||||
const options: ProviderModelOption[] = ids.map((value) => ({
|
||||
value,
|
||||
label: labelForOpenCodeModelId(value),
|
||||
description: descriptionForOpenCodeModelId(value),
|
||||
}));
|
||||
|
||||
const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value
|
||||
?? options[0]?.value
|
||||
?? OPENCODE_FALLBACK_MODELS.DEFAULT;
|
||||
|
||||
return {
|
||||
OPTIONS: options,
|
||||
DEFAULT: defaultValue,
|
||||
};
|
||||
};
|
||||
|
||||
const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => {
|
||||
if (typeof rawModel === 'string') {
|
||||
const trimmed = rawModel.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return parseOpenCodeSessionModelValue(JSON.parse(trimmed));
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
const record = readObjectRecord(rawModel);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readOptionalString(record.id)
|
||||
?? readOptionalString(record.model)
|
||||
?? readOptionalString(record.name)
|
||||
?? readOptionalString(record.value)
|
||||
?? null;
|
||||
};
|
||||
|
||||
const runOpenCodeModelsCommand = (): Promise<string> => new Promise((resolve, reject) => {
|
||||
const openCodeProcess = spawnFunction('opencode', ['models'], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
openCodeProcess.kill('SIGTERM');
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error('opencode models timed out'));
|
||||
}
|
||||
}, OPEN_CODE_MODELS_TIMEOUT_MS);
|
||||
|
||||
const finish = (error: Error | null, output: string) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(output);
|
||||
};
|
||||
|
||||
openCodeProcess.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
openCodeProcess.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
openCodeProcess.on('error', (error) => {
|
||||
finish(error instanceof Error ? error : new Error(String(error)), '');
|
||||
});
|
||||
|
||||
openCodeProcess.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
finish(new Error(stderr.trim() || `opencode models exited with code ${code}`), '');
|
||||
return;
|
||||
}
|
||||
|
||||
finish(null, stdout);
|
||||
});
|
||||
});
|
||||
|
||||
export class OpenCodeProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
try {
|
||||
const stdout = await runOpenCodeModelsCommand();
|
||||
const ids = parseOpenCodeModelsStdout(stdout);
|
||||
if (ids.length === 0) {
|
||||
return OPENCODE_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
return buildOpenCodeDefinitionFromIds(ids);
|
||||
} catch {
|
||||
return OPENCODE_FALLBACK_MODELS;
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
|
||||
if (!sessionId?.trim()) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
try {
|
||||
const dbPath = getOpenCodeDatabasePath();
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
try {
|
||||
const row = db.prepare(`
|
||||
SELECT
|
||||
s.id AS sessionId,
|
||||
s.model AS model,
|
||||
s.agent AS agent,
|
||||
s.directory AS directory,
|
||||
s.time_updated AS timeUpdated,
|
||||
s.time_created AS timeCreated
|
||||
FROM session s
|
||||
WHERE s.id = ?
|
||||
ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC
|
||||
LIMIT 1
|
||||
`).get(sessionId) as {
|
||||
sessionId?: string;
|
||||
model?: unknown;
|
||||
agent?: string | null;
|
||||
directory?: string | null;
|
||||
timeUpdated?: number | null;
|
||||
timeCreated?: number | null;
|
||||
} | undefined;
|
||||
|
||||
const model = parseOpenCodeSessionModelValue(row?.model);
|
||||
if (model) {
|
||||
return {
|
||||
model,
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the provider default when OpenCode session lookup fails.
|
||||
}
|
||||
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('opencode', input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import fsSync from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
import {
|
||||
getOpenCodeDatabasePath,
|
||||
normalizeProviderTimestamp,
|
||||
normalizeSessionName,
|
||||
readJsonRecord,
|
||||
readOptionalString,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
type OpenCodeSessionRow = {
|
||||
id: string;
|
||||
directory: string | null;
|
||||
title: string | null;
|
||||
time_created: number | null;
|
||||
time_updated: number | null;
|
||||
worktree: string | null;
|
||||
};
|
||||
|
||||
type SynchronizeRowsResult = {
|
||||
processed: number;
|
||||
firstSessionId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Session indexer for OpenCode's SQLite-backed session store.
|
||||
*/
|
||||
export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'opencode' as const;
|
||||
|
||||
/**
|
||||
* Scans OpenCode's shared opencode.db and upserts active sessions into DB.
|
||||
*/
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const result = this.synchronizeRows(since);
|
||||
return result.processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles watcher changes for opencode.db.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
if (path.basename(filePath) !== 'opencode.db') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = this.synchronizeRows(undefined, 1);
|
||||
return result.firstSessionId;
|
||||
}
|
||||
|
||||
private synchronizeRows(since?: Date, limit?: number): SynchronizeRowsResult {
|
||||
const dbPath = getOpenCodeDatabasePath();
|
||||
if (!fsSync.existsSync(dbPath)) {
|
||||
return { processed: 0, firstSessionId: null };
|
||||
}
|
||||
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const sinceMillis = since?.getTime() ?? null;
|
||||
const limitClause = limit ? 'LIMIT ?' : '';
|
||||
const params = limit ? [sinceMillis, sinceMillis, limit] : [sinceMillis, sinceMillis];
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
s.id AS id,
|
||||
s.directory AS directory,
|
||||
s.title AS title,
|
||||
s.time_created AS time_created,
|
||||
s.time_updated AS time_updated,
|
||||
p.worktree AS worktree
|
||||
FROM session s
|
||||
LEFT JOIN project p ON p.id = s.project_id
|
||||
WHERE s.time_archived IS NULL
|
||||
AND (? IS NULL OR COALESCE(s.time_updated, s.time_created, 0) >= ?)
|
||||
ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC, s.id DESC
|
||||
${limitClause}
|
||||
`).all(...params) as OpenCodeSessionRow[];
|
||||
|
||||
let processed = 0;
|
||||
let firstSessionId: string | null = null;
|
||||
for (const row of rows) {
|
||||
const indexedSessionId = this.upsertSession(db, row);
|
||||
if (!indexedSessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!firstSessionId) {
|
||||
firstSessionId = indexedSessionId;
|
||||
}
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return { processed, firstSessionId };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn('[OpenCodeProvider] Failed to synchronize sessions:', message);
|
||||
return { processed: 0, firstSessionId: null };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
private upsertSession(db: Database.Database, row: OpenCodeSessionRow): string | null {
|
||||
const sessionId = readOptionalString(row.id);
|
||||
const projectPath = readOptionalString(row.directory) ?? readOptionalString(row.worktree);
|
||||
if (!sessionId || !projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackTitle = 'Untitled OpenCode Session';
|
||||
const existingSession = sessionsDb.getSessionById(sessionId);
|
||||
const existingName = existingSession?.custom_name;
|
||||
const nextName = existingName && existingName !== fallbackTitle
|
||||
? existingName
|
||||
: readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
|
||||
|
||||
// OpenCode stores every session in one shared sqlite database, so jsonl_path
|
||||
// must stay null to avoid deleting opencode.db when one app session is removed.
|
||||
sessionsDb.createSession(
|
||||
sessionId,
|
||||
this.provider,
|
||||
projectPath,
|
||||
normalizeSessionName(nextName, fallbackTitle),
|
||||
normalizeProviderTimestamp(row.time_created),
|
||||
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
|
||||
null,
|
||||
);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {
|
||||
try {
|
||||
const row = db.prepare(`
|
||||
SELECT p.data AS data
|
||||
FROM message m
|
||||
INNER JOIN part p
|
||||
ON p.session_id = m.session_id
|
||||
AND p.message_id = m.id
|
||||
WHERE m.session_id = ?
|
||||
AND json_extract(m.data, '$.role') = 'user'
|
||||
AND json_extract(p.data, '$.type') = 'text'
|
||||
ORDER BY COALESCE(m.time_created, 0), COALESCE(p.time_created, 0)
|
||||
LIMIT 1
|
||||
`).get(sessionId) as { data: string | null } | undefined;
|
||||
|
||||
const data = readJsonRecord(row?.data);
|
||||
return readOptionalString(data?.text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import fsSync from 'node:fs';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import {
|
||||
createNormalizedMessage,
|
||||
generateMessageId,
|
||||
getOpenCodeDatabasePath,
|
||||
normalizeProviderTimestamp,
|
||||
readObjectRecord,
|
||||
readJsonRecord,
|
||||
readOptionalString,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'opencode';
|
||||
|
||||
type OpenCodeHistoryRow = {
|
||||
message_id: string;
|
||||
message_time_created: number | null;
|
||||
message_data: string | null;
|
||||
part_id: string | null;
|
||||
part_time_created: number | null;
|
||||
part_data: string | null;
|
||||
};
|
||||
|
||||
type OpenCodeTokenTotals = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
reasoningTokens: number;
|
||||
};
|
||||
|
||||
const openOpenCodeDatabase = (): Database.Database | null => {
|
||||
const dbPath = getOpenCodeDatabasePath();
|
||||
if (!fsSync.existsSync(dbPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
};
|
||||
|
||||
const formatToolContent = (value: unknown): string => {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenCode can persist the first prompt as a JSON string literal inside a text
|
||||
* part, for example `"hello"` instead of `hello`. Decode only complete JSON
|
||||
* string literals so normal assistant/user prose remains untouched.
|
||||
*/
|
||||
const unwrapJsonStringLiteral = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return typeof parsed === 'string' ? parsed : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const extractText = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return unwrapJsonStringLiteral(value);
|
||||
}
|
||||
|
||||
const record = readObjectRecord(value);
|
||||
const text = readOptionalString(record?.text)
|
||||
?? readOptionalString(record?.content)
|
||||
?? '';
|
||||
return unwrapJsonStringLiteral(text);
|
||||
};
|
||||
|
||||
const hasUserRole = (value: unknown): boolean => {
|
||||
const record = readObjectRecord(value);
|
||||
return readOptionalString(record?.role) === 'user';
|
||||
};
|
||||
|
||||
const isUserTextEcho = (raw: AnyRecord): boolean => {
|
||||
return readOptionalString(raw.role) === 'user'
|
||||
|| hasUserRole(raw.message)
|
||||
|| hasUserRole(raw.part);
|
||||
};
|
||||
|
||||
const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | undefined => {
|
||||
if (!totals) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inputTokens = totals.inputTokens;
|
||||
const outputTokens = totals.outputTokens;
|
||||
const cacheReadTokens = totals.cacheReadTokens;
|
||||
const cacheCreationTokens = totals.cacheCreationTokens;
|
||||
const reasoningTokens = totals.reasoningTokens;
|
||||
const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + reasoningTokens;
|
||||
|
||||
if (used <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
used,
|
||||
total: used,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenCode stores per-message token counts on assistant `message.data` objects
|
||||
* (see MessageV2.Assistant). Older DBs also had session-level counters; this
|
||||
* matches current `opencode.db` layouts that only persist message JSON.
|
||||
*/
|
||||
const aggregateOpenCodeSessionTokenUsage = (
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
): AnyRecord | undefined => {
|
||||
const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[];
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
let reasoningTokens = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const info = readJsonRecord(row.data);
|
||||
if (readOptionalString(info?.role) !== 'assistant') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokens = readObjectRecord(info?.tokens);
|
||||
if (!tokens) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inputTokens += Number(tokens.input ?? 0);
|
||||
outputTokens += Number(tokens.output ?? 0);
|
||||
reasoningTokens += Number(tokens.reasoning ?? 0);
|
||||
const cache = readObjectRecord(tokens.cache);
|
||||
cacheReadTokens += Number(cache?.read ?? 0);
|
||||
cacheCreationTokens += Number(cache?.write ?? 0);
|
||||
}
|
||||
|
||||
return buildTokenUsage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
reasoningTokens,
|
||||
});
|
||||
};
|
||||
|
||||
export class OpenCodeSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Normalizes live `opencode run --format json` events into frontend messages.
|
||||
*/
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
const raw = readObjectRecord(rawMessage);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const type = readOptionalString(raw.type) ?? readOptionalString(raw.event);
|
||||
const eventSessionId = readOptionalString(raw.sessionID) ?? readOptionalString(raw.sessionId) ?? sessionId;
|
||||
const timestamp = normalizeProviderTimestamp(raw.time ?? raw.timestamp);
|
||||
const baseId = readOptionalString(raw.id)
|
||||
?? readOptionalString(raw.messageID)
|
||||
?? generateMessageId('opencode');
|
||||
|
||||
if (type === 'text') {
|
||||
// The client already renders an optimistic user bubble, so provider user
|
||||
// echoes must not be streamed back as assistant text.
|
||||
if (isUserTextEcho(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = extractText(raw.text ?? raw.delta ?? raw.message);
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_delta',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'reasoning') {
|
||||
const content = extractText(raw.text ?? raw.delta ?? raw.message);
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'tool_use') {
|
||||
const toolName = readOptionalString(raw.tool) ?? readOptionalString(raw.name) ?? 'Tool';
|
||||
const toolId = readOptionalString(raw.callID) ?? readOptionalString(raw.toolCallId) ?? baseId;
|
||||
const toolMessage = createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName,
|
||||
toolInput: raw.input ?? raw.arguments ?? {},
|
||||
toolId,
|
||||
});
|
||||
|
||||
if (raw.output !== undefined || raw.error !== undefined) {
|
||||
toolMessage.toolResult = {
|
||||
content: formatToolContent(raw.output ?? raw.error),
|
||||
isError: raw.error !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return [toolMessage];
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown OpenCode error',
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'step_finish') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_end',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads OpenCode history from the shared SQLite session database.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
const db = openOpenCodeDatabase();
|
||||
if (!db) {
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
m.id AS message_id,
|
||||
m.time_created AS message_time_created,
|
||||
m.data AS message_data,
|
||||
p.id AS part_id,
|
||||
p.time_created AS part_time_created,
|
||||
p.data AS part_data
|
||||
FROM message m
|
||||
LEFT JOIN part p
|
||||
ON p.session_id = m.session_id
|
||||
AND p.message_id = m.id
|
||||
WHERE m.session_id = ?
|
||||
ORDER BY
|
||||
COALESCE(m.time_created, 0),
|
||||
m.id,
|
||||
COALESCE(p.time_created, 0),
|
||||
p.id
|
||||
`).all(sessionId) as OpenCodeHistoryRow[];
|
||||
|
||||
const normalized = this.normalizeHistoryRows(rows, sessionId);
|
||||
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
|
||||
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||
const total = normalized.length;
|
||||
const messages = normalizedLimit === null
|
||||
? normalized
|
||||
: normalized.slice(
|
||||
Math.max(0, total - normalizedOffset - normalizedLimit),
|
||||
Math.max(0, total - normalizedOffset),
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
total,
|
||||
hasMore: normalizedLimit === null
|
||||
? false
|
||||
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
|
||||
offset: normalizedOffset,
|
||||
limit: normalizedLimit,
|
||||
tokenUsage,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[OpenCodeProvider] Failed to load session ${sessionId}:`, message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeHistoryRows(rows: OpenCodeHistoryRow[], sessionId: string): NormalizedMessage[] {
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
const emittedMessageErrors = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const timestamp = normalizeProviderTimestamp(row.part_time_created ?? row.message_time_created);
|
||||
const baseId = `${row.message_id}_${row.part_id ?? normalized.length}`;
|
||||
const messageInfo = readJsonRecord(row.message_data);
|
||||
const messageRole = readOptionalString(messageInfo?.role);
|
||||
|
||||
if (
|
||||
messageInfo
|
||||
&& messageRole === 'assistant'
|
||||
&& messageInfo.error != null
|
||||
&& !emittedMessageErrors.has(row.message_id)
|
||||
) {
|
||||
emittedMessageErrors.add(row.message_id);
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_error`,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: formatToolContent(messageInfo.error),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!row.part_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const partData = readJsonRecord(row.part_data) ?? {};
|
||||
const partType = readOptionalString(partData.type);
|
||||
if (!partType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'text') {
|
||||
const content = extractText(partData);
|
||||
if (content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: messageRole === 'user' ? 'user' : 'assistant',
|
||||
content,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'reasoning') {
|
||||
const content = extractText(partData);
|
||||
if (content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'tool') {
|
||||
const state = readObjectRecord(partData.state) ?? {};
|
||||
const status = readOptionalString(state.status);
|
||||
const toolMessage = createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: readOptionalString(partData.tool) ?? 'Tool',
|
||||
toolInput: state.input ?? partData.input ?? {},
|
||||
toolId: readOptionalString(partData.callID) ?? row.part_id,
|
||||
});
|
||||
|
||||
if (status === 'completed' || status === 'error') {
|
||||
toolMessage.toolResult = {
|
||||
content: formatToolContent(state.output ?? state.error),
|
||||
isError: status === 'error',
|
||||
};
|
||||
}
|
||||
|
||||
normalized.push(toolMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'step-finish') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_end',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'patch' || partType === 'agent') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: partType === 'patch' ? 'Patch' : 'Agent',
|
||||
toolInput: partData,
|
||||
toolId: row.part_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||
import {
|
||||
addUniqueProviderSkillSource,
|
||||
findTopmostGitRoot,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const OPENCODE_PROJECT_SKILL_DIRS = [
|
||||
['.opencode', 'skills'],
|
||||
['.claude', 'skills'],
|
||||
['.agents', 'skills'],
|
||||
];
|
||||
|
||||
const OPENCODE_USER_SKILL_DIRS = [
|
||||
['.config', 'opencode', 'skills'],
|
||||
['.claude', 'skills'],
|
||||
['.agents', 'skills'],
|
||||
];
|
||||
|
||||
export class OpenCodeSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('opencode');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
const sources: ProviderSkillSource[] = [];
|
||||
const seenRootDirs = new Set<string>();
|
||||
const repoRoot = await findTopmostGitRoot(workspacePath);
|
||||
|
||||
for (const projectRoot of this.getProjectSearchRoots(workspacePath, repoRoot)) {
|
||||
for (const skillDir of OPENCODE_PROJECT_SKILL_DIRS) {
|
||||
// OpenCode intentionally reads Claude and Agents skill folders so users
|
||||
// can reuse the same skill libraries across compatible coding agents.
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'project',
|
||||
rootDir: path.join(projectRoot, ...skillDir),
|
||||
commandPrefix: '/',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const skillDir of OPENCODE_USER_SKILL_DIRS) {
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), ...skillDir),
|
||||
commandPrefix: '/',
|
||||
});
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
private getProjectSearchRoots(workspacePath: string, repoRoot: string | null): string[] {
|
||||
const roots: string[] = [];
|
||||
const normalizedWorkspacePath = path.resolve(workspacePath);
|
||||
const normalizedRepoRoot = repoRoot ? path.resolve(repoRoot) : null;
|
||||
let currentPath = normalizedWorkspacePath;
|
||||
|
||||
while (true) {
|
||||
roots.push(currentPath);
|
||||
if (!normalizedRepoRoot || currentPath === normalizedRepoRoot) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentPath = parentPath;
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
27
server/modules/providers/list/opencode/opencode.provider.ts
Normal file
27
server/modules/providers/list/opencode/opencode.provider.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { OpenCodeProviderAuth } from '@/modules/providers/list/opencode/opencode-auth.provider.js';
|
||||
import { OpenCodeProviderModels } from '@/modules/providers/list/opencode/opencode-models.provider.js';
|
||||
import { OpenCodeMcpProvider } from '@/modules/providers/list/opencode/opencode-mcp.provider.js';
|
||||
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
|
||||
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
|
||||
import { OpenCodeSkillsProvider } from '@/modules/providers/list/opencode/opencode-skills.provider.js';
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class OpenCodeProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new OpenCodeProviderModels();
|
||||
readonly mcp = new OpenCodeMcpProvider();
|
||||
readonly auth: IProviderAuth = new OpenCodeProviderAuth();
|
||||
readonly skills: IProviderSkills = new OpenCodeSkillsProvider();
|
||||
readonly sessions: IProviderSessions = new OpenCodeSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new OpenCodeSessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('opencode');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
|
||||
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
||||
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
|
||||
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
|
||||
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
|
||||
import type { IProvider } from '@/shared/interfaces.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
@@ -11,6 +12,7 @@ const providers: Record<LLMProvider, IProvider> = {
|
||||
codex: new CodexProvider(),
|
||||
cursor: new CursorProvider(),
|
||||
gemini: new GeminiProvider(),
|
||||
opencode: new OpenCodeProvider(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,10 +2,17 @@ import express, { type Request, type Response } from 'express';
|
||||
|
||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
|
||||
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
||||
import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
|
||||
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
|
||||
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
McpScope,
|
||||
McpTransport,
|
||||
ProviderChangeActiveModelInput,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -173,7 +180,13 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
|
||||
|
||||
const parseProvider = (value: unknown): LLMProvider => {
|
||||
const normalized = normalizeProviderParam(value);
|
||||
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
|
||||
if (
|
||||
normalized === 'claude'
|
||||
|| normalized === 'codex'
|
||||
|| normalized === 'cursor'
|
||||
|| normalized === 'gemini'
|
||||
|| normalized === 'opencode'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -239,6 +252,29 @@ const parseSessionSearchLimit = (value: unknown): number => {
|
||||
return Math.max(1, Math.min(parsed, 100));
|
||||
};
|
||||
|
||||
const parseChangeActiveModelPayload = (payload: unknown): ProviderChangeActiveModelInput => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const body = payload as Record<string, unknown>;
|
||||
const model = readOptionalQueryString(body.model);
|
||||
if (!model) {
|
||||
throw new AppError('model is required.', {
|
||||
code: 'MODEL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: '',
|
||||
model,
|
||||
};
|
||||
};
|
||||
|
||||
router.get(
|
||||
'/:provider/auth/status',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
@@ -248,6 +284,30 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:provider/models',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const bypassCache = parseOptionalBooleanQuery(req.query.bypassCache, 'bypassCache') ?? false;
|
||||
const result = await providerModelsService.getProviderModels(provider, { bypassCache });
|
||||
res.json(createApiSuccessResponse({ provider, models: result.models, cache: result.cache }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/sessions/:sessionId/active-model',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const sessionId = parseSessionId(req.params.sessionId);
|
||||
const payload = parseChangeActiveModelPayload(req.body);
|
||||
const result = await providerModelsService.changeActiveModel(provider, {
|
||||
...payload,
|
||||
sessionId,
|
||||
});
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- Skills routes -----------------
|
||||
router.get(
|
||||
'/:provider/skills',
|
||||
|
||||
325
server/modules/providers/services/provider-models.service.ts
Normal file
325
server/modules/providers/services/provider-models.service.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { IProvider } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsCacheInfo,
|
||||
ProviderModelsDefinition,
|
||||
ProviderModelsResult,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
|
||||
|
||||
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
||||
|
||||
type ProviderModelsServiceDependencies = {
|
||||
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
||||
cachePath?: string;
|
||||
activeModelChangesPath?: string;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
type ProviderModelsOptions = {
|
||||
bypassCache?: boolean;
|
||||
};
|
||||
|
||||
type ProviderModelsCacheEntry = {
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
models: ProviderModelsDefinition;
|
||||
};
|
||||
|
||||
type ProviderModelsCacheFile = {
|
||||
version: number;
|
||||
entries: Record<string, ProviderModelsCacheEntry>;
|
||||
};
|
||||
|
||||
const getProviderModelsCachePath = (): string => path.join(
|
||||
os.homedir(),
|
||||
'.cloudcli',
|
||||
'provider-models-cache.json',
|
||||
);
|
||||
|
||||
const toProviderModelsCacheInfo = (
|
||||
entry: ProviderModelsCacheEntry,
|
||||
source: ProviderModelsCacheInfo['source'],
|
||||
): ProviderModelsCacheInfo => ({
|
||||
updatedAt: new Date(entry.updatedAt).toISOString(),
|
||||
expiresAt: new Date(entry.expiresAt).toISOString(),
|
||||
source,
|
||||
});
|
||||
|
||||
const isProviderModelOption = (
|
||||
value: unknown,
|
||||
): value is ProviderModelsDefinition['OPTIONS'][number] => (
|
||||
Boolean(value)
|
||||
&& typeof value === 'object'
|
||||
&& typeof (value as ProviderModelsDefinition['OPTIONS'][number]).value === 'string'
|
||||
&& typeof (value as ProviderModelsDefinition['OPTIONS'][number]).label === 'string'
|
||||
&& (
|
||||
typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'undefined'
|
||||
|| typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'string'
|
||||
)
|
||||
);
|
||||
|
||||
const isProviderModelsDefinition = (value: unknown): value is ProviderModelsDefinition => (
|
||||
Boolean(value)
|
||||
&& typeof value === 'object'
|
||||
&& Array.isArray((value as ProviderModelsDefinition).OPTIONS)
|
||||
&& (value as ProviderModelsDefinition).OPTIONS.every(isProviderModelOption)
|
||||
&& typeof (value as ProviderModelsDefinition).DEFAULT === 'string'
|
||||
);
|
||||
|
||||
const isProviderModelsCacheEntry = (value: unknown): value is ProviderModelsCacheEntry => (
|
||||
Boolean(value)
|
||||
&& typeof value === 'object'
|
||||
&& typeof (value as ProviderModelsCacheEntry).updatedAt === 'number'
|
||||
&& typeof (value as ProviderModelsCacheEntry).expiresAt === 'number'
|
||||
&& isProviderModelsDefinition((value as ProviderModelsCacheEntry).models)
|
||||
);
|
||||
|
||||
const readProviderModelsCacheFile = async (
|
||||
cachePath: string,
|
||||
): Promise<ProviderModelsCacheFile | null> => {
|
||||
try {
|
||||
const raw = await readFile(cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<ProviderModelsCacheFile>;
|
||||
if (parsed.version !== PROVIDER_MODELS_CACHE_VERSION || !parsed.entries || typeof parsed.entries !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = Object.fromEntries(
|
||||
Object.entries(parsed.entries).filter((entry): entry is [string, ProviderModelsCacheEntry] =>
|
||||
isProviderModelsCacheEntry(entry[1]),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
version: PROVIDER_MODELS_CACHE_VERSION,
|
||||
entries,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeProviderModelsCacheFile = async (
|
||||
cachePath: string,
|
||||
entries: Map<LLMProvider, ProviderModelsCacheEntry>,
|
||||
now: number,
|
||||
): Promise<void> => {
|
||||
const serializableEntries = Object.fromEntries(
|
||||
[...entries.entries()].filter(([, entry]) => entry.expiresAt > now),
|
||||
);
|
||||
const payload: ProviderModelsCacheFile = {
|
||||
version: PROVIDER_MODELS_CACHE_VERSION,
|
||||
entries: serializableEntries,
|
||||
};
|
||||
|
||||
await mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider model lookup service.
|
||||
*
|
||||
* Routes and other service callers use this layer instead of resolving provider
|
||||
* classes directly so the provider-registry dependency stays centralized in one
|
||||
* place.
|
||||
*/
|
||||
export const createProviderModelsService = (dependencies: ProviderModelsServiceDependencies = {}) => {
|
||||
const resolveProvider = dependencies.resolveProvider ?? providerRegistry.resolveProvider;
|
||||
const cachePath = dependencies.cachePath ?? getProviderModelsCachePath();
|
||||
const activeModelChangesPath = dependencies.activeModelChangesPath;
|
||||
const now = dependencies.now ?? (() => Date.now());
|
||||
const memoryCache = new Map<LLMProvider, ProviderModelsCacheEntry>();
|
||||
const pendingRequests = new Map<LLMProvider, Promise<ProviderModelsResult>>();
|
||||
let persistedCacheLoaded = false;
|
||||
let persistedCacheLoadPromise: Promise<void> | null = null;
|
||||
|
||||
const pruneExpiredMemoryEntry = (
|
||||
provider: LLMProvider,
|
||||
currentTime: number,
|
||||
source: ProviderModelsCacheInfo['source'],
|
||||
): ProviderModelsResult | null => {
|
||||
const cachedEntry = memoryCache.get(provider);
|
||||
if (!cachedEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cachedEntry.expiresAt > currentTime) {
|
||||
return {
|
||||
models: cachedEntry.models,
|
||||
cache: toProviderModelsCacheInfo(cachedEntry, source),
|
||||
};
|
||||
}
|
||||
|
||||
memoryCache.delete(provider);
|
||||
return null;
|
||||
};
|
||||
|
||||
const loadPersistedCache = async (): Promise<void> => {
|
||||
if (persistedCacheLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!persistedCacheLoadPromise) {
|
||||
persistedCacheLoadPromise = (async () => {
|
||||
const cacheFile = await readProviderModelsCacheFile(cachePath);
|
||||
const currentTime = now();
|
||||
|
||||
for (const [provider, entry] of Object.entries(cacheFile?.entries ?? {})) {
|
||||
if (entry.expiresAt > currentTime) {
|
||||
memoryCache.set(provider as LLMProvider, entry);
|
||||
}
|
||||
}
|
||||
|
||||
persistedCacheLoaded = true;
|
||||
})().finally(() => {
|
||||
persistedCacheLoadPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
await persistedCacheLoadPromise;
|
||||
};
|
||||
|
||||
const persistCache = async (): Promise<void> => {
|
||||
try {
|
||||
await writeProviderModelsCacheFile(cachePath, memoryCache, now());
|
||||
} catch (error) {
|
||||
console.warn('Unable to persist provider models cache:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const setCacheEntry = async (
|
||||
provider: LLMProvider,
|
||||
models: ProviderModelsDefinition,
|
||||
): Promise<ProviderModelsCacheEntry> => {
|
||||
const currentTime = now();
|
||||
const entry: ProviderModelsCacheEntry = {
|
||||
updatedAt: currentTime,
|
||||
expiresAt: currentTime + PROVIDER_MODELS_CACHE_TTL_MS,
|
||||
models,
|
||||
};
|
||||
|
||||
memoryCache.set(provider, entry);
|
||||
await persistCache();
|
||||
return entry;
|
||||
};
|
||||
|
||||
const loadAndCacheModels = (
|
||||
provider: LLMProvider,
|
||||
): Promise<ProviderModelsResult> => {
|
||||
const request = resolveProvider(provider).models.getSupportedModels()
|
||||
.then(async (models) => {
|
||||
const entry = await setCacheEntry(provider, models);
|
||||
return {
|
||||
models,
|
||||
cache: toProviderModelsCacheInfo(entry, 'fresh'),
|
||||
};
|
||||
})
|
||||
.finally(() => {
|
||||
pendingRequests.delete(provider);
|
||||
});
|
||||
|
||||
pendingRequests.set(provider, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const getProviderModels = async (
|
||||
provider: LLMProvider,
|
||||
options: ProviderModelsOptions = {},
|
||||
): Promise<ProviderModelsResult> => {
|
||||
if (options.bypassCache) {
|
||||
const pendingRequest = pendingRequests.get(provider);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
return loadAndCacheModels(provider);
|
||||
}
|
||||
|
||||
const cachedModels = pruneExpiredMemoryEntry(provider, now(), 'memory');
|
||||
if (cachedModels) {
|
||||
return cachedModels;
|
||||
}
|
||||
|
||||
const pendingRequest = pendingRequests.get(provider);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
await loadPersistedCache();
|
||||
|
||||
const persistedModels = pruneExpiredMemoryEntry(provider, now(), 'disk');
|
||||
if (persistedModels) {
|
||||
return persistedModels;
|
||||
}
|
||||
|
||||
const postLoadPendingRequest = pendingRequests.get(provider);
|
||||
if (postLoadPendingRequest) {
|
||||
return postLoadPendingRequest;
|
||||
}
|
||||
|
||||
return loadAndCacheModels(provider);
|
||||
};
|
||||
|
||||
const getCurrentActiveModel = async (
|
||||
provider: LLMProvider,
|
||||
sessionId?: string,
|
||||
): Promise<ProviderCurrentActiveModel> => resolveProvider(provider).models.getCurrentActiveModel(sessionId);
|
||||
|
||||
const changeActiveModel = async (
|
||||
provider: LLMProvider,
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> => resolveProvider(provider).models.changeActiveModel(input);
|
||||
|
||||
const getChangedActiveModel = async (
|
||||
provider: LLMProvider,
|
||||
sessionId: string,
|
||||
): Promise<ProviderSessionActiveModelChange> => readProviderSessionActiveModelChange(provider, sessionId, {
|
||||
filePath: activeModelChangesPath,
|
||||
});
|
||||
|
||||
const resolveResumeModel = async (
|
||||
provider: LLMProvider,
|
||||
sessionId: string | undefined,
|
||||
requestedModel?: string | null,
|
||||
): Promise<string | undefined> => {
|
||||
const normalizedRequestedModel = typeof requestedModel === 'string' ? requestedModel.trim() : '';
|
||||
if (!sessionId?.trim()) {
|
||||
return normalizedRequestedModel || undefined;
|
||||
}
|
||||
|
||||
const changedModel = await getChangedActiveModel(provider, sessionId);
|
||||
if (changedModel.supported && changedModel.changed && changedModel.model?.trim()) {
|
||||
return changedModel.model.trim();
|
||||
}
|
||||
|
||||
return normalizedRequestedModel || undefined;
|
||||
};
|
||||
|
||||
const clearCache = (): void => {
|
||||
memoryCache.clear();
|
||||
pendingRequests.clear();
|
||||
persistedCacheLoaded = false;
|
||||
persistedCacheLoadPromise = null;
|
||||
};
|
||||
|
||||
return {
|
||||
getProviderModels,
|
||||
getCurrentActiveModel,
|
||||
getChangedActiveModel,
|
||||
changeActiveModel,
|
||||
resolveResumeModel,
|
||||
clearCache,
|
||||
};
|
||||
};
|
||||
|
||||
export const providerModelsService = createProviderModelsService();
|
||||
@@ -22,6 +22,7 @@ export const sessionSynchronizerService = {
|
||||
codex: 0,
|
||||
cursor: 0,
|
||||
gemini: 0,
|
||||
opencode: 0,
|
||||
};
|
||||
const failures: string[] = [];
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
|
||||
provider: 'gemini',
|
||||
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
||||
},
|
||||
{
|
||||
provider: 'opencode',
|
||||
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
|
||||
},
|
||||
];
|
||||
|
||||
const WATCHER_IGNORED_PATTERNS = [
|
||||
@@ -67,6 +71,10 @@ let watcherRescheduleAfterRefresh = false;
|
||||
* Filters watcher events to provider-specific session artifact file types.
|
||||
*/
|
||||
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
|
||||
if (provider === 'opencode') {
|
||||
return path.basename(filePath) === 'opencode.db';
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
IProvider,
|
||||
IProviderAuth,
|
||||
IProviderMcp,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
@@ -17,6 +18,7 @@ import type { LLMProvider } from '@/shared/types.js';
|
||||
*/
|
||||
export abstract class AbstractProvider implements IProvider {
|
||||
readonly id: LLMProvider;
|
||||
abstract readonly models: IProviderModels;
|
||||
abstract readonly mcp: IProviderMcp;
|
||||
abstract readonly auth: IProviderAuth;
|
||||
abstract readonly skills: IProviderSkills;
|
||||
|
||||
@@ -169,6 +169,93 @@ test('providerMcpService handles codex MCP TOML config and capability validation
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers OpenCode MCP support for user/project config files, JSONC-compatible
|
||||
* reads, and validation for unsupported scope/transport combinations.
|
||||
*/
|
||||
test('providerMcpService handles opencode MCP config and capability validation', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-opencode-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
await fs.mkdir(path.join(tempRoot, '.config', 'opencode'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'),
|
||||
`{
|
||||
// Existing comments should not block OpenCode MCP reads.
|
||||
"mcp": {}
|
||||
}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-user-stdio',
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { API_KEY: 'x' },
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-project-http',
|
||||
scope: 'project',
|
||||
transport: 'http',
|
||||
url: 'https://opencode.example.com/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const userConfig = await readJson(path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'));
|
||||
const userServers = userConfig.mcp as Record<string, unknown>;
|
||||
const userStdio = userServers['opencode-user-stdio'] as Record<string, unknown>;
|
||||
assert.equal(userStdio.type, 'local');
|
||||
assert.deepEqual(userStdio.command, ['node', 'server.js']);
|
||||
assert.deepEqual(userStdio.environment, { API_KEY: 'x' });
|
||||
|
||||
const projectConfig = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||
const projectServers = projectConfig.mcp as Record<string, unknown>;
|
||||
const projectHttp = projectServers['opencode-project-http'] as Record<string, unknown>;
|
||||
assert.equal(projectHttp.type, 'remote');
|
||||
assert.equal(projectHttp.url, 'https://opencode.example.com/mcp');
|
||||
|
||||
const grouped = await providerMcpService.listProviderMcpServers('opencode', { workspacePath });
|
||||
assert.ok(grouped.user.some((server) => server.name === 'opencode-user-stdio' && server.transport === 'stdio'));
|
||||
assert.ok(grouped.project.some((server) => server.name === 'opencode-project-http' && server.transport === 'http'));
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-local',
|
||||
scope: 'local',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-sse',
|
||||
scope: 'project',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
workspacePath,
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
|
||||
*/
|
||||
@@ -255,7 +342,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
});
|
||||
|
||||
const expectCursorGlobal = process.platform !== 'win32';
|
||||
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
|
||||
assert.equal(globalResult.length, expectCursorGlobal ? 5 : 4);
|
||||
assert.ok(globalResult.every((entry) => entry.created === true));
|
||||
|
||||
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||
@@ -267,6 +354,9 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
||||
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
|
||||
|
||||
if (expectCursorGlobal) {
|
||||
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
73
server/modules/providers/tests/opencode-models.test.ts
Normal file
73
server/modules/providers/tests/opencode-models.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildOpenCodeDefinitionFromIds,
|
||||
parseOpenCodeModelsStdout,
|
||||
} from '@/modules/providers/list/opencode/opencode-models.provider.js';
|
||||
|
||||
test('OpenCode models provider parses plain CLI output and removes duplicates', () => {
|
||||
const ids = parseOpenCodeModelsStdout(`
|
||||
opencode/big-pickle
|
||||
not a model
|
||||
anthropic/claude-opus-4-7-fast
|
||||
anthropic/claude-opus-4-7-fast
|
||||
openai/gpt-5.5-pro
|
||||
`);
|
||||
|
||||
assert.deepEqual(ids, [
|
||||
'opencode/big-pickle',
|
||||
'anthropic/claude-opus-4-7-fast',
|
||||
'openai/gpt-5.5-pro',
|
||||
]);
|
||||
});
|
||||
|
||||
test('OpenCode models provider formats frontend labels from provider-prefixed ids', () => {
|
||||
const definition = buildOpenCodeDefinitionFromIds([
|
||||
'opencode/deepseek-v4-flash-free',
|
||||
'opencode/nemotron-3-super-free',
|
||||
'anthropic/claude-3-5-sonnet-20241022',
|
||||
'anthropic/claude-opus-4-7-fast',
|
||||
'openai/gpt-5.4-mini-fast',
|
||||
'openai/gpt-5.5-pro',
|
||||
'newprovider/alpha-v12-special-20261231',
|
||||
]);
|
||||
|
||||
assert.deepEqual(definition.OPTIONS, [
|
||||
{
|
||||
value: 'opencode/deepseek-v4-flash-free',
|
||||
label: 'Deepseek V4 Flash Free',
|
||||
description: 'opencode - opencode/deepseek-v4-flash-free',
|
||||
},
|
||||
{
|
||||
value: 'opencode/nemotron-3-super-free',
|
||||
label: 'Nemotron 3 Super Free',
|
||||
description: 'opencode - opencode/nemotron-3-super-free',
|
||||
},
|
||||
{
|
||||
value: 'anthropic/claude-3-5-sonnet-20241022',
|
||||
label: 'Claude 3.5 Sonnet (2024-10-22)',
|
||||
description: 'anthropic - anthropic/claude-3-5-sonnet-20241022',
|
||||
},
|
||||
{
|
||||
value: 'anthropic/claude-opus-4-7-fast',
|
||||
label: 'Claude Opus 4.7 Fast',
|
||||
description: 'anthropic - anthropic/claude-opus-4-7-fast',
|
||||
},
|
||||
{
|
||||
value: 'openai/gpt-5.4-mini-fast',
|
||||
label: 'GPT-5.4 Mini Fast',
|
||||
description: 'openai - openai/gpt-5.4-mini-fast',
|
||||
},
|
||||
{
|
||||
value: 'openai/gpt-5.5-pro',
|
||||
label: 'GPT-5.5 Pro',
|
||||
description: 'openai - openai/gpt-5.5-pro',
|
||||
},
|
||||
{
|
||||
value: 'newprovider/alpha-v12-special-20261231',
|
||||
label: 'Alpha V12 Special (2026-12-31)',
|
||||
description: 'newprovider - newprovider/alpha-v12-special-20261231',
|
||||
},
|
||||
]);
|
||||
});
|
||||
321
server/modules/providers/tests/opencode-sessions.test.ts
Normal file
321
server/modules/providers/tests/opencode-sessions.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
||||
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
|
||||
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
|
||||
|
||||
const patchHomeDir = (nextHomeDir: string) => {
|
||||
const original = os.homedir;
|
||||
(os as any).homedir = () => nextHomeDir;
|
||||
return () => {
|
||||
(os as any).homedir = original;
|
||||
};
|
||||
};
|
||||
|
||||
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'opencode-provider-db-'));
|
||||
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||
|
||||
closeConnection();
|
||||
process.env.DATABASE_PATH = databasePath;
|
||||
await initializeDatabase();
|
||||
|
||||
try {
|
||||
await runTest();
|
||||
} finally {
|
||||
closeConnection();
|
||||
if (previousDatabasePath === undefined) {
|
||||
delete process.env.DATABASE_PATH;
|
||||
} else {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
}
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): Promise<void> => {
|
||||
const dataDir = path.join(homeDir, '.local', 'share', 'opencode');
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
|
||||
const db = new Database(path.join(dataDir, 'opencode.db'));
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE project (
|
||||
id TEXT PRIMARY KEY,
|
||||
worktree TEXT NOT NULL,
|
||||
vcs TEXT,
|
||||
name TEXT,
|
||||
icon_url TEXT,
|
||||
icon_color TEXT,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
time_initialized INTEGER,
|
||||
sandboxes TEXT NOT NULL,
|
||||
commands TEXT,
|
||||
icon_url_override TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
slug TEXT NOT NULL,
|
||||
directory TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
share_url TEXT,
|
||||
summary_additions INTEGER,
|
||||
summary_deletions INTEGER,
|
||||
summary_files INTEGER,
|
||||
summary_diffs TEXT,
|
||||
revert TEXT,
|
||||
permission TEXT,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
time_compacting INTEGER,
|
||||
time_archived INTEGER,
|
||||
workspace_id TEXT,
|
||||
path TEXT,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE part (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
FOREIGN KEY (message_id) REFERENCES message(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX part_session_idx ON part (session_id);
|
||||
CREATE INDEX session_project_idx ON session (project_id);
|
||||
CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id);
|
||||
CREATE INDEX part_message_id_id_idx ON part (message_id, id);
|
||||
`);
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(
|
||||
'project-1',
|
||||
workspacePath,
|
||||
1_700_000_000_000,
|
||||
1_700_000_001_000,
|
||||
'[]',
|
||||
);
|
||||
db.prepare(`
|
||||
INSERT INTO session (
|
||||
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'open-session-1',
|
||||
'project-1',
|
||||
'open-session-1',
|
||||
workspacePath,
|
||||
'OpenCode indexed title',
|
||||
'0.0.0',
|
||||
1_700_000_000_000,
|
||||
1_700_000_004_000,
|
||||
null,
|
||||
);
|
||||
|
||||
const userMessageData = JSON.stringify({
|
||||
role: 'user',
|
||||
time: { created: 1_700_000_001_000 },
|
||||
agent: 'test',
|
||||
model: { providerID: 'anthropic', modelID: 'claude' },
|
||||
});
|
||||
const assistantMessageData = JSON.stringify({
|
||||
role: 'assistant',
|
||||
time: { created: 1_700_000_002_000, completed: 1_700_000_003_000 },
|
||||
parentID: 'message-user',
|
||||
modelID: 'anthropic/claude-sonnet-4-5',
|
||||
providerID: 'anthropic',
|
||||
mode: 'default',
|
||||
agent: 'test',
|
||||
path: { cwd: '.', root: '.' },
|
||||
cost: 0.01,
|
||||
tokens: {
|
||||
input: 10,
|
||||
output: 20,
|
||||
reasoning: 0,
|
||||
cache: { read: 3, write: 2 },
|
||||
},
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('message-user', 'open-session-1', 1_700_000_001_000, 1_700_000_001_500, userMessageData);
|
||||
db.prepare(
|
||||
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('message-assistant', 'open-session-1', 1_700_000_002_000, 1_700_000_003_000, assistantMessageData);
|
||||
|
||||
const insertPart = db.prepare(`
|
||||
INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertPart.run(
|
||||
'part-user-text',
|
||||
'message-user',
|
||||
'open-session-1',
|
||||
1_700_000_001_000,
|
||||
1_700_000_001_000,
|
||||
JSON.stringify({
|
||||
type: 'text',
|
||||
text: JSON.stringify('Build the OpenCode integration.'),
|
||||
}),
|
||||
);
|
||||
insertPart.run(
|
||||
'part-reasoning',
|
||||
'message-assistant',
|
||||
'open-session-1',
|
||||
1_700_000_002_000,
|
||||
1_700_000_002_000,
|
||||
JSON.stringify({
|
||||
type: 'reasoning',
|
||||
text: 'I will inspect the provider shape first.',
|
||||
time: { start: 0, end: 1 },
|
||||
}),
|
||||
);
|
||||
insertPart.run(
|
||||
'part-assistant-text',
|
||||
'message-assistant',
|
||||
'open-session-1',
|
||||
1_700_000_002_500,
|
||||
1_700_000_002_500,
|
||||
JSON.stringify({
|
||||
type: 'text',
|
||||
text: 'The provider is wired.',
|
||||
}),
|
||||
);
|
||||
insertPart.run(
|
||||
'part-tool',
|
||||
'message-assistant',
|
||||
'open-session-1',
|
||||
1_700_000_003_000,
|
||||
1_700_000_003_000,
|
||||
JSON.stringify({
|
||||
type: 'tool',
|
||||
tool: 'bash',
|
||||
callID: 'tool-call-1',
|
||||
state: {
|
||||
status: 'completed',
|
||||
input: { command: 'npm test' },
|
||||
output: 'ok',
|
||||
title: 'bash',
|
||||
metadata: {},
|
||||
time: { start: 0, end: 1 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
|
||||
test('OpenCode session synchronizer indexes sqlite sessions without deletable transcript paths', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||
await withIsolatedDatabase(() => {
|
||||
const synchronizer = new OpenCodeSessionSynchronizer();
|
||||
const processed = synchronizer.synchronize();
|
||||
|
||||
return Promise.resolve(processed).then((count) => {
|
||||
assert.equal(count, 1);
|
||||
const indexed = sessionsDb.getSessionById('open-session-1');
|
||||
assert.equal(indexed?.provider, 'opencode');
|
||||
assert.equal(indexed?.project_path, workspacePath);
|
||||
assert.equal(indexed?.custom_name, 'OpenCode indexed title');
|
||||
assert.equal(indexed?.jsonl_path, null);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
|
||||
const provider = new OpenCodeSessionsProvider();
|
||||
const normalized = provider.normalizeMessage({
|
||||
type: 'text',
|
||||
sessionID: 'open-session-live',
|
||||
text: JSON.stringify('hello bro'),
|
||||
}, null);
|
||||
|
||||
assert.equal(normalized.length, 1);
|
||||
assert.equal(normalized[0]?.kind, 'stream_delta');
|
||||
assert.equal(normalized[0]?.content, 'hello bro');
|
||||
|
||||
const userEcho = provider.normalizeMessage({
|
||||
type: 'text',
|
||||
sessionID: 'open-session-live',
|
||||
role: 'user',
|
||||
text: 'hello bro',
|
||||
}, null);
|
||||
|
||||
assert.deepEqual(userEcho, []);
|
||||
});
|
||||
|
||||
test('OpenCode sessions provider reads sqlite history and token usage', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-history-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||
const provider = new OpenCodeSessionsProvider();
|
||||
const history = await provider.fetchHistory('open-session-1');
|
||||
|
||||
assert.equal(history.total, 4);
|
||||
assert.equal(history.messages[0]?.kind, 'text');
|
||||
assert.equal(history.messages[0]?.role, 'user');
|
||||
assert.equal(history.messages[0]?.content, 'Build the OpenCode integration.');
|
||||
assert.equal(history.messages[1]?.kind, 'thinking');
|
||||
assert.equal(history.messages[2]?.content, 'The provider is wired.');
|
||||
assert.equal(history.messages[3]?.kind, 'tool_use');
|
||||
assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false });
|
||||
assert.deepEqual(history.tokenUsage, {
|
||||
used: 35,
|
||||
total: 35,
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 3,
|
||||
cacheCreationTokens: 2,
|
||||
});
|
||||
|
||||
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });
|
||||
assert.equal(paged.messages.length, 2);
|
||||
assert.equal(paged.hasMore, true);
|
||||
assert.equal(paged.messages[0]?.content, 'The provider is wired.');
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
318
server/modules/providers/tests/provider-models.service.test.ts
Normal file
318
server/modules/providers/tests/provider-models.service.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createProviderModelsService,
|
||||
PROVIDER_MODELS_CACHE_TTL_MS,
|
||||
} from '@/modules/providers/services/provider-models.service.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
LLMProvider,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import { writeProviderSessionActiveModelChange } from '@/shared/utils.js';
|
||||
|
||||
const createModels = (value: string): ProviderModelsDefinition => ({
|
||||
OPTIONS: [{ value, label: value }],
|
||||
DEFAULT: value,
|
||||
});
|
||||
|
||||
const createCurrentActiveModel = (model: string): ProviderCurrentActiveModel => ({
|
||||
model,
|
||||
});
|
||||
|
||||
const createSessionActiveModelChange = (
|
||||
provider: LLMProvider,
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): ProviderSessionActiveModelChange => ({
|
||||
provider,
|
||||
sessionId: input.sessionId,
|
||||
supported: true,
|
||||
changed: true,
|
||||
model: input.model,
|
||||
});
|
||||
|
||||
const createEphemeralCachePath = (): string => path.join(
|
||||
os.tmpdir(),
|
||||
`provider-model-cache-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
|
||||
);
|
||||
|
||||
test('provider models service delegates to the resolved provider model adapter', async () => {
|
||||
const calls: LLMProvider[] = [];
|
||||
const service = createProviderModelsService({
|
||||
cachePath: createEphemeralCachePath(),
|
||||
resolveProvider: (provider) => {
|
||||
calls.push(provider);
|
||||
return {
|
||||
models: {
|
||||
getSupportedModels: async () => createModels(`${provider}-models`),
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const models = await service.getProviderModels('codex', { bypassCache: true });
|
||||
|
||||
assert.deepEqual(calls, ['codex']);
|
||||
assert.equal(models.models.DEFAULT, 'codex-models');
|
||||
assert.equal(models.cache.source, 'fresh');
|
||||
});
|
||||
|
||||
test('provider models service returns each provider adapter result without rewriting it', async () => {
|
||||
const expectedModels: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'cursor-a', label: 'Cursor A' },
|
||||
{ value: 'cursor-b', label: 'Cursor B' },
|
||||
],
|
||||
DEFAULT: 'cursor-b',
|
||||
};
|
||||
|
||||
const service = createProviderModelsService({
|
||||
cachePath: createEphemeralCachePath(),
|
||||
resolveProvider: () => ({
|
||||
models: {
|
||||
getSupportedModels: async () => expectedModels,
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const models = await service.getProviderModels('cursor', { bypassCache: true });
|
||||
|
||||
assert.deepEqual(models.models, expectedModels);
|
||||
});
|
||||
|
||||
test('provider models are cached for the three-day ttl', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-ttl-'));
|
||||
let currentTime = 1_000;
|
||||
let loadCount = 0;
|
||||
|
||||
try {
|
||||
const service = createProviderModelsService({
|
||||
cachePath: path.join(tempRoot, 'models-cache.json'),
|
||||
now: () => currentTime,
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => {
|
||||
loadCount += 1;
|
||||
return createModels(`${provider}-${loadCount}`);
|
||||
},
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const first = await service.getProviderModels('codex');
|
||||
const cached = await service.getProviderModels('codex');
|
||||
assert.equal(loadCount, 1);
|
||||
assert.equal(cached.models.DEFAULT, first.models.DEFAULT);
|
||||
assert.equal(cached.cache.source, 'memory');
|
||||
|
||||
currentTime += PROVIDER_MODELS_CACHE_TTL_MS - 1;
|
||||
await service.getProviderModels('codex');
|
||||
assert.equal(loadCount, 1);
|
||||
|
||||
currentTime += 2;
|
||||
const refreshed = await service.getProviderModels('codex');
|
||||
assert.equal(loadCount, 2);
|
||||
assert.equal(refreshed.models.DEFAULT, 'codex-2');
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('provider model cache is persisted across service instances', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
|
||||
const cachePath = path.join(tempRoot, 'models-cache.json');
|
||||
|
||||
try {
|
||||
const writer = createProviderModelsService({
|
||||
cachePath,
|
||||
resolveProvider: () => ({
|
||||
models: {
|
||||
getSupportedModels: async () => createModels('gemini-cached'),
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
await writer.getProviderModels('gemini');
|
||||
|
||||
const reader = createProviderModelsService({
|
||||
cachePath,
|
||||
resolveProvider: () => ({
|
||||
models: {
|
||||
getSupportedModels: async () => {
|
||||
throw new Error('loader should not be called for persisted cache hits');
|
||||
},
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const models = await reader.getProviderModels('gemini');
|
||||
assert.equal(models.models.DEFAULT, 'gemini-cached');
|
||||
assert.equal(models.cache.source, 'disk');
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('concurrent provider model requests share one load operation', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-pending-'));
|
||||
let loadCount = 0;
|
||||
|
||||
try {
|
||||
const service = createProviderModelsService({
|
||||
cachePath: path.join(tempRoot, 'models-cache.json'),
|
||||
resolveProvider: () => ({
|
||||
models: {
|
||||
getSupportedModels: async () => {
|
||||
loadCount += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return createModels('claude-cached');
|
||||
},
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('claude-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('claude', input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
service.getProviderModels('claude'),
|
||||
service.getProviderModels('claude'),
|
||||
]);
|
||||
|
||||
assert.equal(loadCount, 1);
|
||||
assert.equal(first.models.DEFAULT, 'claude-cached');
|
||||
assert.equal(second.models.DEFAULT, 'claude-cached');
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('bypassCache forces a fresh provider fetch and updates cache metadata', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-refresh-'));
|
||||
let currentTime = 1_000;
|
||||
let loadCount = 0;
|
||||
|
||||
try {
|
||||
const service = createProviderModelsService({
|
||||
cachePath: path.join(tempRoot, 'models-cache.json'),
|
||||
now: () => currentTime,
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => {
|
||||
loadCount += 1;
|
||||
return createModels(`${provider}-${loadCount}`);
|
||||
},
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active-${loadCount}`),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const first = await service.getProviderModels('claude');
|
||||
currentTime += 50;
|
||||
const refreshed = await service.getProviderModels('claude', { bypassCache: true });
|
||||
|
||||
assert.equal(first.models.DEFAULT, 'claude-1');
|
||||
assert.equal(refreshed.models.DEFAULT, 'claude-2');
|
||||
assert.equal(refreshed.cache.source, 'fresh');
|
||||
assert.notEqual(refreshed.cache.updatedAt, first.cache.updatedAt);
|
||||
assert.equal(loadCount, 2);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('provider models service delegates current active model lookups to the provider adapter', async () => {
|
||||
const calls: Array<{ provider: LLMProvider; sessionId?: string }> = [];
|
||||
const service = createProviderModelsService({
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => createModels(`${provider}-models`),
|
||||
getCurrentActiveModel: async (sessionId) => {
|
||||
calls.push({ provider, sessionId });
|
||||
return createCurrentActiveModel(`${provider}-${sessionId}`);
|
||||
},
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const activeModel = await service.getCurrentActiveModel('opencode', 'session-123');
|
||||
|
||||
assert.deepEqual(calls, [{ provider: 'opencode', sessionId: 'session-123' }]);
|
||||
assert.equal(activeModel.model, 'opencode-session-123');
|
||||
});
|
||||
|
||||
test('provider models service delegates active model change requests to the provider adapter', async () => {
|
||||
const calls: Array<{ provider: LLMProvider; input: ProviderChangeActiveModelInput }> = [];
|
||||
const service = createProviderModelsService({
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => createModels(`${provider}-models`),
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||
changeActiveModel: async (input) => {
|
||||
calls.push({ provider, input });
|
||||
return createSessionActiveModelChange(provider, input);
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const changedModel = await service.changeActiveModel('claude', {
|
||||
sessionId: 'session-123',
|
||||
model: 'opus',
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{
|
||||
provider: 'claude',
|
||||
input: {
|
||||
sessionId: 'session-123',
|
||||
model: 'opus',
|
||||
},
|
||||
}]);
|
||||
assert.equal(changedModel.changed, true);
|
||||
assert.equal(changedModel.model, 'opus');
|
||||
});
|
||||
|
||||
test('resolveResumeModel prefers a stored changed model over the requested one', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-change-'));
|
||||
const activeModelChangesPath = path.join(tempRoot, 'session-model-changes.json');
|
||||
|
||||
try {
|
||||
const service = createProviderModelsService({
|
||||
activeModelChangesPath,
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => createModels(`${provider}-models`),
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await writeProviderSessionActiveModelChange('cursor', {
|
||||
sessionId: 'session-456',
|
||||
model: 'composer-2',
|
||||
}, {
|
||||
filePath: activeModelChangesPath,
|
||||
});
|
||||
|
||||
const model = await service.resolveResumeModel('cursor', 'session-456', 'composer-2-fast');
|
||||
assert.equal(model, 'composer-2');
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -377,6 +377,72 @@ test('providerSkillsService lists codex repository, user, and system skills', {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers OpenCode skill lookup across cwd-to-git-root project folders
|
||||
* plus the global OpenCode/Claude/Agents compatibility locations.
|
||||
*/
|
||||
test('providerSkillsService lists opencode project and user compatibility skills', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-opencode-'));
|
||||
const repoRoot = path.join(tempRoot, 'repo');
|
||||
const workspacePath = path.join(repoRoot, 'packages', 'app');
|
||||
await fs.mkdir(path.join(repoRoot, '.git'), { recursive: true });
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.opencode', 'skills'),
|
||||
'opencode-cwd-dir',
|
||||
'opencode-cwd',
|
||||
'OpenCode cwd skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(repoRoot, 'packages', '.claude', 'skills'),
|
||||
'opencode-claude-parent-dir',
|
||||
'opencode-claude-parent',
|
||||
'OpenCode Claude parent skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(repoRoot, '.agents', 'skills'),
|
||||
'opencode-agents-root-dir',
|
||||
'opencode-agents-root',
|
||||
'OpenCode Agents root skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.config', 'opencode', 'skills'),
|
||||
'opencode-user-dir',
|
||||
'opencode-user',
|
||||
'OpenCode user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.claude', 'skills'),
|
||||
'opencode-claude-user-dir',
|
||||
'opencode-claude-user',
|
||||
'OpenCode Claude user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.agents', 'skills'),
|
||||
'opencode-agents-user-dir',
|
||||
'opencode-agents-user',
|
||||
'OpenCode Agents user skill',
|
||||
);
|
||||
|
||||
const skills = await providerSkillsService.listProviderSkills('opencode', { workspacePath });
|
||||
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
|
||||
assert.equal(byName.get('opencode-cwd')?.scope, 'project');
|
||||
assert.equal(byName.get('opencode-claude-parent')?.scope, 'project');
|
||||
assert.equal(byName.get('opencode-agents-root')?.scope, 'project');
|
||||
assert.equal(byName.get('opencode-user')?.scope, 'user');
|
||||
assert.equal(byName.get('opencode-claude-user')?.scope, 'user');
|
||||
assert.equal(byName.get('opencode-agents-user')?.scope, 'user');
|
||||
assert.equal(byName.get('opencode-cwd')?.command, '/opencode-cwd');
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Gemini and Cursor skill directory rules, including shared
|
||||
* `.agents/skills` project support.
|
||||
|
||||
@@ -29,10 +29,12 @@ type ChatWebSocketDependencies = {
|
||||
spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
|
||||
abortCursorSession: (sessionId: string) => boolean;
|
||||
abortCodexSession: (sessionId: string) => boolean;
|
||||
abortGeminiSession: (sessionId: string) => boolean;
|
||||
abortOpenCodeSession: (sessionId: string) => boolean;
|
||||
resolveToolApproval: (
|
||||
requestId: string,
|
||||
payload: {
|
||||
@@ -46,19 +48,21 @@ type ChatWebSocketDependencies = {
|
||||
isCursorSessionActive: (sessionId: string) => boolean;
|
||||
isCodexSessionActive: (sessionId: string) => boolean;
|
||||
isGeminiSessionActive: (sessionId: string) => boolean;
|
||||
isOpenCodeSessionActive: (sessionId: string) => boolean;
|
||||
reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
|
||||
getPendingApprovalsForSession: (sessionId: string) => unknown[];
|
||||
getActiveClaudeSDKSessions: () => unknown;
|
||||
getActiveCursorSessions: () => unknown;
|
||||
getActiveCodexSessions: () => unknown;
|
||||
getActiveGeminiSessions: () => unknown;
|
||||
getActiveOpenCodeSessions: () => unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes potentially invalid provider names coming from websocket payloads.
|
||||
*/
|
||||
function readProvider(value: unknown): LLMProvider {
|
||||
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini') {
|
||||
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -134,6 +138,11 @@ export function handleChatConnection(
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'opencode-command') {
|
||||
await dependencies.spawnOpenCode(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'cursor-resume') {
|
||||
await dependencies.spawnCursor(
|
||||
'',
|
||||
@@ -158,6 +167,8 @@ export function handleChatConnection(
|
||||
success = dependencies.abortCodexSession(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
success = dependencies.abortGeminiSession(sessionId);
|
||||
} else if (provider === 'opencode') {
|
||||
success = dependencies.abortOpenCodeSession(sessionId);
|
||||
} else {
|
||||
success = await dependencies.abortClaudeSDKSession(sessionId);
|
||||
}
|
||||
@@ -214,6 +225,8 @@ export function handleChatConnection(
|
||||
isActive = dependencies.isCodexSessionActive(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
isActive = dependencies.isGeminiSessionActive(sessionId);
|
||||
} else if (provider === 'opencode') {
|
||||
isActive = dependencies.isOpenCodeSessionActive(sessionId);
|
||||
} else {
|
||||
isActive = dependencies.isClaudeSDKSessionActive(sessionId);
|
||||
if (isActive) {
|
||||
@@ -251,6 +264,7 @@ export function handleChatConnection(
|
||||
cursor: dependencies.getActiveCursorSessions(),
|
||||
codex: dependencies.getActiveCodexSessions(),
|
||||
gemini: dependencies.getActiveGeminiSessions(),
|
||||
opencode: dependencies.getActiveOpenCodeSessions(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,6 +136,13 @@ function buildShellCommand(
|
||||
return command;
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
if (hasSession && sessionId) {
|
||||
return `opencode --session "${sessionId}"`;
|
||||
}
|
||||
return initialCommand || 'opencode';
|
||||
}
|
||||
|
||||
const command = initialCommand || 'claude';
|
||||
if (hasSession && sessionId) {
|
||||
if (os.platform() === 'win32') {
|
||||
@@ -389,6 +396,8 @@ export function handleShellConnection(
|
||||
? 'Codex'
|
||||
: provider === 'gemini'
|
||||
? 'Gemini'
|
||||
: provider === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
welcomeMsg = hasSession
|
||||
? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
|
||||
|
||||
Reference in New Issue
Block a user