diff --git a/README.md b/README.md index 7aebe3f4b..3930b5cf5 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,8 @@ Advanced and source-build guides: | Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely | | Codex | `/provider` | Uses existing Codex CLI auth, OpenClaude secure storage, or env credentials | | Gitlawb Opengateway | Startup default, `/provider`, or env vars | Smart gateway at `https://opengateway.gitlawb.com/v1`; requires an API key from https://gitlawb.com/opengateway/keys and routes Xiaomi MiMo and GMI Cloud partner models by `OPENAI_MODEL` | -| OpenCode Zen | `/provider` or env vars | Pay-as-you-go AI gateway (41 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/v1`; shared key with OpenCode Go | -| OpenCode Go | `/provider` or env vars | $10/mo subscription for open models (12 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/go/v1`; shared key with OpenCode Zen | +| OpenCode Zen | `/provider` or env vars | Pay-as-you-go AI gateway (43 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/v1`; shared key with OpenCode Go | +| OpenCode Go | `/provider` or env vars | $10/mo subscription for open models (13 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/go/v1`; shared key with OpenCode Zen | | Xiaomi MiMo | `/provider` or env vars | OpenAI-compatible API at `https://mimo.mi.com`; uses `MIMO_API_KEY` and defaults to `mimo-v2.5-pro` | | Ollama | `/provider` or env vars | Local inference with no API key | | Atomic Chat | `/provider`, env vars, or `bun run dev:atomic-chat` | Local Model Provider; auto-detects loaded models | diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index abaeec4c9..cce0fb3f6 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -194,7 +194,7 @@ export OPENAI_MODEL=gpt-5.4 openclaude ``` -OpenCode Zen is a pay-as-you-go AI gateway with 41 models (GPT, Claude, Gemini, +OpenCode Zen is a pay-as-you-go AI gateway with 43 models (GPT, Claude, Gemini, Qwen, MiniMax, GLM, Kimi, Grok, Big Pickle, DeepSeek, Nemotron). Uses the same `OPENCODE_API_KEY` as OpenCode Go. Get your key from https://opencode.ai. @@ -209,7 +209,7 @@ export OPENAI_MODEL=glm-5.1 openclaude ``` -OpenCode Go is a $10/mo subscription for 12 open models (GLM, Kimi, DeepSeek, +OpenCode Go is a $10/mo subscription for 13 open models (GLM, Kimi, DeepSeek, MiMo, MiniMax, Qwen). Uses the same `OPENCODE_API_KEY` as OpenCode Zen. ### Gitlawb Opengateway diff --git a/src/cli/print.ts b/src/cli/print.ts index f98d770e7..c96d34018 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -272,8 +272,7 @@ import { import { getModelOptions } from 'src/utils/model/modelOptions.js' import { modelSupportsEffort, - modelSupportsMaxEffort, - EFFORT_LEVELS, + getAvailableEffortLevels, resolveAppliedEffort, } from 'src/utils/effort.js' import { modelSupportsAdaptiveThinking } from 'src/utils/thinking.js' @@ -1194,7 +1193,7 @@ function runHeadlessStreaming( } const modelOptions = getModelOptions() - const modelInfos = modelOptions.map(option => { + const modelInfos: ModelInfo[] = modelOptions.map((option): ModelInfo => { const modelId = option.value === null ? 'default' : option.value const resolvedModel = modelId === 'default' @@ -1210,9 +1209,7 @@ function runHeadlessStreaming( description: option.description, ...(hasEffort && { supportsEffort: true, - supportedEffortLevels: modelSupportsMaxEffort(resolvedModel) - ? [...EFFORT_LEVELS] - : EFFORT_LEVELS.filter(l => l !== 'max'), + supportedEffortLevels: getAvailableEffortLevels(resolvedModel), }), ...(hasAdaptiveThinking && { supportsAdaptiveThinking: true }), ...(hasFastMode && { supportsFastMode: true }), diff --git a/src/commands/effort/effort.tsx b/src/commands/effort/effort.tsx index c0ece43e2..aa7fde832 100644 --- a/src/commands/effort/effort.tsx +++ b/src/commands/effort/effort.tsx @@ -176,7 +176,7 @@ function ApplyEffortAndClose(t0) { export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise { args = args?.trim() || ''; if (COMMON_HELP_ARGS.includes(args)) { - onDone('Usage: /effort [low|medium|high|max|xhigh|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6 only)\n- xhigh: Extra-high reasoning for OpenAI/Codex models (alias for max)\n- auto: Use the default effort level for your model'); + onDone('Usage: /effort [low|medium|high|max|xhigh|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6+)\n- xhigh: Extra-high reasoning (OpenAI/Codex and Opus 4.7+)\n- auto: Use the default effort level for your model'); return; } if (args === 'current' || args === 'status') { diff --git a/src/components/EffortPicker.tsx b/src/components/EffortPicker.tsx index e392c1ab8..ce40c6922 100644 --- a/src/components/EffortPicker.tsx +++ b/src/components/EffortPicker.tsx @@ -51,10 +51,10 @@ export function EffortPicker({ onSelect, onCancel }: Props) { isAvailable: true, }, ...availableLevels.map(level => { - const displayLevel = usesOpenAIEffort - ? (level === 'xhigh' ? 'max' : level) - : level - const isCurrent = currentDisplayedLevel === displayLevel + // xhigh is now the persisted level for OpenAI/Codex, so compare against + // it directly. The 'max' alias path is kept only for legacy settings + // that still hold a persisted 'max' from before xhigh was introduced. + const isCurrent = currentDisplayedLevel === level || (usesOpenAIEffort && level === 'xhigh' && currentDisplayedLevel === 'max') return { label: ( { + const focusedModel = resolveOptionModel(focusedValue); + return focusedModel ? getAvailableEffortLevels(focusedModel) : []; + })(); let t9; if ($[23] !== focusedValue) { t9 = getDefaultEffortLevelForOption(focusedValue); @@ -191,7 +195,7 @@ export function ModelPicker(t0) { t9 = $[24]; } const focusedDefaultEffort = t9; - const displayEffort = effort === "max" && !focusedSupportsMax ? "high" : effort; + const displayEffort = focusedAvailableLevels.includes(effort) ? effort : "high"; let t10; if ($[25] !== effortValue || $[26] !== hasToggledEffort) { t10 = value => { @@ -208,20 +212,21 @@ export function ModelPicker(t0) { } const handleFocus = t10; let t11; - if ($[28] !== focusedDefaultEffort || $[29] !== focusedSupportsEffort || $[30] !== focusedSupportsMax) { + if ($[28] !== focusedDefaultEffort || $[29] !== focusedSupportsEffort || $[30] !== focusedSupportsMax || $[31] !== focusedAvailableLevels) { t11 = direction => { if (!focusedSupportsEffort) { return; } - setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax)); + setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedAvailableLevels)); setHasToggledEffort(true); }; $[28] = focusedDefaultEffort; $[29] = focusedSupportsEffort; $[30] = focusedSupportsMax; - $[31] = t11; + $[31] = focusedAvailableLevels; + $[32] = t11; } else { - t11 = $[31]; + t11 = $[32]; } const handleCycleEffort = t11; const t12 = { @@ -242,18 +247,22 @@ export function ModelPicker(t0) { } useKeybindings(t12, t13); let t14; - if ($[35] !== effort || $[36] !== hasToggledEffort || $[37] !== onSelect || $[38] !== setAppState || $[39] !== skipSettingsWrite) { + if ($[35] !== effort || $[36] !== hasToggledEffort || $[37] !== onSelect || $[38] !== setAppState || $[39] !== skipSettingsWrite || $[46] !== focusedAvailableLevels || $[47] !== focusedDefaultEffort) { t14 = function handleSelect(value_0) { const selectedModel = resolveOptionModel(value_0); if (value_0 !== NO_PREFERENCE && selectedModel && !isModelAllowed(selectedModel)) { onSelect(value_0 === NO_PREFERENCE ? null : value_0, undefined); return; } + // Clamp effort to a value in the focused model's available levels so + // emitted/persisted values are always valid for the picked model + // (e.g. toggled 'xhigh' then picked a model that doesn't support it). + const clampedEffort = focusedAvailableLevels.includes(effort) ? effort : focusedDefaultEffort; logEvent("tengu_model_command_menu_effort", { - effort: effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + effort: clampedEffort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS }); if (!skipSettingsWrite) { - const effortLevel = resolvePickerEffortPersistence(effort, getDefaultEffortLevelForOption(value_0), getSettingsForSource("userSettings")?.effortLevel, hasToggledEffort); + const effortLevel = resolvePickerEffortPersistence(clampedEffort, getDefaultEffortLevelForOption(value_0), getSettingsForSource("userSettings")?.effortLevel, hasToggledEffort); const persistable = toPersistableEffort(effortLevel); if (persistable !== undefined) { updateSettingsForSource("userSettings", { @@ -265,7 +274,7 @@ export function ModelPicker(t0) { effortValue: effortLevel })); } - const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? effort : undefined; + const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? clampedEffort : undefined; if (value_0 === NO_PREFERENCE) { onSelect(null, selectedEffort); return; @@ -277,6 +286,8 @@ export function ModelPicker(t0) { $[37] = onSelect; $[38] = setAppState; $[39] = skipSettingsWrite; + $[46] = focusedAvailableLevels; + $[47] = focusedDefaultEffort; $[40] = t14; } else { t14 = $[40]; @@ -447,10 +458,9 @@ function EffortLevelIndicator(t0) { } return t4; } -function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel { - const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high']; +function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', levels: EffortLevel[]): EffortLevel { // If the current level isn't in the cycle (e.g. 'max' after switching to a - // non-Opus model), clamp to 'high'. + // non-max model), clamp to 'high'. const idx = levels.indexOf(current); const currentIndex = idx !== -1 ? idx : levels.indexOf('high'); if (direction === 'right') { diff --git a/src/entrypoints/sdk/controlSchemas.ts b/src/entrypoints/sdk/controlSchemas.ts index 8e27aa781..a00cbb9c8 100644 --- a/src/entrypoints/sdk/controlSchemas.ts +++ b/src/entrypoints/sdk/controlSchemas.ts @@ -507,7 +507,7 @@ export const SDKControlGetSettingsResponseSchema = lazySchema(() => model: z.string(), // String levels only — numeric effort is internal-only and the // Zod→proto generator can't emit enum∪number unions. - effort: z.enum(['low', 'medium', 'high', 'max']).nullable(), + effort: z.enum(['low', 'medium', 'high', 'xhigh', 'max']).nullable(), }) .optional() .describe( diff --git a/src/entrypoints/sdk/coreSchemas.ts b/src/entrypoints/sdk/coreSchemas.ts index b9e375639..9465ad37e 100644 --- a/src/entrypoints/sdk/coreSchemas.ts +++ b/src/entrypoints/sdk/coreSchemas.ts @@ -1081,7 +1081,7 @@ export const ModelInfoSchema = lazySchema(() => .optional() .describe('Whether this model supports effort levels'), supportedEffortLevels: z - .array(z.enum(['low', 'medium', 'high', 'max'])) + .array(z.enum(['low', 'medium', 'high', 'xhigh', 'max'])) .optional() .describe('Available effort levels for this model'), supportsAdaptiveThinking: z @@ -1190,7 +1190,7 @@ export const AgentDefinitionSchema = lazySchema(() => "Scope for auto-loading agent memory files. 'user' - ~/.claude/agent-memory//, 'project' - .claude/agent-memory//, 'local' - .claude/agent-memory-local//", ), effort: z - .union([z.enum(['low', 'medium', 'high', 'max']), z.number().int()]) + .union([z.enum(['low', 'medium', 'high', 'xhigh', 'max']), z.number().int()]) .optional() .describe( 'Reasoning effort level for this agent. Either a named level or an integer', diff --git a/src/entrypoints/sdk/coreTypes.generated.ts b/src/entrypoints/sdk/coreTypes.generated.ts index 4b1d7e80f..6578dd88e 100644 --- a/src/entrypoints/sdk/coreTypes.generated.ts +++ b/src/entrypoints/sdk/coreTypes.generated.ts @@ -1470,7 +1470,7 @@ export type ModelInfo = { displayName: string description: string supportsEffort?: boolean - supportedEffortLevels?: ("low" | "medium" | "high" | "max")[] + supportedEffortLevels?: ("low" | "medium" | "high" | "xhigh" | "max")[] supportsAdaptiveThinking?: boolean supportsFastMode?: boolean supportsAutoMode?: boolean @@ -1534,7 +1534,7 @@ export type AgentDefinition = { maxTurns?: number background?: boolean memory?: "user" | "project" | "local" - effort?: "low" | "medium" | "high" | "max" | number + effort?: "low" | "medium" | "high" | "xhigh" | "max" | number permissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "fullAccess" | "plan" | "dontAsk" } diff --git a/src/entrypoints/sdk/runtimeTypes.ts b/src/entrypoints/sdk/runtimeTypes.ts index a31be73ec..b012b2cbd 100644 --- a/src/entrypoints/sdk/runtimeTypes.ts +++ b/src/entrypoints/sdk/runtimeTypes.ts @@ -1 +1 @@ -export type EffortLevel = 'low' | 'medium' | 'high' | 'max' +export type EffortLevel = 'low' | 'medium' | 'high' | 'max' | 'xhigh' diff --git a/src/integrations/gateways/opencode-go.ts b/src/integrations/gateways/opencode-go.ts index 855314e43..31c7cec83 100644 --- a/src/integrations/gateways/opencode-go.ts +++ b/src/integrations/gateways/opencode-go.ts @@ -29,7 +29,7 @@ export default defineGateway({ preset: { id: 'opencode-go', vendorId: 'openai', - description: 'OpenCode Go — $10/mo subscription for open models (12 models)', + description: 'OpenCode Go — $10/mo subscription for open models (13 models)', apiKeyEnvVars: ['OPENCODE_API_KEY'], modelEnvVars: ['OPENAI_MODEL'], }, @@ -50,6 +50,7 @@ export default defineGateway({ { id: 'opencode-go-minimax-m2.5', apiName: 'minimax-m2.5', label: 'MiniMax M2.5', modelDescriptorId: 'opencode-go-minimax-m2.5', transportOverrides: { openaiShim: { endpointPath: '/messages' } } }, { id: 'opencode-go-qwen3.6-plus', apiName: 'qwen3.6-plus', label: 'Qwen3.6 Plus', modelDescriptorId: 'opencode-go-qwen3.6-plus', transportOverrides: { openaiShim: { endpointPath: '/messages' } } }, { id: 'opencode-go-qwen3.5-plus', apiName: 'qwen3.5-plus', label: 'Qwen3.5 Plus', modelDescriptorId: 'opencode-go-qwen3.5-plus', transportOverrides: { openaiShim: { endpointPath: '/messages' } } }, + { id: 'opencode-go-minimax-m3', apiName: 'minimax-m3', label: 'MiniMax M3', modelDescriptorId: 'opencode-go-minimax-m3', transportOverrides: { openaiShim: { endpointPath: '/messages' } } }, ], }, usage: { supported: false }, diff --git a/src/integrations/gateways/opencode.test.ts b/src/integrations/gateways/opencode.test.ts index f4199bf02..bbe6afd3a 100644 --- a/src/integrations/gateways/opencode.test.ts +++ b/src/integrations/gateways/opencode.test.ts @@ -284,12 +284,12 @@ describe('OpenCode model catalog', () => { test('zen model count matches expected', () => { const models = getCatalogEntriesForRoute('opencode') - expect(models.length).toBe(41) + expect(models.length).toBe(43) }) test('go model count matches expected', () => { const models = getCatalogEntriesForRoute('opencode-go') - expect(models.length).toBe(12) + expect(models.length).toBe(13) }) test('all zen gpt models have modelDescriptorId', () => { diff --git a/src/integrations/gateways/opencode.ts b/src/integrations/gateways/opencode.ts index f58914cd6..384854923 100644 --- a/src/integrations/gateways/opencode.ts +++ b/src/integrations/gateways/opencode.ts @@ -20,7 +20,7 @@ export default defineGateway({ preset: { id: 'opencode', vendorId: 'openai', - description: 'OpenCode Zen — pay-as-you-go AI gateway (41 models)', + description: 'OpenCode Zen — pay-as-you-go AI gateway (43 models)', apiKeyEnvVars: ['OPENCODE_API_KEY'], modelEnvVars: ['OPENAI_MODEL'], }, @@ -56,6 +56,7 @@ export default defineGateway({ { id: 'gpt-5-nano', apiName: 'gpt-5-nano', label: 'GPT 5 Nano', modelDescriptorId: 'opencode-gpt-5-nano', transportOverrides: { openaiShim: { endpointPath: '/responses' } } }, // Claude family — /zen/v1/messages { id: 'claude-opus-4-7', apiName: 'claude-opus-4-7', label: 'Claude Opus 4.7', modelDescriptorId: 'opencode-claude-opus-4-7', transportOverrides: { openaiShim: { endpointPath: '/messages' } } }, + { id: 'claude-opus-4-8', apiName: 'claude-opus-4-8', label: 'Claude Opus 4.8', modelDescriptorId: 'opencode-claude-opus-4-8', transportOverrides: { openaiShim: { endpointPath: '/messages' } } }, { id: 'claude-opus-4-6', apiName: 'claude-opus-4-6', label: 'Claude Opus 4.6', modelDescriptorId: 'opencode-claude-opus-4-6', transportOverrides: { openaiShim: { endpointPath: '/messages' } } }, { id: 'claude-opus-4-5', apiName: 'claude-opus-4-5', label: 'Claude Opus 4.5', modelDescriptorId: 'opencode-claude-opus-4-5', transportOverrides: { openaiShim: { endpointPath: '/messages' } } }, { id: 'claude-opus-4-1', apiName: 'claude-opus-4-1', label: 'Claude Opus 4.1', modelDescriptorId: 'opencode-claude-opus-4-1', transportOverrides: { openaiShim: { endpointPath: '/messages' } } }, @@ -82,6 +83,7 @@ export default defineGateway({ { id: 'big-pickle', apiName: 'big-pickle', label: 'Big Pickle', modelDescriptorId: 'opencode-big-pickle' }, { id: 'deepseek-v4-flash-free', apiName: 'deepseek-v4-flash-free', label: 'DeepSeek V4 Flash Free', modelDescriptorId: 'opencode-deepseek-v4-flash-free' }, { id: 'nemotron-3-super-free', apiName: 'nemotron-3-super-free', label: 'Nemotron 3 Super Free', modelDescriptorId: 'opencode-nemotron-3-super-free' }, + { id: 'mimo-v2.5-free', apiName: 'mimo-v2.5-free', label: 'MiMo V2.5 Free', modelDescriptorId: 'opencode-mimo-v2.5-free' }, ], }, usage: { supported: false }, diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 9ad1a20b4..7a6e20c96 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -309,7 +309,7 @@ export const PROVIDER_PRESET_MANIFEST = [ "routeId": "opencode-go", "vendorId": "openai", "gatewayId": "opencode-go", - "description": "OpenCode Go — $10/mo subscription for open models (12 models)", + "description": "OpenCode Go — $10/mo subscription for open models (13 models)", "apiKeyEnvVars": [ "OPENCODE_API_KEY" ], @@ -323,7 +323,7 @@ export const PROVIDER_PRESET_MANIFEST = [ "routeId": "opencode", "vendorId": "openai", "gatewayId": "opencode", - "description": "OpenCode Zen — pay-as-you-go AI gateway (41 models)", + "description": "OpenCode Zen — pay-as-you-go AI gateway (43 models)", "apiKeyEnvVars": [ "OPENCODE_API_KEY" ], diff --git a/src/integrations/models/opencode.ts b/src/integrations/models/opencode.ts index e4e304201..5d58c7042 100644 --- a/src/integrations/models/opencode.ts +++ b/src/integrations/models/opencode.ts @@ -332,6 +332,24 @@ export default [ contextWindow: 200_000, maxOutputTokens: 65_536, }), + defineModel({ + id: 'opencode-claude-opus-4-8', + label: 'Claude Opus 4.8', + + vendorId: 'openai', + classification: ['chat', 'reasoning'], + defaultModel: 'opencode-claude-opus-4-8', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 200_000, + maxOutputTokens: 65_536, + }), defineModel({ id: 'opencode-claude-opus-4-6', label: 'Claude Opus 4.6', @@ -752,6 +770,24 @@ export default [ contextWindow: 131_072, maxOutputTokens: 32_768, }), + defineModel({ + id: 'opencode-mimo-v2.5-free', + label: 'MiMo V2.5 Free', + + vendorId: 'openai', + classification: ['chat'], + defaultModel: 'opencode-mimo-v2.5-free', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), // ============================================================ // GO MODELS — https://opencode.ai/zen/go/v1 @@ -976,4 +1012,22 @@ export default [ contextWindow: 131_072, maxOutputTokens: 32_768, }), + defineModel({ + id: 'opencode-go-minimax-m3', + label: 'MiniMax M3', + + vendorId: 'openai', + classification: ['chat'], + defaultModel: 'minimax-m3', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), ] diff --git a/src/main.tsx b/src/main.tsx index 05ae6f173..5eb93d909 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -952,9 +952,9 @@ async function run(): Promise { return Number.isFinite(n) ? n : undefined; }).hideHelp()).option('--from-pr [value]', 'Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term', value => value || true).option('--no-session-persistence', 'Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)').addOption(new Option('--resume-session-at ', 'When resuming, only messages up to and including the assistant message with (use with --resume in print mode)').argParser(String).hideHelp()).addOption(new Option('--rewind-files ', 'Restore files to state at the specified user message and exit (requires --resume)').hideHelp()) // @[MODEL LAUNCH]: Update the example model ID in the --model help text. - .option('--model ', `Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6').`).option('--provider ', `AI provider to use (anthropic, openai, gemini, github, bedrock, vertex, ollama). Reads API keys from environment variables.`).addOption(new Option('--effort ', `Effort level for the current session (low, medium, high, max)`).argParser((rawValue: string) => { + .option('--model ', `Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6').`).option('--provider ', `AI provider to use (anthropic, openai, gemini, github, bedrock, vertex, ollama). Reads API keys from environment variables.`).addOption(new Option('--effort ', `Effort level for the current session (low, medium, high, xhigh, max)`).argParser((rawValue: string) => { const value = rawValue.toLowerCase(); - const allowed = ['low', 'medium', 'high', 'max']; + const allowed = ['low', 'medium', 'high', 'xhigh', 'max']; if (!allowed.includes(value)) { throw new InvalidArgumentError(`It must be one of: ${allowed.join(', ')}`); } diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 6e218048b..c704bcc14 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -2235,6 +2235,11 @@ class OpenAIShimMessages { if (params.temperature !== undefined) responsesBody.temperature = params.temperature if (params.top_p !== undefined) responsesBody.top_p = params.top_p + if (request.reasoning?.effort) { + responsesBody.reasoning_effort = request.reasoning.effort + responsesBody.reasoning_summary = 'auto' + responsesBody.include = ['reasoning.encrypted_content'] + } if (!omitResponsesTools && params.tools && params.tools.length > 0) { const convertedTools = convertToolsToResponsesTools( @@ -2285,6 +2290,31 @@ class OpenAIShimMessages { anthropicBody.tool_choice = params.tool_choice } + if (request.reasoning?.effort) { + // Shim receives OpenAI effort levels (xhigh) from client.ts, but + // Anthropic API expects 'max' not 'xhigh'. Convert for the effort field. + const effort = request.reasoning.effort === 'xhigh' ? 'max' : request.reasoning.effort + const modelLower = request.resolvedModel.toLowerCase() + const isAdaptive = modelLower.includes('opus-4-7') || modelLower.includes('opus-4-6') || + modelLower.includes('opus-4-8') || + modelLower.includes('opus-4.6') || modelLower.includes('opus-4.7') || + modelLower.includes('opus-4.8') || + modelLower.includes('sonnet-4-6') || modelLower.includes('sonnet-4.6') + const isOpus45 = modelLower.includes('opus-4-5') || modelLower.includes('opus-4.5') + + if (isAdaptive) { + anthropicBody.thinking = { type: 'adaptive' } + anthropicBody.effort = effort + } else if (isOpus45) { + anthropicBody.effort = effort + } else if (effort === 'high' || effort === 'max') { + anthropicBody.thinking = { + type: 'enabled', + budgetTokens: effort === 'max' ? 31_999 : 16_000, + } + } + } + return anthropicBody } @@ -2378,6 +2408,10 @@ class OpenAIShimMessages { } if (params.temperature !== undefined) genConfig.temperature = params.temperature if (params.top_p !== undefined) genConfig.topP = params.top_p + if (request.reasoning?.effort) { + const level = request.reasoning.effort === 'xhigh' ? 'high' : request.reasoning.effort + genConfig.thinkingConfig = { includeThoughts: true, thinkingLevel: level } + } if (Object.keys(genConfig).length > 0) { geminiBody.generationConfig = genConfig } diff --git a/src/utils/effort.codex.test.ts b/src/utils/effort.codex.test.ts index dca3ce2be..a1e4fd8dc 100644 --- a/src/utils/effort.codex.test.ts +++ b/src/utils/effort.codex.test.ts @@ -106,13 +106,13 @@ test('gpt-5.3-codex-spark stays without effort controls', async () => { expect(getAvailableEffortLevels('gpt-5.3-codex-spark')).toEqual([]) }) -test('toPersistableEffort normalizes xhigh to max so it survives settings write', async () => { +test('toPersistableEffort passes xhigh through as a first-class level', async () => { const { toPersistableEffort } = await importFreshEffortModule({ provider: 'openai', supportsCodexReasoningEffort: true, }) - expect(toPersistableEffort('xhigh')).toBe('max') + expect(toPersistableEffort('xhigh')).toBe('xhigh') expect(toPersistableEffort('max')).toBe('max') expect(toPersistableEffort('high')).toBe('high') expect(toPersistableEffort('medium')).toBe('medium') @@ -128,12 +128,13 @@ test('standardEffortToOpenAI maps max to xhigh for shim payload', async () => { }) expect(standardEffortToOpenAI('max')).toBe('xhigh') + expect(standardEffortToOpenAI('xhigh')).toBe('xhigh') expect(standardEffortToOpenAI('high')).toBe('high') - expect(openAIEffortToStandard('xhigh')).toBe('max') + expect(openAIEffortToStandard('xhigh')).toBe('xhigh') expect(openAIEffortToStandard('high')).toBe('high') }) -test('e2e: xhigh → persisted max → resolveAppliedEffort → wire xhigh on OpenAI/Codex (no high clamp)', async () => { +test('e2e: xhigh → persisted xhigh → resolveAppliedEffort → wire xhigh on OpenAI/Codex (no high clamp)', async () => { const { toPersistableEffort, resolveAppliedEffort, @@ -143,18 +144,16 @@ test('e2e: xhigh → persisted max → resolveAppliedEffort → wire xhigh on Op supportsCodexReasoningEffort: true, }) - // Picker writes the OpenAI-shaped value; toPersistableEffort normalizes. + // Picker writes 'xhigh'; toPersistableEffort passes it through. const persisted = toPersistableEffort('xhigh') - expect(persisted).toBe('max') + expect(persisted).toBe('xhigh') - // App state holds 'max'. Non-Opus 'max' must NOT be downgraded to 'high' - // when the model uses the OpenAI effort scheme — the shim converts back - // to 'xhigh' on the wire. + // App state holds 'xhigh'. The OpenAI-shaped 'xhigh' is sent to the API as-is. const applied = resolveAppliedEffort('gpt-5.4', persisted) - expect(applied).toBe('max') + expect(applied).toBe('xhigh') // Final wire value the client shim emits. - expect(standardEffortToOpenAI(applied as 'max')).toBe('xhigh') + expect(standardEffortToOpenAI(applied as 'xhigh')).toBe('xhigh') }) test('e2e: max on non-Opus Anthropic model still clamps to high', async () => { @@ -165,3 +164,105 @@ test('e2e: max on non-Opus Anthropic model still clamps to high', async () => { expect(resolveAppliedEffort('claude-sonnet-4-6', 'max')).toBe('high') }) + +test('modelSupportsXHighEffort: opus-4-7 and opus-4-8 are allowed; other Claude models are not', async () => { + const { modelSupportsXHighEffort } = await importFreshEffortModule({ + provider: 'firstParty' as unknown as 'openai', + supportsCodexReasoningEffort: false, + }) + + expect(modelSupportsXHighEffort('claude-opus-4-7')).toBe(true) + expect(modelSupportsXHighEffort('claude-opus-4-8')).toBe(true) + expect(modelSupportsXHighEffort('opencode-claude-opus-4-8')).toBe(true) + expect(modelSupportsXHighEffort('claude-opus-4-6')).toBe(false) + expect(modelSupportsXHighEffort('claude-sonnet-4-6')).toBe(false) + expect(modelSupportsXHighEffort('claude-sonnet-4-5')).toBe(false) + expect(modelSupportsXHighEffort('claude-haiku-4-5')).toBe(false) + expect(modelSupportsXHighEffort('claude-3-5-haiku')).toBe(false) +}) + +test('xhigh does not appear in available levels for non-supporting models', async () => { + const { getAvailableEffortLevels } = await importFreshEffortModule({ + provider: 'firstParty' as unknown as 'openai', + supportsCodexReasoningEffort: false, + }) + + // No xhigh, no max + expect(getAvailableEffortLevels('claude-sonnet-4-6')).toEqual([ + 'low', + 'medium', + 'high', + ]) + expect(getAvailableEffortLevels('claude-haiku-4-5')).toEqual([]) + + // Has xhigh AND max (opus-4-8) + const opusLevels = getAvailableEffortLevels('claude-opus-4-8') + expect(opusLevels).toEqual(['low', 'medium', 'high', 'xhigh', 'max']) +}) + +test('effort allowlist is narrowed to the shim isAdaptive||isOpus45 set', async () => { + // The Anthropic /messages shim only serializes low/medium as + // anthropicBody.effort for opus-4-5/4-6/4-7/4-8 and sonnet-4-6. For + // older variants it only emits thinking for high/max — advertising + // effort for them would silently drop low/medium on the wire. + const { modelSupportsEffort, getAvailableEffortLevels } = + await importFreshEffortModule({ + provider: 'firstParty' as unknown as 'openai', + supportsCodexReasoningEffort: false, + }) + + // Inside the shim set → supported + for (const model of [ + 'claude-opus-4-5', + 'claude-opus-4-6', + 'claude-opus-4-7', + 'claude-opus-4-8', + 'claude-sonnet-4-6', + 'opencode-claude-opus-4-7', + ]) { + expect(modelSupportsEffort(model)).toBe(true) + } + + // Outside the shim set → not supported (was previously true via the + // broad `claude-opus-4*` / `claude-sonnet-4*` substring match) + for (const model of [ + 'claude-opus-4-1', + 'claude-opus-4-2', + 'claude-sonnet-4-5', + ]) { + expect(modelSupportsEffort(model)).toBe(false) + expect(getAvailableEffortLevels(model)).toEqual([]) + } +}) + +test('xhigh clamps to high on non-supporting models so stale settings.json values do not produce API errors', async () => { + const { resolveAppliedEffort } = await importFreshEffortModule({ + provider: 'firstParty' as unknown as 'openai', + supportsCodexReasoningEffort: false, + }) + + // sonnet-4-6 supports effort but not xhigh — clamp + expect(resolveAppliedEffort('claude-sonnet-4-6', 'xhigh')).toBe('high') + // opus-4-8 supports xhigh — pass through + expect(resolveAppliedEffort('claude-opus-4-8', 'xhigh')).toBe('xhigh') +}) + +test('modelUsesOpenAIEffort: Claude/Gemini are excluded even on the openai provider (OpenCode native route)', async () => { + const { modelUsesOpenAIEffort, getAvailableEffortLevels } = + await importFreshEffortModule({ + provider: 'openai', + supportsCodexReasoningEffort: true, + }) + + // Native Claude/Gemini on OpenCode use Anthropic/Google format, not OpenAI + expect(modelUsesOpenAIEffort('claude-opus-4-8')).toBe(false) + expect(modelUsesOpenAIEffort('claude-sonnet-4-6')).toBe(false) + expect(modelUsesOpenAIEffort('gemini-3-flash')).toBe(false) + // Real OpenAI-shaped models still classify as OpenAI + expect(modelUsesOpenAIEffort('gpt-5.4')).toBe(true) + + // And the picker excludes xhigh for OpenCode Claude on openai provider + const opusLevels = getAvailableEffortLevels('claude-opus-4-8') + // Standard branch: no OPENAI_EFFORT_LEVELS, just the supported standard levels + expect(opusLevels).toEqual(['low', 'medium', 'high', 'xhigh', 'max']) +}) diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 3ed58281d..d17286ea3 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -15,6 +15,7 @@ export const EFFORT_LEVELS = [ 'low', 'medium', 'high', + 'xhigh', 'max', ] as const satisfies readonly EffortLevel[] @@ -41,8 +42,20 @@ export function modelSupportsEffort(model: string): boolean { if (modelUsesOpenAIEffort(model) && supportsCodexReasoningEffort(model)) { return true } - // Supported by a subset of Claude 4 models - if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) { + // Claude 4 models that support effort. Mirrors the Anthropic /messages + // shim's isAdaptive || isOpus45 set (openaiShim.ts:2292-2297) — only + // these models serialize low/medium as anthropicBody.effort. Older + // variants (opus-4-1, sonnet-4-5, haiku) only emit thinking for + // high/max, so advertising effort for them would silently drop + // low/medium on the wire. The substring match also covers prefix + // variations (e.g. `claude-opus-4-7`, `opencode-claude-opus-4-8`). + if (m.includes('opus-4-5') || m.includes('opus-4-6') || + m.includes('opus-4-7') || m.includes('opus-4-8') || + m.includes('sonnet-4-6')) { + return true + } + // OpenCode Gemini models that support thinking via /models/gemini-* endpoint + if (m.includes('gemini-3')) { return true } // Exclude any other known legacy models (haiku, older opus/sonnet variants) @@ -67,7 +80,7 @@ export function modelSupportsMaxEffort(model: string): boolean { if (supported3P !== undefined) { return supported3P } - if (model.toLowerCase().includes('opus-4-6')) { + if (model.toLowerCase().includes('opus-4-6') || model.toLowerCase().includes('opus-4-7') || model.toLowerCase().includes('opus-4-8')) { return true } if (process.env.USER_TYPE === 'ant' && resolveAntModel(model)) { @@ -76,6 +89,26 @@ export function modelSupportsMaxEffort(model: string): boolean { return false } +// @[MODEL LAUNCH]: Add the new model to the allowlist if it supports 'xhigh' effort. +// xhigh is reserved for OpenAI/Codex models and OpenCode Claude opus 4-7 / 4-8. +// All other effort-supporting models reject xhigh at the API. +export function modelSupportsXHighEffort(model: string): boolean { + if (!modelSupportsEffort(model)) { + return false + } + const supported3P = get3PModelCapabilityOverride(model, 'xhigh_effort') + if (supported3P !== undefined) { + return supported3P + } + if (modelUsesOpenAIEffort(model)) { + return true + } + if (model.toLowerCase().includes('opus-4-7') || model.toLowerCase().includes('opus-4-8')) { + return true + } + return false +} + export function isEffortLevel(value: string): value is EffortLevel { return (EFFORT_LEVELS as readonly string[]).includes(value) } @@ -86,17 +119,39 @@ export function isOpenAIEffortLevel(value: string): value is OpenAIEffortLevel { export function modelUsesOpenAIEffort(model: string): boolean { const provider = getAPIProvider() - return provider === 'openai' || provider === 'codex' + if (provider !== 'openai' && provider !== 'codex') { + return false + } + // Native Claude/Gemini models on OpenCode use Anthropic/Google format + // even though the OpenCode shim is provider=openai. They should not be + // classified as OpenAI-style for effort routing. + const m = model.toLowerCase() + if (m.includes('claude-') || m.includes('gemini-')) { + return false + } + return true } -export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIEffortLevel[] { +export function getAvailableEffortLevels(model: string): EffortLevel[] { if (!modelSupportsEffort(model)) { return [] } - if (modelUsesOpenAIEffort(model)) { - return [...OPENAI_EFFORT_LEVELS] as OpenAIEffortLevel[] + // OpenCode Claude and Gemini models use /messages or /models/gemini-* + // (Anthropic/Google format) even though getAPIProvider() returns 'openai'. + // Show standard levels (max) not OpenAI levels (xhigh). + const m = model.toLowerCase() + const isOpenCodeNativeFormat = ( + m.includes('claude-opus-4') || m.includes('claude-sonnet-4') || + m.includes('opus-4') || m.includes('sonnet-4') || + m.includes('gemini-3') + ) && getAPIProvider() === 'openai' + if (modelUsesOpenAIEffort(model) && !isOpenCodeNativeFormat) { + return [...OPENAI_EFFORT_LEVELS] as EffortLevel[] } const levels: EffortLevel[] = ['low', 'medium', 'high'] + if (modelSupportsXHighEffort(model)) { + levels.push('xhigh') + } if (modelSupportsMaxEffort(model)) { levels.push('max') } @@ -110,8 +165,7 @@ export function getEffortLevelLabel(level: EffortLevel | OpenAIEffortLevel): str } export function openAIEffortToStandard(level: OpenAIEffortLevel): EffortLevel { - if (level === 'xhigh') return 'max' - return level + return level as EffortLevel } export function standardEffortToOpenAI(level: EffortLevel): OpenAIEffortLevel { @@ -144,23 +198,23 @@ export function parseEffortValue(value: unknown): EffortValue | undefined { /** * Numeric values are model-default only and not persisted. * 'max' can now be persisted by all users. - * OpenAI-shaped 'xhigh' is normalized to its EffortLevel equivalent ('max') - * so any code path that leaks the OpenAI label still persists correctly. + * 'xhigh' is a first-class EffortLevel (supported by OpenCode Claude 4.7+) + * and is persisted as 'xhigh' — no normalization needed. * Write sites call this before saving to settings so the Zod schema * (which only accepts string levels) never rejects a write. */ export function toPersistableEffort( value: EffortValue | undefined, ): EffortLevel | undefined { - if (value === 'low' || value === 'medium' || value === 'high') { + if ( + value === 'low' || + value === 'medium' || + value === 'high' || + value === 'max' || + value === 'xhigh' + ) { return value } - if (value === 'max') { - return value - } - if (value === 'xhigh') { - return 'max' - } return undefined } @@ -229,6 +283,12 @@ export function resolveAppliedEffort( ) { return 'high' } + // xhigh is reserved for OpenAI/Codex models and OpenCode opus-4-7/4-8. + // For all other models, downgrade to 'high' so a stale persisted setting + // doesn't surface as an API error. + if (resolved === 'xhigh' && !modelSupportsXHighEffort(model)) { + return 'high' + } return resolved } @@ -296,9 +356,9 @@ export function getEffortLevelDescription(level: EffortLevel | OpenAIEffortLevel case 'high': return 'Comprehensive implementation with extensive testing and documentation' case 'max': - return 'Maximum capability with deepest reasoning (Opus 4.6 only)' + return 'Maximum capability with deepest reasoning (Opus 4.6+)' case 'xhigh': - return 'Extra high reasoning effort for complex tasks (OpenAI/Codex)' + return 'Extra high reasoning effort for complex tasks' } } diff --git a/src/utils/model/modelSupportOverrides.ts b/src/utils/model/modelSupportOverrides.ts index 562fac78f..a8d5050fc 100644 --- a/src/utils/model/modelSupportOverrides.ts +++ b/src/utils/model/modelSupportOverrides.ts @@ -4,6 +4,7 @@ import { getAPIProvider } from './providers.js' export type ModelCapabilityOverride = | 'effort' | 'max_effort' + | 'xhigh_effort' | 'thinking' | 'adaptive_thinking' | 'interleaved_thinking' diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 008e138f7..514a1d617 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -738,7 +738,7 @@ export const SettingsSchema = lazySchema(() => 'enabled automatically for supported models.', ), effortLevel: z - .enum(['low', 'medium', 'high', 'max']) + .enum(['low', 'medium', 'high', 'xhigh', 'max']) .optional() .catch(undefined) .describe('Persisted effort level for supported models.'),