import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' import { parseClaudeModelList } from './claude-model-list-probe' import { labelFromModelId } from './model-id-label' import type { CommitMessageModel, ThinkingLevel } from './commit-message-agent-spec' export const COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS = { structuralTokens: 64 * 1024, nestingDepth: 16 } as const export const BASIC_THINKING_LEVELS: ThinkingLevel[] = [ { id: 'low', label: 'Low' }, { id: 'medium', label: 'Medium' }, { id: 'high', label: 'High' } ] export const OPENAI_THINKING_LEVELS: ThinkingLevel[] = [ { id: 'low', label: 'Low' }, { id: 'medium', label: 'Medium' }, { id: 'high', label: 'High' }, { id: 'xhigh', label: 'Extra High' } ] export const CLAUDE_THINKING_LEVELS: ThinkingLevel[] = [ { id: 'low', label: 'Low' }, { id: 'medium', label: 'Medium' }, { id: 'high', label: 'High' }, { id: 'xhigh', label: 'Extra High' }, { id: 'max', label: 'Max' } ] function uniqueModels(models: CommitMessageModel[]): CommitMessageModel[] { const seen = new Set() return models.filter((model) => { if (!model.id || seen.has(model.id)) { return false } seen.add(model.id) return true }) } function* iterateModelOutputLines(output: string): Generator { let lineStart = 0 for (let index = 0; index < output.length; index++) { const code = output.charCodeAt(index) if (code !== 10 && code !== 13) { continue } yield output.slice(lineStart, index) if (code === 13 && output.charCodeAt(index + 1) === 10) { index++ } lineStart = index + 1 } if (lineStart <= output.length) { yield output.slice(lineStart) } } export function withOpenAiThinking( id: string ): Pick { return /(?:gpt-5|codex)/i.test(id) ? { thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' } : {} } 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) const parsed = JSON.parse(stdout) as { models?: { slug?: string display_name?: string supported_reasoning_levels?: { effort?: string }[] default_reasoning_level?: string }[] } return uniqueModels( (parsed.models ?? []) .filter((model) => model.slug && model.display_name) .map((model) => ({ id: model.slug!, label: model.display_name!, ...(model.supported_reasoning_levels?.length ? { thinkingLevels: model.supported_reasoning_levels .map((level) => level.effort) .filter((effort): effort is string => Boolean(effort)) .map((effort) => ({ id: effort, label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort) })), defaultThinkingLevel: model.default_reasoning_level ?? 'low' } : {}) })) ) } catch { return [] } } export function parseLineModels(stdout: string): CommitMessageModel[] { const models: CommitMessageModel[] = [] for (const rawLine of iterateModelOutputLines(stdout)) { const id = rawLine.trim() if (id.length === 0 || id.includes(' ')) { continue } models.push({ id, label: labelFromModelId(id), ...withOpenAiThinking(id) }) } return uniqueModels(models) } export function parsePiModels(stdout: string): CommitMessageModel[] { const models: CommitMessageModel[] = [] for (const rawLine of iterateModelOutputLines(stdout)) { const parts = getPiModelTableFields(rawLine, 6) if (parts.length < 6 || parts[0] === 'provider') { continue } const [provider, model, , , thinking] = parts const id = `${provider}/${model}` models.push({ id, label: `${labelFromModelId(provider)} ${labelFromModelId(model)}`, ...(thinking === 'yes' ? { thinkingLevels: [ { id: 'off', label: 'Off' }, { id: 'low', label: 'Low' }, { id: 'medium', label: 'Medium' }, { id: 'high', label: 'High' }, { id: 'xhigh', label: 'Extra High' } ], defaultThinkingLevel: 'low' } : {}) }) } return uniqueModels(models) } // Why: model discovery output can include paste-sized noisy lines; only the first fields matter. function getPiModelTableFields(line: string, maxFields: number): string[] { const fields: string[] = [] let tokenStart = -1 for (let index = 0; index <= line.length; index += 1) { const isEnd = index === line.length if (!isEnd && !isPiModelTableWhitespace(line.charCodeAt(index))) { if (tokenStart === -1) { tokenStart = index } continue } if (tokenStart !== -1) { fields.push(line.slice(tokenStart, index)) tokenStart = -1 if (fields.length >= maxFields) { break } } } return fields } function isPiModelTableWhitespace(code: number): boolean { return ( code === 32 || (code >= 9 && code <= 13) || code === 160 || code === 5760 || (code >= 8192 && code <= 8202) || code === 8232 || code === 8233 || code === 8239 || code === 8287 || code === 12288 || code === 65279 ) } export function parseCursorModels(stdout: string): CommitMessageModel[] { const models: CommitMessageModel[] = [] for (const rawLine of iterateModelOutputLines(stdout)) { const match = /^([^\s]+)\s+-\s+(.+)$/.exec(rawLine.trim()) if (!match) { continue } models.push({ id: match[1], label: match[2].replace(/\s+\((?:default|current)\)$/i, ''), ...withOpenAiThinking(match[1]) }) } return uniqueModels(models) } export function parseAntigravityModels(stdout: string): CommitMessageModel[] { const models: CommitMessageModel[] = [] for (const rawLine of iterateModelOutputLines(stdout)) { const id = rawLine.trim() if (id.length === 0) { continue } models.push({ id, label: id }) } return uniqueModels(models) }