import { splitKnownThinkingSuffix as splitThinkingSuffix, type ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts"; import { checkModelScope, SCOPED_PATTERN, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts"; export type { AvailableModelInfo }; export interface ModelSelectionEvidence { model?: string; requestedModel?: string; } export { splitThinkingSuffix }; /** * Aliases apply only to the resolved launch candidate (without its thinking suffix) and the exact raw response ID. * When the child's live selection is a Pi virtual model, assistant messages name the physical model it dispatched * to, so the virtual selection's provider/id is compared exactly with the launch candidate instead. */ export function formatSubagentModelVerificationError( expectedModel: string, observedModel: string, availableModels: AvailableModelInfo[] | undefined, modelResponseAliases?: Record, observedVirtualModel?: string, ): string | undefined { if (!availableModels || availableModels.length === 0) return undefined; const expectedBase = splitThinkingSuffix(expectedModel).baseModel; if (observedVirtualModel !== undefined) { return observedVirtualModel === expectedBase ? undefined : `model_verification_failed: native Pi child selected virtual model '${observedVirtualModel}', but the launch candidate was '${expectedModel}'. modelResponseAliases does not apply to a virtual-model selection.`; } if (modelResponseAliases && Object.hasOwn(modelResponseAliases, expectedBase) && modelResponseAliases[expectedBase]?.includes(observedModel)) return undefined; const observedBase = splitThinkingSuffix(observedModel).baseModel; if (expectedBase === observedBase) return undefined; const expectedEntry = availableModels.find((entry) => entry.fullId === expectedBase); if (expectedEntry) { if (expectedEntry.id === observedBase) return undefined; const expectedIdLeaf = expectedEntry.id.slice(expectedEntry.id.lastIndexOf("/") + 1); const expectedFullIdLeaf = expectedEntry.fullId.slice(expectedEntry.fullId.lastIndexOf("/") + 1); if (expectedIdLeaf === observedBase || expectedFullIdLeaf === observedBase) return undefined; } return `model_verification_failed: native Pi child reported a different model than the launch candidate. Expected '${expectedModel}' but observed '${observedModel}'. If you have independently verified this response ID identifies the requested model, declare the exact mapping in modelResponseAliases in ~/.selesai/agent/extensions/subagent/config.json (see docs/configuration.md#modelresponsealiases). Use the resolved provider/model ID without its thinking suffix as the key. This leaves the outgoing request unchanged. Configuration changes affect new independent native runs; resumed native runs retain their launch-time declaration. External CLI adapters do not use this setting.`; } /** 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; } export function normalizeParentModel(model: unknown): ParentModel | undefined { if (!model || typeof model !== "object") return undefined; const candidate = model as { provider?: unknown; id?: unknown }; if (typeof candidate.provider !== "string" || typeof candidate.id !== "string") return undefined; if (!candidate.provider || !candidate.id) return undefined; return { provider: candidate.provider, id: candidate.id }; } /** * Provider/id ids of the host session's scoped-model snapshot (`ctx.scopedModels`). * Pi reports an empty set when the session is unscoped (nothing configured, or no * pattern matched), and then allows every model; `scoped` narrows that to `inherit`. */ export function scopedModelIdsFromContext(ctx: { scopedModels?: ReadonlyArray<{ model?: unknown }> }): string[] { const scoped = ctx.scopedModels; if (!Array.isArray(scoped)) return []; const ids: string[] = []; for (const entry of scoped) { const model = entry?.model; const normalized = normalizeParentModel(model); if (normalized) ids.push(`${normalized.provider}/${normalized.id}`); } return ids; } /** * 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. */ 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. */ 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 isRegisteredProvider(provider: string, availableModels: AvailableModelInfo[]): boolean { const normalized = normalizeModelSegment(provider); return availableModels.some((entry) => normalizeModelSegment(entry.provider) === normalized); } /** * Split `provider/id` only when the first path segment is a registered provider. * Hugging Face-style `owner/name` ids therefore stay intact unless `owner` is * itself a provider in the active registry. `:` and `.` keep the same rule. */ function splitQualifiedModelQuery( baseModel: string, availableModels: AvailableModelInfo[], ): { queryProvider?: string; queryIdRaw: string } { const slashIdx = baseModel.indexOf("/"); if (slashIdx !== -1) { const providerPart = baseModel.slice(0, slashIdx); if (isRegisteredProvider(providerPart, availableModels)) { return { queryProvider: normalizeModelSegment(providerPart), queryIdRaw: baseModel.slice(slashIdx + 1) }; } return { queryIdRaw: baseModel }; } const providerSeparators = [":", "."]; for (const separator of providerSeparators) { const separatorIdx = baseModel.indexOf(separator); if (separatorIdx <= 0) continue; const providerPart = baseModel.slice(0, separatorIdx); if (!isRegisteredProvider(providerPart, availableModels)) continue; return { queryProvider: normalizeModelSegment(providerPart), queryIdRaw: baseModel.slice(separatorIdx + 1) }; } return { queryIdRaw: baseModel }; } function resolveExactIdMatches( baseModel: string, availableModels: AvailableModelInfo[], preferredProvider?: string, ): string | undefined { 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 undefined; } function resolveBaseModelCandidate( baseModel: string, availableModels: AvailableModelInfo[], preferredProvider?: string, ): string | undefined { const exact = availableModels.find((entry) => entry.fullId === baseModel); if (exact) return exact.fullId; const { queryProvider } = splitQualifiedModelQuery(baseModel, availableModels); if (queryProvider === undefined) { const exactId = resolveExactIdMatches(baseModel, availableModels, preferredProvider); if (exactId) return exactId; } const fuzzy = fuzzyResolveModel(baseModel, availableModels, preferredProvider); if (fuzzy || queryProvider === undefined) return fuzzy; // Some catalogs (OpenRouter's `openrouter/auto-beta`) repeat the provider inside the id. const queryId = normalizeModelSegment(baseModel); const prefixedIdMatches = availableModels.filter((entry) => normalizeModelSegment(entry.id) === queryId && normalizeModelSegment(entry.provider) === queryProvider); return prefixedIdMatches.length === 1 ? prefixedIdMatches[0]!.fullId : undefined; } /** * 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 slash is a provider * prefix only when that prefix is a registered provider; otherwise the whole * string is the model id (Hugging Face `owner/name`). A qualified provider * 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). */ export function fuzzyResolveModel( baseModel: string, availableModels: AvailableModelInfo[], preferredProvider?: string, ): string | undefined { const { queryProvider, queryIdRaw } = splitQualifiedModelQuery(baseModel, availableModels); 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. */ 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; } function resolveSubagentModelCandidate( model: string, availableModels: AvailableModelInfo[] | undefined, preferredProvider?: string, ): string | undefined { if (!availableModels || availableModels.length === 0) return model; const resolvedWhole = resolveBaseModelCandidate(model, availableModels, preferredProvider); if (resolvedWhole) return resolvedWhole; const { baseModel, thinkingSuffix } = splitThinkingSuffix(model); const resolvedBase = thinkingSuffix ? resolveBaseModelCandidate(baseModel, availableModels, preferredProvider) : undefined; return resolvedBase ? `${resolvedBase}${thinkingSuffix}` : undefined; } function suggestAlternateProviderModel( model: string, availableModels: AvailableModelInfo[] | undefined, ): string | undefined { if (!availableModels || availableModels.length === 0) return undefined; const { baseModel, thinkingSuffix } = splitThinkingSuffix(model); const { queryProvider, queryIdRaw } = splitQualifiedModelQuery(baseModel, availableModels); if (queryProvider === undefined) return undefined; const suggestion = resolveBaseModelCandidate(queryIdRaw, availableModels); if (!suggestion) return undefined; const matched = availableModels.find((entry) => entry.fullId === suggestion); if (!matched || normalizeModelSegment(matched.provider) === queryProvider) return undefined; return `${suggestion}${thinkingSuffix}`; } function resolveRequiredSubagentModelCandidate( model: string, availableModels: AvailableModelInfo[] | undefined, preferredProvider?: string, ): string { const resolved = resolveSubagentModelCandidate(model, availableModels, preferredProvider); if (resolved) return resolved; const suggestion = suggestAlternateProviderModel(model, availableModels); throw new Error( `Unknown subagent model '${model}' in the active Pi model registry.${suggestion ? ` Did you mean '${suggestion}'?` : ""}`, ); } export interface ResolveSubagentModelOverrideOptions { /** When set with `enforce: true`, out-of-scope models are rejected. */ scope?: ModelScopeCheckRule | ModelScopeCheckRule[]; /** 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}`); } function configuredScopes(scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined): ModelScopeCheckRule[] { return scope ? (Array.isArray(scope) ? scope : [scope]) : []; } function throwForUnresolvedEnforcedReservedScope(scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined, includeMixed = false): void { const unresolvedReservedScope = configuredScopes(scope) .find((entry) => entry.enforce === true && (includeMixed ? entry.allow?.some((pattern) => pattern === INHERIT_MODEL || pattern === SCOPED_PATTERN) : entry.allow?.length === 1 && (entry.allow[0] === INHERIT_MODEL || entry.allow[0] === SCOPED_PATTERN))); if (!unresolvedReservedScope) return; const origin = unresolvedReservedScope.origin ?? "modelScope"; const token = unresolvedReservedScope.allow?.includes(INHERIT_MODEL) ? INHERIT_MODEL : SCOPED_PATTERN; throw new Error(`Cannot enforce subagent model scope (${origin}): '${token}' requires a current parent session model.`); } function enforceModelScopes( model: string, scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined, source: ModelSource, onWarn: ((violation: ModelScopeViolation) => void) | undefined, ): void { const violations = configuredScopes(scope) .map((entry) => checkModelScope(model, entry, source)) .filter((violation): violation is ModelScopeViolation => violation !== undefined); const error = violations.find((violation) => violation.severity === "error"); if (error) throw new Error(error.message); for (const violation of violations) (onWarn ?? defaultScopeWarn)(violation); } /** * 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, * unless strict scope enforcement makes inherited violations hard errors. */ 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; if (!parentModel) throwForUnresolvedEnforcedReservedScope(options?.scope, explicit === undefined || options?.source === "inherited"); let resolved: string | undefined; let resolvedFromRegistry = explicit === undefined; if (explicit === undefined) { resolved = parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined; } else { const candidate = resolveSubagentModelCandidate(explicit, availableModels, preferredProvider); if (options?.source === "explicit") { resolved = candidate ?? resolveRequiredSubagentModelCandidate(explicit, availableModels, preferredProvider); resolvedFromRegistry = true; } else if (candidate) { resolved = candidate; resolvedFromRegistry = true; } else { resolved = explicit; } } if (resolved && options?.scope && resolvedFromRegistry) { const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited"); enforceModelScopes(resolved, options.scope, source, options.onWarn); } return resolved; } export function resolveEffectiveSubagentModel( explicitModel: string | boolean | undefined, agentModel: string | boolean | undefined, parentModel: ParentModel | undefined, availableModels: AvailableModelInfo[] | undefined, preferredProvider?: string, options?: ResolveSubagentModelOverrideOptions, ): string | undefined { const source = options?.source ?? (explicitModel !== undefined ? "explicit" : "inherited"); const resolved = resolveSubagentModelOverride( explicitModel ?? agentModel, parentModel, availableModels, preferredProvider, { ...options, source }, ); if (resolved || explicitModel === undefined) return resolved; return resolveSubagentModelOverride( agentModel, parentModel, availableModels, preferredProvider, { ...options, source: options?.source ?? "inherited" }, ); } export type ModelOrigin = ModelSource | "configured"; export interface ResolveModelSelectionOptions { scope?: ModelScopeCheckRule | ModelScopeCheckRule[]; onWarn?: (violation: ModelScopeViolation) => void; /** The primary model came from the running parent session, not configuration. */ primaryModelFromParent?: boolean; /** How the model was selected. */ origin?: ModelOrigin; } export function resolveModelOrigin(input: { explicitModel?: string | boolean; agentModel?: string | boolean; parentModel?: ParentModel; fromParent?: boolean; storedOrigin?: ModelOrigin; }): ModelOrigin { if (input.storedOrigin) return input.storedOrigin; if (input.fromParent) return "inherited"; if (inheritsParentModel(input.explicitModel, input.agentModel, input.parentModel)) return "inherited"; const trimmed = typeof input.explicitModel === "string" ? input.explicitModel.trim() : ""; return trimmed && trimmed !== INHERIT_MODEL ? "explicit" : "configured"; } export function inheritsParentModel( explicitModel: string | boolean | undefined, agentModel: string | boolean | undefined, parentModel: ParentModel | undefined, ): boolean { const requestedModel = explicitModel ?? agentModel; const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : ""; return Boolean(parentModel && (!trimmed || trimmed === INHERIT_MODEL)); } export function resolveModelSelection( model: string | undefined, availableModels: AvailableModelInfo[] | undefined, preferredProvider?: string, options?: ResolveModelSelectionOptions, ): ModelSelectionEvidence { if (!model) throwForUnresolvedEnforcedReservedScope(options?.scope, true); const origin = options?.origin ?? (options?.primaryModelFromParent ? "inherited" : "configured"); const requestedModel = origin === "inherited" ? undefined : model; const scopes = configuredScopes(options?.scope); if (origin === "explicit" && model) { const normalized = resolveRequiredSubagentModelCandidate(model.trim(), availableModels, preferredProvider); enforceModelScopes(normalized, scopes, "explicit", options?.onWarn); model = normalized; } const resolved = model && (origin === "inherited" || origin === "explicit" || options?.primaryModelFromParent) ? model.trim() : model ? resolveRequiredSubagentModelCandidate(model.trim(), availableModels, preferredProvider) : undefined; if (resolved && scopes.some((scope) => scope.enforce === true && scope.strict === true)) { enforceModelScopes(resolved, scopes, "inherited", options?.onWarn); } return { ...(resolved ? { model: resolved } : {}), ...(requestedModel ? { requestedModel } : {}) }; } /** Context-overflow signals used to surface a clear input-too-large error. */ const CONTEXT_OVERFLOW_PATTERNS = [ /context(?: length| window| limit)? (?:exceed|overflow|too long)/i, /maximum context length/i, /too many tokens/i, /token limit/i, /context_length_exceeded/i, /length_required/i, /maximum.*tokens/i, /prompt.*too long/i, /input.*too long/i, /exceeded.*context/i, /context.*overflow/i, ]; export function isContextOverflow(error: string | undefined): boolean { if (!error) return false; if (/^[\w.:@/-]+ failed (?:(?:\(exit \d+\):)|(?:with exit code \d+))(?:\s|$)/i.test(error.trim())) return false; return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(error)); }