From cd0b8e3589b2e50dbb93821bc6debd8efd718081 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Wed, 28 Oct 2026 13:10:36 +0900 Subject: [PATCH] fix(claude): stop locking Claude sessions to three hard-coded model aliases (#1948) * feat(shared): derive Claude model choices from the CLI catalog The choices are the rows of the claude CLI's own /model picker, in its order and with its display names. The stored value is what claude --model receives: an alias is resolved again each time the session's claude process starts, a full id stays on its model, so following and staying need no new field. Effort support is read per model so a model switch can drop an effort level the next model does not offer. * feat(cli): discover Claude models from the CLI's initialize response A machine RPC starts claude in stream-json mode, sends only an initialize control request and reads the models it reports, so no turn runs and no transcript is written. claude --print runs a directory's project hooks and .mcp.json servers without a trust prompt, so the probe starts from the home directory with user settings only, no MCP servers and hooks disabled. Successful catalogs are cached per claude executable for five minutes and concurrent requests share one probe; failures are not cached. * feat(hub): expose the Claude model catalog per machine GET /machines/:id/claude-models forwards to the runner's listClaudeModels RPC. A runner that predates the handler answers with the stable rpc_target_missing code so clients can keep their built-in list. * feat(web): offer the machine's Claude models in the model pickers The composer and the create-session form list the rows the machine's claude CLI shows in its own /model picker, as it shows them. Built-in presets the catalog does not list remain selectable after it. Until the catalog arrives, or on a runner without discovery, the built-in presets are shown as before. A saved Claude model is no longer reset to Default when the built-in list lacks it; the create-session form keeps it and marks it as no longer listed when the catalog dropped it. * feat(hub,web): keep Claude effort within what the selected model offers For Claude sessions the model endpoint accepts an optional effort, so a model switch and the effort it keeps reach the session as one config change. Other flavors are refused: Pi, for one, commits the model before its thinking level. When the machine's catalog says the next Claude model does not offer the current effort, the composer clears it in that same request. The create-session form drops such an effort however the model was set, including a restored preference or draft. Effort choices in both places narrow to the selected model's levels; without a catalog every level stays available as before. * fix(web): retry Claude model discovery after a failed probe The machine answers a failed probe with success:false over HTTP 200. The query cached that as an answer for its stale time, so a user who signed in and reopened a picker within a minute still saw only the built-in models. A failed probe is now a query error, which is not kept, and the next picker to mount asks the machine again. * fix(web): list only the Claude CLI's models once its catalog is known The built-in presets were kept after the catalog so that a partial list could not hide Opus. The catalog is the CLI's own /model list, though, not a partial one, and the presets added entries the CLI does not show (Sonnet 1M, Opus 1M, Fable 1M). With a catalog the list is now exactly its rows; a saved model it does not list stays as the current model, and New Session marks it as no longer listed. Without a catalog the built-in presets are shown as before. * fix(shared,web): list earlier Claude versions under Other models Claude Code's model sheet shows each family's newest model first and every earlier version under an "Other models" heading. The CLI's catalog lists models in that order but does not say where the split is, so the first row of each family is taken as its newest and later rows of the same family as earlier versions. The composer's model list and New Session's model select now show the same two groups, keeping the CLI order within each. Nothing is folded: every row stays one click away, as it does in Claude Code. * test(web): pin that Claude 1M variants come from the CLI catalog Claude Code 2.1.105, 2.1.200, 2.1.240 and 2.1.274 list a 1M choice as its own catalog row (opus[1m], claude-fable-5[1m]); 2.1.303 and 2.1.310 list none, and on 2.1.310 opus and sonnet run with a 1M context window. The catalog-only list therefore offers a 1M variant exactly when the CLI does. Pin that and record why the built-in [1m] presets are not appended. --- cli/src/modules/common/claudeModels.test.ts | 298 ++++++++++++++++++ cli/src/modules/common/claudeModels.ts | 241 ++++++++++++++ .../common/handlers/claudeModels.test.ts | 52 +++ .../modules/common/handlers/claudeModels.ts | 25 ++ .../modules/common/registerCommonHandlers.ts | 2 + hub/src/sync/rpcGateway.ts | 6 + hub/src/sync/syncEngine.ts | 6 + hub/src/web/routes/machines.test.ts | 56 +++- hub/src/web/routes/machines.ts | 30 ++ hub/src/web/routes/sessions.test.ts | 47 +++ hub/src/web/routes/sessions.ts | 10 +- shared/src/apiTypes.ts | 19 +- shared/src/models.test.ts | 99 ++++++ shared/src/models.ts | 80 +++++ shared/src/rpcMethods.ts | 1 + web/src/api/client.ts | 15 +- .../HappyComposer.modelEffortButtons.test.tsx | 22 ++ .../AssistantChat/HappyComposer.tsx | 17 +- .../AssistantChat/claudeEffortOptions.test.ts | 24 ++ .../AssistantChat/claudeEffortOptions.ts | 16 +- .../AssistantChat/claudeModelOptions.ts | 2 + .../AssistantChat/modelOptions.test.ts | 113 +++++-- .../components/AssistantChat/modelOptions.ts | 70 ++-- .../NewSession/EffortField.test.tsx | 13 + web/src/components/NewSession/EffortField.tsx | 6 +- web/src/components/NewSession/index.test.tsx | 108 ++++++- web/src/components/NewSession/index.tsx | 37 ++- .../components/NewSession/preferences.test.ts | 8 +- web/src/components/NewSession/preferences.ts | 4 +- web/src/components/SessionChat.test.ts | 28 ++ web/src/components/SessionChat.tsx | 57 +++- web/src/hooks/mutations/useSessionActions.ts | 18 +- .../hooks/queries/useClaudeModels.test.tsx | 75 +++++ web/src/hooks/queries/useClaudeModels.ts | 49 +++ web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/lib/query-keys.ts | 1 + web/src/types/api.ts | 2 + 38 files changed, 1586 insertions(+), 75 deletions(-) create mode 100644 cli/src/modules/common/claudeModels.test.ts create mode 100644 cli/src/modules/common/claudeModels.ts create mode 100644 cli/src/modules/common/handlers/claudeModels.test.ts create mode 100644 cli/src/modules/common/handlers/claudeModels.ts create mode 100644 web/src/hooks/queries/useClaudeModels.test.tsx create mode 100644 web/src/hooks/queries/useClaudeModels.ts diff --git a/cli/src/modules/common/claudeModels.test.ts b/cli/src/modules/common/claudeModels.test.ts new file mode 100644 index 00000000..578ac322 --- /dev/null +++ b/cli/src/modules/common/claudeModels.test.ts @@ -0,0 +1,298 @@ +import { EventEmitter } from 'node:events' +import { homedir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock, killMock, claudePathMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + killMock: vi.fn(), + claudePathMock: vi.fn(), +})) + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, spawn: spawnMock } +}) + +vi.mock('../../utils/process', () => ({ + killProcessByChildProcess: killMock, +})) + +vi.mock('@/claude/sdk/utils', () => ({ + getDefaultClaudeCodePath: claudePathMock, +})) + +import { + _resetClaudeModelsCacheForTests, + listClaudeModels, + parseClaudeModelsProbeLine, +} from './claudeModels' + +const PROBE_REQUEST_ID = 'hapi-claude-models-probe' + +class FakeChild extends EventEmitter { + stdout = new EventEmitter() + stderr = new EventEmitter() + stdin = Object.assign(new EventEmitter(), { write: vi.fn() }) + pid: number | undefined = 4242 + + respond(models: unknown): void { + this.stdout.emit('data', `${JSON.stringify({ + type: 'control_response', + response: { subtype: 'success', request_id: PROBE_REQUEST_ID, response: { models } }, + })}\n`) + } +} + +function successLine(models: unknown, requestId = PROBE_REQUEST_ID): string { + return JSON.stringify({ + type: 'control_response', + response: { subtype: 'success', request_id: requestId, response: { models } }, + }) +} + +const OPUS_ROW = { + value: 'opus', + resolvedModel: 'claude-opus-5-5', + displayName: 'Opus 5.5', + description: 'For complex work', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'], + supportsFastMode: true, +} + +let children: FakeChild[] + +function lastChild(): FakeChild { + const child = children.at(-1) + if (!child) throw new Error('probe was not spawned') + return child +} + +beforeEach(() => { + _resetClaudeModelsCacheForTests() + children = [] + spawnMock.mockReset() + spawnMock.mockImplementation(() => { + const child = new FakeChild() + children.push(child) + return child + }) + killMock.mockReset() + killMock.mockResolvedValue(true) + claudePathMock.mockReset() + claudePathMock.mockReturnValue('/usr/local/bin/claude') +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('parseClaudeModelsProbeLine', () => { + it('keeps only the fields HAPI uses and folds effort support into levels', () => { + const result = parseClaudeModelsProbeLine(successLine([ + OPUS_ROW, + { value: 'haiku', resolvedModel: 'claude-haiku-4-5', displayName: 'Haiku 4.5' }, + { value: 'legacy', supportsEffort: null }, + ])) + + expect(result).toEqual({ + kind: 'models', + models: [ + { value: 'opus', displayName: 'Opus 5.5', effortLevels: ['low', 'high'] }, + { value: 'haiku', displayName: 'Haiku 4.5', effortLevels: [] }, + { value: 'legacy', effortLevels: [] }, + ], + }) + }) + + it('leaves effort support unknown when the CLI reports it for no model at all', () => { + const result = parseClaudeModelsProbeLine(successLine([ + { value: 'opus', resolvedModel: 'claude-opus-5-5' }, + ])) + + expect(result).toEqual({ kind: 'models', models: [{ value: 'opus' }] }) + }) + + it('leaves the levels unknown when the CLI supports effort but lists no levels', () => { + const result = parseClaudeModelsProbeLine(successLine([ + { value: 'opus', supportsEffort: true }, + ])) + + expect(result).toEqual({ kind: 'models', models: [{ value: 'opus' }] }) + }) + + it('trusts supportsEffort over a level list the row still carries', () => { + const result = parseClaudeModelsProbeLine(successLine([ + { value: 'haiku', supportsEffort: false, supportedEffortLevels: ['low'] }, + ])) + + expect(result).toEqual({ kind: 'models', models: [{ value: 'haiku', effortLevels: [] }] }) + }) + + it('drops rows without a usable value and non-string fields', () => { + const result = parseClaudeModelsProbeLine(successLine([ + { value: '' }, + { displayName: 'No value' }, + 'opus', + { value: 'sonnet', displayName: 42, supportsEffort: true, supportedEffortLevels: ['low', 7] }, + ])) + + expect(result).toEqual({ kind: 'models', models: [{ value: 'sonnet', effortLevels: ['low'] }] }) + }) + + it('reports an explicit control error with the CLI message', () => { + const line = JSON.stringify({ + type: 'control_response', + response: { subtype: 'error', request_id: PROBE_REQUEST_ID, error: 'Not logged in' }, + }) + + expect(parseClaudeModelsProbeLine(line)).toEqual({ kind: 'error', error: 'Not logged in' }) + }) + + it('ignores hook events, other requests and non-JSON output', () => { + expect(parseClaudeModelsProbeLine(JSON.stringify({ type: 'system', subtype: 'hook_started' }))).toBeNull() + expect(parseClaudeModelsProbeLine(successLine([OPUS_ROW], 'someone-else'))).toBeNull() + expect(parseClaudeModelsProbeLine('Welcome to Claude Code')).toBeNull() + }) +}) + +describe('listClaudeModels', () => { + it('spawns the session executable isolated from project settings, hooks and MCP servers', async () => { + const pending = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await pending + + const [command, args, options] = spawnMock.mock.calls[0] as [string, string[], { cwd: string }] + expect(command).toBe('/usr/local/bin/claude') + expect(args).toEqual(expect.arrayContaining(['--print', '--strict-mcp-config'])) + expect(args[args.indexOf('--setting-sources') + 1]).toBe('user') + expect(JSON.parse(args[args.indexOf('--settings') + 1] ?? 'null')).toEqual({ disableAllHooks: true }) + expect(args[args.indexOf('--input-format') + 1]).toBe('stream-json') + expect(args[args.indexOf('--output-format') + 1]).toBe('stream-json') + expect(options.cwd).toBe(homedir()) + }) + + it('asks for the catalog with an initialize control request and no prompt', async () => { + const pending = listClaudeModels() + const child = lastChild() + child.respond([OPUS_ROW]) + await pending + + expect(child.stdin.write).toHaveBeenCalledTimes(1) + expect(JSON.parse(String(child.stdin.write.mock.calls[0]?.[0]))).toEqual({ + type: 'control_request', + request_id: PROBE_REQUEST_ID, + request: { subtype: 'initialize' }, + }) + }) + + it('returns the catalog and stops the probe process', async () => { + const pending = listClaudeModels() + lastChild().respond([OPUS_ROW]) + + await expect(pending).resolves.toMatchObject({ + success: true, + availableModels: [{ value: 'opus', displayName: 'Opus 5.5' }], + }) + expect(killMock).toHaveBeenCalledTimes(1) + }) + + it('shares one probe between concurrent requests', async () => { + const first = listClaudeModels() + const second = listClaudeModels() + lastChild().respond([OPUS_ROW]) + + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + expect(spawnMock).toHaveBeenCalledTimes(1) + }) + + it('serves a cached catalog for five minutes, then probes again', async () => { + vi.useFakeTimers() + const first = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await first + + vi.advanceTimersByTime(5 * 60_000 - 1) + await listClaudeModels() + expect(spawnMock).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(1) + const refreshed = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await refreshed + expect(spawnMock).toHaveBeenCalledTimes(2) + }) + + it('keeps a separate cache per claude executable', async () => { + const first = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await first + + claudePathMock.mockReturnValue('/opt/other/claude') + const second = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await second + + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.calls[1]?.[0]).toBe('/opt/other/claude') + }) + + it('does not cache a failure, so the next request probes again', async () => { + const failed = listClaudeModels() + lastChild().emit('error', new Error('spawn claude ENOENT')) + await expect(failed).rejects.toThrow('spawn claude ENOENT') + + const retried = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await expect(retried).resolves.toMatchObject({ success: true }) + expect(spawnMock).toHaveBeenCalledTimes(2) + }) + + it('rejects an empty catalog instead of caching it', async () => { + const pending = listClaudeModels() + lastChild().respond([]) + await expect(pending).rejects.toThrow('no models') + + const retried = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await retried + expect(spawnMock).toHaveBeenCalledTimes(2) + }) + + it('surfaces the CLI error and exit output when it quits before answering', async () => { + const pending = listClaudeModels() + const child = lastChild() + child.stderr.emit('data', 'Invalid API key') + child.emit('close', 1) + + await expect(pending).rejects.toThrow('Invalid API key') + }) + + it('times out a probe that never answers and stops it', async () => { + vi.useFakeTimers() + const pending = listClaudeModels() + const assertion = expect(pending).rejects.toThrow('timed out') + + await vi.advanceTimersByTimeAsync(15_000) + await assertion + expect(killMock).toHaveBeenCalled() + }) + + it('reports a missing claude executable without spawning', async () => { + claudePathMock.mockImplementation(() => { + throw new Error('Claude Code CLI not found on PATH') + }) + + await expect(listClaudeModels()).rejects.toThrow('Claude Code CLI not found on PATH') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('does not let a stdin write failure escape as an unhandled error', async () => { + const pending = listClaudeModels() + const child = lastChild() + + expect(() => child.stdin.emit('error', new Error('write EPIPE'))).not.toThrow() + child.emit('close', null) + await expect(pending).rejects.toThrow() + }) +}) diff --git a/cli/src/modules/common/claudeModels.ts b/cli/src/modules/common/claudeModels.ts new file mode 100644 index 00000000..40918357 --- /dev/null +++ b/cli/src/modules/common/claudeModels.ts @@ -0,0 +1,241 @@ +import { spawn } from 'node:child_process' +import { homedir } from 'node:os' +import type { ClaudeModelSummary, ClaudeModelsResponse } from '@hapi/protocol/apiTypes' +import { getDefaultClaudeCodePath } from '@/claude/sdk/utils' +import { withBunRuntimeEnv } from '@/utils/bunRuntime' +import { killProcessByChildProcess } from '../../utils/process' +import { getErrorMessage } from './rpcResponses' + +export type ListClaudeModelsRequest = Record + +export type ListClaudeModelsResponse = ClaudeModelsResponse + +interface CacheEntry { + expiresAt: number + response: ListClaudeModelsResponse +} + +// The catalog changes rarely (a CLI update, a different login, an alias the +// service moves to a newer model) and each probe starts a full claude process +// (~0.75s), so a successful answer is kept for a few minutes. Failures are not +// cached: a user who just logged in should not wait out the TTL. +const CACHE_TTL_MS = 5 * 60_000 +const PROBE_TIMEOUT_MS = 15_000 +const PROBE_REQUEST_ID = 'hapi-claude-models-probe' +const cache = new Map() +const inflight = new Map>() + +/** + * Machine-level discovery of the models the `claude` CLI offers, read from the + * `models` field of its `initialize` control response. No prompt is sent, so + * the probe starts no turn and writes no transcript. + * + * `claude --print` loads the working directory's `.claude/settings*.json` and + * `.mcp.json` without a workspace trust prompt, so any project hook or MCP + * server there would run. The probe therefore runs from the home directory + * and loads only user settings, no MCP servers, and no hooks at all (the + * user's own hooks would otherwise fire on every catalog refresh). + * + * User settings stay loaded so the probe sees the same `env` a session does + * (e.g. `ANTHROPIC_DEFAULT_OPUS_MODEL` changes what `opus` resolves to). + * Project settings that redefine model aliases are ignored here, so in such + * a directory the catalog can differ from what the session resolves. + */ +const CLAUDE_MODELS_PROBE_ARGS = [ + '--print', + '--input-format', 'stream-json', + '--output-format', 'stream-json', + '--verbose', + '--setting-sources', 'user', + '--strict-mcp-config', + '--settings', JSON.stringify({ disableAllHooks: true }), +] as const + +export type ClaudeModelsProbeLineResult = + | { kind: 'models'; models: ClaudeModelSummary[] } + | { kind: 'error'; error: string } + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined +} + +/** + * A row without `supportsEffort` (or with `null`) has no effort control, but + * only when the CLI reports effort support at all: a CLI that predates the + * field would otherwise hide effort for every model. A model that supports + * effort without listing levels keeps them unknown. + */ +function parseClaudeCatalog(rawModels: unknown): ClaudeModelSummary[] { + if (!Array.isArray(rawModels)) { + return [] + } + const rows = rawModels.map(asRecord).filter((row): row is Record => row !== null) + const reportsEffortSupport = rows.some((row) => 'supportsEffort' in row) + const models: ClaudeModelSummary[] = [] + for (const row of rows) { + const value = optionalString(row.value) + if (!value) { + continue + } + const model: ClaudeModelSummary = { value } + const displayName = optionalString(row.displayName) + if (displayName) model.displayName = displayName + if (reportsEffortSupport && row.supportsEffort !== true) { + model.effortLevels = [] + } else if (Array.isArray(row.supportedEffortLevels)) { + model.effortLevels = row.supportedEffortLevels.filter((level): level is string => typeof level === 'string') + } + models.push(model) + } + return models +} + +/** Scans one stdout line for the answer to the probe's initialize request. */ +export function parseClaudeModelsProbeLine(line: string): ClaudeModelsProbeLineResult | null { + const trimmed = line.trim() + if (!trimmed.startsWith('{')) return null + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + return null + } + const message = asRecord(parsed) + if (message?.type !== 'control_response') return null + const response = asRecord(message.response) + if (response?.request_id !== PROBE_REQUEST_ID) return null + if (response.subtype !== 'success') { + return { kind: 'error', error: optionalString(response.error) ?? 'Claude rejected the model probe request' } + } + return { kind: 'models', models: parseClaudeCatalog(asRecord(response.response)?.models) } +} + +function runClaudeModelsProbe(executable: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executable, [...CLAUDE_MODELS_PROBE_ARGS], { + cwd: homedir(), + env: withBunRuntimeEnv(process.env, { allowBunBeBun: false }), + stdio: ['pipe', 'pipe', 'pipe'], + // getDefaultClaudeCodePath resolves an absolute path (claude.exe on + // Windows), matching how sessions spawn it. + shell: false, + windowsHide: process.platform === 'win32', + }) + let stdoutBuffer = '' + let stderr = '' + let settled = false + + // The claude process stays up waiting for input, so every outcome + // stops it before the caller sees a result. + const finish = (settle: () => void) => { + if (settled) return + settled = true + clearTimeout(timeout) + void (async () => { + if (!child.pid) { + settle() + return + } + let stopped = false + try { + stopped = await killProcessByChildProcess(child, false) + || await killProcessByChildProcess(child, true) + } catch { + stopped = false + } + if (!stopped) { + reject(new Error(`Claude model probe could not be stopped (pid ${child.pid})`)) + return + } + settle() + })() + } + + const timeout = setTimeout(() => { + finish(() => reject(new Error('Claude model discovery timed out'))) + }, PROBE_TIMEOUT_MS) + + child.stdout?.on('data', (chunk) => { + stdoutBuffer += chunk.toString() + let newlineIndex = stdoutBuffer.indexOf('\n') + while (newlineIndex !== -1 && !settled) { + const line = stdoutBuffer.slice(0, newlineIndex) + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1) + newlineIndex = stdoutBuffer.indexOf('\n') + const result = parseClaudeModelsProbeLine(line) + if (result === null) continue + if (result.kind === 'error') { + finish(() => reject(new Error(result.error))) + return + } + if (result.models.length === 0) { + finish(() => reject(new Error('Claude reported no models'))) + return + } + finish(() => resolve({ success: true, availableModels: result.models })) + return + } + }) + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString() + }) + child.on('error', (error) => { + finish(() => reject(error)) + }) + child.on('close', (code) => { + finish(() => reject(new Error( + stderr.trim() || `claude exited with code ${code ?? 'unknown'} before answering the model probe` + ))) + }) + // An early exit surfaces through 'close', not as an unhandled EPIPE. + child.stdin?.on('error', () => { /* handled via close */ }) + child.stdin?.write(`${JSON.stringify({ + type: 'control_request', + request_id: PROBE_REQUEST_ID, + request: { subtype: 'initialize' }, + })}\n`) + }) +} + +/** Clear the module-level probe cache and in-flight map between tests. */ +export function _resetClaudeModelsCacheForTests(): void { + cache.clear() + inflight.clear() +} + +/** Throws on failure; the RPC handler turns that into an error response. */ +export async function listClaudeModels(): Promise { + // Keyed by executable: HAPI_CLAUDE_PATH can point a runner at a different + // claude install, whose catalog is its own. + const executable = getDefaultClaudeCodePath() + const now = Date.now() + const cached = cache.get(executable) + if (cached && cached.expiresAt > now) { + return cached.response + } + + const existing = inflight.get(executable) + if (existing) { + return existing + } + + const pending = runClaudeModelsProbe(executable) + .then((response) => { + cache.set(executable, { expiresAt: Date.now() + CACHE_TTL_MS, response }) + return response + }) + .catch((error) => { + throw new Error(getErrorMessage(error, 'Failed to list Claude models')) + }) + .finally(() => { + inflight.delete(executable) + }) + inflight.set(executable, pending) + return pending +} diff --git a/cli/src/modules/common/handlers/claudeModels.test.ts b/cli/src/modules/common/handlers/claudeModels.test.ts new file mode 100644 index 00000000..16b554e9 --- /dev/null +++ b/cli/src/modules/common/handlers/claudeModels.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' + +const { listClaudeModelsMock } = vi.hoisted(() => ({ + listClaudeModelsMock: vi.fn(), +})) + +vi.mock('../claudeModels', () => ({ + listClaudeModels: listClaudeModelsMock, +})) + +import { registerClaudeModelHandlers } from './claudeModels' + +describe('listClaudeModels machine RPC handler', () => { + let rpc: RpcHandlerManager + + beforeEach(() => { + listClaudeModelsMock.mockReset() + rpc = new RpcHandlerManager({ scopePrefix: 'machine-test' }) + registerClaudeModelHandlers(rpc) + }) + + async function listViaRpc(): Promise { + const raw = await rpc.handleRequest({ + method: `machine-test:${RPC_METHODS.ListClaudeModels}`, + params: '{}' + }) + return JSON.parse(raw) + } + + it('returns the discovered catalog', async () => { + listClaudeModelsMock.mockResolvedValue({ + success: true, + availableModels: [{ value: 'opus' }] + }) + + await expect(listViaRpc()).resolves.toEqual({ + success: true, + availableModels: [{ value: 'opus' }] + }) + }) + + it('answers a failed probe with an error response instead of throwing', async () => { + listClaudeModelsMock.mockRejectedValue(new Error('Claude model discovery timed out')) + + await expect(listViaRpc()).resolves.toEqual({ + success: false, + error: 'Claude model discovery timed out' + }) + }) +}) diff --git a/cli/src/modules/common/handlers/claudeModels.ts b/cli/src/modules/common/handlers/claudeModels.ts new file mode 100644 index 00000000..a3931fa7 --- /dev/null +++ b/cli/src/modules/common/handlers/claudeModels.ts @@ -0,0 +1,25 @@ +import { logger } from '@/ui/logger'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { + listClaudeModels, + type ListClaudeModelsRequest, + type ListClaudeModelsResponse +} from '../claudeModels'; +import { getErrorMessage, rpcError } from '../rpcResponses'; + +export function registerClaudeModelHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler( + RPC_METHODS.ListClaudeModels, + async () => { + logger.debug('List Claude models request'); + + try { + return await listClaudeModels(); + } catch (error) { + logger.debug('Failed to list Claude models:', error); + return rpcError(getErrorMessage(error, 'Failed to list Claude models')); + } + } + ); +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index d00826b8..647a6332 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -1,6 +1,7 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' import { registerAgyModelHandlers } from './handlers/agyModels' import { registerBashHandlers } from './handlers/bash' +import { registerClaudeModelHandlers } from './handlers/claudeModels' import { registerCodexModelHandlers } from './handlers/codexModels' import { registerCursorModelHandlers } from './handlers/cursorModels' import { registerOpencodeModelHandlers } from './handlers/opencodeModels' @@ -21,6 +22,7 @@ import { registerUploadHandlers } from './handlers/uploads' export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { registerAgyModelHandlers(rpcHandlerManager) registerBashHandlers(rpcHandlerManager, workingDirectory) + registerClaudeModelHandlers(rpcHandlerManager) registerCodexModelHandlers(rpcHandlerManager) registerCursorModelHandlers(rpcHandlerManager) registerOpencodeModelHandlers(rpcHandlerManager) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 38f57600..a00cf6c7 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -9,6 +9,7 @@ import { } from '@hapi/protocol/apiTypes' import type { AgyModelsResponse, + ClaudeModelsResponse, AgentAvailabilityResponse, CodexModelSummary, CodexModelsResponse, @@ -130,6 +131,7 @@ export type RpcListOpencodeReasoningEffortOptionsResponse = OpencodeReasoningEff export type RpcListAgyModelsResponse = AgyModelsResponse export type RpcListDshModelsResponse = DshModelsResponse export type RpcListPiModelsResponse = PiModelsResponse +export type RpcListClaudeModelsResponse = ClaudeModelsResponse export class RpcGateway { constructor( @@ -650,6 +652,10 @@ export class RpcGateway { return await this.machineRpc(machineId, RPC_METHODS.ListPiModelsForMachine, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListPiModelsResponse } + async listClaudeModelsForMachine(machineId: string): Promise { + return await this.machineRpc(machineId, RPC_METHODS.ListClaudeModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListClaudeModelsResponse + } + private async sessionRpc( sessionId: string, method: string, diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index d8c70adb..699e8345 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -45,6 +45,7 @@ import { type RpcStatFilesResponse, type RpcListAgyModelsResponse, type RpcListPiModelsResponse, + type RpcListClaudeModelsResponse, type RpcListCodexModelsResponse, type RpcListPiSessionsResponse, type RpcArchiveCodexSessionResponse, @@ -83,6 +84,7 @@ export type { RpcStatFilesResponse, RpcListAgyModelsResponse, RpcListPiModelsResponse, + RpcListClaudeModelsResponse, RpcListCodexModelsResponse, RpcListPiSessionsResponse, RpcListCursorModelsResponse, @@ -4465,6 +4467,10 @@ export class SyncEngine { return await this.rpcGateway.listPiModelsForMachine(machineId) } + async listClaudeModelsForMachine(machineId: string): Promise { + return await this.rpcGateway.listClaudeModelsForMachine(machineId) + } + async listCodexModelsForMachine(machineId: string): Promise { return await this.rpcGateway.listCodexModelsForMachine(machineId) } diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index d0109287..7197c355 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -4,7 +4,7 @@ import type { Machine, SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { createMachinesRoutes } from './machines' import { RpcTargetMissingError } from '../../sync/rpcGateway' -import { MACHINE_CAPABILITIES } from '@hapi/protocol' +import { MACHINE_CAPABILITIES, RPC_TARGET_MISSING_ERROR_CODE } from '@hapi/protocol' function createMachine(overrides?: Partial): Machine { return { @@ -218,6 +218,60 @@ describe('machines routes', () => { }) }) + it('returns Claude models for an online machine', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listClaudeModelsForMachine: async () => ({ + success: true, + availableModels: [ + { value: 'opus', displayName: 'Opus 5.5', effortLevels: ['low'] } + ] + }) + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/claude-models') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + availableModels: [ + { value: 'opus', displayName: 'Opus 5.5', effortLevels: ['low'] } + ] + }) + }) + + it('returns a stable code when a runner predates Claude model discovery', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listClaudeModelsForMachine: async () => { + throw new RpcTargetMissingError('machine-1:listClaudeModels', 'handler-not-registered') + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/claude-models') + + expect(response.status).toBe(503) + expect(await response.json()).toMatchObject({ success: false, code: RPC_TARGET_MISSING_ERROR_CODE }) + }) + it('returns a stable code when the Codex machine RPC target is absent', async () => { const machine = createMachine() const engine = { diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 8dc203cd..f5024613 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -277,6 +277,36 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/machines/:id/claude-models', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + try { + const result = await engine.listClaudeModelsForMachine(machineId) + return c.json(result) + } catch (error) { + if (error instanceof RpcTargetMissingError) { + return c.json({ + success: false, + error: error.message, + code: RPC_TARGET_MISSING_ERROR_CODE + }, 503) + } + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Claude models' + }, 500) + } + }) + app.get('/machines/:id/codex-models', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index fcb21e6d..ed744e56 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -768,6 +768,53 @@ describe('sessions routes', () => { ]) }) + it('applies a Claude model and the effort it keeps in one session config change', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'claude' } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'haiku', effort: null }) + }) + + expect(response.status).toBe(200) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { model: 'haiku', effort: null }] + ]) + }) + + it('rejects an effort sent with a model change for Pi, which applies the two separately', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.6-sol', effort: null }) + }) + + expect(response.status).toBe(400) + expect(applySessionConfigCalls).toEqual([]) + }) + + it('rejects an effort sent with a model change for a flavor without effort', async () => { + const { app, applySessionConfigCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.5', effort: 'high' }) + }) + + expect(response.status).toBe(400) + expect(applySessionConfigCalls).toEqual([]) + }) + it('rejects model changes for local Codex sessions', async () => { const session = createSession({ agentState: { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 3669a341..448d058c 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -718,8 +718,16 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } } + // Only Claude applies a model and an effort as one config change; Pi + // commits the model before setting its thinking level, so a failure in + // between would leave half of the request applied. + const { model, effort } = parsed.data + if (effort !== undefined && flavor !== 'claude') { + return c.json({ error: 'Effort can only be sent with a model change for Claude sessions' }, 400) + } + try { - await engine.applySessionConfig(sessionResult.sessionId, { model: parsed.data.model }) + await engine.applySessionConfig(sessionResult.sessionId, effort === undefined ? { model } : { model, effort }) return c.json({ ok: true }) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to apply model' diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 3b87ccb0..132200d7 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -306,7 +306,9 @@ export const SessionModelRequestSchema = z.object({ provider: z.string().trim().min(1), modelId: z.string().trim().min(1), }), - ]).nullable() + ]).nullable(), + /** Effort to apply together with the model, e.g. cleared when the new model does not offer it. */ + effort: z.string().trim().min(1).nullable().optional() }) export type SessionModelRequest = z.infer @@ -1008,6 +1010,21 @@ export type PiModelsResponse = { export type ListPiModelsResponse = PiModelsResponse +/** One row of the `claude` CLI's own model picker, as reported by its `initialize` control response. */ +export type ClaudeModelSummary = { + /** What `claude --model` accepts: an alias that follows the family (`opus`) or a full model id. */ + value: string + displayName?: string + /** Effort levels the model accepts: `[]` = no effort control, undefined = unknown. */ + effortLevels?: string[] +} + +export type ClaudeModelsResponse = { + success: boolean + availableModels?: ClaudeModelSummary[] + error?: string +} + export type PiCommandSummary = { name: string description?: string diff --git a/shared/src/models.test.ts b/shared/src/models.test.ts index 69d08be9..e80f38b4 100644 --- a/shared/src/models.test.ts +++ b/shared/src/models.test.ts @@ -5,9 +5,13 @@ import { DEFAULT_GEMINI_MODEL, GEMINI_MODEL_LABELS, GEMINI_MODEL_PRESETS, + getClaudeEffortLevelsForModel, + getClaudeModelChoices, getClaudeModelLabel, isClaudeModelPreset, + resolveClaudeEffortForModel, } from './models' +import type { ClaudeModelSummary } from './apiTypes' describe('isClaudeModelPreset', () => { test('accepts valid presets', () => { @@ -64,3 +68,98 @@ describe('model constants consistency', () => { expect(GEMINI_MODEL_PRESETS).toContain(DEFAULT_GEMINI_MODEL) }) }) + +const FULL = ['low', 'medium', 'high', 'xhigh', 'max'] +const NO_XHIGH = ['low', 'medium', 'high', 'max'] + +// Shape of a subscription account's catalog: an alias per family, older +// versions under their full ids. +const SUBSCRIPTION_CATALOG: ClaudeModelSummary[] = [ + { value: 'default', displayName: 'Default (recommended)', effortLevels: FULL }, + { value: 'opus', displayName: 'Opus 5.5', effortLevels: FULL }, + { value: 'claude-fable-5-1', displayName: 'Fable 5.1', effortLevels: FULL }, + { value: 'sonnet', displayName: 'Sonnet 5', effortLevels: FULL }, + { value: 'haiku', displayName: 'Haiku 4.5', effortLevels: [] }, + { value: 'claude-opus-4-7', displayName: 'Opus 4.7', effortLevels: FULL }, + { value: 'claude-sonnet-4-6', displayName: 'Sonnet 4.6', effortLevels: NO_XHIGH }, +] + +describe('getClaudeModelChoices', () => { + test('lists the CLI picker rows with their display names, splitting each family\'s newest from the rest', () => { + expect(getClaudeModelChoices(SUBSCRIPTION_CATALOG)).toEqual([ + { value: 'opus', label: 'Opus 5.5', section: 'main' }, + { value: 'claude-fable-5-1', label: 'Fable 5.1', section: 'main' }, + { value: 'sonnet', label: 'Sonnet 5', section: 'main' }, + { value: 'haiku', label: 'Haiku 4.5', section: 'main' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7', section: 'other' }, + { value: 'claude-sonnet-4-6', label: 'Sonnet 4.6', section: 'other' }, + ]) + }) + + test('reads the family from full ids and variants, keeping the CLI order within each section', () => { + const choices = getClaudeModelChoices([ + { value: 'opus', displayName: 'Opus 5.5' }, + { value: 'claude-haiku-4-5-20251001', displayName: 'Haiku 4.5' }, + { value: 'opus[1m]', displayName: 'Opus 5.5 (1M)' }, + { value: 'claude-sonnet-5-5', displayName: 'Sonnet 5.5' }, + { value: 'claude-sonnet-4-6', displayName: 'Sonnet 4.6' }, + ]) + expect(choices.map((choice) => [choice.value, choice.section])).toEqual([ + ['opus', 'main'], + ['claude-haiku-4-5-20251001', 'main'], + ['claude-sonnet-5-5', 'main'], + ['opus[1m]', 'other'], + ['claude-sonnet-4-6', 'other'], + ]) + }) + + test('leaves the default row to the caller, which stores it as no model', () => { + const choices = getClaudeModelChoices(SUBSCRIPTION_CATALOG) + expect(choices.some((choice) => choice.value === 'default')).toBe(false) + }) + + test('falls back to the value when the CLI gives no display name', () => { + expect(getClaudeModelChoices([{ value: 'claude-custom' }])).toEqual([ + { value: 'claude-custom', label: 'claude-custom', section: 'main' }, + ]) + }) +}) + +describe('getClaudeEffortLevelsForModel', () => { + test('reads the default row for the session default', () => { + expect(getClaudeEffortLevelsForModel(null, SUBSCRIPTION_CATALOG)).toEqual(FULL) + }) + + test('reads the listed row for an alias or a full id', () => { + expect(getClaudeEffortLevelsForModel('haiku', SUBSCRIPTION_CATALOG)).toEqual([]) + expect(getClaudeEffortLevelsForModel('claude-sonnet-4-6', SUBSCRIPTION_CATALOG)).toEqual(NO_XHIGH) + }) + + test('is unknown for a model the catalog does not list, or without a catalog', () => { + expect(getClaudeEffortLevelsForModel('claude-opus-4-1', SUBSCRIPTION_CATALOG)).toBeUndefined() + expect(getClaudeEffortLevelsForModel('opus', null)).toBeUndefined() + }) +}) + +describe('resolveClaudeEffortForModel', () => { + test('keeps an effort the next model supports', () => { + expect(resolveClaudeEffortForModel('max', 'claude-sonnet-4-6', SUBSCRIPTION_CATALOG)).toBe('max') + }) + + test('clears an effort level the next model does not offer', () => { + expect(resolveClaudeEffortForModel('xhigh', 'claude-sonnet-4-6', SUBSCRIPTION_CATALOG)).toBeNull() + }) + + test('clears any effort when the next model has no effort control', () => { + expect(resolveClaudeEffortForModel('low', 'haiku', SUBSCRIPTION_CATALOG)).toBeNull() + }) + + test('keeps the effort when support is unknown', () => { + expect(resolveClaudeEffortForModel('xhigh', 'claude-opus-4-1', SUBSCRIPTION_CATALOG)).toBe('xhigh') + expect(resolveClaudeEffortForModel('xhigh', 'haiku', null)).toBe('xhigh') + }) + + test('leaves an unset effort unset', () => { + expect(resolveClaudeEffortForModel(null, 'haiku', SUBSCRIPTION_CATALOG)).toBeNull() + }) +}) diff --git a/shared/src/models.ts b/shared/src/models.ts index 544fb5dd..14a14e0f 100644 --- a/shared/src/models.ts +++ b/shared/src/models.ts @@ -1,3 +1,5 @@ +import type { ClaudeModelSummary } from './apiTypes' + export const CLAUDE_MODEL_LABELS = { sonnet: 'Sonnet', 'sonnet[1m]': 'Sonnet 1M', @@ -66,3 +68,81 @@ export function getClaudeModelLabel(model: string): string | null { return CLAUDE_MODEL_LABELS[trimmedModel as ClaudeModelPreset] ?? null } + +/** + * A Claude model the user can pick. + * + * `value` is stored on the session and passed to `claude --model` unchanged. + * An alias (`opus`) is resolved by the CLI each time the session's claude + * process starts, so the session moves to a newer model on its next start; + * a full id (`claude-opus-4-7`) stays on that model. + */ +export type ClaudeModelChoice = { + value: string + label: string + /** `main` is each family's newest model; `other` is every earlier version. */ + section: 'main' | 'other' +} + +/** `opus`, `opus[1m]`, `claude-opus-4-7` → `opus`. */ +function getClaudeModelFamily(value: string): string { + return value.replace(/^claude-/, '').split(/[-[]/)[0] ?? value +} + +/** + * Picker choices for a catalog reported by the `claude` CLI: its own `/model` + * rows, with its display names, split the way Claude Code's model sheet + * splits them. The CLI lists each family's newest model before its earlier + * versions, so the first row of a family is `main` and later ones are + * `other`; within each section the CLI order is kept. The `default` row is + * omitted; callers represent it as "no model". + */ +export function getClaudeModelChoices(catalog: readonly ClaudeModelSummary[]): ClaudeModelChoice[] { + const families = new Set() + const choices: ClaudeModelChoice[] = catalog + .filter((model) => model.value !== 'default') + .map((model) => { + const family = getClaudeModelFamily(model.value) + const section = families.has(family) ? 'other' : 'main' + families.add(family) + return { value: model.value, label: model.displayName ?? model.value, section } + }) + return [ + ...choices.filter((choice) => choice.section === 'main'), + ...choices.filter((choice) => choice.section === 'other'), + ] +} + +/** + * Effort levels the CLI offers for `model` (`null` = the session default). + * Undefined when the catalog is missing or does not list the model. + */ +export function getClaudeEffortLevelsForModel( + model: string | null, + catalog: readonly ClaudeModelSummary[] | null | undefined +): readonly string[] | undefined { + if (!catalog) { + return undefined + } + if (model === null) { + return catalog.find((row) => row.value === 'default')?.effortLevels + } + return catalog.find((row) => row.value === model)?.effortLevels +} + +/** + * The effort to keep when switching to `model`: an effort the model does not + * offer is cleared rather than left for the CLI to reject or ignore. Unknown + * support keeps the current effort. + */ +export function resolveClaudeEffortForModel( + effort: string | null, + model: string | null, + catalog: readonly ClaudeModelSummary[] | null | undefined +): string | null { + const levels = getClaudeEffortLevelsForModel(model, catalog) + if (effort === null || levels === undefined || levels.includes(effort)) { + return effort + } + return null +} diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 471a7598..3a2d6548 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -56,6 +56,7 @@ export const RPC_METHODS = { ListDshModelsForMachine: 'listDshModelsForMachine', ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions', ListAgyModels: 'listAgyModels', + ListClaudeModels: 'listClaudeModels', /** Deliver one queued message into the active Pi turn (native steer). */ SteerQueuedMessage: 'steer-queued-message', ForkConversation: 'fork-conversation', diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1d33801b..e7cc5ef0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -53,6 +53,7 @@ import type { OpencodeModelVariantsResponse, OpencodeReasoningEffortResponse, PiModelsResponse, + ClaudeModelsResponse, QueuedStateResponse, ReopenSessionResponse, SqliteStorageUsageResponse, @@ -705,10 +706,14 @@ export class ApiClient { }) } - async setModel(sessionId: string, model: { provider: string; modelId: string } | string | null): Promise { + async setModel( + sessionId: string, + model: { provider: string; modelId: string } | string | null, + options?: { effort: string | null } + ): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, { method: 'POST', - body: JSON.stringify({ model }) + body: JSON.stringify(options ? { model, effort: options.effort } : { model }) }) } @@ -890,6 +895,12 @@ export class ApiClient { ) } + async getMachineClaudeModels(machineId: string): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/claude-models` + ) + } + async getMachineDshModels(machineId: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/dsh-models` diff --git a/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx b/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx index d9d8032d..1d2a94dc 100644 --- a/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx +++ b/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx @@ -153,6 +153,28 @@ describe('HappyComposer generic model/effort value buttons', () => { expect(screen.getByRole('button', { name: 'Settings' })).toBeTruthy() }) + it('heads grouped model rows with their group name once', () => { + renderComposer('claude', { + model: 'opus', + availableModelOptions: [ + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7', group: 'Other models' }, + { value: 'claude-opus-4-6', label: 'Opus 4.6', group: 'Other models' }, + ], + }) + fireEvent.click(screen.getByRole('button', { name: 'Opus 5.5' })) + expect(screen.getAllByText('Other models')).toHaveLength(1) + expect(screen.getByRole('button', { name: 'Opus 4.7' })).toBeTruthy() + }) + + it('offers only the effort levels the current Claude model accepts', () => { + renderComposer('claude', { availableEffortOptions: [{ value: 'low' }, { value: 'high' }] }) + fireEvent.click(screen.getByRole('button', { name: 'High' })) + expect(screen.getByRole('button', { name: 'Low' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Max' })).toBeNull() + expect(screen.queryByRole('button', { name: 'XHigh' })).toBeNull() + }) + it('shows only the model button for flavors without effort support', () => { renderComposer('codex') expect(screen.getByRole('button', { name: 'Sonnet 4' })).toBeTruthy() diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 6ecedb7c..ac091740 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -13,6 +13,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MutableRefObject, type SyntheticEvent as ReactSyntheticEvent, + Fragment, useCallback, useEffect, useImperativeHandle, @@ -310,7 +311,7 @@ export function HappyComposer(props: { controlledByUser?: boolean concurrentClients?: boolean agentFlavor?: string | null - availableModelOptions?: Array<{ value: string | null; label: string }> + availableModelOptions?: Array<{ value: string | null; label: string; group?: string }> /** Full Pi model data with thinkingLevelMap for provider grouping + thinking level filtering */ piModels?: PiModelSummary[] /** Pi: provider-qualified selected model from metadata (survives reload; @@ -1108,7 +1109,7 @@ export function HappyComposer(props: { label: option.name ?? option.value })) ] - : getClaudeComposerEffortOptions(effort), + : getClaudeComposerEffortOptions(effort, availableEffortOptions?.map((option) => option.value)), [agentFlavor, effort, selectedPiModel, availableEffortOptions] ) const permissionModes = useMemo( @@ -1803,13 +1804,20 @@ export function HappyComposer(props: { })} )) - : modelOptions.map((option) => { + : modelOptions.map((option, index) => { const isSelected = selectedModelBase !== undefined ? selectedModelBase === option.value : model === option.value + const startsGroup = option.group !== undefined + && option.group !== modelOptions[index - 1]?.group return ( + + {startsGroup ? ( +
+ {option.group} +
+ ) : null} +
) })} diff --git a/web/src/components/AssistantChat/claudeEffortOptions.test.ts b/web/src/components/AssistantChat/claudeEffortOptions.test.ts index a4e41ed9..d38dcb1c 100644 --- a/web/src/components/AssistantChat/claudeEffortOptions.test.ts +++ b/web/src/components/AssistantChat/claudeEffortOptions.test.ts @@ -25,3 +25,27 @@ describe('getClaudeComposerEffortOptions', () => { ]) }) }) + +describe('getClaudeComposerEffortOptions with the model\'s levels', () => { + it('offers only the levels the current model accepts', () => { + expect(getClaudeComposerEffortOptions(null, ['low', 'medium', 'high', 'max'])).toEqual([ + { value: null, label: 'Auto' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'max', label: 'Max' }, + ]) + }) + + it('offers only Auto for a model without effort control', () => { + expect(getClaudeComposerEffortOptions(null, [])).toEqual([{ value: null, label: 'Auto' }]) + }) + + it('still shows an active effort the model does not list', () => { + expect(getClaudeComposerEffortOptions('xhigh', ['low'])).toEqual([ + { value: null, label: 'Auto' }, + { value: 'xhigh', label: 'XHigh' }, + { value: 'low', label: 'Low' }, + ]) + }) +}) diff --git a/web/src/components/AssistantChat/claudeEffortOptions.ts b/web/src/components/AssistantChat/claudeEffortOptions.ts index f73e13cc..b228bca4 100644 --- a/web/src/components/AssistantChat/claudeEffortOptions.ts +++ b/web/src/components/AssistantChat/claudeEffortOptions.ts @@ -19,15 +19,25 @@ function formatEffortLabel(effort: string): string { ?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}` } -export function getClaudeComposerEffortOptions(currentEffort?: string | null): ClaudeComposerEffortOption[] { +/** + * `supportedLevels` narrows the list to what the current model accepts + * (`[]` = no effort control); undefined offers every level. + */ +export function getClaudeComposerEffortOptions( + currentEffort?: string | null, + supportedLevels?: readonly string[] +): ClaudeComposerEffortOption[] { const normalizedCurrentEffort = normalizeClaudeComposerEffort(currentEffort) + const levels: readonly ClaudeEffortLevel[] = supportedLevels + ? CLAUDE_EFFORT_LEVELS.filter((effort) => supportedLevels.includes(effort)) + : CLAUDE_EFFORT_LEVELS const options: ClaudeComposerEffortOption[] = [ { value: null, label: 'Auto' } ] if ( normalizedCurrentEffort - && !CLAUDE_EFFORT_LEVELS.includes(normalizedCurrentEffort as ClaudeEffortLevel) + && !levels.includes(normalizedCurrentEffort as ClaudeEffortLevel) ) { options.push({ value: normalizedCurrentEffort, @@ -35,7 +45,7 @@ export function getClaudeComposerEffortOptions(currentEffort?: string | null): C }) } - options.push(...CLAUDE_EFFORT_LEVELS.map((effort) => ({ + options.push(...levels.map((effort) => ({ value: effort, label: CLAUDE_EFFORT_LABELS[effort] }))) diff --git a/web/src/components/AssistantChat/claudeModelOptions.ts b/web/src/components/AssistantChat/claudeModelOptions.ts index 086cc5dc..aa79375a 100644 --- a/web/src/components/AssistantChat/claudeModelOptions.ts +++ b/web/src/components/AssistantChat/claudeModelOptions.ts @@ -3,6 +3,8 @@ import { CLAUDE_MODEL_PRESETS, getClaudeModelLabel } from '@hapi/protocol' export type ClaudeComposerModelOption = { value: string | null label: string + /** Heading the option is listed under; ungrouped options come first. */ + group?: string } function normalizeClaudeComposerModel(model?: string | null): string | null { diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index 2acde7be..69c4448d 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' +import { getClaudeLaunchModelOptions, getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' describe('getModelOptionsForFlavor', () => { it('never offers the unsupported default reset in an active AGY session', () => { @@ -64,36 +64,65 @@ describe('getModelOptionsForFlavor', () => { expect(options.some((o) => o.value === 'opus')).toBe(true) }) - it('keeps Claude presets when explicit options only include Sonnet models', () => { + it('uses the Claude catalog as the whole list, as the CLI picker shows it', () => { const options = getModelOptionsForFlavor('claude', null, [ - { value: null, label: 'Default' }, - { value: 'sonnet', label: 'Sonnet' }, - { value: 'sonnet[1m]', label: 'Sonnet 1M' } + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7' }, + { value: 'sonnet', label: 'Sonnet 5' }, ]) expect(options).toEqual([ { value: null, label: 'Default' }, - { value: 'sonnet', label: 'Sonnet' }, - { value: 'sonnet[1m]', label: 'Sonnet 1M' }, - { value: 'opus', label: 'Opus' }, - { value: 'opus[1m]', label: 'Opus 1M' }, - { value: 'fable', label: 'Fable' }, - { value: 'fable[1m]', label: 'Fable 1M' } + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7' }, + { value: 'sonnet', label: 'Sonnet 5' }, ]) }) - it('adds non-preset Claude options without hiding Opus presets', () => { + it('offers a 1M variant the CLI lists as its own row', () => { const options = getModelOptionsForFlavor('claude', null, [ - { value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' } + { value: 'opus[1m]', label: 'Opus (1M context)' }, + { value: 'claude-fable-5[1m]', label: 'Fable' }, + { value: 'sonnet', label: 'Sonnet' }, ]) expect(options).toEqual([ { value: null, label: 'Default' }, - { value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' }, + { value: 'opus[1m]', label: 'Opus (1M context)' }, + { value: 'claude-fable-5[1m]', label: 'Fable' }, { value: 'sonnet', label: 'Sonnet' }, - { value: 'sonnet[1m]', label: 'Sonnet 1M' }, - { value: 'opus', label: 'Opus' }, + ]) + }) + + it('keeps the group of each Claude catalog option', () => { + const options = getModelOptionsForFlavor('claude', null, [ + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7', group: 'Other models' }, + ]) + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7', group: 'Other models' }, + ]) + }) + + it('keeps a saved built-in alias the catalog does not list as the current model', () => { + const options = getModelOptionsForFlavor('claude', 'opus[1m]', [ + { value: 'opus', label: 'Opus 5.5' }, + ]) + expect(options).toEqual([ + { value: null, label: 'Default' }, { value: 'opus[1m]', label: 'Opus 1M' }, - { value: 'fable', label: 'Fable' }, - { value: 'fable[1m]', label: 'Fable 1M' } + { value: 'opus', label: 'Opus 5.5' }, + ]) + }) + + it('keeps a pinned Claude model the catalog no longer lists selected', () => { + const options = getModelOptionsForFlavor('claude', 'claude-opus-4-1', [ + { value: 'opus', label: 'Opus 5.5' }, + ]) + expect(options.slice(0, 3)).toEqual([ + { value: null, label: 'Default' }, + { value: 'claude-opus-4-1', label: 'claude-opus-4-1' }, + { value: 'opus', label: 'Opus 5.5' }, ]) }) @@ -245,6 +274,41 @@ describe('getModelOptionsForFlavor', () => { }) }) +describe('getClaudeLaunchModelOptions', () => { + const catalog = [ + { value: 'default', displayName: 'Default (recommended)' }, + { value: 'opus', displayName: 'Opus 5.5' }, + { value: 'claude-opus-4-7', displayName: 'Opus 4.7' }, + ] + + it('offers the catalog choices with Default as auto and earlier versions under Other models', () => { + expect(getClaudeLaunchModelOptions(catalog, 'auto', 'no longer listed', 'Other models')).toEqual([ + { value: 'auto', label: 'Default' }, + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7', group: 'Other models' }, + ]) + }) + + it('marks a saved model the catalog no longer lists instead of dropping it', () => { + const options = getClaudeLaunchModelOptions(catalog, 'claude-opus-4-1', 'no longer listed', 'Other models') + expect(options[1]).toEqual({ value: 'claude-opus-4-1', label: 'claude-opus-4-1 (no longer listed)' }) + }) + + it('marks a saved built-in alias the catalog no longer lists, like any other model', () => { + const options = getClaudeLaunchModelOptions(catalog, 'opus[1m]', 'no longer listed', 'Other models') + expect(options[1]).toEqual({ value: 'opus[1m]', label: 'Opus 1M (no longer listed)' }) + }) + + it('keeps a saved model selectable before the catalog arrives, without claiming it is gone', () => { + const options = getClaudeLaunchModelOptions([], 'claude-opus-4-1', 'no longer listed', 'Other models') + expect(options.slice(0, 3)).toEqual([ + { value: 'auto', label: 'Default' }, + { value: 'claude-opus-4-1', label: 'claude-opus-4-1' }, + { value: 'sonnet', label: 'Sonnet' }, + ]) + }) +}) + describe('getNextModelForFlavor', () => { it('cycles AGY null, auto, and unknown current values only to concrete models', () => { const firstConcrete = getModelOptionsForFlavor('agy').find((option) => option.value !== null)?.value @@ -275,12 +339,13 @@ describe('getNextModelForFlavor', () => { expect(next).not.toBeNull() }) - it('cycles through Claude presets when explicit options only include Sonnet models', () => { - const next = getNextModelForFlavor('claude', 'sonnet[1m]', [ - { value: 'sonnet', label: 'Sonnet' }, - { value: 'sonnet[1m]', label: 'Sonnet 1M' } - ]) - expect(next).toBe('opus') + it('cycles through the Claude catalog only', () => { + const catalogOptions = [ + { value: 'opus', label: 'Opus 5.5' }, + { value: 'sonnet', label: 'Sonnet 5.5' } + ] + expect(getNextModelForFlavor('claude', 'opus', catalogOptions)).toBe('sonnet') + expect(getNextModelForFlavor('claude', 'sonnet', catalogOptions)).toBeNull() }) it('cycles explicit model options', () => { diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index e8289725..2ddebc14 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -1,4 +1,5 @@ -import { getAgyModelLabel } from '@hapi/protocol' +import { getAgyModelLabel, getClaudeModelChoices, getClaudeModelLabel } from '@hapi/protocol' +import type { ClaudeModelSummary } from '@hapi/protocol/apiTypes' import { MODEL_OPTIONS } from '@/components/NewSession/types' import { CURSOR_AUTO_MODEL_LABEL } from '@/lib/cursorModelOptions' import { getClaudeComposerModelOptions, getNextClaudeComposerModel } from './claudeModelOptions' @@ -53,32 +54,55 @@ function getClaudeModelOptions(currentModel?: string | null, customOptions?: Mod return getClaudeComposerModelOptions(currentModel) } - const options = getClaudeComposerModelOptions(currentModel) - const nextOptions = [...options] - let insertIndex = Math.max(1, nextOptions.findIndex((option) => option.value !== null)) - + // The machine's catalog is the CLI's own /model list, so it replaces the + // built-in presets rather than extending them. A CLI that offers a 1M + // variant as a separate choice lists it as its own row (`opus[1m]`), so + // the built-in `[1m]` presets would only add choices the CLI does not + // offer. A saved model it does not list stays as the current model. + const listed: ModelOption[] = [] for (const option of customOptions) { - const normalizedValue = normalizeCurrentModel(option.value) - if (!normalizedValue) { - continue + const value = normalizeCurrentModel(option.value) + if (value && !listed.some((existing) => existing.value === value)) { + listed.push(option.group ? { value, label: option.label, group: option.group } : { value, label: option.label }) } - - const existingIndex = nextOptions.findIndex((nextOption) => nextOption.value === normalizedValue) - if (existingIndex >= 0) { - if (nextOptions[existingIndex]?.label === normalizedValue) { - nextOptions[existingIndex] = option - } - continue - } - - nextOptions.splice(insertIndex, 0, { - value: normalizedValue, - label: option.label - }) - insertIndex += 1 } - return nextOptions + return withCurrentModelOption( + [{ value: null, label: 'Default' }, ...listed], + currentModel, + getClaudeModelLabel + ) +} + +/** + * Create-session model options for Claude (`auto` = Default). A saved model the + * loaded catalog no longer offers stays selectable, labelled as such, so the + * form never silently switches it to another model. + */ +export function getClaudeLaunchModelOptions( + catalog: readonly ClaudeModelSummary[], + selectedModel: string, + notListedSuffix: string, + otherModelsLabel: string +): Array<{ value: string; label: string; group?: string }> { + const catalogOptions = getClaudeCatalogOptions(catalog, otherModelsLabel) + const selected = normalizeCurrentModel(selectedModel) + const isGone = catalog.length > 0 + && selected !== null + && !catalogOptions.some((option) => option.value === selected) + + return getClaudeModelOptions(selectedModel, catalogOptions).map((option) => ({ + ...option, + value: option.value ?? 'auto', + label: isGone && option.value === selected ? `${option.label} (${notListedSuffix})` : option.label + })) +} + +/** Catalog options with earlier versions under `otherModelsLabel`, as Claude Code's model sheet lists them. */ +export function getClaudeCatalogOptions(catalog: readonly ClaudeModelSummary[], otherModelsLabel: string): ModelOption[] { + return getClaudeModelChoices(catalog).map(({ value, label, section }) => ( + section === 'other' ? { value, label, group: otherModelsLabel } : { value, label } + )) } function getAgyModelOptions(currentModel?: string | null, customOptions?: ModelOption[]): ModelOption[] { diff --git a/web/src/components/NewSession/EffortField.test.tsx b/web/src/components/NewSession/EffortField.test.tsx index e9fca811..3aa270d4 100644 --- a/web/src/components/NewSession/EffortField.test.tsx +++ b/web/src/components/NewSession/EffortField.test.tsx @@ -54,6 +54,19 @@ describe('EffortField', () => { expect(values).toContain('high') }) + it('renders the Claude levels the selected model accepts when provided', () => { + const { container } = render( + + ) + const select = container.querySelector('select') as HTMLSelectElement + + expect(Array.from(select.options).map((option) => option.value)).toEqual(['auto', 'low']) + }) + it('renders Pi thinking levels and forwards the selection', () => { const onChange = vi.fn() const { container } = render( diff --git a/web/src/components/NewSession/EffortField.tsx b/web/src/components/NewSession/EffortField.tsx index f684fac0..b5d674eb 100644 --- a/web/src/components/NewSession/EffortField.tsx +++ b/web/src/components/NewSession/EffortField.tsx @@ -17,6 +17,8 @@ export type EffortFieldProps = { isDisabled: boolean /** Model-dependent launch-effort options (Grok). */ grokOptions?: Array<{ value: string; label: string }> + /** Launch-effort options the selected Claude model accepts; undefined = every level. */ + claudeOptions?: Array<{ value: string; label: string }> /** Model-dependent reasoning-effort options (Codex). */ codexReasoningOptions?: Array<{ value: string; name?: string }> /** Model-dependent variant values from the OpenCode catalog (OpenCode). Array = dynamic list, [] = hide field, null/undefined = static fallback. */ @@ -30,7 +32,7 @@ export type EffortFieldProps = { * configuration descriptor (see shared/src/agentConfig.ts). * * One component serves every flavor with an `effort` field: - * - Claude: static launch-effort levels. + * - Claude: the selected model's launch-effort levels, or every level when unknown. * - Grok: model-dependent launch-effort levels. * - Pi: model-dependent thinking levels (filtered by the selected model's * thinkingLevelMap; hidden when the model cannot reason). @@ -84,7 +86,7 @@ export function EffortField(props: EffortFieldProps) { ) } } else { - options = CLAUDE_EFFORT_OPTIONS + options = props.claudeOptions ?? CLAUDE_EFFORT_OPTIONS } const value = isReasoningEffort ? props.reasoningEffort : props.effort diff --git a/web/src/components/NewSession/index.test.tsx b/web/src/components/NewSession/index.test.tsx index 875cce90..47b5ae63 100644 --- a/web/src/components/NewSession/index.test.tsx +++ b/web/src/components/NewSession/index.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ApiClient } from '@/api/client' -import type { Machine, PiModelSummary } from '@/types/api' +import type { ClaudeModelSummary, Machine, PiModelSummary } from '@/types/api' import { saveNewSessionFormDraft } from './newSessionFormDraft' import { loadPreferredLaunchSettings, @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => ({ piModels: [] as PiModelSummary[], piModelsLoading: false, piModelsError: null as string | null, + claudeModels: [] as ClaudeModelSummary[], nextModelValue: 'gpt-5.6-terra', refetchSessions: vi.fn(), addToast: vi.fn() @@ -122,6 +123,9 @@ vi.mock('@/hooks/queries/useAgyModels', () => ({ refetch: vi.fn() }) })) +vi.mock('@/hooks/queries/useClaudeModels', () => ({ + useClaudeModels: () => ({ availableModels: mocks.claudeModels }) +})) vi.mock('@/hooks/queries/useDshModels', () => ({ useDshModelsForMachine: () => ({ availableModels: mocks.dshModels, @@ -340,6 +344,7 @@ describe('NewSession launch preferences', () => { mocks.piModels = [] mocks.piModelsLoading = false mocks.piModelsError = null + mocks.claudeModels = [] mocks.nextModelValue = 'gpt-5.6-terra' mocks.refetchSessions.mockReset() mocks.refetchSessions.mockResolvedValue(undefined) @@ -540,6 +545,107 @@ describe('NewSession launch preferences', () => { }) }) + it('offers the machine Claude catalog and keeps a remembered model it no longer lists', async () => { + mocks.claudeModels = [ + { value: 'default', displayName: 'Default (recommended)' }, + { value: 'opus', displayName: 'Opus 5.5' }, + { value: 'claude-opus-4-7', displayName: 'Opus 4.7' }, + ] + savePreferredAgent('claude') + savePreferredLaunchSettings('machine-1', 'claude', { + model: 'claude-opus-4-1', + cursorSelectedBase: 'auto', + effort: 'auto', + modelReasoningEffort: 'default' + }) + + render( + {}} + /> + ) + + await waitFor(() => { + expect(screen.getByTestId('model')).toHaveTextContent('claude-opus-4-1') + }) + expect(screen.getByTestId('model-options').textContent?.split(',').slice(0, 4)).toEqual([ + 'Default', + 'claude-opus-4-1 (newSession.claudeModel.notListed)', + 'Opus 5.5', + 'Opus 4.7', + ]) + }) + + it('drops a launch effort the newly picked Claude model does not offer', async () => { + mocks.claudeModels = [ + { value: 'opus', displayName: 'Opus 5.5', effortLevels: ['high', 'xhigh'] }, + { value: 'haiku', displayName: 'Haiku 4.5', effortLevels: [] }, + ] + mocks.nextModelValue = 'haiku' + savePreferredAgent('claude') + savePreferredLaunchSettings('machine-1', 'claude', { + model: 'opus', + cursorSelectedBase: 'auto', + effort: 'xhigh', + modelReasoningEffort: 'default' + }) + + render( + {}} + /> + ) + + await waitFor(() => { + expect(screen.getByTestId('launch-effort')).toHaveTextContent('xhigh') + }) + fireEvent.click(screen.getByTestId('model')) + + await waitFor(() => { + expect(screen.getByTestId('model')).toHaveTextContent('haiku') + expect(screen.getByTestId('launch-effort')).toHaveTextContent('auto') + }) + }) + + it('drops a remembered launch effort the remembered Claude model does not offer', async () => { + mocks.claudeModels = [ + { value: 'haiku', displayName: 'Haiku 4.5', effortLevels: [] }, + ] + savePreferredAgent('claude') + savePreferredLaunchSettings('machine-1', 'claude', { + model: 'haiku', + cursorSelectedBase: 'auto', + effort: 'high', + modelReasoningEffort: 'default' + }) + + render( + {}} + /> + ) + + await waitFor(() => { + expect(screen.getByTestId('model')).toHaveTextContent('haiku') + expect(screen.getByTestId('launch-effort')).toHaveTextContent('auto') + }) + }) + it('restores a remembered Kimi alias instead of resetting it to Default', async () => { mocks.kimiModels = [ { modelId: 'GLM-5.3-flash', provider: 'thehive' } diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 6c8083df..26bb79ee 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -2,6 +2,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, ty import type { ApiClient } from '@/api/client' import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary, Machine, PiLocalSessionSummary } from '@/types/api' import type { CodexCollaborationMode, GrokPermissionMode, PermissionMode, CopilotAgentMode } from '@hapi/protocol' +import { getClaudeEffortLevelsForModel, resolveClaudeEffortForModel } from '@hapi/protocol' import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode' import { usePlatform } from '@/hooks/usePlatform' import { useMachinePathsExists } from '@/hooks/useMachinePathsExists' @@ -9,6 +10,9 @@ import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' import { useCodexModels } from '@/hooks/queries/useCodexModels' import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine' import { useAgyModels } from '@/hooks/queries/useAgyModels' +import { useClaudeModels } from '@/hooks/queries/useClaudeModels' +import { getClaudeLaunchModelOptions } from '@/components/AssistantChat/modelOptions' +import { getClaudeComposerEffortOptions } from '@/components/AssistantChat/claudeEffortOptions' import { useDshModelsForMachine } from '@/hooks/queries/useDshModels' import { useOpencodeModelsForCwd } from '@/hooks/queries/useOpencodeModelsForCwd' import { useOpencodeModelVariants } from '@/hooks/queries/useOpencodeModelVariants' @@ -709,6 +713,36 @@ export function NewSession(props: { machineId, enabled: agent === 'pi' && Boolean(machineId) }) + const claudeModelsState = useClaudeModels({ + api: props.api, + machineId, + enabled: agent === 'claude' && Boolean(machineId) + }) + const claudeModelOptions = useMemo(() => ( + agent === 'claude' + ? getClaudeLaunchModelOptions(claudeModelsState.availableModels, model, t('newSession.claudeModel.notListed'), t('misc.otherModels')) + : undefined + ), [agent, claudeModelsState.availableModels, model, t]) + const claudeEffortOptions = useMemo(() => { + const levels = getClaudeEffortLevelsForModel(model === 'auto' ? null : model, claudeModelsState.availableModels) + return levels + ? getClaudeComposerEffortOptions(effort, levels).map((option) => ({ value: option.value ?? 'auto', label: option.label })) + : undefined + }, [claudeModelsState.availableModels, model, effort]) + // However the model got here (picked, restored from a preference or a + // draft, or the catalog arriving after it), an effort that model does not + // offer is dropped. Only the Claude catalog carries effort levels, so other + // agents keep their effort. + useEffect(() => { + const nextEffort = resolveClaudeEffortForModel( + effort === 'auto' ? null : effort, + model === 'auto' ? null : model, + claudeModelsState.availableModels + ) + if ((nextEffort ?? 'auto') !== effort) { + setEffort('auto') + } + }, [claudeModelsState.availableModels, model, effort]) // Pi models are grouped by provider (optionSource: 'machine' in the agent // config descriptor). Option values are provider-qualified // (`provider/modelId`) so two providers sharing a modelId stay distinct; @@ -2003,7 +2037,7 @@ export function NewSession(props: { ? kimiModelOptions : agent === 'pi' ? (showPiLaunchConfig ? piModelOptions : undefined) - : undefined + : claudeModelOptions } isDisabled={ isFormDisabled @@ -2042,6 +2076,7 @@ export function NewSession(props: { onReasoningEffortChange={setModelReasoningEffort} isDisabled={isFormDisabled || (agent === 'codex' && codexModelsState.isLoading)} grokOptions={agent === 'grok' ? grokEffortOptions : undefined} + claudeOptions={claudeEffortOptions} codexReasoningOptions={agent === 'codex' ? codexReasoningEffortOptions : undefined} opencodeVariantOptions={agent === 'opencode' ? opencodeVariantOptions : undefined} piSelectedModel={agent === 'pi' ? piSelectedModel : null} diff --git a/web/src/components/NewSession/preferences.test.ts b/web/src/components/NewSession/preferences.test.ts index 147517f4..9b4c6faa 100644 --- a/web/src/components/NewSession/preferences.test.ts +++ b/web/src/components/NewSession/preferences.test.ts @@ -133,14 +133,16 @@ describe('NewSession preferences', () => { expect(loadPreferredLaunchSettings('machine-1', 'codex')).toBeNull() }) - it('falls back when remembered static Claude options are no longer available', () => { + it('keeps a remembered Claude model the built-in list lacks, but drops an unknown effort', () => { + // Claude models come from the machine's catalog; a pinned model it no + // longer lists is shown as such rather than silently replaced. expect(resolvePreferredLaunchSettings('claude', { - model: 'retired-model', + model: 'claude-opus-4-1', cursorSelectedBase: 'auto', effort: 'ultra', modelReasoningEffort: 'default' })).toEqual({ - model: 'auto', + model: 'claude-opus-4-1', cursorSelectedBase: 'auto', effort: 'auto', modelReasoningEffort: 'default', diff --git a/web/src/components/NewSession/preferences.ts b/web/src/components/NewSession/preferences.ts index 05a770a1..896cc4a3 100644 --- a/web/src/components/NewSession/preferences.ts +++ b/web/src/components/NewSession/preferences.ts @@ -137,7 +137,9 @@ export function resolvePreferredLaunchSettings( // Kimi's catalog is dynamic (machine discovery); validating a saved alias // against the static list would reset it to 'auto'. The settled-catalog // effect in NewSession validates it once the real catalog has arrived. - const model = staticModelValues.length > 0 && agent !== 'codex' && agent !== 'copilot' && agent !== 'kimi' + // Claude's catalog is dynamic too, and a saved Claude model is kept even + // when the catalog drops it (it is offered as "no longer listed"). + const model = staticModelValues.length > 0 && agent !== 'codex' && agent !== 'copilot' && agent !== 'kimi' && agent !== 'claude' ? resolvePreferredOptionValue(preferredModel, staticModelValues, 'auto') : preferredModel const effort = agent === 'claude' diff --git a/web/src/components/SessionChat.test.ts b/web/src/components/SessionChat.test.ts index 2bd48bac..b11626cc 100644 --- a/web/src/components/SessionChat.test.ts +++ b/web/src/components/SessionChat.test.ts @@ -10,6 +10,7 @@ import { mergeStagedAttachmentsInOrder, opencodeEffortOptionsInvalidationKey, resolvePiContextWindow, + resolveEffortForModelChange, resolveLatestCompletedBoundaryIdForView, shouldAutoClearPendingSchedule, shouldClearReasoningEffortForModelChange, @@ -582,3 +583,30 @@ describe('buildAgyComposerModelOptions', () => { expect(buildAgyComposerModelOptions([])).toBeUndefined() }) }) + +describe('resolveEffortForModelChange', () => { + const claudeModels = [ + { value: 'opus', effortLevels: ['low', 'high', 'xhigh'] }, + { value: 'haiku', effortLevels: [] }, + ] + + it('clears an effort the next Claude model does not offer', () => { + expect(resolveEffortForModelChange({ currentEffort: 'xhigh', model: 'haiku', claudeModels })).toEqual({ effort: null }) + }) + + it('leaves the effort alone when the next model keeps it', () => { + expect(resolveEffortForModelChange({ currentEffort: 'high', model: 'claude-opus-5-5', claudeModels })).toBeUndefined() + }) + + it('leaves the effort alone without a catalog (non-Claude sessions, or discovery unavailable)', () => { + expect(resolveEffortForModelChange({ currentEffort: 'xhigh', model: 'haiku', claudeModels: [] })).toBeUndefined() + }) + + it('ignores provider-qualified selections', () => { + expect(resolveEffortForModelChange({ + currentEffort: 'xhigh', + model: { provider: 'p', modelId: 'haiku' }, + claudeModels + })).toBeUndefined() + }) +}) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 81c6a9cd..01432894 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -9,6 +9,7 @@ import { ApiError, type ApiClient } from '@/api/client' import type { AgyModelSummary, AttachmentMetadata, + ClaudeModelSummary, CodexCollaborationMode, CodexModelSummary, CopilotAgentMode, @@ -61,6 +62,8 @@ import { } from '@/lib/messageDelivery' import type { MessageDeliveryMode } from '@hapi/protocol' import { isSteeringSupportedForSession } from '@hapi/protocol' +import { getClaudeEffortLevelsForModel, resolveClaudeEffortForModel } from '@hapi/protocol' +import { getClaudeCatalogOptions } from '@/components/AssistantChat/modelOptions' import { createAttachmentAdapter } from '@/lib/attachmentAdapter' import { rewindMessageWindow, type OlderLoadOutcome } from '@/lib/message-window-store' import { ShareSeedConsumer } from '@/components/ShareSeedConsumer' @@ -97,6 +100,7 @@ import { useCodexModels } from '@/hooks/queries/useCodexModels' import { useCursorModels } from '@/hooks/queries/useCursorModels' import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine' import { useAgyModels } from '@/hooks/queries/useAgyModels' +import { useClaudeModels } from '@/hooks/queries/useClaudeModels' import { mergeCursorCliModelSkus, resolveCursorBaseFromWire @@ -182,6 +186,23 @@ export function buildAgyComposerModelOptions( })) } +/** + * The effort to send along with a model change, or undefined to leave it. + * Only a Claude catalog carries per-model effort levels, so other flavors + * (whose catalog here is empty) never get one. + */ +export function resolveEffortForModelChange(args: { + currentEffort: string | null + model: SessionModelSelection + claudeModels: readonly ClaudeModelSummary[] +}): { effort: string | null } | undefined { + if (typeof args.model === 'object' && args.model !== null) { + return undefined + } + const effort = resolveClaudeEffortForModel(args.currentEffort, args.model, args.claudeModels) + return effort === args.currentEffort ? undefined : { effort } +} + export async function applyModelChangeWithReasoningRollback(args: { model: SessionModelSelection previousModelReasoningEffort: string | null @@ -1180,6 +1201,24 @@ function SessionChatInner(props: SessionChatProps) { ? buildAgyComposerModelOptions(agyModelsState.availableModels) : undefined ), [agentFlavor, agyModelsState.availableModels]) + const claudeModelsState = useClaudeModels({ + api: props.api, + machineId: sessionMachineId, + enabled: agentFlavor === 'claude' && props.session.active && Boolean(sessionMachineId) + }) + // Until the catalog arrives (or on a runner without discovery) this stays + // undefined and the composer keeps its built-in Claude presets. + const claudeModelOptions = useMemo(() => ( + agentFlavor === 'claude' && claudeModelsState.availableModels.length > 0 + ? getClaudeCatalogOptions(claudeModelsState.availableModels, t('misc.otherModels')) + : undefined + ), [agentFlavor, claudeModelsState.availableModels, t]) + // Levels the current Claude model accepts; undefined when unknown (no + // catalog, or a model it does not list), which keeps every level. + const claudeEffortOptions = useMemo(() => ( + getClaudeEffortLevelsForModel(props.session.model ?? null, claudeModelsState.availableModels) + ?.map((level) => ({ value: level })) + ), [props.session.model, claudeModelsState.availableModels]) const piModelsState = usePiModels({ api: props.api, sessionId: props.session.id, @@ -1608,13 +1647,20 @@ function SessionChatInner(props: SessionChatProps) { codexModels: codexModelsState.models, model }) + // Sent in the same request so the session never runs the new model + // with an effort it does not offer. + const effortChange = resolveEffortForModelChange({ + currentEffort: props.session.effort ?? null, + model, + claudeModels: claudeModelsState.availableModels + }) try { await applyModelChangeWithReasoningRollback({ model, previousModelReasoningEffort, shouldClearReasoningEffort, - setModel, + setModel: (nextModel) => setModel(nextModel, effortChange), setModelReasoningEffort }) haptic.notification('success') @@ -1626,6 +1672,8 @@ function SessionChatInner(props: SessionChatProps) { }, [ agentFlavor, codexModelsState.models, + claudeModelsState.availableModels, + props.session.effort, props.session.modelReasoningEffort, setModelReasoningEffort, setModel, @@ -2127,8 +2175,9 @@ function SessionChatInner(props: SessionChatProps) { // cycler (getNextModelForFlavor) post a bare modelId string, // which loses the provider and can pick the wrong cached // match or throw in runPi. undefined makes the shortcut a no-op - // so Pi model changes go through the settings sheet only. - : undefined + // so Pi model changes go through the settings sheet only; + // claudeModelOptions is undefined for every non-Claude flavor. + : claudeModelOptions } piModels={piModels} piSelectedModel={agentFlavor === 'pi' ? piSelectedModel : undefined} @@ -2142,7 +2191,7 @@ function SessionChatInner(props: SessionChatProps) { availableEffortOptions={ agentFlavor === 'grok' && grokEffortState.options.length > 0 ? grokEffortState.options - : undefined + : claudeEffortOptions } active={props.session.active} allowSendWhenInactive diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 4c9ce03b..13657f3e 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { isPermissionModeAllowedForFlavor } from '@hapi/protocol' import type { ApiClient } from '@/api/client' @@ -22,7 +23,7 @@ export function useSessionActions( setPermissionMode: (mode: PermissionMode) => Promise setCollaborationMode: (mode: CodexCollaborationMode) => Promise setCopilotAgentMode: (mode: CopilotAgentMode) => Promise - setModel: (model: { provider: string; modelId: string } | string | null) => Promise + setModel: (model: { provider: string; modelId: string } | string | null, options?: { effort: string | null }) => Promise setModelReasoningEffort: (modelReasoningEffort: string | null) => Promise setEffort: (effort: string | null) => Promise setServiceTier: (serviceTier: string | null) => Promise @@ -173,11 +174,14 @@ export function useSessionActions( const modelMutation = useMutation({ mutationKey: sessionModelMutationKey(sessionId ?? ''), - mutationFn: async (model: { provider: string; modelId: string } | string | null) => { + mutationFn: async (args: { + model: { provider: string; modelId: string } | string | null + options?: { effort: string | null } + }) => { if (!api || !sessionId) { throw new Error('Session unavailable') } - await api.setModel(sessionId, model) + await api.setModel(sessionId, args.model, args.options) }, onSuccess: async () => { await invalidateSession() @@ -185,6 +189,12 @@ export function useSessionActions( }, }) + const setModel = useCallback( + (model: { provider: string; modelId: string } | string | null, options?: { effort: string | null }) => + modelMutation.mutateAsync({ model, options }), + [modelMutation.mutateAsync] + ) + const modelReasoningEffortMutation = useMutation({ mutationFn: async (modelReasoningEffort: string | null) => { if (!api || !sessionId) { @@ -288,7 +298,7 @@ export function useSessionActions( setPermissionMode: permissionMutation.mutateAsync, setCollaborationMode: collaborationMutation.mutateAsync, setCopilotAgentMode: copilotAgentModeMutation.mutateAsync, - setModel: modelMutation.mutateAsync, + setModel, setModelReasoningEffort: modelReasoningEffortMutation.mutateAsync, setEffort: effortMutation.mutateAsync, setServiceTier: serviceTierMutation.mutateAsync, diff --git a/web/src/hooks/queries/useClaudeModels.test.tsx b/web/src/hooks/queries/useClaudeModels.test.tsx new file mode 100644 index 00000000..43a20467 --- /dev/null +++ b/web/src/hooks/queries/useClaudeModels.test.tsx @@ -0,0 +1,75 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { PropsWithChildren } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { ApiClient } from '@/api/client' +import { queryKeys } from '@/lib/query-keys' +import { useClaudeModels } from './useClaudeModels' + +function wrapper(queryClient: QueryClient) { + return ({ children }: PropsWithChildren) => ( + {children} + ) +} + +const catalog = { + success: true, + availableModels: [{ value: 'opus', effortLevels: ['low'] }] +} + +describe('useClaudeModels', () => { + it('returns the machine catalog while enabled', async () => { + const getMachineClaudeModels = vi.fn(async () => catalog) + const api = { getMachineClaudeModels } as unknown as ApiClient + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + const { result } = renderHook(() => useClaudeModels({ api, machineId: 'machine-1', enabled: true }), { + wrapper: wrapper(queryClient) + }) + + await waitFor(() => expect(result.current.availableModels).toHaveLength(1)) + }) + + it('does not hand a cached Claude catalog to a session of another agent on the same machine', () => { + const getMachineClaudeModels = vi.fn(async () => catalog) + const api = { getMachineClaudeModels } as unknown as ApiClient + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + queryClient.setQueryData(queryKeys.machineClaudeModels('machine-1'), catalog) + + const { result } = renderHook(() => useClaudeModels({ api, machineId: 'machine-1', enabled: false }), { + wrapper: wrapper(queryClient) + }) + + expect(result.current.availableModels).toEqual([]) + expect(getMachineClaudeModels).not.toHaveBeenCalled() + }) + + it('retries a failed discovery the next time a picker mounts', async () => { + // The machine answers a failed probe with success:false; caching that as + // a successful result would keep the built-in list for the stale time + // even after the user signs in. + const getMachineClaudeModels = vi.fn() + .mockResolvedValueOnce({ + success: false, + error: 'Claude model discovery timed out', + availableModels: catalog.availableModels + }) + .mockResolvedValueOnce(catalog) + const api = { getMachineClaudeModels } as unknown as ApiClient + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + const first = renderHook(() => useClaudeModels({ api, machineId: 'machine-1', enabled: true }), { + wrapper: wrapper(queryClient) + }) + await waitFor(() => expect(getMachineClaudeModels).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(queryClient.getQueryState(queryKeys.machineClaudeModels('machine-1'))?.fetchStatus).toBe('idle')) + expect(first.result.current.availableModels).toEqual([]) + first.unmount() + + const second = renderHook(() => useClaudeModels({ api, machineId: 'machine-1', enabled: true }), { + wrapper: wrapper(queryClient) + }) + await waitFor(() => expect(second.result.current.availableModels).toHaveLength(1)) + expect(getMachineClaudeModels).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/src/hooks/queries/useClaudeModels.ts b/web/src/hooks/queries/useClaudeModels.ts new file mode 100644 index 00000000..d148691a --- /dev/null +++ b/web/src/hooks/queries/useClaudeModels.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { ClaudeModelSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +const NO_MODELS: ClaudeModelSummary[] = [] + +/** + * The models a machine's `claude` CLI offers. While loading, on failure, or on + * a runner without discovery the list is empty and callers keep their + * built-in presets, so a selection is never lost to a missing catalog. + */ +export function useClaudeModels(args: { + api: ApiClient | null + machineId?: string | null + enabled?: boolean +}): { + availableModels: ClaudeModelSummary[] +} { + const { api, machineId } = args + const enabled = Boolean(args.enabled && api && machineId) + + const query = useQuery({ + queryKey: machineId + ? queryKeys.machineClaudeModels(machineId) + : ['machine-claude-models', 'unknown'] as const, + queryFn: async () => { + if (!api || !machineId) { + throw new Error('Claude models target unavailable') + } + const response = await api.getMachineClaudeModels(machineId) + // A failed probe arrives as success:false; as a query error it is + // not cached as an answer, so the next picker to open asks again. + if (!response.success) { + throw new Error(response.error ?? 'Failed to load Claude models') + } + return response + }, + enabled, + staleTime: 60_000, + retry: false, + }) + + return { + // Gated on `enabled` too: the query key is per machine, so a catalog + // cached by a Claude session would otherwise reach other agents' sessions. + availableModels: enabled ? (query.data?.availableModels ?? NO_MODELS) : NO_MODELS, + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7d6a8070..49157d5f 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -375,6 +375,7 @@ export default { 'newSession.agyModel.retry': 'Retry', 'newSession.agyModel.notListed': 'no longer listed', 'newSession.agyModel.noModels': 'No Antigravity models available', + 'newSession.claudeModel.notListed': 'no longer listed', 'newSession.opencodeModel.loading': 'Discovering OpenCode models…', 'newSession.opencodeModel.loadFailed': 'Failed to load OpenCode models', 'newSession.opencodeModel.retry': 'Retry', @@ -1144,6 +1145,7 @@ export default { 'misc.copilotAgentMode': 'Agent Mode', 'misc.permissionMode': 'Permission Mode', 'misc.model': 'Model', + 'misc.otherModels': 'Other models', 'misc.reasoningEffort': 'Reasoning Effort', 'misc.effort': 'Effort', 'misc.fastMode': 'Fast Mode', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index ad6bb153..61410342 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -371,6 +371,7 @@ export default { 'newSession.model.optional': '可选', 'newSession.model.default': '默认', 'newSession.model.loadFailed': '加载模型失败', + 'newSession.claudeModel.notListed': '已不在列表中', 'newSession.model.selectVariant': '选择变体', 'newSession.model.cursorUnavailable': '暂无 Cursor 模型列表。请先运行一次 Cursor 会话,或使用 Auto。', 'newSession.opencodeModel.loading': '正在发现 OpenCode 模型…', @@ -1142,6 +1143,7 @@ export default { 'misc.copilotAgentMode': '代理模式', 'misc.permissionMode': '权限模式', 'misc.model': '模型', + 'misc.otherModels': '其他模型', 'misc.reasoningEffort': '推理强度', 'misc.effort': '思考强度', 'misc.fastMode': '快速模式', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index 68c9df93..1624ba81 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -39,6 +39,7 @@ export const queryKeys = { machineDshModels: (machineId: string) => ['machine-dsh-models', machineId] as const, sessionDshModels: (sessionId: string) => ['session-dsh-models', sessionId] as const, machinePiModels: (machineId: string) => ['machine-pi-models', machineId] as const, + machineClaudeModels: (machineId: string) => ['machine-claude-models', machineId] as const, skills: (sessionId: string) => ['skills', sessionId] as const, scratchlist: (sessionId: string) => ['scratchlist', sessionId] as const, } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index a4efd13d..54d85121 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -40,6 +40,8 @@ export type { MachineListDirectoryResponse, MachinePathsExistsResponse, AuthResponse, + ClaudeModelSummary, + ClaudeModelsResponse, MachinesResponse, MessagesResponse, OpencodeModelsResponse,