* Model resolution, scoping, and initial selection
*/
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import {
type Api,
clampThinkingLevelForModel,
DEFAULT_MODEL_PER_PROVIDER,
type Effort,
type KnownProvider,
type Model,
modelsAreEqual,
} from "@oh-my-pi/pi-ai";
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
import { logger } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import MODEL_PRIO from "../priority.json" with { type: "json" };
import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
import type { Settings } from "./settings";
export const defaultModelPerProvider: Record<KnownProvider, string> = DEFAULT_MODEL_PER_PROVIDER;
export interface ScopedModel {
model: Model<Api>;
thinkingLevel?: ThinkingLevel;
explicitThinkingLevel: boolean;
}
* Parse a model string in "provider/modelId" format.
* Returns undefined if the format is invalid.
*/
export function parseModelString(
modelStr: string,
): { provider: string; id: string; thinkingLevel?: ThinkingLevel } | undefined {
const slashIdx = modelStr.indexOf("/");
if (slashIdx <= 0) return undefined;
const id = modelStr.slice(slashIdx + 1);
const provider = modelStr.slice(0, slashIdx);
const colonIdx = id.lastIndexOf(":");
if (colonIdx !== -1) {
const suffix = id.slice(colonIdx + 1);
const thinkingLevel = parseThinkingLevel(suffix);
if (thinkingLevel) {
return { provider, id: id.slice(0, colonIdx), thinkingLevel };
}
}
return { provider, id };
}
* Format a model as "provider/modelId" string.
*/
export function formatModelString(model: Model<Api>): string {
return `${model.provider}/${model.id}`;
}
export function formatModelSelectorValue(selector: string, thinkingLevel: ThinkingLevel | undefined): string {
return thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit ? `${selector}:${thinkingLevel}` : selector;
}
function getOpenRouterRouteSuffix(modelId: string): { baseId: string; suffix: string } | undefined {
const colonIdx = modelId.lastIndexOf(":");
if (colonIdx === -1) {
return undefined;
}
const suffix = modelId.slice(colonIdx + 1).trim();
if (!suffix || parseThinkingLevel(suffix)) {
return undefined;
}
return { baseId: modelId.slice(0, colonIdx), suffix };
}
function stripOpenRouterDateSuffix(modelId: string): string | undefined {
const stripped = modelId.replace(/-\d{8}(?=$|:)/i, "");
return stripped !== modelId ? stripped : undefined;
}
function getOpenRouterFallbackModelIds(modelId: string): string[] {
const orderedCandidates: string[] = [];
const queue = [modelId];
const seen = new Set<string>();
while (queue.length > 0) {
const candidate = queue.shift();
if (!candidate || seen.has(candidate)) {
continue;
}
seen.add(candidate);
orderedCandidates.push(candidate);
const routedSuffix = getOpenRouterRouteSuffix(candidate);
if (routedSuffix) {
queue.push(routedSuffix.baseId);
}
const strippedDate = stripOpenRouterDateSuffix(candidate);
if (strippedDate) {
queue.push(strippedDate);
}
}
return orderedCandidates;
}
function cloneModelWithRequestedId(model: Model<Api>, requestedId: string): Model<Api> {
return {
...model,
id: requestedId,
...(model.name === model.id ? { name: requestedId } : {}),
};
}
const kProviderModelIndex = Symbol("model-resolver.providerIndex");
type ModelsWithProviderIndex = readonly Model<Api>[] & {
[kProviderModelIndex]?: Map<string, Model<Api> | null>;
};
function getProviderModelIndex(availableModels: readonly Model<Api>[]): Map<string, Model<Api> | null> {
const tagged = availableModels as ModelsWithProviderIndex;
const cached = tagged[kProviderModelIndex];
if (cached) return cached;
const index = new Map<string, Model<Api> | null>();
for (const m of availableModels) {
const key = `${m.provider.toLowerCase()}\u0000${m.id.toLowerCase()}`;
if (index.has(key)) {
index.set(key, null);
} else {
index.set(key, m);
}
}
tagged[kProviderModelIndex] = index;
return index;
}
export function resolveProviderModelReference(
provider: string,
modelId: string,
availableModels: readonly Model<Api>[],
): Model<Api> | undefined {
const normalizedProvider = provider.trim().toLowerCase();
const normalizedModelId = modelId.trim().toLowerCase();
if (!normalizedProvider || !normalizedModelId) {
return undefined;
}
const index = getProviderModelIndex(availableModels);
const exact = index.get(`${normalizedProvider}\u0000${normalizedModelId}`);
if (exact === null) {
return undefined;
}
if (exact !== undefined) {
return exact;
}
if (normalizedProvider !== "openrouter") {
return undefined;
}
for (const fallbackId of getOpenRouterFallbackModelIds(modelId).slice(1)) {
const fallback = index.get(`${normalizedProvider}\u0000${fallbackId.toLowerCase()}`);
if (fallback === null) {
return undefined;
}
if (fallback !== undefined) {
return cloneModelWithRequestedId(fallback, modelId);
}
}
return undefined;
}
export interface ModelMatchPreferences {
usageOrder?: string[];
deprioritizeProviders?: string[];
}
export type CanonicalModelRegistry = Partial<
Pick<ModelRegistry, "resolveCanonicalModel" | "getCanonicalVariants" | "getCanonicalId">
>;
export type ModelLookupRegistry = Pick<ModelRegistry, "getAvailable"> & Partial<CanonicalModelRegistry>;
type CliModelRegistry = Pick<ModelRegistry, "getAll"> & Partial<CanonicalModelRegistry>;
type InitialModelRegistry = Pick<ModelRegistry, "getAvailable" | "find">;
type RestorableModelRegistry = Pick<ModelRegistry, "getAvailable" | "find" | "getApiKey">;
interface ModelPreferenceContext {
modelUsageRank: Map<string, number>;
providerUsageRank: Map<string, number>;
deprioritizedProviders: Set<string>;
modelOrder: Map<string, number>;
}
function buildPreferenceContext(
availableModels: Model<Api>[],
preferences: ModelMatchPreferences | undefined,
): ModelPreferenceContext {
const modelUsageRank = new Map<string, number>();
const providerUsageRank = new Map<string, number>();
const usageOrder = preferences?.usageOrder ?? [];
for (let i = 0; i < usageOrder.length; i += 1) {
const key = usageOrder[i];
if (!modelUsageRank.has(key)) {
modelUsageRank.set(key, i);
}
const parsed = parseModelString(key);
if (parsed && !providerUsageRank.has(parsed.provider)) {
providerUsageRank.set(parsed.provider, i);
}
}
const deprioritizedProviders = new Set(preferences?.deprioritizeProviders ?? ["openrouter"]);
const modelOrder = new Map<string, number>();
for (let i = 0; i < availableModels.length; i += 1) {
modelOrder.set(formatModelString(availableModels[i]), i);
}
return { modelUsageRank, providerUsageRank, deprioritizedProviders, modelOrder };
}
function pickPreferredModel(candidates: Model<Api>[], context: ModelPreferenceContext): Model<Api> {
if (candidates.length <= 1) return candidates[0];
return [...candidates].sort((a, b) => {
const aKey = formatModelString(a);
const bKey = formatModelString(b);
const aUsage = context.modelUsageRank.get(aKey);
const bUsage = context.modelUsageRank.get(bKey);
if (aUsage !== undefined || bUsage !== undefined) {
return (aUsage ?? Number.POSITIVE_INFINITY) - (bUsage ?? Number.POSITIVE_INFINITY);
}
const aProviderUsage = context.providerUsageRank.get(a.provider);
const bProviderUsage = context.providerUsageRank.get(b.provider);
if (aProviderUsage !== undefined || bProviderUsage !== undefined) {
return (aProviderUsage ?? Number.POSITIVE_INFINITY) - (bProviderUsage ?? Number.POSITIVE_INFINITY);
}
const aDeprioritized = context.deprioritizedProviders.has(a.provider);
const bDeprioritized = context.deprioritizedProviders.has(b.provider);
if (aDeprioritized !== bDeprioritized) {
return aDeprioritized ? 1 : -1;
}
const aOrder = context.modelOrder.get(aKey) ?? 0;
const bOrder = context.modelOrder.get(bKey) ?? 0;
return aOrder - bOrder;
})[0];
}
* Helper to check if a model ID looks like an alias (no date suffix)
* Dates are typically in format: -20241022 or -20250929
*/
function isAlias(id: string): boolean {
if (id.endsWith("-latest")) return true;
const datePattern = /-\d{8}$/;
return !datePattern.test(id);
}
* Find an exact explicit provider/model match.
* Bare model ids are handled separately so canonical ids can coalesce variants.
*/
export function findExactModelReferenceMatch(
modelReference: string,
availableModels: Model<Api>[],
): Model<Api> | undefined {
const trimmedReference = modelReference.trim();
if (!trimmedReference) {
return undefined;
}
const slashIndex = trimmedReference.indexOf("/");
if (slashIndex !== -1) {
const provider = trimmedReference.substring(0, slashIndex).trim();
const modelId = trimmedReference.substring(slashIndex + 1).trim();
if (provider && modelId) {
return resolveProviderModelReference(provider, modelId, availableModels);
}
}
return undefined;
}
function findExactCanonicalModelMatch(
modelReference: string,
availableModels: Model<Api>[],
modelRegistry: CanonicalModelRegistry | undefined,
): Model<Api> | undefined {
if (!modelRegistry) {
return undefined;
}
const trimmedReference = modelReference.trim();
if (!trimmedReference || trimmedReference.includes("/")) {
return undefined;
}
return modelRegistry.resolveCanonicalModel?.(trimmedReference, {
availableOnly: false,
candidates: availableModels,
});
}
* Try to match a pattern to a model from the available models list.
* Returns the matched model or undefined if no match found.
*/
function tryMatchModel(
modelPattern: string,
availableModels: Model<Api>[],
context: ModelPreferenceContext,
options?: { modelRegistry?: CanonicalModelRegistry },
): Model<Api> | undefined {
const exactRefMatch = findExactModelReferenceMatch(modelPattern, availableModels);
if (exactRefMatch) {
return exactRefMatch;
}
const exactCanonicalMatch = findExactCanonicalModelMatch(modelPattern, availableModels, options?.modelRegistry);
if (exactCanonicalMatch) {
return exactCanonicalMatch;
}
const exactMatches = availableModels.filter(m => m.id.toLowerCase() === modelPattern.toLowerCase());
if (exactMatches.length > 0) {
return pickPreferredModel(exactMatches, context);
}
const slashIndex = modelPattern.indexOf("/");
if (slashIndex !== -1) {
const provider = modelPattern.substring(0, slashIndex);
const modelId = modelPattern.substring(slashIndex + 1);
const providerModels = availableModels.filter(m => m.provider.toLowerCase() === provider.toLowerCase());
if (providerModels.length === 0) {
} else {
const scored = providerModels
.map(model => ({ model, match: fuzzyMatch(modelId, model.id) }))
.filter(entry => entry.match.matches);
if (scored.length === 0) {
return undefined;
}
scored.sort((a, b) => {
if (a.match.score !== b.match.score) return a.match.score - b.match.score;
const aKey = formatModelString(a.model);
const bKey = formatModelString(b.model);
const aUsage = context.modelUsageRank.get(aKey) ?? Number.POSITIVE_INFINITY;
const bUsage = context.modelUsageRank.get(bKey) ?? Number.POSITIVE_INFINITY;
if (aUsage !== bUsage) return aUsage - bUsage;
const aProviderUsage = context.providerUsageRank.get(a.model.provider) ?? Number.POSITIVE_INFINITY;
const bProviderUsage = context.providerUsageRank.get(b.model.provider) ?? Number.POSITIVE_INFINITY;
if (aProviderUsage !== bProviderUsage) return aProviderUsage - bProviderUsage;
const aOrder = context.modelOrder.get(aKey) ?? 0;
const bOrder = context.modelOrder.get(bKey) ?? 0;
return aOrder - bOrder;
});
return scored[0]?.model;
}
}
const matches = availableModels.filter(
m =>
m.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
m.name?.toLowerCase().includes(modelPattern.toLowerCase()),
);
if (matches.length === 0) {
return undefined;
}
const aliases = matches.filter(m => isAlias(m.id));
const datedVersions = matches.filter(m => !isAlias(m.id));
if (aliases.length > 0) {
return pickPreferredModel(aliases, context);
}
if (datedVersions.length === 0) return undefined;
if (datedVersions.length === 1) {
return datedVersions[0];
}
const sortedById = [...datedVersions].sort((a, b) => b.id.localeCompare(a.id));
const topId = sortedById[0]?.id;
if (!topId) return undefined;
const topCandidates = sortedById.filter(model => model.id === topId);
return pickPreferredModel(topCandidates, context);
}
export interface ParsedModelResult {
model: Model<Api> | undefined;
thinkingLevel?: ThinkingLevel;
warning: string | undefined;
explicitThinkingLevel: boolean;
}
* Parse a pattern to extract model and thinking level.
* Handles models with colons in their IDs (e.g., OpenRouter's :exacto suffix).
*
* Algorithm:
* 1. Try to match full pattern as a model
* 2. If found, return it with undefined thinking level
* 3. If not found and has colons, split on last colon:
* - If suffix is valid thinking level, use it and recurse on prefix
* - If suffix is invalid, warn and recurse on prefix
*
* @internal Exported for testing
*/
function parseModelPatternWithContext(
pattern: string,
availableModels: Model<Api>[],
context: ModelPreferenceContext,
options?: { allowInvalidThinkingSelectorFallback?: boolean; modelRegistry?: CanonicalModelRegistry },
): ParsedModelResult {
const exactMatch = tryMatchModel(pattern, availableModels, context, options);
if (exactMatch) {
return { model: exactMatch, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
}
const lastColonIndex = pattern.lastIndexOf(":");
if (lastColonIndex === -1) {
return { model: undefined, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
}
const prefix = pattern.substring(0, lastColonIndex);
const suffix = pattern.substring(lastColonIndex + 1);
const parsedThinkingLevel = parseThinkingLevel(suffix);
if (parsedThinkingLevel) {
const result = parseModelPatternWithContext(prefix, availableModels, context, options);
if (result.model) {
const explicitThinkingLevel = !result.warning;
return {
model: result.model,
thinkingLevel: explicitThinkingLevel ? parsedThinkingLevel : undefined,
warning: result.warning,
explicitThinkingLevel,
};
}
return result;
}
const allowFallback = options?.allowInvalidThinkingSelectorFallback ?? true;
if (!allowFallback) {
return { model: undefined, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
}
const result = parseModelPatternWithContext(prefix, availableModels, context, options);
if (result.model) {
return {
model: result.model,
thinkingLevel: undefined,
warning: `Invalid thinking level "${suffix}" in pattern "${pattern}". Using default instead.`,
explicitThinkingLevel: false,
};
}
return result;
}
export function parseModelPattern(
pattern: string,
availableModels: Model<Api>[],
preferences?: ModelMatchPreferences,
options?: { allowInvalidThinkingSelectorFallback?: boolean; modelRegistry?: CanonicalModelRegistry },
): ParsedModelResult {
const context = buildPreferenceContext(availableModels, preferences);
return parseModelPatternWithContext(pattern, availableModels, context, options);
}
const PREFIX_MODEL_ROLE = "pi/";
const DEFAULT_MODEL_ROLE = "default";
function getModelRoleAlias(value: string): ModelRole | undefined {
const normalized = value.trim();
if (!normalized.startsWith(PREFIX_MODEL_ROLE)) return undefined;
const candidate = normalized.slice(PREFIX_MODEL_ROLE.length);
for (const role of MODEL_ROLE_IDS) {
if (candidate === role) return role;
}
return undefined;
}
function normalizeModelPatternList(value: string | string[] | undefined): string[] {
if (!value) return [];
const patterns = Array.isArray(value) ? value : value.split(",");
return patterns.map(pattern => pattern.trim()).filter(Boolean);
}
function isSessionInheritedAgentPattern(value: string): boolean {
return value === DEFAULT_MODEL_ROLE || value === `${PREFIX_MODEL_ROLE}${DEFAULT_MODEL_ROLE}` || value === "pi/task";
}
function resolveConfiguredRolePattern(value: string, settings?: Settings): string[] | undefined {
const normalized = value.trim();
if (!normalized) return undefined;
const lastColonIndex = normalized.lastIndexOf(":");
const thinkingLevel =
lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined;
const aliasCandidate = thinkingLevel ? normalized.slice(0, lastColonIndex) : normalized;
const role = getModelRoleAlias(aliasCandidate);
if (!role) return [normalized];
const configured = settings?.getModelRole(role)?.trim();
const roleDefaults = normalizeModelPatternList(MODEL_PRIO[role as keyof typeof MODEL_PRIO]);
const resolved = configured ? normalizeModelPatternList(configured) : roleDefaults;
if (!resolved || resolved.length === 0) {
return undefined;
}
return thinkingLevel ? resolved.map(pattern => `${pattern}:${thinkingLevel}`) : resolved;
}
* Expand a role alias like "pi/smol" to the configured model string.
*/
export function expandRoleAlias(value: string, settings?: Settings): string {
const normalized = value.trim();
if (normalized === DEFAULT_MODEL_ROLE) {
return settings?.getModelRole("default") ?? value;
}
const resolved = resolveConfiguredRolePattern(value, settings)?.[0];
return resolved ?? value;
}
export function resolveConfiguredModelPatterns(value: string | string[] | undefined, settings?: Settings): string[] {
const patterns = normalizeModelPatternList(value);
return patterns.flatMap(pattern => {
const resolved = resolveConfiguredRolePattern(pattern, settings);
return resolved ?? [];
});
}
export interface AgentModelPatternResolutionOptions {
settingsOverride?: string | string[];
agentModel?: string | string[];
settings?: Settings;
activeModelPattern?: string;
fallbackModelPattern?: string;
}
export function resolveAgentModelPatterns(options: AgentModelPatternResolutionOptions): string[] {
const { settingsOverride, agentModel, settings, activeModelPattern, fallbackModelPattern } = options;
const overridePatterns = resolveConfiguredModelPatterns(settingsOverride, settings);
if (overridePatterns.length > 0) return overridePatterns;
const normalizedAgentPatterns = normalizeModelPatternList(agentModel);
const configuredAgentPatterns = resolveConfiguredModelPatterns(agentModel, settings);
const singleAgentPattern = normalizedAgentPatterns.length === 1 ? normalizedAgentPatterns[0] : undefined;
const agentInheritsSessionModel = singleAgentPattern ? isSessionInheritedAgentPattern(singleAgentPattern) : false;
if (configuredAgentPatterns.length > 0) {
if (!agentInheritsSessionModel) return configuredAgentPatterns;
if (singleAgentPattern === "pi/task") return configuredAgentPatterns;
}
const fallback =
activeModelPattern?.trim() || fallbackModelPattern?.trim() || settings?.getModelRole("default")?.trim() || "";
return resolveConfiguredModelPatterns(fallback, settings);
}
* Resolve a model role value into a concrete model and thinking metadata.
*/
export interface ResolvedModelRoleValue {
model: Model<Api> | undefined;
thinkingLevel?: ThinkingLevel;
explicitThinkingLevel: boolean;
warning: string | undefined;
}
export function resolveModelRoleValue(
roleValue: string | undefined,
availableModels: Model<Api>[],
options?: { settings?: Settings; matchPreferences?: ModelMatchPreferences; modelRegistry?: CanonicalModelRegistry },
): ResolvedModelRoleValue {
if (!roleValue) {
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
}
const normalized = roleValue.trim();
if (!normalized || normalized === DEFAULT_MODEL_ROLE) {
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
}
const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings);
if (!effectivePatterns || effectivePatterns.length === 0) {
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
}
let warning: string | undefined;
for (const effectivePattern of effectivePatterns) {
const resolved = parseModelPattern(effectivePattern, availableModels, options?.matchPreferences, {
modelRegistry: options?.modelRegistry,
});
if (resolved.model) {
return {
model: resolved.model,
thinkingLevel: resolved.explicitThinkingLevel
? (resolveThinkingLevelForModel(resolved.model, resolved.thinkingLevel) ?? resolved.thinkingLevel)
: resolved.thinkingLevel,
explicitThinkingLevel: resolved.explicitThinkingLevel,
warning: resolved.warning,
};
}
if (!warning && resolved.warning) {
warning = resolved.warning;
}
}
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning };
}
export function extractExplicitThinkingSelector(
value: string | undefined,
settings?: Settings,
): ThinkingLevel | undefined {
if (!value) return undefined;
const normalized = value.trim();
if (!normalized || normalized === DEFAULT_MODEL_ROLE) return undefined;
const visited = new Set<string>();
let current = normalized;
while (!visited.has(current)) {
visited.add(current);
const lastColonIndex = current.lastIndexOf(":");
const thinkingSelector =
lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(current.slice(lastColonIndex + 1)) : undefined;
if (thinkingSelector) {
return thinkingSelector;
}
const expanded = expandRoleAlias(current, settings).trim();
if (!expanded || expanded === current) break;
if (expanded === DEFAULT_MODEL_ROLE) return undefined;
current = expanded;
}
return undefined;
}
* Resolve a model identifier or pattern to a Model instance.
*/
export function resolveModelFromString(
value: string,
available: Model<Api>[],
matchPreferences?: ModelMatchPreferences,
modelRegistry?: CanonicalModelRegistry,
): Model<Api> | undefined {
const parsed = parseModelString(value);
if (parsed) {
const exact = available.find(model => model.provider === parsed.provider && model.id === parsed.id);
if (exact) return exact;
}
return parseModelPattern(value, available, matchPreferences, { modelRegistry }).model;
}
* Resolve a model from configured roles, honoring order and overrides.
*/
export function resolveModelFromSettings(options: {
settings: Settings;
availableModels: Model<Api>[];
matchPreferences?: ModelMatchPreferences;
roleOrder?: readonly ModelRole[];
modelRegistry?: CanonicalModelRegistry;
}): Model<Api> | undefined {
const { settings, availableModels, matchPreferences, roleOrder, modelRegistry } = options;
const roles = roleOrder ?? MODEL_ROLE_IDS;
let sawConfiguredProviderQualifiedRole = false;
for (const role of roles) {
const configured = settings.getModelRole(role);
if (!configured) continue;
const expanded = expandRoleAlias(configured, settings).trim();
if (expanded.includes("/")) {
sawConfiguredProviderQualifiedRole = true;
}
const resolved = resolveModelFromString(expanded, availableModels, matchPreferences, modelRegistry);
if (resolved) return resolved;
}
return sawConfiguredProviderQualifiedRole ? undefined : availableModels[0];
}
* Resolve a list of override patterns to the first matching model.
*/
export function resolveModelOverride(
modelPatterns: string[],
modelRegistry: ModelLookupRegistry,
settings?: Settings,
): { model?: Model<Api>; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } {
if (modelPatterns.length === 0) return { explicitThinkingLevel: false };
const availableModels = modelRegistry.getAvailable();
const matchPreferences = { usageOrder: settings?.getStorage()?.getModelUsageOrder() };
for (const pattern of modelPatterns) {
const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(pattern, availableModels, {
settings,
matchPreferences,
modelRegistry,
});
if (model) {
return { model, thinkingLevel, explicitThinkingLevel };
}
}
return { explicitThinkingLevel: false };
}
* Resolve a list of override patterns to the first matching model, with an
* auth-aware fallback to the parent session's active model.
*
* If the resolved subagent model has no working credentials (provider has no
* usable auth), and the parent's active model resolves with working auth,
* use the parent's model instead. This prevents subagent dispatch from
* silently routing to a provider the user can't actually call (e.g.
* `modelRoles.task` pointing at an unqualified id whose only available
* provider variant has no configured credentials — see #985).
*
* Keyless-by-design providers (llama.cpp, ollama, lm-studio) advertise the
* `kNoAuth` sentinel from `getApiKey` to signal that they do not require
* credentials. Those are treated as authenticated here so an explicitly
* configured local model is never silently rerouted to the parent's remote
* provider (see #1008).
*
* If neither the subagent nor the parent has working auth, returns the
* primary resolution unchanged so the existing error path still surfaces
* a meaningful failure downstream.
*/
export async function resolveModelOverrideWithAuthFallback(
modelPatterns: string[],
parentActiveModelPattern: string | undefined,
modelRegistry: ModelLookupRegistry & Pick<ModelRegistry, "getApiKey">,
settings?: Settings,
): Promise<{
model?: Model<Api>;
thinkingLevel?: ThinkingLevel;
explicitThinkingLevel: boolean;
authFallbackUsed: boolean;
}> {
const primary = resolveModelOverride(modelPatterns, modelRegistry, settings);
if (!primary.model || !parentActiveModelPattern) {
return { ...primary, authFallbackUsed: false };
}
const primaryKey = await modelRegistry.getApiKey(primary.model);
if (primaryKey === kNoAuth || isAuthenticated(primaryKey)) {
return { ...primary, authFallbackUsed: false };
}
const fallback = resolveModelOverride([parentActiveModelPattern], modelRegistry, settings);
if (!fallback.model) {
return { ...primary, authFallbackUsed: false };
}
if (modelsAreEqual(fallback.model, primary.model)) {
return { ...primary, authFallbackUsed: false };
}
const fallbackKey = await modelRegistry.getApiKey(fallback.model);
if (!isAuthenticated(fallbackKey)) {
return { ...primary, authFallbackUsed: false };
}
return { ...fallback, authFallbackUsed: true };
}
* Resolve a list of role patterns to the first matching model.
*/
export function resolveRoleSelection(
roles: readonly string[],
settings: Settings,
availableModels: Model<Api>[],
modelRegistry?: CanonicalModelRegistry,
): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
for (const role of roles) {
const resolved = resolveModelRoleValue(settings.getModelRole(role), availableModels, {
settings,
matchPreferences,
modelRegistry,
});
if (resolved.model) {
return { model: resolved.model, thinkingLevel: resolved.thinkingLevel };
}
}
return undefined;
}
function resolveExactCanonicalScopePattern(
pattern: string,
modelRegistry: Pick<ModelRegistry, "getCanonicalVariants">,
availableModels: Model<Api>[],
): { models: Model<Api>[]; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } | undefined {
const lastColonIndex = pattern.lastIndexOf(":");
let canonicalId = pattern;
let thinkingLevel: ThinkingLevel | undefined;
let explicitThinkingLevel = false;
if (lastColonIndex !== -1) {
const suffix = pattern.substring(lastColonIndex + 1);
const parsedThinkingLevel = parseThinkingLevel(suffix);
if (parsedThinkingLevel) {
canonicalId = pattern.substring(0, lastColonIndex);
thinkingLevel = parsedThinkingLevel;
explicitThinkingLevel = true;
}
}
const variants = modelRegistry
.getCanonicalVariants(canonicalId, { availableOnly: true, candidates: availableModels })
.map(variant => variant.model);
if (variants.length === 0) {
return undefined;
}
return { models: variants, thinkingLevel, explicitThinkingLevel };
}
* Resolve model patterns to actual Model objects with optional thinking levels
* Format: "pattern:level" where :level is optional
* For each pattern, finds all matching models and picks the best version:
* 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929)
* 2. If no alias, pick the latest dated version
*
* Supports models with colons in their IDs (e.g., OpenRouter's model:exacto).
* The algorithm tries to match the full pattern first, then progressively
* strips colon-suffixes to find a match.
*/
export async function resolveModelScope(
patterns: string[],
modelRegistry: Pick<ModelRegistry, "getAvailable" | "getCanonicalVariants">,
preferences?: ModelMatchPreferences,
): Promise<ScopedModel[]> {
const availableModels = modelRegistry.getAvailable();
const context = buildPreferenceContext(availableModels, preferences);
const scopedModels: ScopedModel[] = [];
for (const pattern of patterns) {
if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) {
const colonIdx = pattern.lastIndexOf(":");
let globPattern = pattern;
let thinkingLevel: ThinkingLevel | undefined;
let explicitThinkingLevel = false;
if (colonIdx !== -1) {
const suffix = pattern.substring(colonIdx + 1);
const parsedThinkingLevel = parseThinkingLevel(suffix);
if (parsedThinkingLevel) {
thinkingLevel = parsedThinkingLevel;
explicitThinkingLevel = true;
globPattern = pattern.substring(0, colonIdx);
}
}
const matchingModels = availableModels.filter(m => {
const fullId = `${m.provider}/${m.id}`;
const glob = new Bun.Glob(globPattern.toLowerCase());
return glob.match(fullId.toLowerCase()) || glob.match(m.id.toLowerCase());
});
if (matchingModels.length === 0) {
logger.warn(`No models match pattern "${pattern}"`);
continue;
}
for (const model of matchingModels) {
if (!scopedModels.find(sm => modelsAreEqual(sm.model, model))) {
scopedModels.push({
model,
thinkingLevel: explicitThinkingLevel
? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel)
: thinkingLevel,
explicitThinkingLevel,
});
}
}
continue;
}
const exactCanonical = resolveExactCanonicalScopePattern(pattern, modelRegistry, availableModels);
if (exactCanonical) {
for (const model of exactCanonical.models) {
if (!scopedModels.find(sm => modelsAreEqual(sm.model, model))) {
scopedModels.push({
model,
thinkingLevel: exactCanonical.explicitThinkingLevel
? (resolveThinkingLevelForModel(model, exactCanonical.thinkingLevel) ??
exactCanonical.thinkingLevel)
: exactCanonical.thinkingLevel,
explicitThinkingLevel: exactCanonical.explicitThinkingLevel,
});
}
}
continue;
}
const { model, thinkingLevel, warning, explicitThinkingLevel } = parseModelPatternWithContext(
pattern,
availableModels,
context,
{ modelRegistry },
);
if (warning) {
logger.warn(warning);
}
if (!model) {
logger.warn(`No models match pattern "${pattern}"`);
continue;
}
if (!scopedModels.find(sm => modelsAreEqual(sm.model, model))) {
scopedModels.push({
model,
thinkingLevel: explicitThinkingLevel
? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel)
: thinkingLevel,
explicitThinkingLevel,
});
}
}
return scopedModels;
}
* Resolve the set of models a session is allowed to use, given the active
* settings. Starts from `modelRegistry.getAvailable()` (so disabled providers
* and providers without credentials are already filtered out) and, when
* `enabledModels` is configured for the current path scope, further restricts
* the result to models matching those patterns.
*
* Returns the unfiltered available list when `enabledModels` is empty.
* Returns an empty list when `enabledModels` is configured but no available
* model matches any pattern — callers MUST treat this as "no usable model"
* rather than falling back to the global default (see issue #1022).
*/
export async function resolveAllowedModels(
modelRegistry: Pick<ModelRegistry, "getAvailable" | "getCanonicalVariants">,
settings: Settings | undefined,
preferences?: ModelMatchPreferences,
): Promise<Model<Api>[]> {
const available = modelRegistry.getAvailable();
const patterns = settings?.get("enabledModels");
if (!patterns || patterns.length === 0) {
return available;
}
const scoped = await resolveModelScope(patterns, modelRegistry, preferences);
if (scoped.length === 0) {
return [];
}
const allowed = new Set(scoped.map(entry => `${entry.model.provider}/${entry.model.id}`));
return available.filter(model => allowed.has(`${model.provider}/${model.id}`));
}
export interface ResolveCliModelResult {
model: Model<Api> | undefined;
selector?: string;
thinkingLevel?: ThinkingLevel;
warning: string | undefined;
error: string | undefined;
}
* Resolve a single model from CLI flags.
*/
export function resolveCliModel(options: {
cliProvider?: string;
cliModel?: string;
modelRegistry: CliModelRegistry;
preferences?: ModelMatchPreferences;
}): ResolveCliModelResult {
const { cliProvider, cliModel, modelRegistry, preferences } = options;
if (!cliModel) {
return { model: undefined, selector: undefined, warning: undefined, error: undefined };
}
const availableModels = modelRegistry.getAll();
if (availableModels.length === 0) {
return {
model: undefined,
selector: undefined,
warning: undefined,
error: "No models available. Check your installation or add models to models.json.",
};
}
const providerMap = new Map<string, string>();
for (const model of availableModels) {
providerMap.set(model.provider.toLowerCase(), model.provider);
}
let provider = cliProvider ? providerMap.get(cliProvider.toLowerCase()) : undefined;
if (cliProvider && !provider) {
return {
model: undefined,
selector: undefined,
warning: undefined,
error: `Unknown provider "${cliProvider}". Use --list-models to see available providers/models.`,
};
}
const trimmedModel = cliModel.trim();
if (!provider) {
const lower = trimmedModel.toLowerCase();
const slashIdx = lower.indexOf("/");
let exact: (typeof availableModels)[number] | undefined;
if (slashIdx !== -1) {
const prefix = lower.substring(0, slashIdx);
const suffix = trimmedModel.substring(slashIdx + 1);
exact = resolveProviderModelReference(prefix, suffix, availableModels);
}
if (!exact && !trimmedModel.includes(":")) {
const canonicalMatch = modelRegistry.resolveCanonicalModel?.(trimmedModel, { availableOnly: false });
if (canonicalMatch) {
return {
model: canonicalMatch,
selector: modelRegistry.getCanonicalId?.(canonicalMatch) ?? trimmedModel,
warning: undefined,
thinkingLevel: undefined,
error: undefined,
};
}
}
if (!exact) {
exact = availableModels.find(
model => model.id.toLowerCase() === lower || `${model.provider}/${model.id}`.toLowerCase() === lower,
);
}
if (exact) {
return {
model: exact,
selector: formatModelString(exact),
warning: undefined,
thinkingLevel: undefined,
error: undefined,
};
}
}
let pattern = trimmedModel;
if (!provider) {
const slashIndex = cliModel.indexOf("/");
if (slashIndex !== -1) {
const maybeProvider = cliModel.substring(0, slashIndex);
const canonical = providerMap.get(maybeProvider.toLowerCase());
if (canonical) {
provider = canonical;
pattern = cliModel.substring(slashIndex + 1);
}
}
} else {
const prefix = `${provider}/`;
if (cliModel.toLowerCase().startsWith(prefix.toLowerCase())) {
pattern = cliModel.substring(prefix.length);
}
}
if (provider) {
const exactProviderMatch = resolveProviderModelReference(provider, pattern, availableModels);
if (exactProviderMatch) {
return {
model: exactProviderMatch,
selector: formatModelString(exactProviderMatch),
warning: undefined,
thinkingLevel: undefined,
error: undefined,
};
}
}
const candidates = provider ? availableModels.filter(model => model.provider === provider) : availableModels;
const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, preferences, {
allowInvalidThinkingSelectorFallback: false,
modelRegistry,
});
if (!model) {
const display = provider ? `${provider}/${pattern}` : cliModel;
return {
model: undefined,
selector: undefined,
thinkingLevel: undefined,
warning,
error: `Model "${display}" not found. Use --list-models to see available models.`,
};
}
let selector = provider ? formatModelString(model) : undefined;
if (!provider) {
const lastColonIndex = pattern.lastIndexOf(":");
const canonicalCandidate =
lastColonIndex !== -1 && parseThinkingLevel(pattern.substring(lastColonIndex + 1))
? pattern.substring(0, lastColonIndex)
: pattern;
if (!canonicalCandidate.includes("/")) {
const canonicalResolved = modelRegistry.resolveCanonicalModel?.(canonicalCandidate, { availableOnly: false });
if (canonicalResolved && canonicalResolved.provider === model.provider && canonicalResolved.id === model.id) {
selector = modelRegistry.getCanonicalId?.(canonicalResolved) ?? canonicalCandidate;
}
}
}
return {
model,
selector,
thinkingLevel,
warning,
error: undefined,
};
}
export interface InitialModelResult {
model: Model<Api> | undefined;
thinkingLevel?: ThinkingLevel;
fallbackMessage: string | undefined;
}
* Find the initial model to use based on priority:
* 1. CLI args (provider + model)
* 2. First model from scoped models (if not continuing/resuming)
* 3. Restored from session (if continuing/resuming)
* 4. Saved default from settings
* 5. First available model with valid API key
*/
export async function findInitialModel(options: {
cliProvider?: string;
cliModel?: string;
scopedModels: ScopedModel[];
isContinuing: boolean;
defaultProvider?: string;
defaultModelId?: string;
defaultThinkingSelector?: Effort;
modelRegistry: InitialModelRegistry;
}): Promise<InitialModelResult> {
const {
cliProvider,
cliModel,
scopedModels,
isContinuing,
defaultProvider,
defaultModelId,
defaultThinkingSelector,
modelRegistry,
} = options;
let model: Model<Api> | undefined;
let thinkingLevel: Effort | undefined;
if (cliProvider && cliModel) {
const found = modelRegistry.find(cliProvider, cliModel);
if (!found) {
console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`));
process.exit(1);
}
return { model: found, thinkingLevel: undefined, fallbackMessage: undefined };
}
if (scopedModels.length > 0 && !isContinuing) {
const scoped = scopedModels[0];
const scopedThinkingSelector =
scoped.thinkingLevel === ThinkingLevel.Inherit
? defaultThinkingSelector
: (scoped.thinkingLevel ?? defaultThinkingSelector);
return {
model: scoped.model,
thinkingLevel:
scopedThinkingSelector === ThinkingLevel.Off
? ThinkingLevel.Off
: clampThinkingLevelForModel(scoped.model, scopedThinkingSelector),
fallbackMessage: undefined,
};
}
if (defaultProvider && defaultModelId) {
const found = modelRegistry.find(defaultProvider, defaultModelId);
if (found) {
model = found;
thinkingLevel = clampThinkingLevelForModel(found, defaultThinkingSelector);
return { model, thinkingLevel, fallbackMessage: undefined };
}
}
const availableModels = modelRegistry.getAvailable();
if (availableModels.length > 0) {
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find(m => m.provider === provider && m.id === defaultId);
if (match) {
return { model: match, thinkingLevel: undefined, fallbackMessage: undefined };
}
}
return { model: availableModels[0], thinkingLevel: undefined, fallbackMessage: undefined };
}
return { model: undefined, thinkingLevel: undefined, fallbackMessage: undefined };
}
* Restore model from session, with fallback to available models
*/
export async function restoreModelFromSession(
savedProvider: string,
savedModelId: string,
currentModel: Model<Api> | undefined,
shouldPrintMessages: boolean,
modelRegistry: RestorableModelRegistry,
): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
const restoredModel = modelRegistry.find(savedProvider, savedModelId);
const hasApiKey = restoredModel ? !!(await modelRegistry.getApiKey(restoredModel)) : false;
if (restoredModel && hasApiKey) {
if (shouldPrintMessages) {
console.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`));
}
return { model: restoredModel, fallbackMessage: undefined };
}
const reason = !restoredModel ? "model no longer exists" : "no API key available";
if (shouldPrintMessages) {
console.error(chalk.yellow(`Warning: Could not restore model ${savedProvider}/${savedModelId} (${reason}).`));
}
if (currentModel) {
if (shouldPrintMessages) {
console.log(chalk.dim(`Falling back to: ${currentModel.provider}/${currentModel.id}`));
}
return {
model: currentModel,
fallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${currentModel.provider}/${currentModel.id}.`,
};
}
const availableModels = modelRegistry.getAvailable();
if (availableModels.length > 0) {
let fallbackModel: Model<Api> | undefined;
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find(m => m.provider === provider && m.id === defaultId);
if (match) {
fallbackModel = match;
break;
}
}
if (!fallbackModel) {
fallbackModel = availableModels[0];
}
if (shouldPrintMessages) {
console.log(chalk.dim(`Falling back to: ${fallbackModel.provider}/${fallbackModel.id}`));
}
return {
model: fallbackModel,
fallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${fallbackModel.provider}/${fallbackModel.id}.`,
};
}
return { model: undefined, fallbackMessage: undefined };
}
* Find a smol/fast model using the priority chain.
* Tries exact matches first, then fuzzy matches.
*
* @param modelRegistry The model registry to search
* @param savedModel Optional saved model string from settings (provider/modelId)
* @returns The best available smol model, or undefined if none found
*/
export async function findSmolModel(
modelRegistry: ModelLookupRegistry,
savedModel?: string,
): Promise<Model<Api> | undefined> {
const availableModels = modelRegistry.getAvailable();
if (availableModels.length === 0) return undefined;
if (savedModel) {
const match = resolveModelFromString(savedModel, availableModels, undefined, modelRegistry);
if (match) return match;
}
for (const pattern of MODEL_PRIO.smol) {
const providerMatch = availableModels.find(m => `${m.provider}/${m.id}`.toLowerCase() === pattern);
if (providerMatch) return providerMatch;
const exactMatch = parseModelPattern(pattern, availableModels, undefined, { modelRegistry }).model;
if (exactMatch) return exactMatch;
const fuzzyMatch = availableModels.find(m => m.id.toLowerCase().includes(pattern));
if (fuzzyMatch) return fuzzyMatch;
}
return availableModels[0];
}
* Find a slow/comprehensive model using the priority chain.
* Prioritizes reasoning and codex models for thorough analysis.
*
* @param modelRegistry The model registry to search
* @param savedModel Optional saved model string from settings (provider/modelId)
* @returns The best available slow model, or undefined if none found
*/
export async function findSlowModel(
modelRegistry: ModelLookupRegistry,
savedModel?: string,
): Promise<Model<Api> | undefined> {
const availableModels = modelRegistry.getAvailable();
if (availableModels.length === 0) return undefined;
if (savedModel) {
const match = resolveModelFromString(savedModel, availableModels, undefined, modelRegistry);
if (match) return match;
}
for (const pattern of MODEL_PRIO.slow) {
const exactMatch = parseModelPattern(pattern, availableModels, undefined, { modelRegistry }).model;
if (exactMatch) return exactMatch;
const fuzzyMatch = availableModels.find(m => m.id.toLowerCase().includes(pattern.toLowerCase()));
if (fuzzyMatch) return fuzzyMatch;
}
return availableModels[0];
}