da705d76e6
- Enabled comma-separated string splitting within array-based model patterns. - Expanded deferred model patterns before registration to align with immediate resolution behavior. - Unified resolution logic to ensure deferred patterns support the same role aliases and chaining as the standard path.
1799 lines
64 KiB
TypeScript
1799 lines
64 KiB
TypeScript
/**
|
|
* Model resolution, scoping, and initial selection.
|
|
*
|
|
* Layering:
|
|
* - `matchModel` is the single matching engine. Order: exact `provider/id`
|
|
* reference (with variant-alias and OpenRouter routed/date fallbacks) →
|
|
* exact bare id → retired variant alias → provider-scoped fuzzy → substring
|
|
* with alias-vs-dated pick.
|
|
* - `parseModelPatternWithContext`/`parseModelPattern` layer the selector
|
|
* grammar on top: trailing `:level` thinking suffixes (`splitThinkingSuffix`)
|
|
* and `@upstream` provider routing (`splitUpstreamRouting`).
|
|
* - Everything else (`resolveModelFromString`, `resolveModelOverride*`,
|
|
* `resolveRoleSelection`, `resolveModelScope`, `resolveCliModel`,
|
|
* `findSmolModel`/`findSlowModel`) adapts inputs — roles, settings patterns,
|
|
* CLI flags, scope globs — onto that pipeline.
|
|
*/
|
|
|
|
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
import type { Api, Effort, KnownProvider, Model, ModelSpec } from "@oh-my-pi/pi-ai";
|
|
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
|
import { modelMatchesHost } from "@oh-my-pi/pi-catalog/hosts";
|
|
import { buildModelProviderPriorityRank } from "@oh-my-pi/pi-catalog/identity";
|
|
import { stripThinkingVariantToken } from "@oh-my-pi/pi-catalog/identity/family";
|
|
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
import { DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models";
|
|
import { resolveBareVariantAlias, resolveVariantAlias } from "@oh-my-pi/pi-catalog/variant-collapse";
|
|
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 {
|
|
AUTO_THINKING,
|
|
type ConfiguredThinkingLevel,
|
|
concreteThinkingLevel,
|
|
parseThinkingLevel,
|
|
resolveThinkingLevelForModel,
|
|
} from "../thinking";
|
|
import { isAuthenticated, kNoAuth, type ModelRegistry } from "./model-registry";
|
|
import { MODEL_ROLE_IDS, type ModelRole } from "./model-roles";
|
|
import type { Settings } from "./settings";
|
|
|
|
function isKnownProvider(provider: string): provider is KnownProvider {
|
|
return provider in DEFAULT_MODEL_PER_PROVIDER;
|
|
}
|
|
|
|
/**
|
|
* Pick the first provider-default model in availability order.
|
|
*
|
|
* If multiple providers expose that same default id, rank only that shared-id
|
|
* group by canonical provider priority so native/OAuth transports beat mirrors
|
|
* without changing unrelated provider fallback precedence.
|
|
*/
|
|
export function pickDefaultAvailableModel(availableModels: Model<Api>[]): Model<Api> | undefined {
|
|
const firstDefault = availableModels.find(
|
|
model => isKnownProvider(model.provider) && DEFAULT_MODEL_PER_PROVIDER[model.provider] === model.id,
|
|
);
|
|
if (!firstDefault) return availableModels[0];
|
|
|
|
const providerPriority = buildModelProviderPriorityRank();
|
|
const sharedDefaultMatches = availableModels.filter(
|
|
model =>
|
|
model.id === firstDefault.id &&
|
|
isKnownProvider(model.provider) &&
|
|
DEFAULT_MODEL_PER_PROVIDER[model.provider] === model.id,
|
|
);
|
|
return [...sharedDefaultMatches].sort((a, b) => {
|
|
const aRank = providerPriority.get(a.provider.toLowerCase()) ?? Number.POSITIVE_INFINITY;
|
|
const bRank = providerPriority.get(b.provider.toLowerCase()) ?? Number.POSITIVE_INFINITY;
|
|
if (aRank !== bRank) return aRank - bRank;
|
|
return availableModels.indexOf(a) - availableModels.indexOf(b);
|
|
})[0];
|
|
}
|
|
|
|
export interface ScopedModel {
|
|
model: Model<Api>;
|
|
thinkingLevel?: ThinkingLevel;
|
|
explicitThinkingLevel: boolean;
|
|
}
|
|
|
|
interface ThinkingSuffixOptions {
|
|
allowMaxAlias?: boolean;
|
|
allowAutoAlias?: boolean;
|
|
}
|
|
|
|
interface ModelStringParseOptions extends ThinkingSuffixOptions {
|
|
isLiteralModelId?: (provider: string, id: string) => boolean;
|
|
}
|
|
// Alias-suffix recognition for the model-pattern parser: `:max` maps to xhigh
|
|
// and `:auto` maps to the auto sentinel. Both are gated behind the alias flags
|
|
// (and the literal-id / exact-match guards on the callers) so a real model id
|
|
// ending in `:max` / `:auto` isn't silently reinterpreted as a thinking suffix.
|
|
const MAX_THINKING_SUFFIX_OPTIONS: ThinkingSuffixOptions = { allowMaxAlias: true, allowAutoAlias: true };
|
|
|
|
function parseThinkingSuffix(value: string, options?: ThinkingSuffixOptions): ConfiguredThinkingLevel | undefined {
|
|
const level = parseThinkingLevel(value);
|
|
if (level !== undefined) return level;
|
|
if (options?.allowMaxAlias === true && value === "max") return ThinkingLevel.XHigh;
|
|
if (options?.allowAutoAlias === true && value === AUTO_THINKING) return AUTO_THINKING;
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Split a trailing `:<level>` thinking selector off a model pattern.
|
|
*
|
|
* `level` is set when the suffix parses as a concrete thinking level (or, when
|
|
* the caller opts in via `allowMaxAlias`/`allowAutoAlias`, the `:max` / `:auto`
|
|
* aliases); `base` then has the suffix stripped. Otherwise `base` is the input.
|
|
* `minColonIndex` requires the colon to appear strictly after that index —
|
|
* role-alias callers pass `PREFIX_MODEL_ROLE.length` so the base is at least
|
|
* as long as the `pi/` prefix.
|
|
*/
|
|
function splitThinkingSuffix(
|
|
pattern: string,
|
|
minColonIndex = -1,
|
|
options?: ThinkingSuffixOptions,
|
|
): { base: string; level?: ConfiguredThinkingLevel } {
|
|
const colonIdx = pattern.lastIndexOf(":");
|
|
if (colonIdx <= minColonIndex) return { base: pattern };
|
|
const level = parseThinkingSuffix(pattern.slice(colonIdx + 1), options);
|
|
return level ? { base: pattern.slice(0, colonIdx), level } : { base: pattern };
|
|
}
|
|
|
|
function matchingGlobModels(pattern: string, availableModels: readonly Model<Api>[]): Model<Api>[] {
|
|
const glob = new Bun.Glob(pattern.toLowerCase());
|
|
return availableModels.filter(model => {
|
|
const fullId = `${model.provider}/${model.id}`;
|
|
return glob.match(fullId.toLowerCase()) || glob.match(model.id.toLowerCase());
|
|
});
|
|
}
|
|
|
|
function resolveGlobScopePattern(
|
|
pattern: string,
|
|
availableModels: readonly Model<Api>[],
|
|
): { models: Model<Api>[]; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } {
|
|
// Glob scopes describe which models are enabled, not per-role thinking.
|
|
// Coerce the `auto` sentinel to a concrete-only view so scope callers stay
|
|
// typed on `ThinkingLevel` and `enabledModels: [\"openai/*:auto\"]` doesn't
|
|
// pin a stray per-model level.
|
|
const strictSuffix = splitThinkingSuffix(pattern);
|
|
if (strictSuffix.level !== undefined) {
|
|
const thinkingLevel = concreteThinkingLevel(strictSuffix.level);
|
|
return {
|
|
models: matchingGlobModels(strictSuffix.base, availableModels),
|
|
thinkingLevel,
|
|
explicitThinkingLevel: thinkingLevel !== undefined,
|
|
};
|
|
}
|
|
|
|
const maxSuffix = splitThinkingSuffix(pattern, -1, MAX_THINKING_SUFFIX_OPTIONS);
|
|
if (maxSuffix.level !== undefined) {
|
|
const literalMatches = matchingGlobModels(pattern, availableModels);
|
|
if (literalMatches.length > 0) {
|
|
return { models: literalMatches, thinkingLevel: undefined, explicitThinkingLevel: false };
|
|
}
|
|
const thinkingLevel = concreteThinkingLevel(maxSuffix.level);
|
|
return {
|
|
models: matchingGlobModels(maxSuffix.base, availableModels),
|
|
thinkingLevel,
|
|
explicitThinkingLevel: thinkingLevel !== undefined,
|
|
};
|
|
}
|
|
|
|
return {
|
|
models: matchingGlobModels(pattern, availableModels),
|
|
thinkingLevel: undefined,
|
|
explicitThinkingLevel: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse a model string in "provider/modelId" format.
|
|
* Returns undefined if the format is invalid.
|
|
*/
|
|
export function parseModelString(
|
|
modelStr: string,
|
|
options?: ModelStringParseOptions,
|
|
): { provider: string; id: string; thinkingLevel?: ConfiguredThinkingLevel } | undefined {
|
|
const slashIdx = modelStr.indexOf("/");
|
|
if (slashIdx <= 0) return undefined;
|
|
const id = modelStr.slice(slashIdx + 1);
|
|
const provider = modelStr.slice(0, slashIdx);
|
|
// Strip strict thinking level suffixes first (e.g. "claude-sonnet-4-6:high" -> id "claude-sonnet-4-6", thinkingLevel "high").
|
|
const strict = splitThinkingSuffix(id);
|
|
if (strict.level) return { provider, id: strict.base, thinkingLevel: strict.level };
|
|
// `max` is a provider-facing alias for xhigh, but real model IDs can end in
|
|
// `:max`. Context-aware callers pass a literal lookup so those models win.
|
|
const maxAlias = splitThinkingSuffix(id, -1, options);
|
|
if (maxAlias.level) {
|
|
return options?.isLiteralModelId?.(provider, id) === true
|
|
? { provider, id }
|
|
: { provider, id: maxAlias.base, thinkingLevel: maxAlias.level };
|
|
}
|
|
return { provider, id };
|
|
}
|
|
|
|
/**
|
|
* Format a model as "provider/modelId" string.
|
|
*/
|
|
export function formatModelString(model: Model<Api>): string {
|
|
return `${model.provider}/${model.id}`;
|
|
}
|
|
|
|
function getSingleRoutingOnly(routing: unknown): string | undefined {
|
|
if (!routing || typeof routing !== "object" || !("only" in routing) || !Array.isArray(routing.only)) {
|
|
return undefined;
|
|
}
|
|
if (routing.only.length !== 1) return undefined;
|
|
const upstream = routing.only[0];
|
|
return typeof upstream === "string" && upstream ? upstream : undefined;
|
|
}
|
|
|
|
function getSingleUpstreamRoute(model: Model<Api>): string | undefined {
|
|
const compat = model.compat;
|
|
if (!compat || typeof compat !== "object") return undefined;
|
|
if (modelMatchesHost(model, "vercelAIGateway") && "vercelGatewayRouting" in compat) {
|
|
return getSingleRoutingOnly(compat.vercelGatewayRouting);
|
|
}
|
|
if (modelMatchesHost(model, "openrouter") && "openRouterRouting" in compat) {
|
|
return getSingleRoutingOnly(compat.openRouterRouting);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function formatModelStringWithRouting(model: Model<Api>): string {
|
|
const selector = formatModelString(model);
|
|
const upstream = getSingleUpstreamRoute(model);
|
|
return upstream ? `${selector}@${upstream}` : selector;
|
|
}
|
|
|
|
export function formatModelSelectorValue(selector: string, thinkingLevel: ConfiguredThinkingLevel | 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();
|
|
// `max` is a thinking-level alias (xhigh), never an OpenRouter route suffix, so
|
|
// `openrouter/<id>:max` falls through to the max-aware selector split instead of
|
|
// being cloned into a literal `<id>:max` model id with the reasoning level lost.
|
|
if (!suffix || parseThinkingSuffix(suffix, MAX_THINKING_SUFFIX_OPTIONS)) {
|
|
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 AMAZON_BEDROCK_PROVIDER = "amazon-bedrock";
|
|
const BEDROCK_INFERENCE_PROFILE_ARN =
|
|
/^arn:aws(?:-[a-z]+)*:bedrock:[a-z0-9-]+:[0-9]*:(?:application-inference-profile|inference-profile)\/[a-z0-9][a-z0-9._:-]*$/i;
|
|
|
|
function hasBedrockInferenceProfileThinkingSuffix(modelId: string): boolean {
|
|
const { base, level } = splitThinkingSuffix(modelId);
|
|
return level !== undefined && BEDROCK_INFERENCE_PROFILE_ARN.test(base.trim());
|
|
}
|
|
|
|
function resolveBedrockInferenceProfileModelId(
|
|
modelId: string,
|
|
availableModels: readonly Model<Api>[],
|
|
): Model<Api> | undefined {
|
|
const requestedId = modelId.trim();
|
|
if (hasBedrockInferenceProfileThinkingSuffix(requestedId) || !BEDROCK_INFERENCE_PROFILE_ARN.test(requestedId)) {
|
|
return undefined;
|
|
}
|
|
|
|
const template = availableModels.find(model => model.provider.toLowerCase() === AMAZON_BEDROCK_PROVIDER);
|
|
if (!template) return undefined;
|
|
|
|
return buildModel({
|
|
id: requestedId,
|
|
name: "Bedrock inference profile",
|
|
api: "bedrock-converse-stream",
|
|
provider: AMAZON_BEDROCK_PROVIDER,
|
|
baseUrl: template.baseUrl,
|
|
reasoning: false,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: null,
|
|
maxTokens: null,
|
|
});
|
|
}
|
|
|
|
function resolveBedrockInferenceProfileReference(
|
|
provider: string,
|
|
modelId: string,
|
|
availableModels: readonly Model<Api>[],
|
|
): Model<Api> | undefined {
|
|
if (provider.toLowerCase() !== AMAZON_BEDROCK_PROVIDER) return undefined;
|
|
return resolveBedrockInferenceProfileModelId(modelId, availableModels);
|
|
}
|
|
|
|
const UPSTREAM_ROUTING_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
|
|
|
|
/**
|
|
* Split a trailing `@<upstream>` provider-routing selector off a model pattern.
|
|
*
|
|
* `openrouter/z-ai/glm-4.7@cerebras` -> base `openrouter/z-ai/glm-4.7`, upstream
|
|
* `cerebras`. A `:thinking` suffix after the slug is kept on the base
|
|
* (`...@cerebras:high` -> base `...:high`). Returns undefined when there is no
|
|
* `@` or the suffix is not a bare provider slug, so model ids that legitimately
|
|
* contain `@` (`claude-opus-4-8@default`, `workers-ai/@cf/...`) are never split.
|
|
*/
|
|
export function splitUpstreamRouting(pattern: string): { base: string; upstream: string } | undefined {
|
|
const at = pattern.lastIndexOf("@");
|
|
if (at <= 0) return undefined;
|
|
const rest = pattern.slice(at + 1);
|
|
const colon = rest.indexOf(":");
|
|
const upstream = colon === -1 ? rest : rest.slice(0, colon);
|
|
if (!UPSTREAM_ROUTING_SLUG.test(upstream)) return undefined;
|
|
const trailing = colon === -1 ? "" : rest.slice(colon);
|
|
return { base: pattern.slice(0, at) + trailing, upstream };
|
|
}
|
|
|
|
/** OpenRouter and Vercel AI Gateway are the aggregators that honor per-request upstream routing. */
|
|
function supportsUpstreamRouting(model: Model<Api>): boolean {
|
|
return modelMatchesHost(model, "openrouter") || modelMatchesHost(model, "vercelAIGateway");
|
|
}
|
|
|
|
/** Pin a resolved aggregator model to a single upstream provider via its compat routing block. */
|
|
function applyUpstreamRouting(model: Model<Api>, upstream: string): Model<Api> {
|
|
const aggregatorModel = model as Model<"openai-completions">;
|
|
const routing = { only: [upstream] };
|
|
return buildModel({
|
|
...model,
|
|
compat: modelMatchesHost(model, "vercelAIGateway")
|
|
? { ...aggregatorModel.compatConfig, vercelGatewayRouting: routing }
|
|
: { ...aggregatorModel.compatConfig, openRouterRouting: routing },
|
|
} as ModelSpec<Api>);
|
|
}
|
|
|
|
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); // ambiguous sentinel; do not overwrite back
|
|
} 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; // ambiguous
|
|
}
|
|
if (exact !== undefined) {
|
|
return exact;
|
|
}
|
|
|
|
// Retired effort-tier variant ids resolve to their collapsed logical
|
|
// model: hand-table aliases first, then the `X-thinking` → `X` grammar
|
|
// for auto-derived pairs. Exact lookup above always wins while raw is live.
|
|
const variantAliasId =
|
|
resolveVariantAlias(normalizedProvider, normalizedModelId) ?? stripThinkingVariantToken(normalizedModelId);
|
|
if (variantAliasId) {
|
|
const aliased = index.get(`${normalizedProvider}\u0000${variantAliasId.toLowerCase()}`);
|
|
if (aliased) {
|
|
return aliased;
|
|
}
|
|
}
|
|
|
|
const bedrockInferenceProfile = resolveBedrockInferenceProfileReference(provider, modelId, availableModels);
|
|
if (bedrockInferenceProfile) {
|
|
return bedrockInferenceProfile;
|
|
}
|
|
|
|
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 {
|
|
/** Most-recently-used model keys (provider/modelId) to prefer when ambiguous. */
|
|
usageOrder?: string[];
|
|
/** Provider precedence used for ambiguous unqualified model patterns. */
|
|
providerOrder?: readonly string[];
|
|
/** Providers to deprioritize when no recent usage or provider priority is available. */
|
|
deprioritizeProviders?: string[];
|
|
}
|
|
|
|
export type ModelLookupRegistry = Pick<ModelRegistry, "getAvailable">;
|
|
type CliModelRegistry = Pick<ModelRegistry, "getAll">;
|
|
type InitialModelRegistry = Pick<ModelRegistry, "getAvailable" | "find">;
|
|
type RestorableModelRegistry = Pick<ModelRegistry, "getAvailable" | "find" | "getApiKey">;
|
|
|
|
interface ModelPreferenceContext {
|
|
modelUsageRank: Map<string, number>;
|
|
providerUsageRank: Map<string, number>;
|
|
providerPriorityRank: 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 providerPriorityRank = buildModelProviderPriorityRank(preferences?.providerOrder);
|
|
const deprioritizedProviders = new Set(preferences?.deprioritizeProviders ?? []);
|
|
const modelOrder = new Map<string, number>();
|
|
for (let i = 0; i < availableModels.length; i += 1) {
|
|
modelOrder.set(formatModelString(availableModels[i]), i);
|
|
}
|
|
|
|
return { modelUsageRank, providerUsageRank, providerPriorityRank, deprioritizedProviders, modelOrder };
|
|
}
|
|
|
|
export function getModelMatchPreferences(
|
|
settings?: Partial<Pick<Settings, "get" | "getStorage">>,
|
|
): ModelMatchPreferences {
|
|
return {
|
|
usageOrder: settings?.getStorage?.()?.getModelUsageOrder(),
|
|
providerOrder: settings?.get?.("modelProviderOrder"),
|
|
};
|
|
}
|
|
|
|
function mergeModelMatchPreferences(
|
|
settings: Settings | undefined,
|
|
preferences: ModelMatchPreferences | undefined,
|
|
): ModelMatchPreferences {
|
|
const settingsPreferences = getModelMatchPreferences(settings);
|
|
return {
|
|
usageOrder: preferences?.usageOrder ?? settingsPreferences.usageOrder,
|
|
providerOrder: preferences?.providerOrder ?? settingsPreferences.providerOrder,
|
|
deprioritizeProviders: preferences?.deprioritizeProviders,
|
|
};
|
|
}
|
|
|
|
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 aProviderPriority = context.providerPriorityRank.get(a.provider.toLowerCase());
|
|
const bProviderPriority = context.providerPriorityRank.get(b.provider.toLowerCase());
|
|
if (aProviderPriority !== undefined || bProviderPriority !== undefined) {
|
|
return (aProviderPriority ?? Number.POSITIVE_INFINITY) - (bProviderPriority ?? 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 {
|
|
// Check if ID ends with -latest
|
|
if (id.endsWith("-latest")) return true;
|
|
|
|
// Check if ID ends with a date pattern (-YYYYMMDD)
|
|
const datePattern = /-\d{8}$/;
|
|
return !datePattern.test(id);
|
|
}
|
|
|
|
function includeSyntheticAllowedModels(available: Model<Api>[], allowedModels: Iterable<Model<Api>>): Model<Api>[] {
|
|
const allowedByKey = new Map<string, Model<Api>>();
|
|
for (const model of allowedModels) {
|
|
const key = formatModelString(model);
|
|
if (!allowedByKey.has(key)) {
|
|
allowedByKey.set(key, model);
|
|
}
|
|
}
|
|
if (allowedByKey.size === 0) return [];
|
|
|
|
const result: Model<Api>[] = [];
|
|
for (const model of available) {
|
|
if (allowedByKey.delete(formatModelString(model))) {
|
|
result.push(model);
|
|
}
|
|
}
|
|
|
|
result.push(...allowedByKey.values());
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Find an exact explicit provider/model match.
|
|
*/
|
|
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;
|
|
}
|
|
/**
|
|
* The single model-matching engine. Tries, in order:
|
|
* 1. exact `provider/id` reference (variant-alias and OpenRouter routed/date
|
|
* fallbacks included),
|
|
* 2. exact bare id (preference-ranked),
|
|
* 3. retired effort-tier variant alias (collapsed catalog entries),
|
|
* 4. provider-scoped fuzzy match,
|
|
* 5. substring match with the alias-vs-dated pick.
|
|
* Returns the matched model or undefined if no match found.
|
|
*/
|
|
function matchModel(
|
|
modelPattern: string,
|
|
availableModels: Model<Api>[],
|
|
context: ModelPreferenceContext,
|
|
): Model<Api> | undefined {
|
|
const exactRefMatch = findExactModelReferenceMatch(modelPattern, availableModels);
|
|
if (exactRefMatch) {
|
|
return exactRefMatch;
|
|
}
|
|
|
|
// Exact ID match (case-insensitive) — this must happen before provider-scoped
|
|
// fuzzy matching so raw IDs that contain slashes (for example OpenRouter model
|
|
// IDs like "openai/gpt-4o:extended") still resolve as IDs instead of being
|
|
// misread as a provider-qualified selector.
|
|
const lowerPattern = modelPattern.toLowerCase();
|
|
const exactMatches = availableModels.filter(m => m.id.toLowerCase() === lowerPattern);
|
|
if (exactMatches.length > 0) {
|
|
return pickPreferredModel(exactMatches, context);
|
|
}
|
|
|
|
const bedrockInferenceProfile = resolveBedrockInferenceProfileModelId(modelPattern, availableModels);
|
|
if (bedrockInferenceProfile) {
|
|
return bedrockInferenceProfile;
|
|
}
|
|
|
|
// Retired effort-tier variant ids (bare, no provider prefix) resolve to
|
|
// their collapsed logical model; models from the providers whose table
|
|
// declared the alias win ties. Auto-derived `X-thinking` pairs resolve
|
|
// through the grammar fallback.
|
|
const bareAlias = resolveBareVariantAlias(modelPattern);
|
|
const bareAliasTargetId = bareAlias?.id ?? stripThinkingVariantToken(modelPattern);
|
|
if (bareAliasTargetId) {
|
|
const lowerAliasTarget = bareAliasTargetId.toLowerCase();
|
|
const aliasMatches = availableModels.filter(m => m.id.toLowerCase() === lowerAliasTarget);
|
|
if (aliasMatches.length > 0) {
|
|
const preferred = bareAlias ? aliasMatches.filter(m => bareAlias.providers.includes(m.provider)) : [];
|
|
return pickPreferredModel(preferred.length > 0 ? preferred : aliasMatches, context);
|
|
}
|
|
}
|
|
// Check for provider/modelId format — fuzzy match within provider only.
|
|
const slashIndex = modelPattern.indexOf("/");
|
|
if (slashIndex !== -1) {
|
|
const provider = modelPattern.substring(0, slashIndex);
|
|
const modelId = modelPattern.substring(slashIndex + 1);
|
|
const lowerProvider = provider.toLowerCase();
|
|
const providerModels = availableModels.filter(m => m.provider.toLowerCase() === lowerProvider);
|
|
if (providerModels.length === 0) {
|
|
// The prefix is not a known provider in this candidate set, so treat the
|
|
// slash as part of the raw model ID and continue with generic matching.
|
|
} else {
|
|
// Let the routing fallback apply `@upstream` before fuzzy matching can consume the
|
|
// slug — but only for aggregator providers (OpenRouter / Vercel Gateway). Other
|
|
// providers have ids that legitimately end in `@` (Vertex `claude-opus-4-8@default`),
|
|
// and the fallback never routes them, so they must keep fuzzy matching.
|
|
if (splitUpstreamRouting(modelId) && providerModels.some(supportsUpstreamRouting)) {
|
|
return undefined;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
// No exact match - fall back to partial matching
|
|
const matches = availableModels.filter(
|
|
m => m.id.toLowerCase().includes(lowerPattern) || m.name?.toLowerCase().includes(lowerPattern),
|
|
);
|
|
|
|
if (matches.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
// Separate into aliases and dated versions
|
|
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;
|
|
/** Thinking level if explicitly specified in pattern, undefined otherwise */
|
|
thinkingLevel?: ConfiguredThinkingLevel;
|
|
/** Upstream provider slug from an `@upstream` routing selector, if present. */
|
|
upstream?: string;
|
|
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 },
|
|
): ParsedModelResult {
|
|
// Try exact match first
|
|
const exactMatch = matchModel(pattern, availableModels, context);
|
|
if (exactMatch) {
|
|
return { model: exactMatch, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
|
|
}
|
|
|
|
// No match - try stripping a valid thinking suffix and recursing.
|
|
// `max` is accepted only after the full pattern failed, so literal model IDs
|
|
// ending in `:max` keep winning over the alias.
|
|
const { base, level } = splitThinkingSuffix(pattern, -1, MAX_THINKING_SUFFIX_OPTIONS);
|
|
if (level) {
|
|
const result = parseModelPatternWithContext(base, availableModels, context, options);
|
|
if (result.model) {
|
|
// Only use this thinking level if no warning from inner recursion
|
|
const explicitThinkingLevel = !result.warning;
|
|
return {
|
|
model: result.model,
|
|
thinkingLevel: explicitThinkingLevel ? level : undefined,
|
|
warning: result.warning,
|
|
explicitThinkingLevel,
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const lastColonIndex = pattern.lastIndexOf(":");
|
|
if (lastColonIndex === -1) {
|
|
// No colons, pattern simply doesn't match any model
|
|
return { model: undefined, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
|
|
}
|
|
const prefix = pattern.substring(0, lastColonIndex);
|
|
const suffix = pattern.substring(lastColonIndex + 1);
|
|
|
|
const allowFallback = options?.allowInvalidThinkingSelectorFallback ?? true;
|
|
if (!allowFallback) {
|
|
return { model: undefined, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
|
|
}
|
|
|
|
// Invalid suffix - recurse on prefix and warn
|
|
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;
|
|
}
|
|
|
|
/** Match a single pattern with a pre-built preference context (direct match plus
|
|
* the `@upstream` routing fallback), so role resolution can reuse one context
|
|
* across every fallback pattern instead of rebuilding it per pattern. */
|
|
function matchPatternWithContext(
|
|
pattern: string,
|
|
availableModels: Model<Api>[],
|
|
context: ModelPreferenceContext,
|
|
options?: { allowInvalidThinkingSelectorFallback?: boolean },
|
|
): ParsedModelResult {
|
|
const direct = parseModelPatternWithContext(pattern, availableModels, context, options);
|
|
if (direct.model) return direct;
|
|
|
|
// No direct match: a trailing `@upstream` may be a provider-routing selector.
|
|
// Only honor it when the base resolves to an aggregator model (OpenRouter /
|
|
// Vercel Gateway); otherwise `@` stays part of the id and `direct` stands.
|
|
const routing = splitUpstreamRouting(pattern);
|
|
if (routing) {
|
|
const routed = parseModelPatternWithContext(routing.base, availableModels, context, options);
|
|
if (routed.model && supportsUpstreamRouting(routed.model)) {
|
|
return { ...routed, model: applyUpstreamRouting(routed.model, routing.upstream), upstream: routing.upstream };
|
|
}
|
|
}
|
|
return direct;
|
|
}
|
|
|
|
export function parseModelPattern(
|
|
pattern: string,
|
|
availableModels: Model<Api>[],
|
|
preferences?: ModelMatchPreferences,
|
|
options?: { allowInvalidThinkingSelectorFallback?: boolean },
|
|
): ParsedModelResult {
|
|
return matchPatternWithContext(
|
|
pattern,
|
|
availableModels,
|
|
buildPreferenceContext(availableModels, preferences),
|
|
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.flatMap(pattern => pattern.split(",")) : 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 shouldInheritDefaultBeforePriority(role: ModelRole): boolean {
|
|
return role === "smol" || role === "slow" || role === "designer";
|
|
}
|
|
|
|
/**
|
|
* Roles that have no priority.json chain of their own reuse another role's
|
|
* list. The advisor — a second-opinion reviewer — defaults to the `slow`
|
|
* reasoning chain, but (unlike the `slow` role, see
|
|
* {@link shouldInheritDefaultBeforePriority}) never inherits the primary's
|
|
* model, so it stays a distinct strong model out of the box. The `tiny` role —
|
|
* the override for online title/memory/classifier tasks — reuses the `smol`
|
|
* fast chain so an unset tiny role auto-resolves to the same fast model smol
|
|
* would pick.
|
|
*/
|
|
const ROLE_PRIORITY_ALIAS: Partial<Record<ModelRole, keyof typeof MODEL_PRIO>> = {
|
|
advisor: "slow",
|
|
tiny: "smol",
|
|
};
|
|
|
|
/** Built-in priority patterns for a role, following {@link ROLE_PRIORITY_ALIAS}. */
|
|
function rolePriorityDefaults(role: ModelRole): string[] {
|
|
const key = ROLE_PRIORITY_ALIAS[role] ?? (role as keyof typeof MODEL_PRIO);
|
|
return normalizeModelPatternList(MODEL_PRIO[key]);
|
|
}
|
|
|
|
function resolveDefaultInheritedPatterns(
|
|
role: ModelRole,
|
|
configuredDefault: string | undefined,
|
|
roleDefaults: string[],
|
|
settings: Settings | undefined,
|
|
visited: Set<ModelRole>,
|
|
): string[] {
|
|
if (!shouldInheritDefaultBeforePriority(role) || !configuredDefault) return [];
|
|
|
|
const resolved: string[] = [];
|
|
for (const pattern of normalizeModelPatternList(configuredDefault)) {
|
|
const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix(
|
|
pattern,
|
|
PREFIX_MODEL_ROLE.length,
|
|
MAX_THINKING_SUFFIX_OPTIONS,
|
|
);
|
|
const aliasRole = getModelRoleAlias(aliasCandidate);
|
|
if (aliasRole === role) {
|
|
// Self-alias (e.g. modelRoles.default = "pi/smol") would loop back to the
|
|
// same unset role; collapse straight to the built-in priority chain.
|
|
resolved.push(
|
|
...(thinkingLevel
|
|
? roleDefaults.map(defaultPattern => `${defaultPattern}:${thinkingLevel}`)
|
|
: roleDefaults),
|
|
);
|
|
continue;
|
|
}
|
|
if (aliasRole && !visited.has(aliasRole)) {
|
|
// Cross-role alias (e.g. modelRoles.default = "pi/slow"): resolve the
|
|
// target role's patterns now so downstream one-layer expanders see
|
|
// concrete model patterns instead of another role alias.
|
|
const recursed = resolveConfiguredRolePattern(pattern, settings, new Set(visited));
|
|
if (recursed && recursed.length > 0) {
|
|
resolved.push(...recursed);
|
|
continue;
|
|
}
|
|
}
|
|
resolved.push(pattern);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function resolveConfiguredRolePattern(
|
|
value: string,
|
|
settings?: Settings,
|
|
visited: Set<ModelRole> = new Set(),
|
|
): string[] | undefined {
|
|
const normalized = value.trim();
|
|
if (!normalized) return undefined;
|
|
|
|
const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix(
|
|
normalized,
|
|
PREFIX_MODEL_ROLE.length,
|
|
MAX_THINKING_SUFFIX_OPTIONS,
|
|
);
|
|
const role = getModelRoleAlias(aliasCandidate);
|
|
if (!role) return [normalized];
|
|
if (visited.has(role)) return undefined;
|
|
visited.add(role);
|
|
|
|
const configured = settings?.getModelRole(role)?.trim();
|
|
const configuredDefault = settings?.getModelRole(DEFAULT_MODEL_ROLE)?.trim();
|
|
const roleDefaults = rolePriorityDefaults(role);
|
|
const resolved = configured
|
|
? normalizeModelPatternList(configured)
|
|
: resolveDefaultInheritedPatterns(role, configuredDefault, roleDefaults, settings, visited);
|
|
if (resolved.length === 0) {
|
|
resolved.push(...roleDefaults);
|
|
}
|
|
if (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?: ConfiguredThinkingLevel;
|
|
explicitThinkingLevel: boolean;
|
|
warning: string | undefined;
|
|
}
|
|
|
|
export function resolveModelRoleValue(
|
|
roleValue: string | undefined,
|
|
availableModels: Model<Api>[],
|
|
options?: { settings?: Settings; matchPreferences?: ModelMatchPreferences },
|
|
): 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 = resolveConfiguredModelPatterns(normalized, options?.settings);
|
|
if (!effectivePatterns || effectivePatterns.length === 0) {
|
|
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
|
|
}
|
|
|
|
let warning: string | undefined;
|
|
const matchPreferences = mergeModelMatchPreferences(options?.settings, options?.matchPreferences);
|
|
// Build the O(n) preference context (model-order map over all available
|
|
// models) once and reuse it across every fallback pattern instead of
|
|
// rebuilding it per pattern inside parseModelPattern.
|
|
const preferenceContext = buildPreferenceContext(availableModels, matchPreferences);
|
|
for (const effectivePattern of effectivePatterns) {
|
|
const resolved = matchPatternWithContext(effectivePattern, availableModels, preferenceContext);
|
|
if (resolved.model) {
|
|
return {
|
|
model: resolved.model,
|
|
thinkingLevel: resolved.explicitThinkingLevel
|
|
? resolved.thinkingLevel === AUTO_THINKING
|
|
? AUTO_THINKING
|
|
: (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 };
|
|
}
|
|
|
|
interface ExplicitThinkingSelectorOptions {
|
|
isLiteralModelId?: (provider: string, id: string) => boolean;
|
|
}
|
|
|
|
function isLiteralModelSelector(value: string, options?: ExplicitThinkingSelectorOptions): boolean {
|
|
const parsed = parseModelString(value);
|
|
return parsed !== undefined && options?.isLiteralModelId?.(parsed.provider, parsed.id) === true;
|
|
}
|
|
|
|
export function extractExplicitThinkingSelector(
|
|
value: string | undefined,
|
|
settings?: Settings,
|
|
options?: ExplicitThinkingSelectorOptions,
|
|
): ConfiguredThinkingLevel | 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 strictSelector = splitThinkingSuffix(current, PREFIX_MODEL_ROLE.length).level;
|
|
if (strictSelector) {
|
|
return strictSelector;
|
|
}
|
|
const maxSelector = splitThinkingSuffix(current, PREFIX_MODEL_ROLE.length, MAX_THINKING_SUFFIX_OPTIONS).level;
|
|
if (maxSelector && (current.startsWith(PREFIX_MODEL_ROLE) || !isLiteralModelSelector(current, options))) {
|
|
return maxSelector;
|
|
}
|
|
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,
|
|
): Model<Api> | undefined {
|
|
const exact = available.find(model => `${model.provider}/${model.id}` === value);
|
|
if (exact) return exact;
|
|
const parsed = parseModelString(value, {
|
|
...MAX_THINKING_SUFFIX_OPTIONS,
|
|
isLiteralModelId: (provider, id) => available.some(model => model.provider === provider && model.id === id),
|
|
});
|
|
if (parsed) {
|
|
const parsedExact = available.find(model => model.provider === parsed.provider && model.id === parsed.id);
|
|
if (parsedExact) return parsedExact;
|
|
}
|
|
return parseModelPattern(value, available, matchPreferences).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[];
|
|
}): Model<Api> | undefined {
|
|
const { settings, availableModels, matchPreferences, roleOrder } = 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);
|
|
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?: ConfiguredThinkingLevel; explicitThinkingLevel: boolean } {
|
|
if (modelPatterns.length === 0) return { explicitThinkingLevel: false };
|
|
const availableModels = modelRegistry.getAvailable();
|
|
const matchPreferences = getModelMatchPreferences(settings);
|
|
for (const pattern of modelPatterns) {
|
|
const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(pattern, availableModels, {
|
|
settings,
|
|
matchPreferences,
|
|
});
|
|
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?: ConfiguredThinkingLevel;
|
|
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>[],
|
|
): { model: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel } | undefined {
|
|
const matchPreferences = getModelMatchPreferences(settings);
|
|
for (const role of roles) {
|
|
const resolved = resolveModelRoleValue(settings.getModelRole(role), availableModels, {
|
|
settings,
|
|
matchPreferences,
|
|
});
|
|
if (resolved.model) {
|
|
return { model: resolved.model, thinkingLevel: resolved.thinkingLevel };
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Resolve the model for the `advisor` role. A configured `modelRoles.advisor`
|
|
* wins outright (a bad override surfaces as no model rather than silently
|
|
* running something else); when unset it falls back to the `slow` priority
|
|
* chain via {@link ROLE_PRIORITY_ALIAS} — a strong reasoning model that, unlike
|
|
* the `slow` role itself, never inherits the primary's model. Returns undefined
|
|
* only when no candidate in the resolved chain is available.
|
|
*/
|
|
export function resolveAdvisorRoleSelection(
|
|
settings: Settings,
|
|
availableModels: Model<Api>[],
|
|
): { model: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel } | undefined {
|
|
const resolved = resolveModelRoleValue(`${PREFIX_MODEL_ROLE}advisor`, availableModels, {
|
|
settings,
|
|
matchPreferences: getModelMatchPreferences(settings),
|
|
});
|
|
return resolved.model ? { model: resolved.model, thinkingLevel: resolved.thinkingLevel } : undefined;
|
|
}
|
|
|
|
/**
|
|
* 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">,
|
|
preferences?: ModelMatchPreferences,
|
|
): Promise<ScopedModel[]> {
|
|
const availableModels = modelRegistry.getAvailable();
|
|
const context = buildPreferenceContext(availableModels, preferences);
|
|
const scopedModels: ScopedModel[] = [];
|
|
const addScopedModel = (model: Model<Api>, thinkingLevel: ThinkingLevel | undefined, explicit: boolean) => {
|
|
if (scopedModels.some(sm => modelsAreEqual(sm.model, model))) return;
|
|
scopedModels.push({
|
|
model,
|
|
thinkingLevel: explicit
|
|
? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel)
|
|
: thinkingLevel,
|
|
explicitThinkingLevel: explicit,
|
|
});
|
|
};
|
|
|
|
for (const pattern of patterns) {
|
|
// Check if pattern contains glob characters
|
|
if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) {
|
|
// Extract optional thinking level suffix (e.g., "provider/*:high") only
|
|
// after literal `:max` globs had a chance to match real model IDs.
|
|
const {
|
|
models: matchingModels,
|
|
thinkingLevel,
|
|
explicitThinkingLevel,
|
|
} = resolveGlobScopePattern(pattern, availableModels);
|
|
|
|
if (matchingModels.length === 0) {
|
|
logger.warn(`No models match pattern "${pattern}"`);
|
|
continue;
|
|
}
|
|
|
|
for (const model of matchingModels) {
|
|
addScopedModel(model, thinkingLevel, explicitThinkingLevel);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const { model, thinkingLevel, warning, explicitThinkingLevel } = parseModelPatternWithContext(
|
|
pattern,
|
|
availableModels,
|
|
context,
|
|
);
|
|
|
|
if (warning) {
|
|
logger.warn(warning);
|
|
}
|
|
|
|
if (!model) {
|
|
logger.warn(`No models match pattern "${pattern}"`);
|
|
continue;
|
|
}
|
|
|
|
// Scoped models (Ctrl+P cycling) carry concrete per-model overrides;
|
|
// `auto` lives on the session, so drop the sentinel here.
|
|
if (thinkingLevel === AUTO_THINKING) {
|
|
addScopedModel(model, undefined, false);
|
|
} else {
|
|
addScopedModel(model, 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 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">,
|
|
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 [];
|
|
}
|
|
return includeSyntheticAllowedModels(
|
|
available,
|
|
scoped.map(entry => entry.model),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Synchronous subset of {@link resolveAllowedModels} for contexts where async is unavailable
|
|
* (e.g. `getAvailableModels()` which is called from the ACP model-list advertisement, RPC
|
|
* `get_available_models`, and the `/model` slash command). Uses the same effective
|
|
* `enabledModels` scope semantics as startup resolution:
|
|
*
|
|
* - Glob selectors match `provider/modelId` and bare model id
|
|
* - Exact `provider/modelId`, bare ids, provider-scoped fuzzy, and substring selectors
|
|
* resolve through the shared model-pattern matcher
|
|
* - Optional `:thinkingLevel` suffixes are stripped only when valid
|
|
*
|
|
* When no pattern resolves to any model (misconfiguration / typo) an empty list is returned,
|
|
* consistent with the empty-list contract of {@link resolveAllowedModels}. Callers that render
|
|
* a UI picker should treat an empty list as "hide the picker entry", matching how the SDK
|
|
* surfaces the same misconfiguration during session initialization.
|
|
*/
|
|
export function filterAvailableModelsByEnabledPatterns(
|
|
available: Model<Api>[],
|
|
patterns: readonly string[],
|
|
): Model<Api>[] {
|
|
if (patterns.length === 0) return available;
|
|
|
|
const context = buildPreferenceContext(available, undefined);
|
|
const allowedModels: Model<Api>[] = [];
|
|
const addAllowed = (model: Model<Api>) => {
|
|
allowedModels.push(model);
|
|
};
|
|
|
|
for (const pattern of patterns) {
|
|
if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) {
|
|
for (const model of resolveGlobScopePattern(pattern, available).models) {
|
|
addAllowed(model);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const { model } = parseModelPatternWithContext(pattern, available, context);
|
|
if (model) {
|
|
addAllowed(model);
|
|
}
|
|
}
|
|
|
|
return includeSyntheticAllowedModels(available, allowedModels);
|
|
}
|
|
|
|
export interface ResolveCliModelResult {
|
|
model: Model<Api> | undefined;
|
|
selector?: string;
|
|
thinkingLevel?: ConfiguredThinkingLevel;
|
|
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}". Run "omp models" to see available providers/models.`,
|
|
};
|
|
}
|
|
|
|
const trimmedModel = cliModel.trim();
|
|
if (!provider) {
|
|
const lower = trimmedModel.toLowerCase();
|
|
// When input has provider/id format (e.g. "zai/glm-5"), prefer decomposed
|
|
// provider+id match over flat id match. Without this, a model with id
|
|
// "zai/glm-5" on provider "vercel-ai-gateway" wins over provider "zai"
|
|
// with id "glm-5", because Array.find returns the first catalog hit.
|
|
let exact = findExactModelReferenceMatch(trimmedModel, availableModels);
|
|
if (!exact) {
|
|
// Flat exact id (or full selector) by catalog order: CLI resolution
|
|
// stays deterministic across runs regardless of usage-based ranking.
|
|
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, upstream } = parseModelPattern(pattern, candidates, preferences, {
|
|
allowInvalidThinkingSelectorFallback: false,
|
|
});
|
|
|
|
if (!model) {
|
|
const display = provider ? `${provider}/${pattern}` : cliModel;
|
|
return {
|
|
model: undefined,
|
|
selector: undefined,
|
|
thinkingLevel: undefined,
|
|
warning,
|
|
error: `Model "${display}" not found. Run "omp models" to see available models.`,
|
|
};
|
|
}
|
|
|
|
let selector = provider ? formatModelString(model) : undefined;
|
|
if (selector !== undefined && upstream) {
|
|
selector = `${selector}@${upstream}`;
|
|
}
|
|
|
|
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;
|
|
|
|
// 1. CLI args take priority
|
|
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 };
|
|
}
|
|
|
|
// 2. Use first model from scoped models (skip if continuing/resuming)
|
|
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,
|
|
};
|
|
}
|
|
|
|
// 3. Try saved default from settings
|
|
if (defaultProvider && defaultModelId) {
|
|
const found = modelRegistry.find(defaultProvider, defaultModelId);
|
|
if (found) {
|
|
model = found;
|
|
thinkingLevel = clampThinkingLevelForModel(found, defaultThinkingSelector);
|
|
return { model, thinkingLevel, fallbackMessage: undefined };
|
|
}
|
|
}
|
|
|
|
// 4. Try first available model with valid API key
|
|
const availableModels = modelRegistry.getAvailable();
|
|
|
|
const fallback = pickDefaultAvailableModel(availableModels);
|
|
if (fallback) {
|
|
return { model: fallback, thinkingLevel: undefined, fallbackMessage: undefined };
|
|
}
|
|
|
|
// 5. No model found
|
|
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);
|
|
|
|
// Check if restored model exists and has a valid API key
|
|
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 };
|
|
}
|
|
|
|
// Model not found or no API key - fall back
|
|
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 we already have a model, use it as fallback
|
|
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}.`,
|
|
};
|
|
}
|
|
|
|
// Try to find any available model
|
|
const availableModels = modelRegistry.getAvailable();
|
|
|
|
const fallbackModel = pickDefaultAvailableModel(availableModels);
|
|
if (fallbackModel) {
|
|
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}.`,
|
|
};
|
|
}
|
|
|
|
// No models available
|
|
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;
|
|
|
|
// 1. Try saved model from settings
|
|
if (savedModel) {
|
|
const match = resolveModelFromString(savedModel, availableModels, undefined);
|
|
if (match) return match;
|
|
}
|
|
|
|
// 2. Try priority chain
|
|
for (const pattern of MODEL_PRIO.smol) {
|
|
// Try exact match with provider prefix
|
|
const providerMatch = availableModels.find(m => `${m.provider}/${m.id}`.toLowerCase() === pattern);
|
|
if (providerMatch) return providerMatch;
|
|
|
|
// Try exact match first
|
|
const exactMatch = parseModelPattern(pattern, availableModels, undefined).model;
|
|
if (exactMatch) return exactMatch;
|
|
|
|
// Try fuzzy match (substring)
|
|
const fuzzyMatch = availableModels.find(m => m.id.toLowerCase().includes(pattern));
|
|
if (fuzzyMatch) return fuzzyMatch;
|
|
}
|
|
|
|
// 3. Fallback to first available (same as default)
|
|
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;
|
|
|
|
// 1. Try saved model from settings
|
|
if (savedModel) {
|
|
const match = resolveModelFromString(savedModel, availableModels, undefined);
|
|
if (match) return match;
|
|
}
|
|
|
|
// 2. Try priority chain
|
|
for (const pattern of MODEL_PRIO.slow) {
|
|
// Try exact match first
|
|
const exactMatch = parseModelPattern(pattern, availableModels, undefined).model;
|
|
if (exactMatch) return exactMatch;
|
|
|
|
// Try fuzzy match (substring)
|
|
const fuzzyMatch = availableModels.find(m => m.id.toLowerCase().includes(pattern.toLowerCase()));
|
|
if (fuzzyMatch) return fuzzyMatch;
|
|
}
|
|
|
|
// 3. Fallback to first available (same as default)
|
|
return availableModels[0];
|
|
}
|