Files
hapi/shared/src/models.ts
T
Junmo KimandGitHub cd0b8e3589 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.
2026-10-28 12:10:36 +08:00

149 lines
5.8 KiB
TypeScript

import type { ClaudeModelSummary } from './apiTypes'
export const CLAUDE_MODEL_LABELS = {
sonnet: 'Sonnet',
'sonnet[1m]': 'Sonnet 1M',
opus: 'Opus',
'opus[1m]': 'Opus 1M',
fable: 'Fable',
'fable[1m]': 'Fable 1M'
} as const
export type ClaudeModelPreset = keyof typeof CLAUDE_MODEL_LABELS
export const CLAUDE_MODEL_PRESETS = Object.keys(CLAUDE_MODEL_LABELS) as ClaudeModelPreset[]
export const GEMINI_MODEL_LABELS = {
'gemini-3.1-pro-preview': 'Gemini 3.1 Pro Preview',
'gemini-3-flash-preview': 'Gemini 3 Flash Preview',
'gemini-2.5-pro': 'Gemini 2.5 Pro',
'gemini-2.5-flash': 'Gemini 2.5 Flash',
'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite',
} as const
export type GeminiModelPreset = keyof typeof GEMINI_MODEL_LABELS
export const GEMINI_MODEL_PRESETS = Object.keys(GEMINI_MODEL_LABELS) as GeminiModelPreset[]
export const DEFAULT_GEMINI_MODEL: GeminiModelPreset = 'gemini-2.5-pro'
// Order and labels mirror `agy models` output (the agy CLI's own listing) so the
// HAPI picker matches what users see in the terminal. IDs follow agy's
// `<model>-<effort>` convention (e.g. `gemini-3.5-flash-low` verified accepted by
// `agy --model`). NOTE: agy fetches the live list server-side and `agy models`
// needs an interactive keyring unlock, so this stays a hand-maintained mirror —
// update it when agy's listing changes.
export const AGY_MODEL_LABELS = {
'gemini-3.7-flash-high': 'Gemini 3.7 Flash (High)',
'gemini-3.7-flash-medium': 'Gemini 3.7 Flash (Medium)',
'gemini-3.7-flash-low': 'Gemini 3.7 Flash (Low)',
'gemini-3.6-flash-high': 'Gemini 3.6 Flash (High)',
'gemini-3.6-flash-medium': 'Gemini 3.6 Flash (Medium)',
'gemini-3.6-flash-low': 'Gemini 3.6 Flash (Low)',
'gemini-3.5-flash-medium': 'Gemini 3.5 Flash (Medium)',
'gemini-3.5-flash-high': 'Gemini 3.5 Flash (High)',
'gemini-3.5-flash-low': 'Gemini 3.5 Flash (Low)',
'gemini-3.1-pro-low': 'Gemini 3.1 Pro (Low)',
'gemini-3.1-pro-high': 'Gemini 3.1 Pro (High)',
'claude-sonnet-4-6': 'Claude Sonnet 4.6 (Thinking)',
'claude-opus-4-6-thinking': 'Claude Opus 4.6 (Thinking)',
'gpt-oss-120b-medium': 'GPT-OSS 120B (Medium)',
} as const
export type AgyModelPreset = keyof typeof AGY_MODEL_LABELS
export const AGY_MODEL_PRESETS = Object.keys(AGY_MODEL_LABELS) as AgyModelPreset[]
export function getAgyModelLabel(model: string): string | null {
const trimmedModel = model.trim()
if (!trimmedModel) return null
return AGY_MODEL_LABELS[trimmedModel as AgyModelPreset] ?? null
}
export function isClaudeModelPreset(model: string | null | undefined): model is ClaudeModelPreset {
return typeof model === 'string' && Object.hasOwn(CLAUDE_MODEL_LABELS, model)
}
export function getClaudeModelLabel(model: string): string | null {
const trimmedModel = model.trim()
if (!trimmedModel) {
return 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<string>()
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
}