mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
* 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.
149 lines
5.8 KiB
TypeScript
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
|
|
}
|