diff --git a/crates/executors/src/executors/claude.rs b/crates/executors/src/executors/claude.rs index 2fb0bd12d4c..6e34c27698b 100644 --- a/crates/executors/src/executors/claude.rs +++ b/crates/executors/src/executors/claude.rs @@ -181,9 +181,20 @@ fn normalize_claude_stderr_logs( } use derivative::Derivative; -use strum_macros::{AsRefStr, EnumString}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS, JsonSchema, AsRefStr, EnumString)] +use strum_macros::{AsRefStr, EnumString, VariantNames}; + +#[derive( + Debug, + Clone, + PartialEq, + Serialize, + Deserialize, + TS, + JsonSchema, + AsRefStr, + EnumString, + VariantNames, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ClaudeEffort { @@ -192,6 +203,37 @@ pub enum ClaudeEffort { High, XHigh, Max, + // Undocumented accepted `--effort` alias on the interactive CLI (2.1.204): + // resolves to `xhigh` plus standing dynamic-workflow orchestration for the + // session. Only the interactive `claude` TUI understands the alias — the + // pinned headless CLI (2.1.154) hard-rejects unknown values, so the headless + // path maps it to its underlying `xhigh` tier (see + // `ClaudeEffort::headless_arg`). Plain `//` (not `///`) keeps the generated + // JSON schema a flat string enum for the settings agent-config editor. + Ultracode, +} + +impl ClaudeEffort { + /// Effort value to pass to the pinned headless CLI (`@2.1.154`). The + /// `ultracode` alias does not exist there (2.1.154 hard-rejects unknown + /// `--effort` values), and the dynamic-workflow orchestration it enables is + /// interactive-only, so headless runs use the equivalent `xhigh` tier. Every + /// other variant maps to the same string strum's `as_ref()` produces; only + /// `Ultracode` is a deliberate delta (see the `headless_arg` tests). + fn headless_arg(&self) -> &'static str { + match self { + ClaudeEffort::Low => "low", + ClaudeEffort::Medium => "medium", + ClaudeEffort::High => "high", + ClaudeEffort::XHigh => "xhigh", + ClaudeEffort::Max => "max", + // 2.1.154 hard-rejects the `ultracode` alias ("argument is invalid. + // It must be one of: low, medium, high, xhigh, max"), and its + // dynamic-workflow orchestration is interactive-only, so headless + // runs use the underlying xhigh tier. + ClaudeEffort::Ultracode => "xhigh", + } + } } #[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)] @@ -260,7 +302,7 @@ impl ClaudeCode { builder = builder.extend_params(["--model", model.as_str()]); } if let Some(effort) = &self.effort { - builder = builder.extend_params(["--effort", effort.as_ref()]); + builder = builder.extend_params(["--effort", effort.headless_arg()]); } if let Some(agent) = &self.agent { builder = builder.extend_params(["--agent", agent]); @@ -343,10 +385,15 @@ impl ClaudeCode { } } -/// Models whose CLI accepts a reasoning `--effort` flag (Opus / Sonnet -/// families). Haiku has no effort control. +/// Models whose CLI accepts a reasoning `--effort` flag (Opus / Sonnet / Fable +/// families). Haiku has no effort control. Matched case-insensitively so a +/// free-text / custom id like `Claude-Fable-5` is gated the same in the UI and +/// at launch (otherwise the interactive path would silently drop `--effort`). +/// Keep in sync with `modelSupportsEffort` in +/// `packages/web-core/src/shared/lib/modelSelector.ts`. pub(crate) fn model_supports_effort(id: &str) -> bool { - id.contains("opus") || id.contains("sonnet") + let id = id.to_lowercase(); + id.contains("opus") || id.contains("sonnet") || id.contains("fable") } /// Claude model ids use hyphens in the version (e.g. `claude-opus-4-8`), but a @@ -426,6 +473,111 @@ mod cli_launch_tests { ); } + #[test] + fn fable_models_carry_effort() { + // Regression: model_supports_effort() previously excluded fable, so + // CLI-mode launches of claude-fable-5 silently dropped --effort. + assert_eq!( + interactive_cli_args(Some("claude-fable-5"), Some("xhigh")), + v(&["--model", "claude-fable-5", "--effort", "xhigh"]) + ); + assert_eq!( + interactive_cli_args(Some("fable"), Some("ultracode")), + v(&["--model", "fable", "--effort", "ultracode"]) + ); + } + + #[test] + fn interactive_passes_ultracode_verbatim() { + // The interactive CLI (2.1.204) understands the `ultracode` alias, so it + // must be forwarded unchanged rather than mapped to xhigh. + assert_eq!( + interactive_cli_args(Some("opus"), Some("ultracode")), + v(&["--model", "opus", "--effort", "ultracode"]) + ); + } + + use super::ClaudeEffort; + + #[test] + fn ultracode_effort_round_trips() { + let effort: ClaudeEffort = "ultracode".parse().unwrap(); + assert_eq!(effort, ClaudeEffort::Ultracode); + assert_eq!(effort.as_ref(), "ultracode"); + } + + #[test] + fn ultracode_maps_to_xhigh_in_headless() { + // The pinned headless CLI (2.1.154) does not know the `ultracode` alias, + // and its dynamic-workflow orchestration is interactive-only, so headless + // runs fall back to the underlying xhigh tier. + assert_eq!(ClaudeEffort::Ultracode.headless_arg(), "xhigh"); + assert_eq!(ClaudeEffort::XHigh.headless_arg(), "xhigh"); + assert_eq!(ClaudeEffort::Max.headless_arg(), "max"); + } + + #[test] + fn headless_arg_matches_as_ref_except_ultracode() { + // Invariant: the headless CLI receives the same value as the interactive + // path (strum's `as_ref()`) for every tier EXCEPT `ultracode`, which the + // pinned @2.1.154 headless CLI does not accept and so maps to `xhigh`. + // If someone customizes a variant's serialized form (e.g. a strum + // `serialize = "..."`), this pins that both paths keep agreeing. + for effort in [ + ClaudeEffort::Low, + ClaudeEffort::Medium, + ClaudeEffort::High, + ClaudeEffort::XHigh, + ClaudeEffort::Max, + ] { + assert_eq!( + effort.headless_arg(), + effort.as_ref(), + "{effort:?} must map to its interactive wire value in headless mode" + ); + } + // The one deliberate delta. + assert_eq!(ClaudeEffort::Ultracode.as_ref(), "ultracode"); + assert_eq!(ClaudeEffort::Ultracode.headless_arg(), "xhigh"); + } + + #[test] + fn effort_wire_strings_are_stable() { + // ClaudeEffort is serialized into profiles.json and stored + // ExecutorActions history. Renaming/reordering a variant would corrupt + // that persisted data, so pin the exact on-the-wire string of every + // variant. ADD-only: new variants extend this list, never mutate it. + assert_eq!(ClaudeEffort::Low.as_ref(), "low"); + assert_eq!(ClaudeEffort::Medium.as_ref(), "medium"); + assert_eq!(ClaudeEffort::High.as_ref(), "high"); + assert_eq!(ClaudeEffort::XHigh.as_ref(), "xhigh"); + assert_eq!(ClaudeEffort::Max.as_ref(), "max"); + assert_eq!(ClaudeEffort::Ultracode.as_ref(), "ultracode"); + + // The picker's effort list is derived from these VARIANTS (strum + // VariantNames honoring serialize_all="lowercase"); pin the exact set so + // a rename or a strum casing change can't silently corrupt the wire ids. + use strum::VariantNames; + assert_eq!( + ClaudeEffort::VARIANTS, + ["low", "medium", "high", "xhigh", "max", "ultracode"] + ); + } + + #[test] + fn model_supports_effort_boundary() { + use super::model_supports_effort; + // Effort-capable families (any id form, case-insensitive). + assert!(model_supports_effort("opus")); + assert!(model_supports_effort("opus[1m]")); + assert!(model_supports_effort("sonnet")); + assert!(model_supports_effort("claude-fable-5")); + assert!(model_supports_effort("Claude-Fable-5")); // mirrors the TS lowercasing + // Haiku has no effort control — keep it out. + assert!(!model_supports_effort("haiku")); + assert!(!model_supports_effort("claude-haiku-4-5-20251001")); + } + #[test] fn blank_values_fall_back_to_defaults() { assert_eq!( @@ -507,13 +659,18 @@ mod cli_launch_tests { } fn default_discovered_options() -> crate::executor_discovery::ExecutorDiscoveredOptions { + // Derived from the ClaudeEffort enum (strum `VariantNames`, lowercased) so a + // future effort tier automatically appears in the picker instead of silently + // missing from this list. `from_names_with_default` sorts by rank, so the + // declaration order here is irrelevant. + use strum::VariantNames; + use crate::{ executor_discovery::ExecutorDiscoveredOptions, model_selector::{ModelInfo, ModelSelectorConfig, ReasoningOption}, }; - let effort_options = ReasoningOption::from_names_with_default( - ["low", "medium", "high", "xhigh", "max"].map(String::from), + ClaudeEffort::VARIANTS.iter().copied(), CLI_DEFAULT_EFFORT, ); @@ -521,6 +678,7 @@ fn default_discovered_options() -> crate::executor_discovery::ExecutorDiscovered model_selector: ModelSelectorConfig { providers: vec![], models: [ + ("fable", "Fable"), ("opus", "Opus"), ("opus[1m]", "Opus (1M context)"), ("sonnet", "Sonnet"), diff --git a/crates/executors/src/model_selector.rs b/crates/executors/src/model_selector.rs index daa38f4fe3c..b16baf2bf05 100644 --- a/crates/executors/src/model_selector.rs +++ b/crates/executors/src/model_selector.rs @@ -118,6 +118,7 @@ impl ReasoningOption { "high" => Some(3), "xhigh" => Some(4), "max" => Some(5), + "ultracode" => Some(6), _ => None, }; @@ -147,6 +148,9 @@ impl ReasoningOption { fn reasoning_label(id: &str) -> String { match id { + // Only override tiers the Title-case fallback would render wrong. + // "xhigh" -> "Xhigh" is wrong; "ultracode" -> "Ultracode" is already + // correct via the fallback, like low/medium/high/max. "xhigh" => "Extra High".to_string(), _ => id.to_case(Case::Title), } diff --git a/packages/ui/src/components/ModelList.tsx b/packages/ui/src/components/ModelList.tsx index aa85c16fed4..3e3410fc320 100644 --- a/packages/ui/src/components/ModelList.tsx +++ b/packages/ui/src/components/ModelList.tsx @@ -117,6 +117,13 @@ export interface ModelListProps { showDefaultOption?: boolean; onSelectDefault?: () => void; scrollRef?: Ref; + /** + * When provided, a "Use \"\"" row is offered whenever the trimmed + * search query matches no model, letting the user select an arbitrary + * (custom) model id. The downstream override chain tolerates ids not present + * in the config. + */ + onUseCustomModel?: (id: string) => void; } export function ModelList({ @@ -132,9 +139,11 @@ export function ModelList({ showDefaultOption = false, onSelectDefault, scrollRef, + onUseCustomModel, }: ModelListProps) { const { t } = useTranslation('common'); - const normalizedSearch = searchQuery.trim().toLowerCase(); + const trimmedSearch = searchQuery.trim(); + const normalizedSearch = trimmedSearch.toLowerCase(); const filteredModels = normalizedSearch ? models.filter((model) => { @@ -144,7 +153,37 @@ export function ModelList({ }) : models; - const showEmptyState = filteredModels.length === 0 && !showDefaultOption; + const showCustomEntry = + Boolean(onUseCustomModel) && + trimmedSearch.length > 0 && + filteredModels.length === 0; + const showEmptyState = + filteredModels.length === 0 && !showDefaultOption && !showCustomEntry; + + const customEntryRow = showCustomEntry ? ( +
+ +
+ ) : null; const isDefaultSelected = selectedModelId === null; const normalizedSelectedId = selectedModelId?.toLowerCase() ?? null; @@ -269,6 +308,7 @@ export function ModelList({ ); })} + {customEntryRow} {defaultRow} )} diff --git a/packages/ui/src/components/ModelSelectorPopover.tsx b/packages/ui/src/components/ModelSelectorPopover.tsx index 2e98f9a1a76..1b00823b058 100644 --- a/packages/ui/src/components/ModelSelectorPopover.tsx +++ b/packages/ui/src/components/ModelSelectorPopover.tsx @@ -48,8 +48,16 @@ export interface ModelSelectorPopoverProps { expandedProviderId?: string; onExpandedProviderIdChange?: (id: string) => void; resolvedTheme?: 'light' | 'dark'; + /** + * Enables the free-text "Use \"\"" custom-model row (and forces the + * search box on) for the flat picker. Off by default: only executors whose + * downstream tolerates arbitrary model ids (Claude) opt in; other flat + * executors keep their fixed model lists. + */ + allowCustomModel?: boolean; } +// Above this many models the flat picker shows a search box to filter the list. const MODEL_LIST_PAGE_SIZE = 8; function getModelKey(model: ModelListModel): string { @@ -297,6 +305,7 @@ export function ModelSelectorPopover({ expandedProviderId = '', onExpandedProviderIdChange, resolvedTheme = 'light', + allowCustomModel = false, }: ModelSelectorPopoverProps) { const { t } = useTranslation('common'); const models = config.models; @@ -307,6 +316,9 @@ export function ModelSelectorPopover({ const popoverWidth = getPopoverWidth(hasProviders, hasReasoning); const popoverHeightClass = hasProviders ? 'h-[280px]' : ''; + // Provider (accordion) pickers always show the search box. The flat picker + // shows it once the list is long enough to warrant filtering, or whenever + // custom-model entry is allowed (which needs the box to type an id into). let showSearch = true; let content: ReactElement; @@ -335,8 +347,7 @@ export function ModelSelectorPopover({ selectedProviderId, selectedModelId ); - showSearch = models.length > MODEL_LIST_PAGE_SIZE; - + showSearch = allowCustomModel || models.length > MODEL_LIST_PAGE_SIZE; content = ( onModelSelect(id) : undefined + } /> ); } diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index 2fdbb996bf1..5528e866b2a 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -491,7 +491,8 @@ "permissionPlan": "Plan", "agent": "Agent", "default": "Default", - "model": "Model" + "model": "Model", + "useCustom": "Use \"{{query}}\"" }, "crashScreen": { "title": "Something went wrong", diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index 69bfad73312..c5ecb49a351 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -477,7 +477,8 @@ "permissionPlan": "Plan", "agent": "Agente", "default": "Predeterminado", - "model": "Modelo" + "model": "Modelo", + "useCustom": "Usar \"{{query}}\"" }, "crashScreen": { "title": "Algo salió mal", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index 68aad865352..11bc9b8f919 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -477,7 +477,8 @@ "permissionPlan": "Plan", "agent": "Agent", "default": "Par défaut", - "model": "Modèle" + "model": "Modèle", + "useCustom": "Utiliser \"{{query}}\"" }, "crashScreen": { "title": "Une erreur est survenue", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index be469d84da3..dd4f99aa4d8 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -477,7 +477,8 @@ "permissionPlan": "計画", "agent": "エージェント", "default": "デフォルト", - "model": "モデル" + "model": "モデル", + "useCustom": "\"{{query}}\" を使用" }, "crashScreen": { "title": "問題が発生しました", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index 35cbed41e21..11c47712c32 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -477,7 +477,8 @@ "permissionPlan": "계획", "agent": "에이전트", "default": "기본값", - "model": "모델" + "model": "모델", + "useCustom": "\"{{query}}\" 사용" }, "crashScreen": { "title": "문제가 발생했습니다", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index 93bcdbfaf76..b6049ad9ad0 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -477,7 +477,8 @@ "permissionPlan": "计划", "agent": "代理", "default": "默认", - "model": "模型" + "model": "模型", + "useCustom": "使用 \"{{query}}\"" }, "crashScreen": { "title": "出了点问题", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index 93806fed3cf..768cc10bf13 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -477,7 +477,8 @@ "permissionPlan": "計畫", "agent": "代理", "default": "預設", - "model": "模型" + "model": "模型", + "useCustom": "使用 \"{{query}}\"" }, "crashScreen": { "title": "發生了錯誤", diff --git a/packages/web-core/src/shared/components/ModelSelectorContainer.tsx b/packages/web-core/src/shared/components/ModelSelectorContainer.tsx index 524b7b84043..0030fafbbf4 100644 --- a/packages/web-core/src/shared/components/ModelSelectorContainer.tsx +++ b/packages/web-core/src/shared/components/ModelSelectorContainer.tsx @@ -9,8 +9,8 @@ import { SlidersHorizontalIcon, type Icon, } from '@phosphor-icons/react'; -import type { BaseCodingAgent, ExecutorConfig, ModelInfo } from 'shared/types'; -import { PermissionPolicy } from 'shared/types'; +import type { ExecutorConfig, ModelInfo } from 'shared/types'; +import { BaseCodingAgent, PermissionPolicy } from 'shared/types'; import { toPrettyCase } from '@/shared/lib/string'; import { getModelKey, @@ -117,7 +117,22 @@ export function ModelSelectorContainer({ }, [streamError]); const baseConfig = streamConfig; - const config = appendPresetModel(baseConfig, presetOptions?.model_id); + // Claude is the only executor whose downstream tolerates arbitrary model ids + // and uses `--effort`, so it alone gets free-text custom entry + the effort + // fallback. Always surface the profile preset (unchanged behavior). For Claude, + // additionally surface the current override — which may be a free-text custom + // id — so it shows in the picker with an effort selector; other executors never + // produce free-text overrides, so injecting theirs would only risk surfacing a + // stale id. appendPresetModel is a no-op when the id is already present. + const isClaude = agent === BaseCodingAgent.CLAUDE_CODE; + const configWithPreset = appendPresetModel( + baseConfig, + presetOptions?.model_id, + isClaude + ); + const config = isClaude + ? appendPresetModel(configWithPreset, executorConfig?.model_id, isClaude) + : configWithPreset; const availableProviderIds = useMemo( () => config?.providers.map((item) => item.id) ?? [], @@ -492,6 +507,7 @@ export function ModelSelectorContainer({ expandedProviderId={expandedProviderId} onExpandedProviderIdChange={setExpandedProviderId} resolvedTheme={resolvedTheme} + allowCustomModel={isClaude} /> )} diff --git a/packages/web-core/src/shared/lib/modelSelector.test.ts b/packages/web-core/src/shared/lib/modelSelector.test.ts new file mode 100644 index 00000000000..c9c0c840d87 --- /dev/null +++ b/packages/web-core/src/shared/lib/modelSelector.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import type { ModelSelectorConfig, ReasoningOption } from 'shared/types'; + +import { appendPresetModel } from './modelSelector'; + +const effortOptions: ReasoningOption[] = [ + { id: 'low', label: 'Low', is_default: false }, + { id: 'high', label: 'High', is_default: true }, + { id: 'ultracode', label: 'Ultracode', is_default: false }, +]; + +function claudeConfig(): ModelSelectorConfig { + return { + providers: [], + models: [ + { + id: 'opus', + name: 'Opus', + provider_id: null, + reasoning_options: effortOptions, + }, + { + id: 'haiku', + name: 'Haiku', + provider_id: null, + reasoning_options: [], + }, + ], + default_model: 'opus', + agents: [], + permissions: [], + }; +} + +function providerConfig(): ModelSelectorConfig { + return { + providers: [{ id: 'openai', name: 'OpenAI' }], + models: [ + { + id: 'gpt-5', + name: 'GPT-5', + provider_id: 'openai', + reasoning_options: [{ id: 'high', label: 'High', is_default: true }], + }, + ], + default_model: 'openai/gpt-5', + agents: [], + permissions: [], + }; +} + +describe('appendPresetModel', () => { + it('injects an effort-capable Claude preset with inherited reasoning options', () => { + const result = appendPresetModel(claudeConfig(), 'claude-fable-5', true); + expect(result).not.toBeNull(); + const injected = result!.models[0]; + expect(injected.id).toBe('claude-fable-5'); + expect(injected.provider_id).toBeNull(); + // Fable is effort-capable, so it inherits the effort options from the first + // config model that has them (opus) — including ultracode. + expect(injected.reasoning_options.map((o) => o.id)).toEqual([ + 'low', + 'high', + 'ultracode', + ]); + }); + + it('injects a free-text custom effort-capable id with inherited options', () => { + // Custom (typed) ids get the same effort fallback as the profile preset. + const result = appendPresetModel(claudeConfig(), 'claude-opus-4-8', true); + const injected = result!.models[0]; + expect(injected.id).toBe('claude-opus-4-8'); + expect(injected.reasoning_options.map((o) => o.id)).toEqual([ + 'low', + 'high', + 'ultracode', + ]); + }); + + it('does not inherit effort options when the fallback is disabled', () => { + // Non-Claude executors pass enableEffortFallback=false (the default): even a + // provider-less id containing "opus" must not invent effort options. + const result = appendPresetModel(claudeConfig(), 'claude-fable-5'); + expect(result!.models[0].id).toBe('claude-fable-5'); + expect(result!.models[0].reasoning_options).toEqual([]); + }); + + it('does not invent effort options for a non-effort-capable preset', () => { + const result = appendPresetModel(claudeConfig(), 'some-random-model', true); + expect(result!.models[0].id).toBe('some-random-model'); + expect(result!.models[0].reasoning_options).toEqual([]); + }); + + it('leaves provider-scoped presets without inherited effort options', () => { + // codex-style providers path: the injected id belongs to a provider, so no + // reasoning fallback is applied even if the id string matched the heuristic. + const result = appendPresetModel( + providerConfig(), + 'openai/opus-custom', + true + ); + const injected = result!.models[0]; + expect(injected.id).toBe('opus-custom'); + expect(injected.provider_id).toBe('openai'); + expect(injected.reasoning_options).toEqual([]); + }); + + it('returns the config unchanged when the preset already exists', () => { + const config = claudeConfig(); + const result = appendPresetModel(config, 'opus', true); + expect(result).toBe(config); + }); + + it('returns the config unchanged when no preset is given', () => { + const config = claudeConfig(); + expect(appendPresetModel(config, null, true)).toBe(config); + expect(appendPresetModel(config, undefined, true)).toBe(config); + }); +}); diff --git a/packages/web-core/src/shared/lib/modelSelector.ts b/packages/web-core/src/shared/lib/modelSelector.ts index 2337cd7a279..f09dde8bfcd 100644 --- a/packages/web-core/src/shared/lib/modelSelector.ts +++ b/packages/web-core/src/shared/lib/modelSelector.ts @@ -66,9 +66,28 @@ export function parseModelId( }; } +/** + * Claude model ids whose CLI accepts a reasoning `--effort` flag (Opus / Sonnet + * / Fable families). Mirrors `model_supports_effort` in the Rust executor. Used + * to decide whether an injected/custom model should inherit effort options. + */ +function modelSupportsEffort(id: string): boolean { + const lower = id.toLowerCase(); + return ( + lower.includes('opus') || + lower.includes('sonnet') || + lower.includes('fable') + ); +} + export function appendPresetModel( config: ModelSelectorConfig | null, - presetModel: string | null | undefined + presetModel: string | null | undefined, + // Only the Claude executor uses the `--effort` reasoning flag, so the caller + // opts in explicitly. Keeps this shared helper executor-agnostic: without it, + // any provider-less executor whose id happened to contain "opus"/"sonnet"/ + // "fable" would inherit effort options it does not support. + enableEffortFallback = false ): ModelSelectorConfig | null { if (!config || !presetModel) return config; const hasProviders = config.providers.length > 0; @@ -82,6 +101,16 @@ export function appendPresetModel( ); if (exists) return config; + // An injected/custom model has no reasoning options of its own. If effort is + // enabled and it looks like an effort-capable Claude model, inherit the effort + // choices from the first config model that has them so the user can still pick + // an effort (e.g. Miguel's preset `claude-fable-5`, or a free-text custom id). + const reasoningOptions = + enableEffortFallback && !providerId && modelSupportsEffort(modelId) + ? (config.models.find((m) => m.reasoning_options.length > 0) + ?.reasoning_options ?? []) + : []; + return { ...config, models: [ @@ -89,7 +118,7 @@ export function appendPresetModel( id: modelId, name: modelId, provider_id: providerId, - reasoning_options: [], + reasoning_options: reasoningOptions, }, ...config.models, ], diff --git a/shared/schemas/claude_code.json b/shared/schemas/claude_code.json index 94d023656ab..d5e1134954a 100644 --- a/shared/schemas/claude_code.json +++ b/shared/schemas/claude_code.json @@ -46,6 +46,7 @@ "high", "xhigh", "max", + "ultracode", null ] }, diff --git a/shared/types.ts b/shared/types.ts index 778506992bd..cd82c3b0450 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -712,7 +712,7 @@ export type ExecutorConfigs = { executors: { [key in BaseCodingAgent]?: Executor export enum BaseAgentCapability { SESSION_FORK = "SESSION_FORK", SETUP_HELPER = "SETUP_HELPER", CONTEXT_USAGE = "CONTEXT_USAGE" } -export type ClaudeEffort = "low" | "medium" | "high" | "xhigh" | "max"; +export type ClaudeEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultracode"; export type ClaudeCode = { append_prompt: AppendPrompt, claude_code_router?: boolean | null, plan?: boolean | null, approvals?: boolean | null, model?: string | null, effort?: ClaudeEffort | null, agent?: string | null, dangerously_skip_permissions?: boolean | null, disable_api_key?: boolean | null, base_command_override?: string | null, additional_params?: Array | null, env?: { [key in string]?: string } | null, };