import type { ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts"; import type { Usage } from "../../shared/types.ts"; import { checkModelScope, type ModelScopeConfig, type ModelScopeViolation, type ModelSource } from "./model-scope.ts"; export type { AvailableModelInfo }; interface ModelAttemptSummary { model: string; success: boolean; exitCode?: number | null; error?: string; usage?: Usage; } export function splitThinkingSuffix(model: string): { baseModel: string; thinkingSuffix: string } { const colonIdx = model.lastIndexOf(":"); if (colonIdx === -1) return { baseModel: model, thinkingSuffix: "" }; return { baseModel: model.substring(0, colonIdx), thinkingSuffix: model.substring(colonIdx), }; } /** Sentinel model value requesting that a subagent inherit the parent session's model. */ export const INHERIT_MODEL = "inherit"; /** Minimal shape of the parent session's in-memory model (`ctx.model`). */ export interface ParentModel { provider: string; id: string; } /** * Normalize a model id or provider segment for fuzzy comparison: case-fold, * treat dots/underscores as dashes (so `4.5` matches `4-5`), and collapse * repeated separators. Pure. */ export function normalizeModelSegment(segment: string): string { return segment .toLowerCase() .replace(/[._]+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } function isPlausibleDateStamp(year: string, month: string, day: string): boolean { const yyyy = Number(year); const mm = Number(month); const dd = Number(day); return yyyy >= 1900 && yyyy <= 2099 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31; } /** Drop a trailing date stamp (`-20251001` or `-2025-10-01`) so dated and undated ids match. Pure. */ function stripTrailingDateStamp(segment: string): string { const dashed = /^(.*)-(\d{4})-(\d{2})-(\d{2})$/.exec(segment); if (dashed && isPlausibleDateStamp(dashed[2]!, dashed[3]!, dashed[4]!)) return dashed[1]!; const compact = /^(.*)-(\d{4})(\d{2})(\d{2})$/.exec(segment); if (compact && isPlausibleDateStamp(compact[2]!, compact[3]!, compact[4]!)) return compact[1]!; return segment; } function resolveBaseModelCandidate( baseModel: string, availableModels: AvailableModelInfo[], preferredProvider?: string, ): string | undefined { if (baseModel.includes("/")) { const exact = availableModels.find((entry) => entry.fullId === baseModel); if (exact) return exact.fullId; } else { const exactMatches = availableModels.filter((entry) => entry.id === baseModel); if (preferredProvider) { const preferredMatch = exactMatches.find((entry) => entry.provider === preferredProvider); if (preferredMatch) return preferredMatch.fullId; } if (exactMatches.length === 1) return exactMatches[0]!.fullId; } return fuzzyResolveModel(baseModel, availableModels, preferredProvider); } /** * Fuzzy-resolve a base model id (thinking suffix already stripped) against the * registry, tolerating separator, case, and optional date-stamp differences so * users do not have to spell provider/model exactly. A qualified `provider/id` * query only matches within the named provider — this never silently switches * providers for security/cost-sensitive configs. Returns the matched `fullId`, * or `undefined` when there is no match or the match is ambiguous across * providers (and no `preferredProvider` disambiguates). Pure. */ export function fuzzyResolveModel( baseModel: string, availableModels: AvailableModelInfo[], preferredProvider?: string, ): string | undefined { let queryProvider: string | undefined; let queryIdRaw = baseModel; const slashIdx = baseModel.indexOf("/"); if (slashIdx !== -1) { queryProvider = normalizeModelSegment(baseModel.slice(0, slashIdx)); queryIdRaw = baseModel.slice(slashIdx + 1); } else { const providerSeparators = [":", "."]; for (const separator of providerSeparators) { const separatorIdx = baseModel.indexOf(separator); if (separatorIdx <= 0) continue; const providerPart = normalizeModelSegment(baseModel.slice(0, separatorIdx)); if (!availableModels.some((entry) => normalizeModelSegment(entry.provider) === providerPart)) continue; queryProvider = providerPart; queryIdRaw = baseModel.slice(separatorIdx + 1); break; } } const queryId = normalizeModelSegment(queryIdRaw); const queryIdNoDate = stripTrailingDateStamp(queryId); const candidates = availableModels.filter((entry) => { const entryId = normalizeModelSegment(entry.id); if (entryId !== queryId && stripTrailingDateStamp(entryId) !== queryIdNoDate) return false; if (queryProvider !== undefined && normalizeModelSegment(entry.provider) !== queryProvider) return false; return true; }); if (candidates.length === 0) return undefined; if (preferredProvider) { const preferredProviderNorm = normalizeModelSegment(preferredProvider); const preferred = candidates.find((entry) => normalizeModelSegment(entry.provider) === preferredProviderNorm); if (preferred) return preferred.fullId; } if (candidates.length === 1) return candidates[0]!.fullId; return undefined; } /** * Resolve a possibly-loose model id to a canonical `provider/id` (plus any * thinking suffix). Exact registry matches win; fuzzy normalization * (separator/case/date-stamp via {@link fuzzyResolveModel}) is a fallback so * spelling differences still resolve. Never switches providers for a qualified * query. Pure. */ export function resolveModelCandidate( model: string | undefined, availableModels: AvailableModelInfo[] | undefined, preferredProvider?: string, ): string | undefined { if (!model) return undefined; if (!availableModels || availableModels.length === 0) return model; const resolvedWhole = resolveBaseModelCandidate(model, availableModels, preferredProvider); if (resolvedWhole) return resolvedWhole; const { baseModel, thinkingSuffix } = splitThinkingSuffix(model); if (!thinkingSuffix) return model; const resolvedBase = resolveBaseModelCandidate(baseModel, availableModels, preferredProvider); if (resolvedBase) return `${resolvedBase}${thinkingSuffix}`; return model; } export interface ResolveSubagentModelOverrideOptions { /** When set with `enforce: true`, out-of-scope models are rejected. */ scope?: ModelScopeConfig; /** Origin of the requested model: explicit caller-supplied (hard error) vs inherited (warn). Defaults to `"inherited"`. */ source?: ModelSource; /** Called for warn-severity violations instead of `console.warn`. */ onWarn?: (violation: ModelScopeViolation) => void; } function defaultScopeWarn(violation: ModelScopeViolation): void { console.warn(`[pi-subagents] ${violation.message}`); } /** * Resolve the `--model` override passed to a spawned subagent. * * When no model is requested (`undefined`, `false`, empty, or the `"inherit"` * sentinel), the child must inherit the parent session's *in-memory* model * (`provider/id`) instead of being left to resolve its own model. Without an * explicit `provider/id`, the child falls back to the global * `~/.selesai/agent/settings.json` default, which is shared across every open PI * session — so a different session that last changed its model in the TUI would * silently contaminate this session's subagents (see issue #266). Passing an * explicit `provider/id` keeps each session's children isolated to that * session's model. * * An explicitly requested model string is resolved via {@link resolveModelCandidate}. * When `options.scope.enforce` is on, an out-of-scope resolved model throws for * an explicit (`source: "explicit"`) request and warns for an inherited one. */ export function resolveSubagentModelOverride( requestedModel: string | boolean | undefined, parentModel: ParentModel | undefined, availableModels: AvailableModelInfo[] | undefined, preferredProvider?: string, options?: ResolveSubagentModelOverrideOptions, ): string | undefined { const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : ""; const explicit = trimmed && trimmed !== INHERIT_MODEL ? trimmed : undefined; let resolved: string | undefined; if (explicit === undefined) { resolved = parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined; } else { resolved = resolveModelCandidate(explicit, availableModels, preferredProvider); } if (resolved && options?.scope?.enforce) { const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited"); const violation = checkModelScope(resolved, options.scope, source); if (violation) { if (violation.severity === "error") throw new Error(violation.message); (options.onWarn ?? defaultScopeWarn)(violation); } } return resolved; } export interface BuildModelCandidatesOptions { /** Fallback models are inherited agent config and warn, rather than error, when out of scope. */ scope?: ModelScopeConfig; onWarn?: (violation: ModelScopeViolation) => void; } export function buildModelCandidates( primaryModel: string | undefined, fallbackModels: string[] | undefined, availableModels: AvailableModelInfo[] | undefined, preferredProvider?: string, options?: BuildModelCandidatesOptions, ): string[] { const seen = new Set(); const candidates: string[] = []; const rawCandidates = [primaryModel, ...(fallbackModels ?? [])]; for (let index = 0; index < rawCandidates.length; index++) { const raw = rawCandidates[index]; if (!raw) continue; const normalized = resolveModelCandidate(raw.trim(), availableModels, preferredProvider); if (!normalized || seen.has(normalized)) continue; if (index > 0 && options?.scope?.enforce) { const violation = checkModelScope(normalized, options.scope, "inherited"); if (violation) (options.onWarn ?? defaultScopeWarn)(violation); } seen.add(normalized); candidates.push(normalized); } return candidates; } const RETRYABLE_MODEL_FAILURE_PATTERNS = [ /rate\s*limit/i, /too many requests/i, /\b429\b/, /quota/i, /billing/i, /credit/i, /auth(?:entication)?/i, /unauthori[sz]ed/i, /forbidden/i, /api key/i, /token expired/i, /invalid key/i, /provider.*unavailable/i, /model.*unavailable/i, /model.*disabled/i, /model.*not found/i, /unknown model/i, /overloaded/i, /service unavailable/i, /temporar(?:ily)? unavailable/i, /connection refused/i, /fetch failed/i, /network error/i, /socket hang up/i, /upstream/i, /timed? out/i, /timeout/i, /\b502\b/, /\b503\b/, /\b504\b/, /cold.?start/i, /empty response/i, /no output/i, /model.*(?:load|fail|error)/i, ]; export function isRetryableModelFailure(error: string | undefined): boolean { if (!error) return false; return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error)); } export function formatModelAttemptNote(attempt: ModelAttemptSummary, nextModel?: string): string { const failure = attempt.error?.trim() || `exit ${attempt.exitCode ?? 1}`; return nextModel ? `[fallback] ${attempt.model} failed: ${failure}. Retrying with ${nextModel}.` : `[fallback] ${attempt.model} failed: ${failure}.`; }