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 = (