feat: add Claude and Codex effort controls (#943)

* feat: add Claude and Codex effort controls

* refactor: generic provider effort handling

* fix: reconcile provider effort after model changes

* fix: pass effort through external agent api

* feat: add effort support for opencode

* chore: update gpt fallback models

* fix: use portal for showing effort dropdown

---------

Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
This commit is contained in:
Simos Mikelatos
2026-07-03 18:11:49 +02:00
committed by GitHub
parent e93c83addb
commit d272922d87
19 changed files with 812 additions and 73 deletions

View File

@@ -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;

View File

@@ -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[] = [];

View File

@@ -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;

View File

@@ -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,
},
};

View File

@@ -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 = {

View File

@@ -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' },
],
},
},
]);
});