diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index 612e2d1d6..e1bcc679f 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildCliArgs } from './run' +import { buildCliArgs, shouldSetClaudeHaikuModelEnv } from './run' describe('buildCliArgs', () => { it('adds --permission-mode for valid permission mode', () => { @@ -61,4 +61,14 @@ describe('buildCliArgs', () => { expect(args).toContain(mode) } }) + + it('detects custom Claude models for Haiku env override', () => { + expect(shouldSetClaudeHaikuModelEnv('claude', 'claude-3-5-haiku-latest')).toBe(true) + expect(shouldSetClaudeHaikuModelEnv('claude', 'opus')).toBe(false) + expect(shouldSetClaudeHaikuModelEnv('claude', 'opus[1m]')).toBe(false) + expect(shouldSetClaudeHaikuModelEnv('claude', 'sonnet')).toBe(false) + expect(shouldSetClaudeHaikuModelEnv('claude', 'sonnet[1m]')).toBe(false) + expect(shouldSetClaudeHaikuModelEnv('codex', 'claude-3-5-haiku-latest')).toBe(false) + expect(shouldSetClaudeHaikuModelEnv('claude')).toBe(false) + }) }) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 2e6361101..d0cc10035 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -25,6 +25,16 @@ import { buildMachineMetadata } from '@/agent/sessionFactory'; import { resolveWorkspaceRoot } from '@/utils/workspaceRoot'; import { hashRunnerCliApiToken } from './runnerIdentity'; +const CLAUDE_BUILT_IN_MODEL_ALIASES = new Set(['opus', 'opus[1m]', 'sonnet', 'sonnet[1m]']); + +export function shouldSetClaudeHaikuModelEnv(agent: string, model?: string): boolean { + if (agent !== 'claude') { + return false; + } + const normalizedModel = model?.trim(); + return Boolean(normalizedModel && !CLAUDE_BUILT_IN_MODEL_ALIASES.has(normalizedModel)); +} + export async function startRunner(options: { workspaceRoot?: string } = {}): Promise { // We don't have cleanup function at the time of server construction // Control flow is: @@ -378,6 +388,13 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro }; } + if (shouldSetClaudeHaikuModelEnv(agent, options.model) && options.model) { + extraEnv = { + ...extraEnv, + ANTHROPIC_DEFAULT_HAIKU_MODEL: options.model + }; + } + const args = buildCliArgs(agent, options, yolo); // sessionId reserved for future use diff --git a/web/src/components/NewSession/ModelSelector.tsx b/web/src/components/NewSession/ModelSelector.tsx index 7977c55a5..32483a96a 100644 --- a/web/src/components/NewSession/ModelSelector.tsx +++ b/web/src/components/NewSession/ModelSelector.tsx @@ -5,11 +5,13 @@ import { useTranslation } from '@/lib/use-translation' export function ModelSelector(props: { agent: AgentType model: string + customModel: string options?: Array<{ value: string; label: string }> isDisabled: boolean isLoading?: boolean error?: string | null onModelChange: (value: string) => void + onCustomModelChange: (value: string) => void }) { const { t } = useTranslation() const options = props.options ?? MODEL_OPTIONS[props.agent] @@ -40,6 +42,16 @@ export function ModelSelector(props: { {props.error} ) : null} + {props.agent === 'claude' && props.model === 'custom' ? ( + props.onCustomModelChange(e.target.value)} + disabled={props.isDisabled || props.isLoading} + placeholder={t('newSession.model.custom.placeholder')} + className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50" + /> + ) : null} ) } diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 721596b8d..756218372 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -51,6 +51,7 @@ export function NewSession(props: { const [isDirectoryFocused, setIsDirectoryFocused] = useState(false) const [agent, setAgent] = useState(loadPreferredAgent) const [model, setModel] = useState('auto') + const [customModel, setCustomModel] = useState('') const [effort, setEffort] = useState('auto') const [modelReasoningEffort, setModelReasoningEffort] = useState('default') const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode) @@ -68,6 +69,7 @@ export function NewSession(props: { useEffect(() => { setModel('auto') + setCustomModel('') setEffort('auto') }, [agent]) @@ -277,7 +279,16 @@ export function NewSession(props: { return } - const resolvedModel = model !== 'auto' && agent !== 'opencode' ? model : undefined + const trimmedCustomModel = customModel.trim() + if (agent === 'claude' && model === 'custom' && !trimmedCustomModel) { + setError(t('newSession.model.custom.required')) + return + } + const resolvedModel = agent === 'claude' && model === 'custom' + ? trimmedCustomModel + : model !== 'auto' && agent !== 'opencode' + ? model + : undefined const resolvedEffort = agent === 'claude' && effort !== 'auto' ? effort : undefined const resolvedModelReasoningEffort = agent === 'codex' && modelReasoningEffort !== 'default' ? modelReasoningEffort @@ -358,6 +369,7 @@ export function NewSession(props: { { { value: 'opus[1m]', label: 'Opus 1M' }, { value: 'sonnet', label: 'Sonnet' }, { value: 'sonnet[1m]', label: 'Sonnet 1M' }, + { value: 'custom', label: 'Custom' }, ]) }) diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index a950a6ff0..6e15e9c8f 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -12,6 +12,7 @@ export const MODEL_OPTIONS: Record