mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-04 20:42:57 +08:00
Merge branch 'main' into camoufox-novnc-browser-use
This commit is contained in:
@@ -12,11 +12,13 @@
|
||||
* - WebSocket message streaming
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
||||
@@ -41,6 +43,15 @@ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEO
|
||||
|
||||
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
|
||||
|
||||
function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_FALLBACK_MODELS) {
|
||||
const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model) || null;
|
||||
const allowedEfforts = selectedModel?.effort?.values
|
||||
?.map((value) => value.value) || [];
|
||||
return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
|
||||
? effort
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function createRequestId() {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
@@ -145,13 +156,8 @@ function matchesToolPermission(entry, toolName, input) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CLI options to SDK-compatible options format
|
||||
* @param {Object} options - CLI options
|
||||
* @returns {Object} SDK-compatible options
|
||||
*/
|
||||
function mapCliOptionsToSDK(options = {}) {
|
||||
const { sessionId, cwd, toolsSettings, permissionMode } = options;
|
||||
const { sessionId, cwd, toolsSettings, permissionMode, effort } = options;
|
||||
|
||||
const sdkOptions = {};
|
||||
|
||||
@@ -163,32 +169,26 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
// which does not reliably follow npm's shell wrappers like cross-spawn does.
|
||||
sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
|
||||
|
||||
// Map working directory
|
||||
if (cwd) {
|
||||
sdkOptions.cwd = cwd;
|
||||
}
|
||||
|
||||
// Map permission mode
|
||||
if (permissionMode && permissionMode !== 'default') {
|
||||
sdkOptions.permissionMode = permissionMode;
|
||||
}
|
||||
|
||||
// Map tool settings
|
||||
const settings = toolsSettings || {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false
|
||||
};
|
||||
|
||||
// Handle tool permissions
|
||||
if (settings.skipPermissions && permissionMode !== 'plan') {
|
||||
// When skipping permissions, use bypassPermissions mode
|
||||
sdkOptions.permissionMode = 'bypassPermissions';
|
||||
}
|
||||
|
||||
let allowedTools = [...(settings.allowedTools || [])];
|
||||
|
||||
// Add plan mode default tools
|
||||
if (permissionMode === 'plan') {
|
||||
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
|
||||
for (const tool of planModeTools) {
|
||||
@@ -207,22 +207,24 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
|
||||
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||
|
||||
// Map model (default to sonnet)
|
||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
|
||||
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
||||
// Model logged at query start below
|
||||
|
||||
// Map system prompt configuration
|
||||
const resolvedEffort = resolveClaudeEffort(
|
||||
sdkOptions.model,
|
||||
effort,
|
||||
options.effortModels || CLAUDE_FALLBACK_MODELS,
|
||||
);
|
||||
if (resolvedEffort) {
|
||||
sdkOptions.effort = resolvedEffort;
|
||||
}
|
||||
|
||||
sdkOptions.systemPrompt = {
|
||||
type: 'preset',
|
||||
preset: 'claude_code' // Required to use CLAUDE.md
|
||||
preset: 'claude_code'
|
||||
};
|
||||
|
||||
// Map setting sources for CLAUDE.md loading
|
||||
// This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories
|
||||
sdkOptions.settingSources = ['project', 'user', 'local'];
|
||||
|
||||
// Map resume session
|
||||
if (sessionId) {
|
||||
sdkOptions.resume = sessionId;
|
||||
}
|
||||
@@ -533,20 +535,24 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
sessionId,
|
||||
options.model,
|
||||
);
|
||||
let effortModels = CLAUDE_FALLBACK_MODELS;
|
||||
try {
|
||||
effortModels = (await providerModelsService.getProviderModels('claude')).models;
|
||||
} catch (error) {
|
||||
console.warn('[Claude SDK] Unable to load provider models for effort validation:', error);
|
||||
}
|
||||
|
||||
// Map CLI options to SDK format
|
||||
const sdkOptions = mapCliOptionsToSDK({
|
||||
...options,
|
||||
model: resolvedModel || options.model,
|
||||
effortModels,
|
||||
});
|
||||
|
||||
// Load MCP configuration
|
||||
const mcpServers = await loadMcpConfig(options.cwd);
|
||||
if (mcpServers) {
|
||||
sdkOptions.mcpServers = mcpServers;
|
||||
}
|
||||
|
||||
// Handle images - save to temp files and modify prompt
|
||||
const imageResult = await handleImages(command, options.images, options.cwd);
|
||||
const finalCommand = imageResult.modifiedCommand;
|
||||
tempImagePaths = imageResult.tempImagePaths;
|
||||
@@ -650,7 +656,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
|
||||
};
|
||||
|
||||
// Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
|
||||
// Query constructor reads this synchronously.
|
||||
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelOption,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
@@ -18,27 +19,89 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default (recommended)',
|
||||
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
|
||||
description: 'Use the Claude Code default model (currently Sonnet 4.6)',
|
||||
effort: {
|
||||
default: 'high',
|
||||
values: [
|
||||
{ value: 'low' },
|
||||
{ value: 'medium' },
|
||||
{ value: 'high' },
|
||||
{ value: 'max' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'fable',
|
||||
label: 'Fable',
|
||||
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
|
||||
effort: {
|
||||
default: 'high',
|
||||
values: [
|
||||
{ value: 'low' },
|
||||
{ value: 'medium' },
|
||||
{ value: 'high' },
|
||||
{ value: 'xhigh' },
|
||||
{ value: 'max' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
label: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
||||
effort: {
|
||||
default: 'high',
|
||||
values: [
|
||||
{ value: 'low' },
|
||||
{ value: 'medium' },
|
||||
{ value: 'high' },
|
||||
{ value: 'max' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'sonnet[1m]',
|
||||
label: 'Sonnet (1M context)',
|
||||
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
|
||||
effort: {
|
||||
default: 'high',
|
||||
values: [
|
||||
{ value: 'low' },
|
||||
{ value: 'medium' },
|
||||
{ value: 'high' },
|
||||
{ value: 'max' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'opus',
|
||||
label: 'Opus',
|
||||
description: 'Opus 4.8 · Best for everyday, complex tasks · ~2× usage vs Sonnet',
|
||||
effort: {
|
||||
default: 'high',
|
||||
values: [
|
||||
{ value: 'low' },
|
||||
{ value: 'medium' },
|
||||
{ value: 'high' },
|
||||
{ value: 'xhigh' },
|
||||
{ value: 'max' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'opus[1m]',
|
||||
label: 'Opus 4.8 (1M context)',
|
||||
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
|
||||
effort: {
|
||||
default: 'high',
|
||||
values: [
|
||||
{ value: 'low' },
|
||||
{ value: 'medium' },
|
||||
{ value: 'high' },
|
||||
{ value: 'xhigh' },
|
||||
{ value: 'max' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'haiku',
|
||||
@@ -48,6 +111,15 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
],
|
||||
DEFAULT: 'default',
|
||||
};
|
||||
|
||||
export const findClaudeModelOption = (model: string | undefined | null): ProviderModelOption | null => {
|
||||
const normalizedModel = typeof model === 'string' ? model.trim() : '';
|
||||
if (!normalizedModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CLAUDE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === normalizedModel) ?? null;
|
||||
};
|
||||
type ClaudeInitEvent = {
|
||||
sessionId?: string;
|
||||
session_id?: string;
|
||||
|
||||
@@ -21,11 +21,30 @@ import {
|
||||
|
||||
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' },
|
||||
{
|
||||
value: 'gpt-5.5',
|
||||
label: 'gpt-5.5',
|
||||
effort: {
|
||||
default: 'medium',
|
||||
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'gpt-5.4',
|
||||
label: 'gpt-5.4',
|
||||
effort: {
|
||||
default: 'medium',
|
||||
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'gpt-5.4-mini',
|
||||
label: 'gpt-5.4-mini',
|
||||
effort: {
|
||||
default: 'medium',
|
||||
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
DEFAULT: 'gpt-5.4',
|
||||
};
|
||||
@@ -37,6 +56,11 @@ type CodexCachedModel = {
|
||||
priority?: number;
|
||||
visibility?: string;
|
||||
supported_in_api?: boolean;
|
||||
default_reasoning_level?: string;
|
||||
supported_reasoning_levels?: Array<{
|
||||
effort?: string;
|
||||
description?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const CODEX_MODELS_CACHE_PATH = path.join(os.homedir(), '.codex', 'models_cache.json');
|
||||
@@ -51,15 +75,39 @@ 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 mapCodexModel = (model: CodexCachedModel): ProviderModelOption => {
|
||||
const effortValues = Array.isArray(model.supported_reasoning_levels)
|
||||
? model.supported_reasoning_levels
|
||||
.map((level) => {
|
||||
const value = readOptionalString(level?.effort);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
description: readOptionalString(level?.description),
|
||||
};
|
||||
})
|
||||
.filter((level): level is NonNullable<typeof level> => Boolean(level))
|
||||
: [];
|
||||
|
||||
return {
|
||||
value: model.slug as string,
|
||||
label: readOptionalString(model.display_name) ?? (model.slug as string),
|
||||
description: readOptionalString(model.description),
|
||||
effort: effortValues.length > 0
|
||||
? {
|
||||
default: readOptionalString(model.default_reasoning_level) ?? undefined,
|
||||
values: effortValues,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
|
||||
const sortedModels = [...models]
|
||||
.filter((model) => model.visibility !== 'hidden' && model.supported_in_api !== false)
|
||||
.filter((model) => model.visibility === 'list' && model.supported_in_api !== false)
|
||||
.sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority));
|
||||
|
||||
const options: ProviderModelOption[] = [];
|
||||
|
||||
@@ -74,6 +74,13 @@ const VERSION_TOKEN = /^[a-z]\d+$/i;
|
||||
const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/;
|
||||
const SHORT_ACRONYM_TOKEN = /^[a-z]{2,3}$/;
|
||||
|
||||
type OpenCodeVerboseModel = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
providerID?: string;
|
||||
variants?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const parseOpenCodeModelsStdout = (stdout: string): string[] => {
|
||||
const ids: string[] = [];
|
||||
|
||||
@@ -91,6 +98,83 @@ export const parseOpenCodeModelsStdout = (stdout: string): string[] => {
|
||||
return [...new Set(ids)];
|
||||
};
|
||||
|
||||
const countJsonBraceDelta = (value: string): number => {
|
||||
let delta = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (const character of value) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '\\') {
|
||||
escaped = inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '{') {
|
||||
delta += 1;
|
||||
} else if (character === '}') {
|
||||
delta -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return delta;
|
||||
};
|
||||
|
||||
const isOpenCodeVerboseModel = (value: unknown): value is OpenCodeVerboseModel => {
|
||||
const record = readObjectRecord(value);
|
||||
return Boolean(record && readOptionalString(record.id));
|
||||
};
|
||||
|
||||
export const parseOpenCodeVerboseModelsStdout = (stdout: string): OpenCodeVerboseModel[] => {
|
||||
const models: OpenCodeVerboseModel[] = [];
|
||||
let buffer: string[] = [];
|
||||
let depth = 0;
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (buffer.length === 0) {
|
||||
if (line === '{') {
|
||||
buffer = [rawLine];
|
||||
depth = 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.push(rawLine);
|
||||
depth += countJsonBraceDelta(rawLine);
|
||||
|
||||
if (depth !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(buffer.join('\n'));
|
||||
if (isOpenCodeVerboseModel(parsed)) {
|
||||
models.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed verbose blocks and fall back to the plain id parser.
|
||||
}
|
||||
|
||||
buffer = [];
|
||||
}
|
||||
|
||||
return models;
|
||||
};
|
||||
|
||||
const formatDateToken = (token: string): string => (
|
||||
`${token.slice(0, 4)}-${token.slice(4, 6)}-${token.slice(6, 8)}`
|
||||
);
|
||||
@@ -155,6 +239,20 @@ const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: s
|
||||
};
|
||||
};
|
||||
|
||||
const readOpenCodeVerboseModelId = (model: OpenCodeVerboseModel): string | null => {
|
||||
const id = readOptionalString(model.id);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (id.includes('/')) {
|
||||
return id;
|
||||
}
|
||||
|
||||
const upstreamProvider = readOptionalString(model.providerID);
|
||||
return upstreamProvider ? `${upstreamProvider}/${id}` : id;
|
||||
};
|
||||
|
||||
const labelForOpenCodeModelId = (id: string): string => {
|
||||
const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label;
|
||||
if (fallbackLabel) {
|
||||
@@ -170,6 +268,52 @@ const descriptionForOpenCodeModelId = (id: string): string => {
|
||||
return upstreamProvider ? `${upstreamProvider} - ${id}` : id;
|
||||
};
|
||||
|
||||
const readOpenCodeVariantEffort = (key: string, value: unknown): string | null => {
|
||||
const variant = readObjectRecord(value);
|
||||
return readOptionalString(variant?.reasoningEffort)
|
||||
?? readOptionalString(variant?.effort)
|
||||
?? key;
|
||||
};
|
||||
|
||||
const readOpenCodeEffortValues = (
|
||||
variants: OpenCodeVerboseModel['variants'],
|
||||
): NonNullable<ProviderModelOption['effort']>['values'] => {
|
||||
const effortValues: NonNullable<ProviderModelOption['effort']>['values'] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
for (const [key, value] of Object.entries(variants ?? {})) {
|
||||
const effort = readOpenCodeVariantEffort(key, value);
|
||||
if (!effort || seenValues.has(effort)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(effort);
|
||||
effortValues.push({ value: effort });
|
||||
}
|
||||
|
||||
return effortValues;
|
||||
};
|
||||
|
||||
const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOption | null => {
|
||||
const value = readOpenCodeVerboseModelId(model);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effortValues = readOpenCodeEffortValues(model.variants);
|
||||
|
||||
return {
|
||||
value,
|
||||
label: readOptionalString(model.name) ?? labelForOpenCodeModelId(value),
|
||||
description: descriptionForOpenCodeModelId(value),
|
||||
effort: effortValues.length > 0
|
||||
? {
|
||||
values: effortValues,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
|
||||
const options: ProviderModelOption[] = ids.map((value) => ({
|
||||
value,
|
||||
@@ -187,6 +331,36 @@ export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDef
|
||||
};
|
||||
};
|
||||
|
||||
export const buildOpenCodeDefinitionFromVerboseModels = (
|
||||
models: OpenCodeVerboseModel[],
|
||||
): ProviderModelsDefinition => {
|
||||
const options: ProviderModelOption[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
for (const model of models) {
|
||||
const mappedModel = mapOpenCodeVerboseModel(model);
|
||||
if (!mappedModel || seenValues.has(mappedModel.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(mappedModel.value);
|
||||
options.push(mappedModel);
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return OPENCODE_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -214,7 +388,7 @@ const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => {
|
||||
};
|
||||
|
||||
const runOpenCodeModelsCommand = (): Promise<string> => new Promise((resolve, reject) => {
|
||||
const openCodeProcess = spawnFunction('opencode', ['models'], {
|
||||
const openCodeProcess = spawnFunction('opencode', ['models', '--verbose'], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env },
|
||||
});
|
||||
@@ -273,6 +447,11 @@ export class OpenCodeProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
try {
|
||||
const stdout = await runOpenCodeModelsCommand();
|
||||
const verboseModels = parseOpenCodeVerboseModelsStdout(stdout);
|
||||
if (verboseModels.length > 0) {
|
||||
return buildOpenCodeDefinitionFromVerboseModels(verboseModels);
|
||||
}
|
||||
|
||||
const ids = parseOpenCodeModelsStdout(stdout);
|
||||
if (ids.length === 0) {
|
||||
return OPENCODE_FALLBACK_MODELS;
|
||||
|
||||
@@ -21,6 +21,8 @@ type ProviderCapabilities = {
|
||||
supportsPermissionRequests: boolean;
|
||||
/** Whether the token-usage endpoint has data for this provider. */
|
||||
supportsTokenUsage: boolean;
|
||||
/** Whether the provider runtime can accept model-level reasoning effort. */
|
||||
supportsEffort: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -38,6 +40,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: true,
|
||||
supportsTokenUsage: true,
|
||||
supportsEffort: true,
|
||||
},
|
||||
cursor: {
|
||||
provider: 'cursor',
|
||||
@@ -47,6 +50,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: false,
|
||||
supportsEffort: false,
|
||||
},
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
@@ -56,6 +60,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
supportsEffort: true,
|
||||
},
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
@@ -65,6 +70,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
supportsEffort: false,
|
||||
},
|
||||
opencode: {
|
||||
provider: 'opencode',
|
||||
@@ -74,6 +80,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
supportsEffort: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
|
||||
|
||||
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
||||
const PROVIDER_MODELS_CACHE_VERSION = 2;
|
||||
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
|
||||
|
||||
type ProviderModelsServiceDependencies = {
|
||||
|
||||
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildOpenCodeDefinitionFromVerboseModels,
|
||||
buildOpenCodeDefinitionFromIds,
|
||||
parseOpenCodeModelsStdout,
|
||||
parseOpenCodeVerboseModelsStdout,
|
||||
} from '@/modules/providers/list/opencode/opencode-models.provider.js';
|
||||
|
||||
test('OpenCode models provider parses plain CLI output and removes duplicates', () => {
|
||||
@@ -71,3 +73,63 @@ test('OpenCode models provider formats frontend labels from provider-prefixed id
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('OpenCode models provider maps verbose model variants to effort options', () => {
|
||||
const models = parseOpenCodeVerboseModelsStdout(`
|
||||
opencode/deepseek-v4-flash-free
|
||||
{
|
||||
"id": "deepseek-v4-flash-free",
|
||||
"providerID": "opencode",
|
||||
"name": "DeepSeek V4 Flash Free",
|
||||
"variants": {
|
||||
"low": {
|
||||
"reasoningEffort": "low"
|
||||
},
|
||||
"high": {
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
}
|
||||
}
|
||||
anthropic/claude-sonnet-5
|
||||
{
|
||||
"id": "claude-sonnet-5",
|
||||
"providerID": "anthropic",
|
||||
"name": "Claude Sonnet 5",
|
||||
"variants": {
|
||||
"low": {
|
||||
"effort": "low"
|
||||
},
|
||||
"max": {
|
||||
"effort": "max"
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const definition = buildOpenCodeDefinitionFromVerboseModels(models);
|
||||
|
||||
assert.deepEqual(definition.OPTIONS, [
|
||||
{
|
||||
value: 'opencode/deepseek-v4-flash-free',
|
||||
label: 'DeepSeek V4 Flash Free',
|
||||
description: 'opencode - opencode/deepseek-v4-flash-free',
|
||||
effort: {
|
||||
values: [
|
||||
{ value: 'low' },
|
||||
{ value: 'high' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'anthropic/claude-sonnet-5',
|
||||
label: 'Claude Sonnet 5',
|
||||
description: 'anthropic - anthropic/claude-sonnet-5',
|
||||
effort: {
|
||||
values: [
|
||||
{ value: 'low' },
|
||||
{ value: 'max' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import { providerAuthService } from './modules/providers/services/provider-auth.
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Track active sessions
|
||||
const activeCodexSessions = new Map();
|
||||
|
||||
function readUsageNumber(value) {
|
||||
@@ -228,6 +227,7 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
cwd,
|
||||
projectPath,
|
||||
model,
|
||||
effort,
|
||||
permissionMode = 'default'
|
||||
} = options;
|
||||
|
||||
@@ -239,6 +239,12 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
|
||||
const workingDirectory = cwd || projectPath || process.cwd();
|
||||
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
|
||||
const catalog = (await providerModelsService.getProviderModels('codex')).models;
|
||||
const selectedModel = catalog.OPTIONS.find((option) => option.value === resolvedModel) || null;
|
||||
const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
|
||||
const resolvedEffort = typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
|
||||
? effort
|
||||
: undefined;
|
||||
|
||||
let codex;
|
||||
let thread;
|
||||
@@ -248,19 +254,17 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
const abortController = new AbortController();
|
||||
|
||||
try {
|
||||
// Initialize Codex SDK
|
||||
codex = new Codex();
|
||||
|
||||
// Thread options with sandbox and approval settings
|
||||
const threadOptions = {
|
||||
workingDirectory,
|
||||
skipGitRepoCheck: true,
|
||||
sandboxMode,
|
||||
approvalPolicy,
|
||||
model: resolvedModel
|
||||
model: resolvedModel,
|
||||
modelReasoningEffort: resolvedEffort,
|
||||
};
|
||||
|
||||
// Start or resume thread
|
||||
if (sessionId) {
|
||||
thread = codex.resumeThread(sessionId, threadOptions);
|
||||
} else {
|
||||
@@ -280,12 +284,10 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
});
|
||||
};
|
||||
|
||||
// Existing sessions can be tracked immediately; new sessions are tracked after thread.started.
|
||||
if (capturedSessionId) {
|
||||
registerSession(capturedSessionId);
|
||||
}
|
||||
|
||||
// Execute with streaming
|
||||
const streamedTurn = await thread.runStreamed(command, {
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
@@ -14,6 +14,14 @@ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
const activeOpenCodeProcesses = new Map();
|
||||
|
||||
function resolveOpenCodeEffort(model, effort, modelsDefinition) {
|
||||
const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model);
|
||||
const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
|
||||
return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
|
||||
? effort
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readOpenCodeSessionId(event) {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return null;
|
||||
@@ -84,7 +92,7 @@ function readOpenCodeTokenUsage(sessionId) {
|
||||
|
||||
async function spawnOpenCode(command, options = {}, ws) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
|
||||
const { sessionId, projectPath, cwd, model, effort, sessionSummary } = options;
|
||||
const workingDir = cwd || projectPath || process.cwd();
|
||||
const processKey = sessionId || Date.now().toString();
|
||||
let capturedSessionId = sessionId || null;
|
||||
@@ -192,7 +200,15 @@ async function spawnOpenCode(command, options = {}, ws) {
|
||||
}
|
||||
};
|
||||
|
||||
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
|
||||
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then(async (resolvedModel) => {
|
||||
let effortModels = null;
|
||||
try {
|
||||
effortModels = (await providerModelsService.getProviderModels('opencode')).models;
|
||||
} catch (error) {
|
||||
console.warn('[OpenCode] Unable to load provider models for effort validation:', error);
|
||||
}
|
||||
|
||||
const resolvedEffort = resolveOpenCodeEffort(resolvedModel, effort, effortModels);
|
||||
const args = ['run', '--format', 'json'];
|
||||
// OpenCode's `run` command owns workspace selection through `--dir`.
|
||||
// Relying on the child-process cwd alone is not enough on Linux, where
|
||||
@@ -204,6 +220,9 @@ async function spawnOpenCode(command, options = {}, ws) {
|
||||
if (resolvedModel) {
|
||||
args.push('--model', resolvedModel);
|
||||
}
|
||||
if (resolvedEffort) {
|
||||
args.push('--variant', resolvedEffort);
|
||||
}
|
||||
if (command && command.trim()) {
|
||||
args.push(command.trim());
|
||||
}
|
||||
|
||||
@@ -646,12 +646,17 @@ class ResponseCollector {
|
||||
*
|
||||
* @param {string} model - (Optional) Model identifier for providers.
|
||||
*
|
||||
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
|
||||
* Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'fable'
|
||||
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
|
||||
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
|
||||
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
|
||||
* 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
|
||||
* Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
|
||||
* Codex models: 'gpt-5.4' (default), 'gpt-5.5', 'gpt-5.4-mini'
|
||||
*
|
||||
* @param {string} effort - (Optional) Reasoning effort for providers/models that support it.
|
||||
* Claude supports: 'low', 'medium', 'high', 'xhigh', 'max' depending on model.
|
||||
* Codex supports: 'low', 'medium', 'high', 'xhigh'.
|
||||
* 'default' or omission lets the provider decide.
|
||||
*
|
||||
* @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
|
||||
* Default: true
|
||||
@@ -844,6 +849,9 @@ class ResponseCollector {
|
||||
*/
|
||||
router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName, sessionId } = req.body;
|
||||
const effort = typeof req.body.effort === 'string' && req.body.effort.trim()
|
||||
? req.body.effort.trim()
|
||||
: undefined;
|
||||
|
||||
// Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
|
||||
const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
|
||||
@@ -954,6 +962,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
cwd: finalProjectPath,
|
||||
sessionId: sessionId || null,
|
||||
model: model,
|
||||
effort,
|
||||
permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
|
||||
}, writer);
|
||||
|
||||
@@ -975,6 +984,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
cwd: finalProjectPath,
|
||||
sessionId: sessionId || null,
|
||||
model: model || codexModels.DEFAULT,
|
||||
effort,
|
||||
permissionMode: 'bypassPermissions'
|
||||
}, writer);
|
||||
} else if (provider === 'gemini') {
|
||||
@@ -994,7 +1004,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: sessionId || null,
|
||||
model: model || opencodeModels.DEFAULT
|
||||
model: model || opencodeModels.DEFAULT,
|
||||
effort
|
||||
}, writer);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,13 @@ export type ProviderModelOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
effort?: {
|
||||
default?: string;
|
||||
values: {
|
||||
value: string;
|
||||
description?: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user