mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 14:55:34 +08:00
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.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { query, type ModelInfo, type Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.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'>;
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
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: '',
|
||||
options: buildClaudeQueryOptions(),
|
||||
});
|
||||
|
||||
const supportedModels = await queryInstance.supportedModels();
|
||||
|
||||
return buildClaudeModelsDefinition(supportedModels);
|
||||
} catch {
|
||||
return CLAUDE_FALLBACK_MODELS;
|
||||
} finally {
|
||||
queryInstance?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
88
server/modules/providers/list/codex/codex-models.provider.ts
Normal file
88
server/modules/providers/list/codex/codex-models.provider.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } 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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
180
server/modules/providers/list/cursor/cursor-models.provider.ts
Normal file
180
server/modules/providers/list/cursor/cursor-models.provider.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js';
|
||||
|
||||
export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'composer-2-fast', label: 'Composer 2 Fast' },
|
||||
{ value: 'composer-2', label: 'Composer 2' },
|
||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3' },
|
||||
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
|
||||
],
|
||||
DEFAULT: 'composer-2-fast',
|
||||
};
|
||||
|
||||
type CursorModelRow = {
|
||||
name: string;
|
||||
description: string;
|
||||
current: boolean;
|
||||
default: boolean;
|
||||
};
|
||||
|
||||
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,23 @@
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type { ProviderModelsDefinition } from '@/shared/types.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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js';
|
||||
|
||||
export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'anthropic/claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
|
||||
{ value: 'anthropic/claude-opus-4-1', label: 'Claude Opus 4.1' },
|
||||
{ value: 'anthropic/claude-haiku-4-5', label: 'Claude Haiku 4.5' },
|
||||
{ value: 'openai/gpt-5.1', label: 'GPT-5.1' },
|
||||
{ value: 'openai/gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'openai/gpt-5.4-mini', label: 'GPT-5.4 Mini' },
|
||||
{ value: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'google/gemini-2.5-flash', label: '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 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 labelForOpenCodeModelId = (id: string): string => {
|
||||
const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label;
|
||||
if (fallbackLabel) {
|
||||
return fallbackLabel;
|
||||
}
|
||||
|
||||
const tail = id.includes('/') ? id.slice(id.indexOf('/') + 1) : id;
|
||||
return tail.replace(/-/g, ' ');
|
||||
};
|
||||
|
||||
const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
|
||||
const options: ProviderModelOption[] = ids.map((value) => ({
|
||||
value,
|
||||
label: labelForOpenCodeModelId(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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -6,12 +7,14 @@ import { OpenCodeSkillsProvider } from '@/modules/providers/list/opencode/openco
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user