diff --git a/public/api-docs.html b/public/api-docs.html
index 03dbb9b8..040d8680 100644
--- a/public/api-docs.html
+++ b/public/api-docs.html
@@ -489,7 +489,7 @@
http://localhost:3001/api/agent
-
Trigger an AI agent (Claude, Cursor, or Codex) to work on a project.
+ Trigger an AI agent (Claude, Cursor, Codex, Gemini, or OpenCode) to work on a project.
Request Body Parameters
@@ -524,7 +524,7 @@
provider |
string |
Optional |
- claude, cursor, or codex (default: claude) |
+ claude, cursor, codex, gemini, or opencode (default: claude) |
stream |
@@ -540,6 +540,12 @@
Model identifier for the AI provider (loading from constants...)
+
+ effort |
+ string |
+ Optional |
+ Reasoning effort for Claude, Codex, and OpenCode models that expose effort metadata. Use default or omit it to let the provider decide. |
+
cleanup |
boolean |
diff --git a/server/claude-sdk.js b/server/claude-sdk.js
index a0a795c6..426ce029 100644
--- a/server/claude-sdk.js
+++ b/server/claude-sdk.js
@@ -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';
diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts
index f6c4c0c6..2c80cc40 100644
--- a/server/modules/providers/list/claude/claude-models.provider.ts
+++ b/server/modules/providers/list/claude/claude-models.provider.ts
@@ -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;
diff --git a/server/modules/providers/list/codex/codex-models.provider.ts b/server/modules/providers/list/codex/codex-models.provider.ts
index de4de0ea..bc157377 100644
--- a/server/modules/providers/list/codex/codex-models.provider.ts
+++ b/server/modules/providers/list/codex/codex-models.provider.ts
@@ -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 => 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[] = [];
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 1b7cbbb3..dcd5f1ab 100644
--- a/server/modules/providers/services/provider-capabilities.service.ts
+++ b/server/modules/providers/services/provider-capabilities.service.ts
@@ -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 = {
supportsAbort: true,
supportsPermissionRequests: true,
supportsTokenUsage: true,
+ supportsEffort: true,
},
cursor: {
provider: 'cursor',
@@ -47,6 +50,7 @@ const PROVIDER_CAPABILITIES: Record = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: false,
+ supportsEffort: false,
},
codex: {
provider: 'codex',
@@ -56,6 +60,7 @@ const PROVIDER_CAPABILITIES: Record = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
+ supportsEffort: true,
},
gemini: {
provider: 'gemini',
@@ -65,6 +70,7 @@ const PROVIDER_CAPABILITIES: Record = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
+ supportsEffort: false,
},
opencode: {
provider: 'opencode',
@@ -74,6 +80,7 @@ const PROVIDER_CAPABILITIES: Record = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
+ supportsEffort: true,
},
};
diff --git a/server/modules/providers/services/provider-models.service.ts b/server/modules/providers/services/provider-models.service.ts
index 9162df0c..cb183f99 100644
--- a/server/modules/providers/services/provider-models.service.ts
+++ b/server/modules/providers/services/provider-models.service.ts
@@ -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(['claude', 'gemini']);
type ProviderModelsServiceDependencies = {
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/openai-codex.js b/server/openai-codex.js
index 34f5bc05..47e89ab6 100644
--- a/server/openai-codex.js
+++ b/server/openai-codex.js
@@ -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
});
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 f2273181..5febbf61 100644
--- a/server/routes/agent.js
+++ b/server/routes/agent.js
@@ -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);
}
diff --git a/server/shared/types.ts b/server/shared/types.ts
index 5d411efe..c803856b 100644
--- a/server/shared/types.ts
+++ b/server/shared/types.ts
@@ -74,6 +74,13 @@ export type ProviderModelOption = {
value: string;
label: string;
description?: string;
+ effort?: {
+ default?: string;
+ values: {
+ value: string;
+ description?: string;
+ }[];
+ };
};
/**
diff --git a/src/components/chat/constants/providerEffort.ts b/src/components/chat/constants/providerEffort.ts
new file mode 100644
index 00000000..28e26d35
--- /dev/null
+++ b/src/components/chat/constants/providerEffort.ts
@@ -0,0 +1,13 @@
+import type { LLMProvider, ProviderModelOption } from '../../../types/app';
+
+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 = (
+ values: readonly string[],
+): NonNullable['values'] => values.map((value) => ({ value }));
diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts
index 9817b6d4..9dd1bbc0 100644
--- a/src/components/chat/hooks/useChatComposerState.ts
+++ b/src/components/chat/hooks/useChatComposerState.ts
@@ -34,9 +34,11 @@ interface UseChatComposerStateArgs {
provider: LLMProvider;
permissionMode: PermissionMode | string;
cyclePermissionMode: () => void;
+ resolvePermissionModeForProvider: (provider: LLMProvider, requestedMode: PermissionMode | string) => PermissionMode;
cursorModel: string;
claudeModel: string;
codexModel: string;
+ currentProviderEffort: string;
geminiModel: string;
opencodeModel: string;
isLoading: boolean;
@@ -168,9 +170,11 @@ export function useChatComposerState({
provider,
permissionMode,
cyclePermissionMode,
+ resolvePermissionModeForProvider,
cursorModel,
claudeModel,
codexModel,
+ currentProviderEffort,
geminiModel,
opencodeModel,
isLoading,
@@ -728,8 +732,9 @@ export function useChatComposerState({
: provider === 'gemini'
? geminiModel
: provider === 'opencode'
- ? opencodeModel
- : claudeModel;
+ ? opencodeModel
+ : claudeModel;
+ const effort = currentProviderEffort;
// One message shape for every provider. The backend resolves the
// provider, project path, and provider-native resume id from the
@@ -740,9 +745,8 @@ export function useChatComposerState({
content: messageContent,
options: {
model,
- // Codex has no plan mode; downgrade rather than sending an
- // unsupported value to its runtime.
- permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
+ effort,
+ permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
toolsSettings,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
@@ -769,6 +773,7 @@ export function useChatComposerState({
attachedImages,
claudeModel,
codexModel,
+ currentProviderEffort,
currentSessionId,
cursorModel,
executeCommand,
@@ -779,6 +784,7 @@ export function useChatComposerState({
onSessionEstablished,
permissionMode,
provider,
+ resolvePermissionModeForProvider,
resetCommandMenuState,
scrollToBottom,
selectedProject,
diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts
index ea49d841..86a0eced 100644
--- a/src/components/chat/hooks/useChatProviderState.ts
+++ b/src/components/chat/hooks/useChatProviderState.ts
@@ -1,22 +1,31 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
import { authenticatedFetch } from '../../../utils/api';
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type {
ProjectSession,
LLMProvider,
Project,
+ ProviderModelOption,
ProviderModelsCacheInfo,
ProviderModelsDefinition,
} from '../../../types/app';
+import {
+ DEFAULT_EFFORT_VALUE,
+ FALLBACK_PROVIDER_EFFORT_VALUES,
+ toProviderEffortOptions,
+} from '../constants/providerEffort';
const FALLBACK_DEFAULT_MODEL: Record = {
- claude: 'opus',
+ claude: 'default',
cursor: 'gpt-5.3-codex',
codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5',
};
+const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
+
/**
* Fallback permission-mode matrix used only until the backend capability
* matrix (`GET /api/providers/capabilities`) has loaded. The backend is the
@@ -39,6 +48,7 @@ type ProviderCapabilities = {
supportsAbort: boolean;
supportsPermissionRequests: boolean;
supportsTokenUsage: boolean;
+ supportsEffort?: boolean;
};
type ProviderCapabilitiesApiResponse = {
@@ -72,7 +82,7 @@ type ChangeActiveModelApiResponse = {
};
};
-export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) {
+export function useChatProviderState({ selectedSession, selectedProject: _selectedProject }: UseChatProviderStateArgs) {
const [permissionMode, setPermissionMode] = useState('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]);
const [provider, setProvider] = useState(() => {
@@ -87,6 +97,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [codexModel, setCodexModel] = useState(() => {
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
});
+ const [providerEfforts, setProviderEfforts] = useState>>(() => {
+ return PROVIDERS.reduce>>((acc, targetProvider) => {
+ acc[targetProvider] = localStorage.getItem(`${targetProvider}-effort`) || DEFAULT_EFFORT_VALUE;
+ return acc;
+ }, {});
+ });
const [geminiModel, setGeminiModel] = useState(() => {
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
});
@@ -145,8 +161,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
localStorage.setItem('opencode-model', model);
}, []);
+ const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => {
+ setProviderEfforts((previous) => (
+ previous[targetProvider] === effort
+ ? previous
+ : { ...previous, [targetProvider]: effort }
+ ));
+ localStorage.setItem(`${targetProvider}-effort`, effort);
+ }, []);
+
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
- const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const requestId = providerModelsRequestIdRef.current + 1;
providerModelsRequestIdRef.current = requestId;
const isHardRefresh = options.bypassCache === true;
@@ -159,7 +183,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
try {
const results = await Promise.all(
- providers.map(async (p) => {
+ PROVIDERS.map(async (p) => {
const params = new URLSearchParams();
if (options.bypassCache) {
params.set('bypassCache', 'true');
@@ -183,7 +207,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const nextCatalog: Partial> = {};
const nextCacheCatalog: Partial> = {};
- providers.forEach((p, i) => {
+ PROVIDERS.forEach((p, i) => {
const entry = results[i];
if (!entry) {
return;
@@ -244,6 +268,23 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
}, [providerCapabilities]);
+ const getDefaultPermissionModeForProvider = useCallback((targetProvider: LLMProvider): PermissionMode => {
+ const modes = getPermissionModesForProvider(targetProvider);
+ const capabilityDefault = providerCapabilities?.[targetProvider]?.defaultPermissionMode as PermissionMode | undefined;
+ if (capabilityDefault && modes.includes(capabilityDefault)) {
+ return capabilityDefault;
+ }
+ return modes[0] ?? 'default';
+ }, [getPermissionModesForProvider, providerCapabilities]);
+
+ const getSupportsEffortForProvider = useCallback((targetProvider: LLMProvider): boolean => {
+ const capabilitySupport = providerCapabilities?.[targetProvider]?.supportsEffort;
+ if (typeof capabilitySupport === 'boolean') {
+ return capabilitySupport;
+ }
+ return Boolean(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider]?.length);
+ }, [providerCapabilities]);
+
const pickStoredOrCurrent = (
storageKey: string,
current: string,
@@ -259,6 +300,70 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return def.DEFAULT;
};
+ const getModelOption = useCallback((
+ targetProvider: LLMProvider,
+ model: string,
+ ): ProviderModelOption | null => {
+ const definition = providerModelCatalog[targetProvider];
+ if (!definition) {
+ return null;
+ }
+
+ return definition.OPTIONS.find((option) => option.value === model) ?? null;
+ }, [providerModelCatalog]);
+
+ const getEffortOptionsForModel = useCallback((
+ targetProvider: LLMProvider,
+ model: string,
+ ): NonNullable['values'] => {
+ if (!getSupportsEffortForProvider(targetProvider)) {
+ return [];
+ }
+
+ const option = getModelOption(targetProvider, model);
+ if (option) {
+ return option.effort?.values ?? [];
+ }
+
+ return toProviderEffortOptions(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider] ?? []);
+ }, [getModelOption, getSupportsEffortForProvider]);
+
+ const getAllowedEffortValues = useCallback((
+ targetProvider: LLMProvider,
+ model: string,
+ ): string[] => (
+ getEffortOptionsForModel(targetProvider, model).map((value) => value.value)
+ ), [getEffortOptionsForModel]);
+
+ const reconcileStoredEffort = useCallback((
+ targetProvider: LLMProvider,
+ model: string,
+ currentEffort: string,
+ ): string => {
+ const allowedValues = getAllowedEffortValues(targetProvider, model);
+ if (allowedValues.length === 0) {
+ return DEFAULT_EFFORT_VALUE;
+ }
+
+ if (currentEffort === DEFAULT_EFFORT_VALUE || !currentEffort) {
+ return DEFAULT_EFFORT_VALUE;
+ }
+
+ if (allowedValues.includes(currentEffort)) {
+ return currentEffort;
+ }
+
+ return DEFAULT_EFFORT_VALUE;
+ }, [getAllowedEffortValues]);
+
+ const providerModels = useMemo>(() => ({
+ claude: claudeModel,
+ cursor: cursorModel,
+ codex: codexModel,
+ gemini: geminiModel,
+ opencode: opencodeModel,
+ }), [claudeModel, cursorModel, codexModel, geminiModel, opencodeModel]);
+
useEffect(() => {
const claude = providerModelCatalog.claude;
if (claude) {
@@ -324,6 +429,27 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [providerModelCatalog.opencode, opencodeModel]);
+ useEffect(() => {
+ const nextEfforts: Partial> = {};
+ let hasUpdates = false;
+
+ for (const targetProvider of PROVIDERS) {
+ const currentEffort = providerEfforts[targetProvider] ?? DEFAULT_EFFORT_VALUE;
+ const nextEffort = reconcileStoredEffort(targetProvider, providerModels[targetProvider], currentEffort);
+ if (nextEffort === currentEffort) {
+ continue;
+ }
+
+ nextEfforts[targetProvider] = nextEffort;
+ localStorage.setItem(`${targetProvider}-effort`, nextEffort);
+ hasUpdates = true;
+ }
+
+ if (hasUpdates) {
+ setProviderEfforts((previous) => ({ ...previous, ...nextEfforts }));
+ }
+ }, [providerEfforts, providerModels, reconcileStoredEffort]);
+
useEffect(() => {
if (!selectedSession?.id) {
return;
@@ -331,8 +457,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
const validModes = getPermissionModesForProvider(provider);
- setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
- }, [selectedSession?.id, provider, getPermissionModesForProvider]);
+ setPermissionMode(
+ savedMode && validModes.includes(savedMode)
+ ? savedMode
+ : getDefaultPermissionModeForProvider(provider),
+ );
+ }, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
useEffect(() => {
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
@@ -386,6 +516,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]);
+ const resolvePermissionModeForProvider = useCallback((
+ targetProvider: LLMProvider,
+ requestedMode: PermissionMode | string,
+ ): PermissionMode => {
+ const validModes = getPermissionModesForProvider(targetProvider);
+ return validModes.includes(requestedMode as PermissionMode)
+ ? requestedMode as PermissionMode
+ : getDefaultPermissionModeForProvider(targetProvider);
+ }, [getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
+
const selectProviderModel = useCallback(async (
targetProvider: LLMProvider,
model: string,
@@ -421,6 +561,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
};
}, [setStoredProviderModel]);
+ const currentProviderEffortOptions = useMemo(() => {
+ return getEffortOptionsForModel(provider, providerModels[provider]);
+ }, [getEffortOptionsForModel, provider, providerModels]);
+ const currentProviderEffort = useMemo(() => {
+ return reconcileStoredEffort(
+ provider,
+ providerModels[provider],
+ providerEfforts[provider] ?? DEFAULT_EFFORT_VALUE,
+ );
+ }, [provider, providerEfforts, providerModels, reconcileStoredEffort]);
+
return {
provider,
setProvider,
@@ -430,6 +581,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setClaudeModel,
codexModel,
setCodexModel,
+ currentProviderEffort,
+ currentProviderEffortOptions,
geminiModel,
setGeminiModel,
opencodeModel,
@@ -445,5 +598,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
providerModelsRefreshing,
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
selectProviderModel,
+ setStoredProviderEffort,
+ resolvePermissionModeForProvider,
};
}
diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx
index 2fdcba57..7544ee44 100644
--- a/src/components/chat/view/ChatInterface.tsx
+++ b/src/components/chat/view/ChatInterface.tsx
@@ -17,7 +17,6 @@ import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal';
-
function ChatInterface({
selectedProject,
selectedSession,
@@ -70,6 +69,8 @@ function ChatInterface({
setClaudeModel,
codexModel,
setCodexModel,
+ currentProviderEffort,
+ currentProviderEffortOptions,
geminiModel,
setGeminiModel,
opencodeModel,
@@ -84,6 +85,8 @@ function ChatInterface({
providerModelsRefreshing,
hardRefreshProviderModels,
selectProviderModel,
+ setStoredProviderEffort,
+ resolvePermissionModeForProvider,
} = useChatProviderState({
selectedSession,
selectedProject,
@@ -197,6 +200,7 @@ function ChatInterface({
cursorModel,
claudeModel,
codexModel,
+ currentProviderEffort,
geminiModel,
opencodeModel,
isLoading: isProcessing,
@@ -213,6 +217,7 @@ function ChatInterface({
addMessage,
setIsUserScrolledUp,
setPendingPermissionRequests,
+ resolvePermissionModeForProvider,
});
// On WebSocket reconnect, re-fetch the current session's messages from the
@@ -383,6 +388,9 @@ function ChatInterface({
onAbortSession={handleAbortSession}
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
+ effort={currentProviderEffort}
+ availableEffortOptions={currentProviderEffortOptions}
+ onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)}
tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal}
slashCommandsCount={slashCommandsCount}
diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx
index cc3f397c..ba35dfd8 100644
--- a/src/components/chat/view/subcomponents/ChatComposer.tsx
+++ b/src/components/chat/view/subcomponents/ChatComposer.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
-import { useMemo } from 'react';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
import type {
ChangeEvent,
ClipboardEvent,
@@ -11,12 +11,13 @@ import type {
RefObject,
TouchEvent,
} from 'react';
-import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
+import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check } from 'lucide-react';
import { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
+import type { ProviderModelOption } from '../../../../types/app';
import {
PromptInput,
PromptInputHeader,
@@ -62,6 +63,9 @@ interface ChatComposerProps {
onAbortSession: () => void;
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
+ effort: string;
+ availableEffortOptions: NonNullable['values'];
+ onSelectEffort: (effort: string) => void;
tokenBudget: Record | null;
onShowTokenUsage: () => void;
slashCommandsCount: number;
@@ -114,6 +118,9 @@ export default function ChatComposer({
onAbortSession,
permissionMode,
onModeSwitch,
+ effort,
+ availableEffortOptions,
+ onSelectEffort,
tokenBudget,
onShowTokenUsage,
slashCommandsCount,
@@ -167,7 +174,7 @@ export default function ChatComposer({
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
- }, [input, isCommandMenuOpen, textareaRef]);
+ }, [isCommandMenuOpen, textareaRef]);
// Voice state is hosted here (not in the mic button) so the main Send button can stop
// recording and send the transcript in one tap, the way the mic button drops it in the box.
@@ -189,6 +196,67 @@ export default function ChatComposer({
);
const isRecording = voiceState === 'recording';
const isTranscribing = voiceState === 'transcribing';
+ const [isEffortDropdownOpen, setIsEffortDropdownOpen] = useState(false);
+ const effortDropdownRef = useRef(null);
+ const effortDropdownMenuRef = useRef(null);
+ const effortDropdownButtonRef = useRef(null);
+ const [effortDropdownPosition, setEffortDropdownPosition] = useState<{
+ left: number;
+ top: number;
+ maxHeight: number;
+ } | null>(null);
+ const effortOptions = useMemo(
+ () => [{ value: 'default' }, ...availableEffortOptions],
+ [availableEffortOptions],
+ );
+ const selectedEffortLabel = effort === 'default' ? 'Default' : effort;
+ const updateEffortDropdownPosition = useCallback(() => {
+ const rect = effortDropdownButtonRef.current?.getBoundingClientRect();
+ if (!rect) {
+ return;
+ }
+
+ setEffortDropdownPosition({
+ left: rect.left,
+ top: rect.top - 8,
+ maxHeight: Math.max(96, rect.top - 16),
+ });
+ }, []);
+
+ useEffect(() => {
+ if (!isEffortDropdownOpen) return;
+
+ const handlePointerDown = (event: PointerEvent) => {
+ const target = event.target as Node;
+ if (
+ !effortDropdownRef.current?.contains(target)
+ && !effortDropdownMenuRef.current?.contains(target)
+ ) {
+ setIsEffortDropdownOpen(false);
+ }
+ };
+
+ const handleKeyDown = (event: globalThis.KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+ setIsEffortDropdownOpen(false);
+ }
+ };
+
+ document.addEventListener('pointerdown', handlePointerDown);
+ window.addEventListener('resize', updateEffortDropdownPosition);
+ window.addEventListener('scroll', updateEffortDropdownPosition, true);
+ window.addEventListener('keydown', handleKeyDown, { capture: true });
+ updateEffortDropdownPosition();
+
+ return () => {
+ document.removeEventListener('pointerdown', handlePointerDown);
+ window.removeEventListener('resize', updateEffortDropdownPosition);
+ window.removeEventListener('scroll', updateEffortDropdownPosition, true);
+ window.removeEventListener('keydown', handleKeyDown, { capture: true });
+ };
+ }, [isEffortDropdownOpen, updateEffortDropdownPosition]);
// Detect if the AskUserQuestion interactive panel is active
const hasQuestionPanel = pendingPermissionRequests.some(
@@ -376,6 +444,70 @@ export default function ChatComposer({
+ {availableEffortOptions.length > 0 && (
+
+
+
+ {isEffortDropdownOpen && effortDropdownPosition && createPortal(
+
+ {effortOptions.map((option) => {
+ const isSelected = option.value === effort;
+ const label = option.value === 'default' ? 'Default' : option.value;
+ return (
+
+ );
+ })}
+
,
+ document.body,
+ )}
+
+ )}
+
({ value: model, label: model }));
}, [data, liveDefinition]);
-
const filteredOptions = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) {
diff --git a/src/types/app.ts b/src/types/app.ts
index f81c3e26..b2ea97c4 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -4,6 +4,13 @@ export type ProviderModelOption = {
value: string;
label: string;
description?: string;
+ effort?: {
+ default?: string;
+ values: {
+ value: string;
+ description?: string;
+ }[];
+ };
};
export type ProviderModelsDefinition = {