From 7785763c140435458826bd1d85dbb7abe69ac566 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:32:13 +0300 Subject: [PATCH] feat: add effort support for opencode --- public/api-docs.html | 2 +- .../list/opencode/opencode-models.provider.ts | 181 +++++++++++++++++- .../services/provider-capabilities.service.ts | 2 +- .../providers/tests/opencode-models.test.ts | 62 ++++++ server/opencode-cli.js | 23 ++- server/routes/agent.js | 3 +- .../chat/constants/providerEffort.ts | 1 + 7 files changed, 268 insertions(+), 6 deletions(-) diff --git a/public/api-docs.html b/public/api-docs.html index e6db0a46..040d8680 100644 --- a/public/api-docs.html +++ b/public/api-docs.html @@ -544,7 +544,7 @@ effort string Optional - Reasoning effort for Claude and Codex models that expose effort metadata. Use default or omit it to let the provider decide. + Reasoning effort for Claude, Codex, and OpenCode models that expose effort metadata. Use default or omit it to let the provider decide. cleanup diff --git a/server/modules/providers/list/opencode/opencode-models.provider.ts b/server/modules/providers/list/opencode/opencode-models.provider.ts index 0e5f7477..6891b4df 100644 --- a/server/modules/providers/list/opencode/opencode-models.provider.ts +++ b/server/modules/providers/list/opencode/opencode-models.provider.ts @@ -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; +}; + 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['values'] => { + const effortValues: NonNullable['values'] = []; + const seenValues = new Set(); + + 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(); + + 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 => 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 { 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; diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index ea49e3a1..dcd5f1ab 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -80,7 +80,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, - supportsEffort: false, + supportsEffort: true, }, }; diff --git a/server/modules/providers/tests/opencode-models.test.ts b/server/modules/providers/tests/opencode-models.test.ts index c28ac0ef..097c1272 100644 --- a/server/modules/providers/tests/opencode-models.test.ts +++ b/server/modules/providers/tests/opencode-models.test.ts @@ -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' }, + ], + }, + }, + ]); +}); diff --git a/server/opencode-cli.js b/server/opencode-cli.js index e8446329..d486d949 100644 --- a/server/opencode-cli.js +++ b/server/opencode-cli.js @@ -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()); } diff --git a/server/routes/agent.js b/server/routes/agent.js index c36242db..7cdf7027 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -1004,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); } diff --git a/src/components/chat/constants/providerEffort.ts b/src/components/chat/constants/providerEffort.ts index 5a930733..28e26d35 100644 --- a/src/components/chat/constants/providerEffort.ts +++ b/src/components/chat/constants/providerEffort.ts @@ -5,6 +5,7 @@ export const DEFAULT_EFFORT_VALUE = 'default'; export const FALLBACK_PROVIDER_EFFORT_VALUES: Partial> = { claude: ['low', 'medium', 'high', 'xhigh', 'max'], codex: ['low', 'medium', 'high', 'xhigh'], + opencode: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], }; export const toProviderEffortOptions = (