Files
hapi/shared/src/models.ts
T
3f8686e623 feat(claude): offer the four current Claude models, drop the 1M presets (#2153)
* fix(claude): drop the obsolete 1M model presets

The opus, sonnet and fable aliases now resolve to models with a native 1M
window (Opus 5.5, Sonnet 5.5, Fable 5.1), so Claude Code documents the
"[1m]" suffix as having no effect on them and its own /model picker no
longer lists 1M rows. Remove Sonnet 1M / Opus 1M / Fable 1M from the
shared, Android and iOS catalogs.

Sessions stored with a "[1m]" value keep working: the composer keeps
the current model selectable, and the context-budget fallback treats any
"[1m]" model and the remaining aliases as 1M, so local-mode sessions on
plain opus/sonnet no longer show a 200k budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): seed a 1M turn-1 context window for bare Claude aliases

With the 1M presets gone, a session on plain fable/opus/sonnet must not
seed 200k before its first result: those aliases now resolve to models
with a native 1M window. Seed 1M when the selected model is a preset
alias; an explicit full id still seeds conservatively until its result
arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(claude): offer the four current Claude models as presets

The Claude presets now mirror the aliases Claude Code's /model lists, in
its order: Opus, Fable, Sonnet, Haiku. Each alias resolves to the newest
model of its family, and all four have a native 1M window, so neither
1M variants nor pinned older versions are offered. Haiku was missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(claude): read the effective context window from get_context_usage

The 1M seed for the aliases is wrong for users who disable 1M
(CLAUDE_CODE_DISABLE_1M_CONTEXT) or cap it with autoCompactWindow. In
remote mode, ask Claude via the get_context_usage control request after
each system/init and use its maxTokens, which is what /context shows.
A result's raw contextWindow no longer overwrites that value. Older CLIs
that reject the request keep the existing seed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: weishu <twsxtd@gmail.com>
2026-10-29 22:28:59 +08:00

149 lines
5.9 KiB
TypeScript

import type { ClaudeModelSummary } from './apiTypes'
// Fallback aliases in Claude Code's /model order, used until its live catalog
// arrives. Each resolves to the newest model of its family.
export const CLAUDE_MODEL_LABELS = {
opus: 'Opus',
fable: 'Fable',
sonnet: 'Sonnet',
haiku: 'Haiku'
} 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
}