mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
Track Claude models from the installed CLI per host (STA-3330) (#12369)
* feat(native-chat): track Claude models from the installed CLI per host (STA-3330) The Claude seed no longer pins version labels to aliases that resolve differently across CLI versions, and the catalog now defines listModels backed by a one-shot list_models control request over --print stream-json. Hosts whose CLI predates the request answer with a control error and keep the seed. Discovery also feeds Source Control AI via the commit-message spec, and the /model echo detector matches resolved model names. * fix(native-chat): preserve discovered Claude capabilities * fix(native-chat): tolerate malformed Claude model entries * fix(native-chat): discover models in folder workspaces * fix(native-chat): trust discovered Claude capabilities * fix(native-chat): remove Claude model fallbacks * fix(native-chat): keep the Claude model picker rendered The Claude picker rendered nothing until the per-host `list_models` probe returned, so it popped in ~1s after mount and never appeared at all when the probe failed — an old CLI without `list_models`, no `claude` on PATH, or an older remote runtime whose response omits `catalogOrigin`. Restore the version-neutral family seed as the starting list; discovery still replaces it wholesale on success, so a host with a real catalog never shows an obsolete hardcoded row. Separately, the tracked model could fall outside the active list: the terminal header scrape yields family ids (`opus`) while a current CLI lists `opus[1m]` and no plain `opus`. That blanked the picker trigger and dropped the model's effort and fast-mode controls. Reconcile the tracked id into the active list once, so the snapshot, the appliers, and typed command recording all see a labelled, operable row for it.
This commit is contained in:
@@ -1,4 +1,13 @@
|
||||
import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types'
|
||||
import type {
|
||||
AgentSessionOptionCatalog,
|
||||
CatalogModel,
|
||||
CatalogOption
|
||||
} from './agent-session-option-catalog-types'
|
||||
import {
|
||||
CLAUDE_MODEL_LIST_ARGS,
|
||||
CLAUDE_MODEL_LIST_STDIN,
|
||||
parseClaudeModelList
|
||||
} from './claude-model-list-probe'
|
||||
|
||||
function hasFlag(tokens: readonly string[], flags: readonly string[]): boolean {
|
||||
return tokens.some((token) =>
|
||||
@@ -40,14 +49,20 @@ const EXTENDED_EFFORT_CHOICES = [
|
||||
]
|
||||
|
||||
function claudeEffort(extended: boolean): CatalogOption {
|
||||
return claudeEffortWithChoices(extended ? EXTENDED_EFFORT_CHOICES : STANDARD_EFFORT_CHOICES)
|
||||
}
|
||||
|
||||
function claudeEffortWithChoices(choices: typeof EXTENDED_EFFORT_CHOICES): CatalogOption {
|
||||
return {
|
||||
id: 'effort',
|
||||
label: 'Effort',
|
||||
category: 'thought_level',
|
||||
kind: {
|
||||
type: 'select',
|
||||
choices: extended ? EXTENDED_EFFORT_CHOICES : STANDARD_EFFORT_CHOICES,
|
||||
defaultValue: 'high'
|
||||
choices,
|
||||
defaultValue: choices.some((choice) => choice.value === 'high')
|
||||
? 'high'
|
||||
: (choices[0]?.value ?? 'high')
|
||||
},
|
||||
apply: {
|
||||
launchArgs: (value) => ['--effort', String(value)],
|
||||
@@ -57,6 +72,33 @@ function claudeEffort(extended: boolean): CatalogOption {
|
||||
}
|
||||
}
|
||||
|
||||
export function createClaudeCatalogOptions(args: {
|
||||
effortLevelIds: readonly string[]
|
||||
supportsFastMode?: boolean
|
||||
}): CatalogOption[] {
|
||||
const effortChoices = EXTENDED_EFFORT_CHOICES.filter((choice) =>
|
||||
args.effortLevelIds.includes(choice.value)
|
||||
)
|
||||
return [
|
||||
...(effortChoices.length > 0 ? [claudeEffortWithChoices(effortChoices)] : []),
|
||||
...(args.supportsFastMode ? [CLAUDE_FAST_MODE] : [])
|
||||
]
|
||||
}
|
||||
|
||||
function parseClaudeCatalogModels(stdout: string): CatalogModel[] {
|
||||
return parseClaudeModelList(stdout).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
...(model.description ? { description: model.description } : {}),
|
||||
options: createClaudeCatalogOptions({
|
||||
effortLevelIds: model.effortLevels,
|
||||
supportsFastMode: model.supportsFastMode
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const CLAUDE_FAST_MODE: CatalogOption = {
|
||||
id: 'fastMode',
|
||||
label: 'Fast mode',
|
||||
@@ -66,26 +108,35 @@ const CLAUDE_FAST_MODE: CatalogOption = {
|
||||
}
|
||||
|
||||
export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
|
||||
// Why: these ids are Claude CLI aliases that resolve to the newest model of
|
||||
// each family on the host's CLI (`opus` is Opus 5 on current CLIs, older
|
||||
// Opus on older CLIs), so pinned version labels lie on part of the fleet.
|
||||
// Family labels also keep header scraping and /model echo detection working
|
||||
// across CLI versions; listModels overlays exact per-host names below.
|
||||
models: [
|
||||
{
|
||||
id: 'fable',
|
||||
label: 'Fable 5',
|
||||
label: 'Fable',
|
||||
description: 'Most capable for the hardest, longest-running tasks',
|
||||
options: [claudeEffort(true)]
|
||||
},
|
||||
{
|
||||
id: 'opus',
|
||||
label: 'Opus 4.8',
|
||||
label: 'Opus',
|
||||
description: 'Best for everyday, complex tasks',
|
||||
options: [claudeEffort(true), CLAUDE_FAST_MODE]
|
||||
},
|
||||
{
|
||||
id: 'sonnet',
|
||||
label: 'Sonnet 5',
|
||||
label: 'Sonnet',
|
||||
description: 'Efficient for routine tasks',
|
||||
isDefault: true,
|
||||
options: [claudeEffort(true)]
|
||||
},
|
||||
{
|
||||
id: 'haiku',
|
||||
label: 'Haiku',
|
||||
description: 'Fastest for quick answers',
|
||||
options: []
|
||||
}
|
||||
],
|
||||
@@ -100,6 +151,10 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
|
||||
// actual prompt so ordinary model changes stay in native chat.
|
||||
detectAgentInteraction: 'claude-model-switch-confirmation'
|
||||
}
|
||||
},
|
||||
listModels: {
|
||||
command: `echo '${CLAUDE_MODEL_LIST_STDIN.trim()}' | claude ${CLAUDE_MODEL_LIST_ARGS.join(' ')}`,
|
||||
parse: parseClaudeCatalogModels
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,91 @@ describe('agent session option catalog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('labels Claude seed models by alias family so no host is mislabeled', () => {
|
||||
const catalog = getAgentSessionOptionCatalog('claude')!
|
||||
expect(catalog.models.map(({ id, label }) => ({ id, label }))).toEqual([
|
||||
{ id: 'fable', label: 'Fable' },
|
||||
{ id: 'opus', label: 'Opus' },
|
||||
{ id: 'sonnet', label: 'Sonnet' },
|
||||
{ id: 'haiku', label: 'Haiku' }
|
||||
])
|
||||
expect(catalog.models.find((model) => model.isDefault)?.id).toBe('sonnet')
|
||||
})
|
||||
|
||||
it('parses Claude list_models discovery into catalog models with options', () => {
|
||||
const stdout = JSON.stringify({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
response: {
|
||||
models: [
|
||||
{
|
||||
value: 'default',
|
||||
displayName: 'Default (recommended)',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
supportsFastMode: true
|
||||
},
|
||||
{
|
||||
value: 'opus[1m]',
|
||||
displayName: 'Opus (1M context)',
|
||||
description: 'Opus 5 with 1M context',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
supportsFastMode: true
|
||||
},
|
||||
{
|
||||
value: 'sonnet',
|
||||
displayName: 'Sonnet',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high']
|
||||
},
|
||||
{ value: 'haiku', displayName: 'Haiku' }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
const parsed = getAgentSessionOptionCatalog('claude')!.listModels!.parse(stdout)
|
||||
expect(parsed.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet', 'haiku'])
|
||||
expect(parsed[0]).toMatchObject({
|
||||
label: 'Opus (1M context)',
|
||||
description: 'Opus 5 with 1M context'
|
||||
})
|
||||
expect(parsed[0].options.map(({ id }) => id)).toEqual(['effort', 'fastMode'])
|
||||
const opusEffort = parsed[0].options[0]
|
||||
expect(opusEffort.kind).toMatchObject({ defaultValue: 'high' })
|
||||
expect(
|
||||
opusEffort.kind.type === 'select' ? opusEffort.kind.choices.map((c) => c.value) : []
|
||||
).toEqual(['low', 'medium', 'high', 'xhigh', 'max'])
|
||||
const sonnetEffort = parsed[1].options[0]
|
||||
expect(
|
||||
sonnetEffort.kind.type === 'select' ? sonnetEffort.kind.choices.map((c) => c.value) : []
|
||||
).toEqual(['low', 'medium', 'high'])
|
||||
expect(parsed[2].options).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the Claude seed when list_models output is unsupported or malformed', () => {
|
||||
const parse = getAgentSessionOptionCatalog('claude')!.listModels!.parse
|
||||
const unsupported =
|
||||
'{"type":"control_response","response":{"subtype":"error","request_id":"x","error":"Unsupported control request subtype: list_models"}}'
|
||||
expect(parse(unsupported)).toEqual([])
|
||||
expect(parse('')).toEqual([])
|
||||
expect(parse('garbage')).toEqual([])
|
||||
})
|
||||
|
||||
it('merges discovered Claude variants after the seed and overlays matched labels', () => {
|
||||
const catalog = getAgentSessionOptionCatalog('claude')!
|
||||
const merged = mergeCatalogModels(catalog.models, [
|
||||
{ id: 'opus[1m]', label: 'Opus (1M context)', options: [] },
|
||||
{ id: 'sonnet', label: 'Sonnet', description: 'Sonnet 5 · Efficient', options: [] }
|
||||
])
|
||||
expect(merged.map(({ id }) => id)).toEqual(['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]'])
|
||||
const sonnet = merged.find((model) => model.id === 'sonnet')!
|
||||
expect(sonnet.description).toBe('Sonnet 5 · Efficient')
|
||||
expect(sonnet.isDefault).toBe(true)
|
||||
expect(sonnet.options.map(({ id }) => id)).toEqual(['effort'])
|
||||
})
|
||||
|
||||
it('parses Cursor model discovery without treating headings as models', () => {
|
||||
const parsed = getAgentSessionOptionCatalog('cursor')!.listModels!.parse(
|
||||
'Available models:\n- auto (default)\n- gpt-5.3-codex\nmodels\n'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { AgentType } from './agent-status-types'
|
||||
import {
|
||||
CLAUDE_SESSION_OPTION_CATALOG,
|
||||
CODEX_SESSION_OPTION_CATALOG
|
||||
CODEX_SESSION_OPTION_CATALOG,
|
||||
createClaudeCatalogOptions
|
||||
} from './agent-session-option-catalog-claude-codex'
|
||||
import {
|
||||
CURSOR_SESSION_OPTION_CATALOG,
|
||||
@@ -23,6 +24,7 @@ export type {
|
||||
CatalogOption,
|
||||
CatalogOptionApply
|
||||
} from './agent-session-option-catalog-types'
|
||||
export { createClaudeCatalogOptions }
|
||||
|
||||
const CATALOGS: AgentSessionOptionCatalogMap = {
|
||||
claude: CLAUDE_SESSION_OPTION_CATALOG,
|
||||
@@ -49,8 +51,7 @@ export function findCatalogOption(
|
||||
return model?.options.find((option) => option.id === optionId)
|
||||
}
|
||||
|
||||
/** Merge live rows over the static seed while retaining only option shapes Orca
|
||||
* can actually map. Newly discovered ids remain model-only until cataloged. */
|
||||
/** Merge live rows over the static seed while retaining cataloged option mappings. */
|
||||
export function mergeCatalogModels(
|
||||
seed: readonly CatalogModel[],
|
||||
discovered: readonly CatalogModel[]
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseClaudeModelList } from './claude-model-list-probe'
|
||||
|
||||
function controlResponseLine(models: unknown[]): string {
|
||||
return JSON.stringify({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'orca-model-discovery',
|
||||
response: { models }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Captured from `claude` 2.1.220 answering a list_models control request.
|
||||
const LIVE_MODELS = [
|
||||
{
|
||||
value: 'default',
|
||||
resolvedModel: 'claude-opus-5[1m]',
|
||||
displayName: 'Default (recommended)',
|
||||
description: 'Use the default model (currently Opus 5 (1M context)) · $5/$25 per Mtok',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
supportsFastMode: true
|
||||
},
|
||||
{
|
||||
value: 'opus[1m]',
|
||||
resolvedModel: 'claude-opus-5[1m]',
|
||||
displayName: 'Opus (1M context)',
|
||||
description: 'Opus 5 with 1M context · Best for everyday, complex tasks · $5/$25 per Mtok',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
supportsFastMode: true
|
||||
},
|
||||
{
|
||||
value: 'sonnet',
|
||||
resolvedModel: 'claude-sonnet-5',
|
||||
displayName: 'Sonnet',
|
||||
description: 'Sonnet 5 · Efficient for routine tasks · $2/$10 per Mtok',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
supportsAdaptiveThinking: true
|
||||
},
|
||||
{
|
||||
value: 'haiku',
|
||||
resolvedModel: 'claude-haiku-4-5-20251001',
|
||||
displayName: 'Haiku',
|
||||
description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok'
|
||||
}
|
||||
]
|
||||
|
||||
describe('parseClaudeModelList', () => {
|
||||
it('parses the picker catalog and drops the mirror default row', () => {
|
||||
const parsed = parseClaudeModelList(`${controlResponseLine(LIVE_MODELS)}\n`)
|
||||
expect(parsed.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet', 'haiku'])
|
||||
expect(parsed[0]).toEqual({
|
||||
id: 'opus[1m]',
|
||||
label: 'Opus (1M context)',
|
||||
description: 'Opus 5 with 1M context · Best for everyday, complex tasks · $5/$25 per Mtok',
|
||||
effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
supportsFastMode: true
|
||||
})
|
||||
expect(parsed[2]).toMatchObject({ effortLevels: [], supportsFastMode: false })
|
||||
})
|
||||
|
||||
it('skips init noise, CRLF endings, and duplicate values', () => {
|
||||
const stdout =
|
||||
'{"type":"system","subtype":"init","model":"claude-sonnet-5"}\r\n' +
|
||||
'not json at all\r\n' +
|
||||
`${controlResponseLine([
|
||||
{ value: 'sonnet', displayName: 'Sonnet' },
|
||||
{ value: 'sonnet', displayName: 'Sonnet (duplicate)' },
|
||||
{ value: ' ', displayName: 'Blank' }
|
||||
])}\r\n`
|
||||
expect(parseClaudeModelList(stdout)).toEqual([
|
||||
{ id: 'sonnet', label: 'Sonnet', effortLevels: [], supportsFastMode: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('skips non-object model entries instead of failing the discovery response', () => {
|
||||
const stdout = controlResponseLine([null, 7, [], { value: 'sonnet', displayName: 'Sonnet' }])
|
||||
expect(parseClaudeModelList(stdout)).toEqual([
|
||||
{ id: 'sonnet', label: 'Sonnet', effortLevels: [], supportsFastMode: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no models for the control error emitted by CLIs without list_models', () => {
|
||||
// Captured from `claude` 2.1.100: unsupported subtype still exits 0.
|
||||
const stdout =
|
||||
'{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n'
|
||||
expect(parseClaudeModelList(stdout)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no models for empty, malformed, or structurally hostile output', () => {
|
||||
expect(parseClaudeModelList('')).toEqual([])
|
||||
expect(parseClaudeModelList('{"type":"control_response"')).toEqual([])
|
||||
expect(
|
||||
parseClaudeModelList(
|
||||
`{"type":"control_response","response":{"subtype":"success","response":{"models":${'['.repeat(64)}${']'.repeat(64)}}}}`
|
||||
)
|
||||
).toEqual([])
|
||||
const hostile = `{"a":${'['.repeat(40)}${']'.repeat(40)},"type":"control_response"}`
|
||||
expect(parseClaudeModelList(hostile)).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores effort levels when the model does not declare effort support', () => {
|
||||
const parsed = parseClaudeModelList(
|
||||
controlResponseLine([
|
||||
{ value: 'haiku', displayName: 'Haiku', supportedEffortLevels: ['low', 'high'] }
|
||||
])
|
||||
)
|
||||
expect(parsed[0]?.effortLevels).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
|
||||
|
||||
// Why: the Claude CLI has no model-listing subcommand (`claude models` starts a
|
||||
// chat session). One `list_models` control request over --print stream-json
|
||||
// returns the CLI's /model picker catalog without starting an API turn. CLIs
|
||||
// that predate the request answer `{"subtype":"error"}` and still exit 0, so
|
||||
// parsing yields no models and callers keep their seed list.
|
||||
export const CLAUDE_MODEL_LIST_STDIN = `${JSON.stringify({
|
||||
type: 'control_request',
|
||||
request_id: 'orca-model-discovery',
|
||||
request: { subtype: 'list_models' }
|
||||
})}\n`
|
||||
|
||||
// Why: --print rejects stream-json output unless --verbose is also set.
|
||||
export const CLAUDE_MODEL_LIST_ARGS = [
|
||||
'-p',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose'
|
||||
]
|
||||
|
||||
export type ClaudeListedModel = {
|
||||
/** Value the CLI accepts for `--model` and `/model` (e.g. `opus[1m]`). */
|
||||
id: string
|
||||
/** The CLI's own picker label (e.g. `Opus (1M context)`). */
|
||||
label: string
|
||||
/** Names what the value resolves to on this host (e.g. `Opus 5 with 1M context …`). */
|
||||
description?: string
|
||||
/** `--effort` values this model accepts; empty when it has no effort control. */
|
||||
effortLevels: string[]
|
||||
supportsFastMode: boolean
|
||||
}
|
||||
|
||||
const CLAUDE_MODEL_LIST_JSON_LIMITS = {
|
||||
structuralTokens: 64 * 1024,
|
||||
nestingDepth: 16
|
||||
} as const
|
||||
|
||||
type RawControlResponse = {
|
||||
type?: unknown
|
||||
response?: {
|
||||
subtype?: unknown
|
||||
response?: { models?: unknown }
|
||||
}
|
||||
}
|
||||
|
||||
type RawListedModel = {
|
||||
value?: unknown
|
||||
displayName?: unknown
|
||||
description?: unknown
|
||||
supportsEffort?: unknown
|
||||
supportedEffortLevels?: unknown
|
||||
supportsFastMode?: unknown
|
||||
}
|
||||
|
||||
function toListedModel(value: unknown): ClaudeListedModel | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
const raw = value as RawListedModel
|
||||
const id = typeof raw.value === 'string' ? raw.value.trim() : ''
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
const label = typeof raw.displayName === 'string' && raw.displayName.trim() ? raw.displayName : id
|
||||
const description =
|
||||
typeof raw.description === 'string' && raw.description.trim() ? raw.description : undefined
|
||||
const effortLevels =
|
||||
raw.supportsEffort === true && Array.isArray(raw.supportedEffortLevels)
|
||||
? raw.supportedEffortLevels.filter((level): level is string => typeof level === 'string')
|
||||
: []
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
...(description ? { description } : {}),
|
||||
effortLevels,
|
||||
supportsFastMode: raw.supportsFastMode === true
|
||||
}
|
||||
}
|
||||
|
||||
export function parseClaudeModelList(stdout: string): ClaudeListedModel[] {
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line.startsWith('{') || !line.includes('control_response')) {
|
||||
continue
|
||||
}
|
||||
let parsed: RawControlResponse
|
||||
try {
|
||||
assertJsonTextStructureWithinLimits(line, CLAUDE_MODEL_LIST_JSON_LIMITS)
|
||||
parsed = JSON.parse(line) as RawControlResponse
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (parsed.type !== 'control_response' || parsed.response?.subtype !== 'success') {
|
||||
continue
|
||||
}
|
||||
const models = parsed.response.response?.models
|
||||
if (!Array.isArray(models)) {
|
||||
continue
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const listed: ClaudeListedModel[] = []
|
||||
for (const entry of models) {
|
||||
const model = toListedModel(entry)
|
||||
// Why: the `default` row mirrors whichever entry it currently resolves
|
||||
// to; Orca's pickers manage their own default selection.
|
||||
if (!model || model.id === 'default' || seen.has(model.id)) {
|
||||
continue
|
||||
}
|
||||
seen.add(model.id)
|
||||
listed.push(model)
|
||||
}
|
||||
if (listed.length > 0) {
|
||||
return listed
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
listCommitMessageAgentCapabilities,
|
||||
listCommitMessageAgentIds,
|
||||
parseAntigravityModels,
|
||||
parseClaudeModels,
|
||||
parseCodexModels,
|
||||
parseCursorModels,
|
||||
parseLineModels,
|
||||
@@ -194,6 +195,81 @@ describe('buildArgs (Claude)', () => {
|
||||
})
|
||||
|
||||
describe('model discovery parsers', () => {
|
||||
it('parses Claude list_models output into commit-message models', () => {
|
||||
const stdout = `${JSON.stringify({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'orca-model-discovery',
|
||||
response: {
|
||||
models: [
|
||||
{
|
||||
value: 'default',
|
||||
displayName: 'Default (recommended)',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
|
||||
},
|
||||
{
|
||||
value: 'opus[1m]',
|
||||
displayName: 'Opus (1M context)',
|
||||
description: 'Opus 5 with 1M context · $5/$25 per Mtok',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
supportsFastMode: true
|
||||
},
|
||||
{ value: 'haiku', displayName: 'Haiku' }
|
||||
]
|
||||
}
|
||||
}
|
||||
})}\n`
|
||||
expect(parseClaudeModels(stdout)).toEqual([
|
||||
{
|
||||
id: 'opus[1m]',
|
||||
label: 'Opus (1M context)',
|
||||
description: 'Opus 5 with 1M context · $5/$25 per Mtok',
|
||||
thinkingLevels: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' },
|
||||
{ id: 'max', label: 'Max' }
|
||||
],
|
||||
defaultThinkingLevel: 'low',
|
||||
supportsFastMode: true
|
||||
},
|
||||
{ id: 'haiku', label: 'Haiku' }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no Claude models when the CLI lacks list_models so the seed stays', () => {
|
||||
expect(
|
||||
parseClaudeModels(
|
||||
'{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('declares stdin-driven dynamic discovery for Claude', () => {
|
||||
const discovery = COMMIT_MESSAGE_AGENT_SPECS.claude?.modelDiscovery
|
||||
expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.modelSource).toBe('dynamic')
|
||||
expect(discovery?.binary).toBe('claude')
|
||||
expect(discovery?.args).toEqual([
|
||||
'-p',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose'
|
||||
])
|
||||
const payload = JSON.parse(discovery?.stdinPayload ?? '') as {
|
||||
type?: string
|
||||
request?: { subtype?: string }
|
||||
}
|
||||
expect(payload.type).toBe('control_request')
|
||||
expect(payload.request?.subtype).toBe('list_models')
|
||||
expect(discovery?.stdinPayload?.endsWith('\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('parses Codex model JSON', () => {
|
||||
expect(
|
||||
parseCodexModels(
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { TuiAgent } from './types'
|
||||
import { isTuiAgentEnabled } from './tui-agent-selection'
|
||||
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
|
||||
import {
|
||||
CLAUDE_MODEL_LIST_ARGS,
|
||||
CLAUDE_MODEL_LIST_STDIN,
|
||||
parseClaudeModelList
|
||||
} from './claude-model-list-probe'
|
||||
|
||||
/* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */
|
||||
|
||||
@@ -16,10 +21,14 @@ export type CommitMessageModel = {
|
||||
id: string
|
||||
/** Visible label in the model dropdown. */
|
||||
label: string
|
||||
/** Discovery-provided detail, e.g. what a CLI alias resolves to on this host. */
|
||||
description?: string
|
||||
/** Omit when the model does not expose an effort selector — the UI then hides the dropdown. */
|
||||
thinkingLevels?: ThinkingLevel[]
|
||||
/** Required when thinkingLevels is present. */
|
||||
defaultThinkingLevel?: string
|
||||
/** Whether the model exposes Claude's mid-session Fast mode toggle. */
|
||||
supportsFastMode?: boolean
|
||||
}
|
||||
|
||||
export type CommitMessageAgentSpec = {
|
||||
@@ -37,6 +46,8 @@ export type CommitMessageAgentSpec = {
|
||||
modelDiscovery?: {
|
||||
binary: string
|
||||
args: string[]
|
||||
/** Written to the CLI's stdin, for CLIs whose listing is request-driven. */
|
||||
stdinPayload?: string
|
||||
parse: (stdout: string) => CommitMessageModel[]
|
||||
}
|
||||
models: CommitMessageModel[]
|
||||
@@ -46,8 +57,10 @@ export type CommitMessageAgentSpec = {
|
||||
export type CommitMessageModelCapability = {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
thinkingLevels?: ThinkingLevel[]
|
||||
defaultThinkingLevel?: string
|
||||
supportsFastMode?: boolean
|
||||
}
|
||||
|
||||
export type CommitMessageAgentCapability = {
|
||||
@@ -139,6 +152,30 @@ function withOpenAiThinking(
|
||||
: {}
|
||||
}
|
||||
|
||||
export function parseClaudeModels(stdout: string): CommitMessageModel[] {
|
||||
return uniqueModels(
|
||||
parseClaudeModelList(stdout).map((model) => {
|
||||
const thinkingLevels = CLAUDE_THINKING_LEVELS.filter((level) =>
|
||||
model.effortLevels.includes(level.id)
|
||||
)
|
||||
return {
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
...(model.description ? { description: model.description } : {}),
|
||||
...(thinkingLevels.length > 0
|
||||
? {
|
||||
thinkingLevels,
|
||||
defaultThinkingLevel: thinkingLevels.some((level) => level.id === 'low')
|
||||
? 'low'
|
||||
: thinkingLevels[0].id
|
||||
}
|
||||
: {}),
|
||||
...(model.supportsFastMode ? { supportsFastMode: true } : {})
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function parseCodexModels(stdout: string): CommitMessageModel[] {
|
||||
try {
|
||||
assertJsonTextStructureWithinLimits(stdout, COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS)
|
||||
@@ -311,7 +348,16 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
|
||||
'plan',
|
||||
...(thinkingLevel ? ['--effort', thinkingLevel] : [])
|
||||
],
|
||||
modelSource: 'static',
|
||||
modelSource: 'dynamic',
|
||||
// Why: the Claude CLI has no listing subcommand; one list_models control
|
||||
// request over --print stream-json returns the /model picker catalog.
|
||||
// Older CLIs answer with a control error and exit 0, keeping the fallback.
|
||||
modelDiscovery: {
|
||||
binary: 'claude',
|
||||
args: [...CLAUDE_MODEL_LIST_ARGS],
|
||||
stdinPayload: CLAUDE_MODEL_LIST_STDIN,
|
||||
parse: parseClaudeModels
|
||||
},
|
||||
models: [
|
||||
{
|
||||
// Why: Claude Code aliases track the account/provider's supported
|
||||
@@ -744,8 +790,10 @@ function toCommitMessageAgentCapability(
|
||||
models: spec.models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
...(model.description ? { description: model.description } : {}),
|
||||
...(model.thinkingLevels ? { thinkingLevels: [...model.thinkingLevels] } : {}),
|
||||
...(model.defaultThinkingLevel ? { defaultThinkingLevel: model.defaultThinkingLevel } : {})
|
||||
...(model.defaultThinkingLevel ? { defaultThinkingLevel: model.defaultThinkingLevel } : {}),
|
||||
...(model.supportsFastMode ? { supportsFastMode: true } : {})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@ export const LOCAL_COMMIT_MESSAGE_HOST_KEY = 'local'
|
||||
export const UNKNOWN_COMMIT_MESSAGE_HOST_KEY = 'unknown'
|
||||
export const RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX = 'runtime:'
|
||||
|
||||
export function getCommitMessageModelDiscoveryHostKeyForLocalRuntime(
|
||||
wslDistro: string | null | undefined
|
||||
): string {
|
||||
const distro = wslDistro?.trim()
|
||||
return distro ? `wsl:${distro}` : LOCAL_COMMIT_MESSAGE_HOST_KEY
|
||||
}
|
||||
|
||||
export function getCommitMessageModelDiscoveryHostKey(
|
||||
connectionId: string | null | undefined
|
||||
): string {
|
||||
|
||||
Reference in New Issue
Block a user