@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
492 lines • 22.8 kB
JavaScript
/**
* Model resolution, scoping, and initial selection
*/
import { modelsAreEqual, } from "@earendil-works/pi-ai";
import chalk from "chalk";
import { minimatch } from "minimatch";
import { isValidThinkingLevel } from "../cli/args.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
/** Default chat model IDs for providers with built-in chat models. */
export const defaultModelPerProvider = {
"amazon-bedrock": "us.anthropic.claude-opus-5-5",
"ant-ling": "Ring-2.6-1T",
anthropic: "claude-opus-5-5",
openai: "gpt-6.1-sol",
azure: "gpt-6.1-sol",
"openai-codex": "gpt-6.1-sol",
radius: "balanced",
nvidia: "nvidia/nemotron-3.5-super-vl-120b-a12b",
deepseek: "deepseek-v4-pro",
google: "gemini-4-argon",
"google-vertex": "gemini-4-argon",
"github-copilot": "gpt-6.1-sol",
openrouter: "moonshotai/kimi-k2.7-code",
"vercel-ai-gateway": "zai/glm-5.3",
xai: "grok-4.7",
groq: "openai/gpt-oss-120b",
cerebras: "gpt-oss-120b",
zai: "glm-5.3",
"zai-coding-cn": "glm-5.3",
mistral: "devstral-medium-latest",
minimax: "MiniMax-M3",
"minimax-cn": "MiniMax-M3",
moonshotai: "kimi-k2.7-code",
"moonshotai-cn": "kimi-k2.7-code",
huggingface: "moonshotai/Kimi-K2.7-Code",
fireworks: "accounts/fireworks/models/kimi-k3",
together: "moonshotai/Kimi-K3",
baseten: "zai-org/GLM-5.3",
opencode: "kimi-k2.7-code",
"opencode-go": "kimi-k3.1-pro",
"kimi-coding": "kimi-for-coding",
meta: "muse-spark-1.3",
"cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.7-code",
"cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.7-code",
"qwen-token-plan": "qwen3.8-max",
"qwen-token-plan-cn": "qwen3.8-max",
"qwen-token-plan-individual": "qwen3.8-max",
"qwen-token-plan-individual-cn": "qwen3.8-max",
xiaomi: "mimo-v2.6-pro",
"xiaomi-token-plan-cn": "mimo-v2.6-pro",
"xiaomi-token-plan-ams": "mimo-v2.6-pro",
"xiaomi-token-plan-sgp": "mimo-v2.6-pro",
};
function splitThinkingSuffix(text) {
const colonIndex = text.lastIndexOf(":");
const suffix = text.substring(colonIndex + 1);
if (colonIndex === -1 || !isValidThinkingLevel(suffix))
return undefined;
return { base: text.substring(0, colonIndex), thinkingLevel: suffix };
}
function splitProviderPrefix(text, models) {
// Provider ids may contain "/" themselves (e.g. URL-derived ids), so match known ids, longest first
const lowerText = text.toLowerCase();
let best;
for (const { provider } of models) {
const prefix = provider.trim().toLowerCase();
if (!prefix || (best && best.provider.length >= provider.length))
continue;
const slashIndex = lowerText.indexOf("/", prefix.length);
if (slashIndex === -1 || lowerText.substring(0, slashIndex).trim() !== prefix)
continue;
const modelText = text.substring(slashIndex + 1).trim();
if (modelText)
best = { provider, modelText };
}
return best;
}
/**
* `usable` only breaks ties between providers, so a unique match resolves even without credentials
* (`--api-key` first-time setup).
*/
export function resolveModelReference(request, models, usable) {
const text = request.text.trim();
if (!text)
return { status: "not-found" };
const constraint = request.provider?.toLowerCase();
const pool = constraint ? models.filter((model) => model.provider.toLowerCase() === constraint) : models;
const result = resolveText(text, request, pool, usable);
if (result.status !== "not-found")
return result;
const split = splitProviderPrefix(text, pool);
const anchor = constraint ? pool[0] : pool.find((model) => model.provider === split?.provider);
if (!anchor)
return result;
const modelText = split?.modelText ?? text;
const suffix = request.thinkingSuffix === "none" ? undefined : splitThinkingSuffix(modelText);
return {
status: "not-found",
target: {
provider: anchor.provider,
providerModels: [anchor, ...pool.filter((model) => model.provider === anchor.provider && model !== anchor)],
modelText,
modelId: suffix?.base ?? modelText,
thinkingLevel: suffix?.thinkingLevel,
},
};
}
/** Exact `provider/id` or bare id lookup without thinking suffixes or fuzzy matching. */
export function findExactModelReferenceMatch(modelReference, models) {
const result = resolveModelReference({ text: modelReference, thinkingSuffix: "none", fuzzy: false }, models, () => true);
return result.status === "resolved" ? result.model : undefined;
}
function resolveText(text, request, pool, usable) {
const reference = selectReference(text, pool, request.fuzzy, usable);
if (reference)
return reference;
if (request.fuzzy) {
const fuzzy = selectFuzzy(fuzzyMatches(text, pool), usable);
if (fuzzy)
return fuzzy;
}
if (request.thinkingSuffix === "none")
return { status: "not-found" };
const thinking = splitThinkingSuffix(text);
if (thinking) {
const inner = resolveText(thinking.base, request, pool, usable);
if (inner.status === "not-found")
return inner;
// Only use this thinking level if no warning from inner recursion
return { ...inner, thinkingLevel: inner.warning ? undefined : thinking.thinkingLevel };
}
// Keep an invalid suffix as part of the id and fail, rather than resolve a different model
if (request.thinkingSuffix === "strict")
return { status: "not-found" };
const colonIndex = text.lastIndexOf(":");
if (colonIndex === -1)
return { status: "not-found" };
const suffix = text.substring(colonIndex + 1);
const inner = resolveText(text.substring(0, colonIndex), request, pool, usable);
if (inner.status === "not-found")
return inner;
return {
...inner,
thinkingLevel: undefined,
warning: `Invalid thinking level "${suffix}" in pattern "${text}". Using default instead.`,
};
}
/**
* `provider/rest` is only a hint: the whole text may also be a model id on another provider, such as
* gateway id `zai/glm-5` or commandcode id `xiaomi/mimo-v2.6-pro`. The named provider wins when usable,
* since that is what the syntax usually means; otherwise a sole usable raw match wins, since the named
* provider could not serve the request. An unusable named match is still preferred over unusable raw
* matches, so `--api-key` can set up the named provider.
*/
function selectReference(text, pool, fuzzy, usable) {
const lowerText = text.toLowerCase();
const raw = pool.filter((model) => model.id.toLowerCase() === lowerText);
const split = splitProviderPrefix(text, pool);
let named;
if (split) {
const providerModels = pool.filter((model) => model.provider === split.provider);
const lowerModelText = split.modelText.toLowerCase();
// The provider's own literal id (OpenRouter's `openrouter/free`) beats its fuzzy matches
named =
providerModels.find((model) => model.id.toLowerCase() === lowerModelText) ??
raw.find((model) => model.provider === split.provider) ??
(fuzzy ? bestFuzzyMatch(fuzzyMatches(split.modelText, providerModels)) : undefined);
}
const others = raw.filter((model) => model !== named);
const candidates = named ? [named, ...others] : others;
if (candidates.length === 0)
return undefined;
if (new Set(candidates.map((model) => model.provider)).size === 1)
return { status: "resolved", model: candidates[0] };
if (named && usable(named.provider))
return { status: "resolved", model: named };
const usableOthers = others.filter((model) => usable(model.provider));
if (usableOthers.length === 1)
return { status: "resolved", model: usableOthers[0] };
if (usableOthers.length === 0 && named)
return { status: "resolved", model: named };
return { status: "ambiguous", candidates, usableCount: usableOthers.length };
}
function fuzzyMatches(text, models) {
const lowerText = text.toLowerCase();
return models.filter((model) => model.id.toLowerCase().includes(lowerText) || model.name?.toLowerCase().includes(lowerText));
}
function selectFuzzy(matches, usable) {
if (matches.length === 0)
return undefined;
const multipleProviders = new Set(matches.map((model) => model.provider)).size > 1;
const usableMatches = multipleProviders ? matches.filter((model) => usable(model.provider)) : [];
const pool = usableMatches.length > 0 ? usableMatches : matches;
const best = bestFuzzyMatch(pool);
if (!best)
return undefined;
// The same id on several providers would otherwise be decided by catalog order
const tied = pool.filter((model) => model.id === best.id);
if (new Set(tied.map((model) => model.provider)).size === 1)
return { status: "resolved", model: best };
return {
status: "ambiguous",
candidates: tied,
usableCount: usableMatches.length > 0 ? tied.length : 0,
};
}
/** Prefer aliases (`claude-sonnet-4-5`) over dated versions (`claude-sonnet-4-5-20250929`), then the latest. */
function bestFuzzyMatch(matches) {
const aliases = matches.filter((model) => isAlias(model.id));
return [...(aliases.length > 0 ? aliases : matches)].sort((a, b) => b.id.localeCompare(a.id))[0];
}
function isAlias(id) {
return id.endsWith("-latest") || !/-\d{8}$/.test(id);
}
function buildFallbackModel(providerModels, modelId) {
const defaultId = defaultModelPerProvider[providerModels[0].provider];
const baseModel = defaultId
? (providerModels.find((m) => m.id === defaultId) ?? providerModels[0])
: providerModels[0];
return {
...baseModel,
id: modelId,
name: modelId,
};
}
/**
* Resolve scope patterns (`--models`, `enabledModels`) to models with optional thinking levels.
*
* Every model in `models` is usable, so credentials cannot choose between providers offering the same
* id. All of them are added, with a warning so the user can pick one with `provider/id`.
*/
export function resolveModelScopeFromModels(patterns, models) {
const scopedModels = [];
const diagnostics = [];
const add = (model, thinkingLevel) => {
if (!scopedModels.some((scoped) => modelsAreEqual(scoped.model, model))) {
scopedModels.push({ model, thinkingLevel });
}
};
const noMatch = (pattern) => diagnostics.push({ type: "warning", code: "no-match", message: `No models match pattern "${pattern}"`, pattern });
const ambiguous = (pattern, candidates) => {
const providers = [...new Set(candidates.map((m) => m.provider))].sort((a, b) => a.localeCompare(b));
diagnostics.push({
type: "warning",
code: "ambiguous",
message: `Pattern "${pattern}" matches several providers: ${providers.join(", ")}. Use provider/id to choose one.`,
pattern,
});
};
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")
const thinking = splitThinkingSuffix(pattern);
const globPattern = thinking?.base ?? pattern;
const thinkingLevel = thinking?.thinkingLevel;
// Ids such as `bracketed-model[1m]` are not globs
const exact = resolveModelReference({ text: globPattern, thinkingSuffix: "none", fuzzy: false }, models, () => true);
let matches = [];
if (exact.status === "resolved")
matches = [exact.model];
else if (exact.status === "ambiguous") {
matches = exact.candidates;
ambiguous(pattern, matches);
}
else {
// Match against "provider/modelId" format OR just model ID
// This allows "*sonnet*" to match without requiring "anthropic/*sonnet*"
matches = models.filter((m) => minimatch(`${m.provider}/${m.id}`, globPattern, { nocase: true }) ||
minimatch(m.id, globPattern, { nocase: true }));
}
if (matches.length === 0)
noMatch(pattern);
for (const model of matches)
add(model, thinkingLevel);
continue;
}
const result = resolveModelReference({ text: pattern, thinkingSuffix: "lenient", fuzzy: true }, models, () => true);
if (result.status === "not-found") {
noMatch(pattern);
continue;
}
if (result.warning) {
diagnostics.push({ type: "warning", code: "invalid-thinking-level", message: result.warning, pattern });
}
if (result.status === "ambiguous")
ambiguous(pattern, result.candidates);
const matches = result.status === "resolved" ? [result.model] : result.candidates;
for (const model of matches)
add(model, result.thinkingLevel);
}
return { scopedModels, diagnostics };
}
export async function resolveModelScopeWithDiagnostics(patterns, modelRuntime, options) {
return resolveModelScopeFromModels(patterns, await modelRuntime.getAvailable(undefined, options));
}
export async function resolveModelScope(patterns, modelRuntime, options) {
const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(patterns, modelRuntime, options);
for (const diagnostic of diagnostics) {
console.warn(chalk.yellow(`Warning: ${diagnostic.message}`));
}
return scopedModels;
}
/**
* Resolve a single model from CLI flags.
*
* Supports:
* - --provider <provider> --model <pattern>
* - --model <provider>/<pattern>
* - Fuzzy matching (same rules as model scoping: exact id, then partial id/name)
*
* Note: This does not apply the thinking level by itself, but it may *parse* and
* return a thinking level from "<pattern>:<thinking>" so the caller can apply it.
*/
export function resolveCliModel(options) {
const { cliProvider, cliModel, cliThinking, inlineApiKey, modelRuntime } = options;
if (!cliModel) {
return { model: undefined, warning: undefined, error: undefined };
}
// Important: use *all* models here, not just models with pre-configured auth.
// This allows "--api-key" to be used for first-time setup.
const models = [...modelRuntime.getModels()];
if (models.length === 0) {
return {
model: undefined,
warning: undefined,
error: "No models available. Check your installation or add models to models.json.",
};
}
const provider = cliProvider
? models.find((m) => m.provider.toLowerCase() === cliProvider.toLowerCase())?.provider
: undefined;
if (cliProvider && !provider) {
return {
model: undefined,
warning: undefined,
error: `Unknown provider "${cliProvider}". Use --list-models to see available providers/models.`,
};
}
const result = resolveModelReference({ text: cliModel, provider, thinkingSuffix: "strict", fuzzy: true }, models,
// An inline key goes to whichever provider is selected, so stored credentials must not choose it
inlineApiKey ? () => false : (providerId) => modelRuntime.hasConfiguredAuth(providerId));
if (result.status === "resolved") {
return { model: result.model, thinkingLevel: result.thinkingLevel, warning: result.warning, error: undefined };
}
if (result.status === "ambiguous") {
const matches = result.candidates
.map((m) => `${m.provider}/${m.id}`)
.sort((a, b) => a.localeCompare(b))
.join(", ");
const authHint = inlineApiKey
? "--api-key does not choose a provider."
: result.usableCount === 0
? "No matching provider is authenticated."
: "More than one matching provider is authenticated.";
return {
model: undefined,
warning: undefined,
error: `Model "${cliModel}" is ambiguous across providers: ${matches}. ${authHint} Use --provider or provider/model.`,
};
}
const { target } = result;
if (!target) {
return {
model: undefined,
warning: undefined,
error: `Model "${cliModel}" not found. Use --list-models to see available models.`,
};
}
// Use the thinking level suffix only when --thinking is not explicitly provided; otherwise it
// stays part of the custom model id.
// e.g. "zai-org/GLM-5.1-FP8:high" → modelId="zai-org/GLM-5.1-FP8", fallbackThinking="high"
const modelId = cliThinking ? target.modelText : target.modelId;
const fallbackThinking = cliThinking ? undefined : target.thinkingLevel;
const fallbackModel = buildFallbackModel(target.providerModels, modelId);
const requestedThinking = cliThinking ?? fallbackThinking;
return {
model: requestedThinking && requestedThinking !== "off" ? { ...fallbackModel, reasoning: true } : fallbackModel,
thinkingLevel: fallbackThinking,
warning: `Model "${modelId}" not found for provider "${target.provider}". Using custom model id.`,
error: undefined,
};
}
/**
* Find the initial model to use based on priority:
* 1. First model from scoped models (if not continuing/resuming)
* 2. Saved default from settings
* 3. First available model with valid API key
*/
export async function findInitialModel(options) {
const { scopedModels, isContinuing, defaultProvider, defaultModelId, defaultThinkingLevel, modelThinkingLevels, modelRuntime, } = options;
let model;
let thinkingLevel = DEFAULT_THINKING_LEVEL;
// 1. Use first model from scoped models (skip if continuing/resuming)
if (scopedModels.length > 0 && !isContinuing) {
const scopedModel = scopedModels[0];
const perModel = modelThinkingLevels?.[`${scopedModel.model.provider}/${scopedModel.model.id}`];
return {
model: scopedModel.model,
thinkingLevel: scopedModel.thinkingLevel ?? perModel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL,
fallbackMessage: undefined,
};
}
// 2. Try saved default from settings if auth is configured.
if (defaultProvider && defaultModelId) {
const found = modelRuntime.getModel(defaultProvider, defaultModelId);
if (found && modelRuntime.hasConfiguredAuth(found.provider)) {
model = found;
const perModel = modelThinkingLevels?.[`${defaultProvider}/${defaultModelId}`];
if (perModel) {
thinkingLevel = perModel;
}
else if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel;
}
return { model, thinkingLevel, fallbackMessage: undefined };
}
}
// 3. Try first available model with valid API key
const availableModels = [...modelRuntime.getAvailableSnapshot()];
if (availableModels.length > 0) {
// Try to find a default model from known providers
for (const provider of Object.keys(defaultModelPerProvider)) {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
if (match) {
return { model: match, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
}
}
// If no default found, use first available
return { model: availableModels[0], thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
}
// 4. No model found
return { model: undefined, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
}
/**
* Restore model from session, with fallback to available models
*/
export async function restoreModelFromSession(savedProvider, savedModelId, currentModel, shouldPrintMessages, modelRuntime) {
const restoredModel = modelRuntime.getModel(savedProvider, savedModelId);
// Check if restored model exists and still has auth configured
const hasConfiguredAuth = restoredModel ? modelRuntime.hasConfiguredAuth(restoredModel.provider) : false;
if (restoredModel && hasConfiguredAuth) {
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 auth configured";
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 = [...modelRuntime.getAvailableSnapshot()];
if (availableModels.length > 0) {
// Try to find a default model from known providers
let fallbackModel;
for (const provider of Object.keys(defaultModelPerProvider)) {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
if (match) {
fallbackModel = match;
break;
}
}
// If no default found, use first available
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}.`,
};
}
// No models available
return { model: undefined, fallbackMessage: undefined };
}
//# sourceMappingURL=model-resolver.js.map