Files
orca/src/shared/commit-message-model-parsers.ts
T

249 lines
6.9 KiB
TypeScript

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<string>()
return models.filter((model) => {
if (!model.id || seen.has(model.id)) {
return false
}
seen.add(model.id)
return true
})
}
function* iterateModelOutputLines(output: string): Generator<string> {
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<CommitMessageModel, 'thinkingLevels' | 'defaultThinkingLevel'> {
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)
}