fix(coding-agent): resolve model references through one provider selection policy (#10913)

* fix(coding-agent): resolve model references through one provider selection policy

Model references from --model, --models/enabledModels, /model, and auth credential printing now share one resolver. It collects every candidate before choosing a provider, so catalog order never decides, a :<thinking> suffix cannot change the provider, and an ambiguous exact match no longer falls through to a fuzzy match.

provider/id still prefers the named provider's exact or fuzzy match. A gateway exposing provider/id as a raw model id is used when the named provider has no match, or lacks credentials while the gateway has them. Several gateways without a unique authenticated one are reported as ambiguous.

fixes #10160

* docs(coding-agent): shorten model selection notes in CLI reference

* docs(coding-agent): condense #10160 changelog entry

* fix(coding-agent): move model reference resolution into model-resolver

* fix(coding-agent): warn when a model scope pattern matches several providers

* docs(coding-agent): trim model resolver comments to the reasons

* fix(coding-agent): build custom model ids from the resolver's not-found target
This commit is contained in:
Christian Klotz 2026-10-14 11:00:21 +01:00 • committed by GitHub
parent 47fc5f38ec
commit d7f7e0dfee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 664 additions and 782 deletions

View file

@ -12,6 +12,7 @@
- Fixed resumed sessions dropping tools activated during the session. Resume now restores the session's active tools, with `--tools +name`/`-name` entries applied to them, unless `--tools` with tool names, `--no-tools`, or `--no-builtin-tools` selects tools explicitly ([#10785](https://github.com/earendil-works/pi/issues/10785))
- Fixed stopping a codemode script or reaching its deadline waiting for a non-interruptible built-in to return on Bun ([#10748](https://github.com/earendil-works/pi/issues/10748))
- Fixed repeated codemode timeouts or aborts accumulating surviving workers; admission now limits live workers to two across sessions, counting cancelled workers until actual exit ([#10748](https://github.com/earendil-works/pi/issues/10748))
- Fixed `--model` choosing a provider by catalog order when several providers offer the match; Pi now prefers the provider with credentials and otherwise reports the ambiguity. `--models` and `enabledModels` add a model for each provider that offers it and warn about the ambiguity ([#10160](https://github.com/earendil-works/pi/issues/10160))
## [1.2.0] - 2026-10-12

View file

@ -63,13 +63,13 @@ See [Choose a Model](models.md) for model selection and [Providers](providers.md
- `--provider <name>`<br>
Restricts `--model` lookup to one provider. It requires `--model`.
- `--model <pattern>`<br>
Selects by exact ID or fuzzy ID/name match. It accepts `provider/id` and an optional `:<thinking>` suffix.
Selects by exact ID or fuzzy ID/name match. It accepts `provider/id` and an optional `:<thinking>` suffix. If several providers match, Pi prefers the one with credentials, otherwise it asks for `--provider`.
- `--api-key <key>`<br>
Uses a non-persistent API-key override. It requires a model selected through `--model` or `--models`.
Uses a non-persistent API-key override. It requires a model selected through `--model` or `--models`. It does not choose between providers that offer the same model.
- `--thinking <level>`<br>
Sets `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. It overrides a `--model` suffix and is clamped to the model's capabilities.
- `--models <patterns>`<br>
Sets a comma-separated scope for startup and cycling. It accepts exact IDs, fuzzy matches, case-insensitive globs, and optional `:<thinking>` suffixes.
Sets a comma-separated scope for startup and cycling. It accepts exact IDs, fuzzy matches, case-insensitive globs, and optional `:<thinking>` suffixes. A model offered by several providers is added for each, with a warning.
- `--list-models [search]`<br>
Lists available models, optionally filtered by a fuzzy search, then exits.

View file

@ -1,5 +1,5 @@
import type { Api, CredentialInfo, Model } from "@earendil-works/pi-ai";
import { resolveCliModel } from "../core/model-resolver.ts";
import { resolveCliModel, resolveModelReference } from "../core/model-resolver.ts";
import type { ModelRuntime } from "../core/model-runtime.ts";
import type { Args } from "./args.ts";
import { AuthCommandError, type AuthCommandKind, getAuthCredential, validateAuthCommandArgs } from "./auth-command.ts";
@ -41,12 +41,15 @@ export async function resolveCredentialForPrint(
providers.push({ id: provider.id });
}
} else {
const models = modelRuntime.getModels();
for (const provider of modelRuntime.getProviders()) {
if (!credentialTypes.has(provider.id)) continue;
const resolved = resolveCliModel({ cliProvider: provider.id, cliModel: cliModel!, modelRuntime });
if (resolved.model && !resolved.error && !resolved.warning?.includes("Using custom model id")) {
providers.push({ id: provider.id, model: resolved.model });
}
const resolved = resolveModelReference(
{ text: cliModel!, provider: provider.id, thinkingSuffix: "strict", fuzzy: true },
models,
() => true,
);
if (resolved.status === "resolved") providers.push({ id: provider.id, model: resolved.model });
}
if (providers.length === 0) {
throw new AuthCommandError(`Model "${cliModel}" not found. Use --list-models to see available models.`);

View file

@ -67,116 +67,234 @@ export interface ScopedModel {
thinkingLevel?: ThinkingLevel;
}
/**
* Helper to check if a model ID looks like an alias (no date suffix)
* Dates are typically in format: -20241022 or -20250929
/*
* Model reference resolution. Candidates are collected before a provider is chosen, so catalog order
* never decides the provider and an ambiguous match is reported instead of falling through to a
* weaker one.
*/
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);
export interface ModelReferenceRequest {
/** Bare model id or `provider/id`, optionally followed by `:<thinking>`. */
text: string;
/** Only consider models from this provider. */
provider?: string;
/**
* A trailing `:<suffix>` is only considered when the full text matches no model, since ids such as
* OpenRouter's `qwen/qwen3-coder:exacto` contain colons.
* - `none`: the suffix stays part of the id
* - `strict`: a valid thinking level is parsed; any other suffix stays part of the id
* - `lenient`: like `strict`, but an invalid suffix is dropped with a warning
*/
thinkingSuffix: "none" | "strict" | "lenient";
/** Match id and name substrings when no exact match exists. */
fuzzy: boolean;
}
export type ModelReferenceResult =
| { status: "resolved"; model: Model<Api>; thinkingLevel?: ThinkingLevel; warning?: string }
| {
status: "ambiguous";
candidates: Model<Api>[];
usableCount: number;
thinkingLevel?: ThinkingLevel;
warning?: string;
}
| {
status: "not-found";
/** Set when a provider is explicit or named by a `provider/` prefix, for building a custom model id. */
target?: ModelReferenceTarget;
};
export interface ModelReferenceTarget {
provider: string;
providerModels: [Model<Api>, ...Model<Api>[]];
/** Text after the provider prefix as typed, including any `:<suffix>`. */
modelText: string;
/** `modelText` without a valid thinking suffix (unless `thinkingSuffix` is `none`). */
modelId: string;
thinkingLevel?: ThinkingLevel;
}
type ResolvedOrAmbiguous = Exclude<ModelReferenceResult, { status: "not-found" }>;
function splitThinkingSuffix(text: string): { base: string; thinkingLevel: ThinkingLevel } | undefined {
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: string,
models: readonly Model<Api>[],
): { provider: string; modelText: string } | undefined {
const slashIndex = text.indexOf("/");
if (slashIndex === -1) return undefined;
const prefix = text.substring(0, slashIndex).trim().toLowerCase();
const modelText = text.substring(slashIndex + 1).trim();
if (!prefix || !modelText) return undefined;
const provider = models.find((model) => model.provider.toLowerCase() === prefix)?.provider;
return provider ? { provider, modelText } : undefined;
}
/**
* Find an exact model reference match.
* Supports either a bare model id or a canonical provider/modelId reference.
* When matching by bare id, ambiguous matches across providers are rejected.
* `usable` only breaks ties between providers, so a unique match resolves even without credentials
* (`--api-key` first-time setup).
*/
export function resolveModelReference(
request: ModelReferenceRequest,
models: readonly Model<Api>[],
usable: (provider: string) => boolean,
): ModelReferenceResult {
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: string,
availableModels: Model<Api>[],
models: readonly Model<Api>[],
): Model<Api> | undefined {
const trimmedReference = modelReference.trim();
if (!trimmedReference) {
return undefined;
}
const normalizedReference = trimmedReference.toLowerCase();
const canonicalMatches = availableModels.filter(
(model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference,
const result = resolveModelReference(
{ text: modelReference, thinkingSuffix: "none", fuzzy: false },
models,
() => true,
);
if (canonicalMatches.length === 1) {
return canonicalMatches[0];
}
if (canonicalMatches.length > 1) {
return undefined;
return result.status === "resolved" ? result.model : undefined;
}
function resolveText(
text: string,
request: ModelReferenceRequest,
pool: readonly Model<Api>[],
usable: (provider: string) => boolean,
): ModelReferenceResult {
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;
}
const slashIndex = trimmedReference.indexOf("/");
if (slashIndex !== -1) {
const provider = trimmedReference.substring(0, slashIndex).trim();
const modelId = trimmedReference.substring(slashIndex + 1).trim();
if (provider && modelId) {
const providerMatches = availableModels.filter(
(model) =>
model.provider.toLowerCase() === provider.toLowerCase() &&
model.id.toLowerCase() === modelId.toLowerCase(),
);
if (providerMatches.length === 1) {
return providerMatches[0];
}
if (providerMatches.length > 1) {
return undefined;
}
}
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 idMatches = availableModels.filter((model) => model.id.toLowerCase() === normalizedReference);
return idMatches.length === 1 ? idMatches[0] : undefined;
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.`,
};
}
/**
* Try to match a pattern to a model from the available models list.
* Returns the matched model or undefined if no match found.
* `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.5-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 tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Model<Api> | undefined {
const exactMatch = findExactModelReferenceMatch(modelPattern, availableModels);
if (exactMatch) {
return exactMatch;
function selectReference(
text: string,
pool: readonly Model<Api>[],
fuzzy: boolean,
usable: (provider: string) => boolean,
): ResolvedOrAmbiguous | undefined {
const lowerText = text.toLowerCase();
const raw = pool.filter((model) => model.id.toLowerCase() === lowerText);
const split = splitProviderPrefix(text, pool);
let named: Model<Api> | undefined;
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);
}
// No exact match - fall back to partial matching
const matches = availableModels.filter(
(m) =>
m.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
m.name?.toLowerCase().includes(modelPattern.toLowerCase()),
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: string, models: readonly Model<Api>[]): Model<Api>[] {
const lowerText = text.toLowerCase();
return models.filter(
(model) => model.id.toLowerCase().includes(lowerText) || model.name?.toLowerCase().includes(lowerText),
);
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) {
// Prefer alias - if multiple aliases, pick the one that sorts highest
aliases.sort((a, b) => b.id.localeCompare(a.id));
return aliases[0];
} else {
// No alias found, pick latest dated version
datedVersions.sort((a, b) => b.id.localeCompare(a.id));
return datedVersions[0];
}
}
export interface ParsedModelResult {
model: Model<Api> | undefined;
/** Thinking level if explicitly specified in pattern, undefined otherwise */
thinkingLevel?: ThinkingLevel;
warning: string | undefined;
function selectFuzzy(matches: Model<Api>[], usable: (provider: string) => boolean): ResolvedOrAmbiguous | undefined {
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,
};
}
function buildFallbackModel(provider: string, modelId: string, availableModels: Model<Api>[]): Model<Api> | undefined {
const providerModels = availableModels.filter((m) => m.provider === provider);
if (providerModels.length === 0) return undefined;
/** Prefer aliases (`claude-sonnet-4-5`) over dated versions (`claude-sonnet-4-5-20250929`), then the latest. */
function bestFuzzyMatch(matches: readonly Model<Api>[]): Model<Api> | undefined {
const aliases = matches.filter((model) => isAlias(model.id));
return [...(aliases.length > 0 ? aliases : matches)].sort((a, b) => b.id.localeCompare(a.id))[0];
}
const defaultId = defaultModelPerProvider[provider as KnownProvider];
function isAlias(id: string): boolean {
return id.endsWith("-latest") || !/-\d{8}$/.test(id);
}
function buildFallbackModel(providerModels: [Model<Api>, ...Model<Api>[]], modelId: string): Model<Api> {
const defaultId = defaultModelPerProvider[providerModels[0].provider as KnownProvider];
const baseModel = defaultId
? (providerModels.find((m) => m.id === defaultId) ?? providerModels[0])
: providerModels[0];
@ -188,88 +306,9 @@ function buildFallbackModel(provider: string, modelId: string, availableModels:
};
}
/**
* 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 "off" 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 with "off"
*
* @internal Exported for testing
*/
export function parseModelPattern(
pattern: string,
availableModels: Model<Api>[],
options?: { allowInvalidThinkingLevelFallback?: boolean },
): ParsedModelResult {
// Try exact match first
const exactMatch = tryMatchModel(pattern, availableModels);
if (exactMatch) {
return { model: exactMatch, thinkingLevel: undefined, warning: undefined };
}
// No match - try splitting on last colon if present
const lastColonIndex = pattern.lastIndexOf(":");
if (lastColonIndex === -1) {
// No colons, pattern simply doesn't match any model
return { model: undefined, thinkingLevel: undefined, warning: undefined };
}
const prefix = pattern.substring(0, lastColonIndex);
const suffix = pattern.substring(lastColonIndex + 1);
if (isValidThinkingLevel(suffix)) {
// Valid thinking level - recurse on prefix and use this level
const result = parseModelPattern(prefix, availableModels, options);
if (result.model) {
// Only use this thinking level if no warning from inner recursion
return {
model: result.model,
thinkingLevel: result.warning ? undefined : suffix,
warning: result.warning,
};
}
return result;
} else {
// Invalid suffix
const allowFallback = options?.allowInvalidThinkingLevelFallback ?? true;
if (!allowFallback) {
// In strict mode (CLI --model parsing), treat it as part of the model id and fail.
// This avoids accidentally resolving to a different model.
return { model: undefined, thinkingLevel: undefined, warning: undefined };
}
// Scope mode: recurse on prefix and warn
const result = parseModelPattern(prefix, availableModels, options);
if (result.model) {
return {
model: result.model,
thinkingLevel: undefined,
warning: `Invalid thinking level "${suffix}" in pattern "${pattern}". Using default instead.`,
};
}
return result;
}
}
/**
* 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 interface ModelScopeDiagnostic {
type: "warning";
code: "no-match" | "invalid-thinking-level";
code: "no-match" | "invalid-thinking-level" | "ambiguous";
message: string;
pattern: string;
}
@ -279,83 +318,83 @@ export interface ResolveModelScopeResult {
diagnostics: ModelScopeDiagnostic[];
}
/**
* 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: string[],
models: readonly Model<Api>[],
): ResolveModelScopeResult {
const availableModels = [...models];
const scopedModels: ScopedModel[] = [];
const diagnostics: ModelScopeDiagnostic[] = [];
const add = (model: Model<Api>, thinkingLevel: ThinkingLevel | undefined) => {
if (!scopedModels.some((scoped) => modelsAreEqual(scoped.model, model))) {
scopedModels.push({ model, thinkingLevel });
}
};
const noMatch = (pattern: string) =>
diagnostics.push({ type: "warning", code: "no-match", message: `No models match pattern "${pattern}"`, pattern });
const ambiguous = (pattern: string, candidates: readonly Model<Api>[]) => {
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 colonIdx = pattern.lastIndexOf(":");
let globPattern = pattern;
let thinkingLevel: ThinkingLevel | undefined;
const thinking = splitThinkingSuffix(pattern);
const globPattern = thinking?.base ?? pattern;
const thinkingLevel = thinking?.thinkingLevel;
if (colonIdx !== -1) {
const suffix = pattern.substring(colonIdx + 1);
if (isValidThinkingLevel(suffix)) {
thinkingLevel = suffix;
globPattern = pattern.substring(0, colonIdx);
}
}
const exactMatch = findExactModelReferenceMatch(globPattern, availableModels);
if (exactMatch) {
if (!scopedModels.find((sm) => modelsAreEqual(sm.model, exactMatch))) {
scopedModels.push({ model: exactMatch, thinkingLevel });
}
continue;
}
// Match against "provider/modelId" format OR just model ID
// This allows "*sonnet*" to match without requiring "anthropic/*sonnet*"
const matchingModels = availableModels.filter((m) => {
const fullId = `${m.provider}/${m.id}`;
return minimatch(fullId, globPattern, { nocase: true }) || minimatch(m.id, globPattern, { nocase: true });
});
if (matchingModels.length === 0) {
diagnostics.push({
type: "warning",
code: "no-match",
message: `No models match pattern "${pattern}"`,
pattern,
});
continue;
}
for (const model of matchingModels) {
if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) {
scopedModels.push({ model, thinkingLevel });
}
// Ids such as `bracketed-model[1m]` are not globs
const exact = resolveModelReference(
{ text: globPattern, thinkingSuffix: "none", fuzzy: false },
models,
() => true,
);
let matches: readonly Model<Api>[] = [];
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 { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels);
if (warning) {
diagnostics.push({ type: "warning", code: "invalid-thinking-level", message: warning, pattern });
}
if (!model) {
diagnostics.push({
type: "warning",
code: "no-match",
message: `No models match pattern "${pattern}"`,
pattern,
});
const result = resolveModelReference(
{ text: pattern, thinkingSuffix: "lenient", fuzzy: true },
models,
() => true,
);
if (result.status === "not-found") {
noMatch(pattern);
continue;
}
// Avoid duplicates
if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) {
scopedModels.push({ model, thinkingLevel });
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 };
@ -407,9 +446,11 @@ export function resolveCliModel(options: {
cliProvider?: string;
cliModel?: string;
cliThinking?: ThinkingLevel;
/** `--api-key` was supplied for the selected provider. */
inlineApiKey?: boolean;
modelRuntime: ModelRuntime;
}): ResolveCliModelResult {
const { cliProvider, cliModel, cliThinking, modelRuntime } = options;
const { cliProvider, cliModel, cliThinking, inlineApiKey, modelRuntime } = options;
if (!cliModel) {
return { model: undefined, warning: undefined, error: undefined };
@ -417,8 +458,8 @@ export function resolveCliModel(options: {
// Important: use *all* models here, not just models with pre-configured auth.
// This allows "--api-key" to be used for first-time setup.
const availableModels = [...modelRuntime.getModels()];
if (availableModels.length === 0) {
const models = [...modelRuntime.getModels()];
if (models.length === 0) {
return {
model: undefined,
warning: undefined,
@ -426,13 +467,9 @@ export function resolveCliModel(options: {
};
}
// Build canonical provider lookup (case-insensitive)
const providerMap = new Map<string, string>();
for (const m of availableModels) {
providerMap.set(m.provider.toLowerCase(), m.provider);
}
let provider = cliProvider ? providerMap.get(cliProvider.toLowerCase()) : undefined;
const provider = cliProvider
? models.find((m) => m.provider.toLowerCase() === cliProvider.toLowerCase())?.provider
: undefined;
if (cliProvider && !provider) {
return {
model: undefined,
@ -441,167 +478,53 @@ export function resolveCliModel(options: {
};
}
// If no explicit --provider, try to interpret "provider/model" format first.
// When the prefix before the first slash matches a known provider, prefer that
// interpretation over matching models whose IDs literally contain slashes
// (e.g. "zai/glm-5" should resolve to provider=zai, model=glm-5, not to a
// vercel-ai-gateway model with id "zai/glm-5").
let pattern = cliModel;
let inferredProvider = false;
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);
inferredProvider = true;
}
}
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.`,
};
}
// If no provider was inferred from the slash, try exact matches without provider inference.
// This handles models whose IDs naturally contain slashes (e.g. OpenRouter-style IDs).
// Bare exact IDs can exist in multiple providers, so do not choose by catalog order.
// Prefer the sole authenticated provider when there is one; otherwise require an
// explicit provider to avoid silently selecting an unusable provider.
if (!provider) {
const lower = cliModel.toLowerCase();
const exactMatches = availableModels.filter(
(m) => m.id.toLowerCase() === lower || `${m.provider}/${m.id}`.toLowerCase() === lower,
);
if (exactMatches.length === 1) {
return { model: exactMatches[0], warning: undefined, thinkingLevel: undefined, error: undefined };
}
if (exactMatches.length > 1) {
const authenticatedExactMatches = exactMatches.filter((m) => modelRuntime.hasConfiguredAuth(m.provider));
if (authenticatedExactMatches.length === 1) {
return {
model: authenticatedExactMatches[0],
warning: undefined,
thinkingLevel: undefined,
error: undefined,
};
}
const matches = exactMatches
.map((m) => `${m.provider}/${m.id}`)
.sort((a, b) => a.localeCompare(b))
.join(", ");
const authHint =
authenticatedExactMatches.length === 0
? "No matching provider is authenticated."
: "More than one matching provider is authenticated.";
return {
model: undefined,
warning: undefined,
thinkingLevel: 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.`,
};
}
if (cliProvider && provider) {
// If both were provided, tolerate --model <provider>/<pattern> by stripping the provider prefix
const prefix = `${provider}/`;
if (cliModel.toLowerCase().startsWith(prefix.toLowerCase())) {
pattern = cliModel.substring(prefix.length);
}
}
const candidates = provider ? availableModels.filter((m) => m.provider === provider) : availableModels;
const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, {
allowInvalidThinkingLevelFallback: false,
});
if (model) {
// If provider inference matched an unauthenticated provider/model pair, prefer
// one exact raw model-id match that is authenticated. This keeps
// "provider/model" syntax preferred when usable, but handles models whose
// literal id starts with a known provider name (for example
// commandcode model id "xiaomi/mimo-v2.5-pro").
if (inferredProvider) {
const rawExactMatches = availableModels.filter(
(m) => m.id.toLowerCase() === cliModel.toLowerCase() && !modelsAreEqual(m, model),
);
if (rawExactMatches.length > 0 && !modelRuntime.hasConfiguredAuth(model.provider)) {
const authenticatedRawMatches = rawExactMatches.filter((m) => modelRuntime.hasConfiguredAuth(m.provider));
if (authenticatedRawMatches.length === 1) {
return {
model: authenticatedRawMatches[0],
thinkingLevel: undefined,
warning: undefined,
error: undefined,
};
}
}
}
return { model, thinkingLevel, warning, error: undefined };
}
// If we inferred a provider from the slash but found no match within that provider,
// fall back to matching the full input as a raw model id across all models.
// This handles OpenRouter-style IDs like "openai/gpt-4o:extended" where "openai"
// looks like a provider but the full string is actually a model id on openrouter.
if (inferredProvider) {
const lower = cliModel.toLowerCase();
const exact = availableModels.find(
(m) => m.id.toLowerCase() === lower || `${m.provider}/${m.id}`.toLowerCase() === lower,
);
if (exact) {
return { model: exact, warning: undefined, thinkingLevel: undefined, error: undefined };
}
// Also try parseModelPattern on the full input against all models
const fallback = parseModelPattern(cliModel, availableModels, {
allowInvalidThinkingLevelFallback: false,
});
if (fallback.model) {
return {
model: fallback.model,
thinkingLevel: fallback.thinkingLevel,
warning: fallback.warning,
error: undefined,
};
}
}
if (provider) {
// Parse thinking level suffix from the pattern before building the fallback model,
// but only when --thinking is not explicitly provided.
// e.g. "zai-org/GLM-5.1-FP8:high" → modelId="zai-org/GLM-5.1-FP8", fallbackThinking="high"
let fallbackPattern = pattern;
let fallbackThinking: ThinkingLevel | undefined;
if (!cliThinking) {
const lastColon = pattern.lastIndexOf(":");
if (lastColon !== -1) {
const suffix = pattern.substring(lastColon + 1);
if (isValidThinkingLevel(suffix)) {
fallbackPattern = pattern.substring(0, lastColon);
fallbackThinking = suffix;
}
}
}
const fallbackModel = buildFallbackModel(provider, fallbackPattern, availableModels);
if (fallbackModel) {
const requestedThinking = cliThinking ?? fallbackThinking;
const model =
requestedThinking && requestedThinking !== "off" ? { ...fallbackModel, reasoning: true } : fallbackModel;
const fallbackWarning = warning
? `${warning} Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`
: `Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`;
return { model, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined };
}
}
const display = provider ? `${provider}/${pattern}` : cliModel;
// 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: undefined,
thinkingLevel: undefined,
warning,
error: `Model "${display}" not found. Use --list-models to see available models.`,
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,
};
}
@ -613,15 +536,11 @@ export interface InitialModelResult {
/**
* 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
* 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: {
cliProvider?: string;
cliModel?: string;
scopedModels: ScopedModel[];
isContinuing: boolean;
defaultProvider?: string;
@ -631,8 +550,6 @@ export async function findInitialModel(options: {
modelRuntime: ModelRuntime;
}): Promise<InitialModelResult> {
const {
cliProvider,
cliModel,
scopedModels,
isContinuing,
defaultProvider,
@ -645,23 +562,7 @@ export async function findInitialModel(options: {
let model: Model<Api> | undefined;
let thinkingLevel: ThinkingLevel = DEFAULT_THINKING_LEVEL;
// 1. CLI args take priority
if (cliProvider && cliModel) {
const resolved = resolveCliModel({
cliProvider,
cliModel,
modelRuntime,
});
if (resolved.error) {
console.error(chalk.red(resolved.error));
process.exit(1);
}
if (resolved.model) {
return { model: resolved.model, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
}
}
// 2. Use first model from scoped models (skip if continuing/resuming)
// 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}`];
@ -672,7 +573,7 @@ export async function findInitialModel(options: {
};
}
// 3. Try saved default from settings if auth is configured.
// 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)) {
@ -687,7 +588,7 @@ export async function findInitialModel(options: {
}
}
// 4. Try first available model with valid API key
// 3. Try first available model with valid API key
const availableModels = [...modelRuntime.getAvailableSnapshot()];
if (availableModels.length > 0) {
@ -704,7 +605,7 @@ export async function findInitialModel(options: {
return { model: availableModels[0], thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
}
// 5. No model found
// 4. No model found
return { model: undefined, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
}

View file

@ -477,6 +477,7 @@ function buildSessionOptions(
cliProvider: parsed.provider,
cliModel: parsed.model,
cliThinking: parsed.thinking,
inlineApiKey: Boolean(parsed.apiKey),
modelRuntime,
});
if (resolved.warning) {

View file

@ -24,6 +24,20 @@ describe("credential print commands", () => {
await expect(resolveCredentialForPrint(args, runtime, "api_key")).resolves.toBe("test-api-key");
});
test("resolves --model without --provider only among catalog models of configured providers", async () => {
const runtime = await createRuntime(
AuthStorage.inMemory({
openai: { type: "api_key", key: "openai-test-key" },
anthropic: { type: "api_key", key: "anthropic-test-key" },
}),
);
// anthropic has no gpt-5.5; an uncatalogued id on a configured provider must not count as a match.
await expect(resolveCredentialForPrint(parseArgs(["--model", "gpt-5.5"]), runtime, "api_key")).resolves.toBe(
"openai-test-key",
);
});
test("prints bearer tokens resolved from an Authorization header", async () => {
const runtime = await createRuntime(
AuthStorage.inMemory({

View file

@ -2,7 +2,7 @@ import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai";
import type { Api, Model } from "@earendil-works/pi-ai";
import { getModel, streamSimple } from "@earendil-works/pi-ai/compat";
import { getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
import { afterEach, describe, expect, test, vi } from "vitest";
@ -11,8 +11,9 @@ import { AuthStorage } from "../src/core/auth-storage.ts";
import {
defaultModelPerProvider,
findInitialModel,
parseModelPattern,
type ModelReferenceRequest,
resolveCliModel,
resolveModelReference,
resolveModelScope,
resolveModelScopeWithDiagnostics,
} from "../src/core/model-resolver.ts";
@ -79,144 +80,258 @@ const mockOpenRouterModels: Model<"anthropic-messages">[] = [
const allModels = [...mockModels, ...mockOpenRouterModels];
describe("parseModelPattern", () => {
describe("simple patterns without colons", () => {
test("exact match returns model with undefined thinking level", () => {
const result = parseModelPattern("claude-sonnet-4-5", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBeUndefined();
expect(result.warning).toBeUndefined();
});
function referenceModel(provider: string, id: string): Model<Api> {
return {
id,
name: id,
api: "openai-completions",
provider,
baseUrl: "https://example.invalid",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
};
}
test("partial match returns best model with undefined thinking level", () => {
const result = parseModelPattern("sonnet", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBeUndefined();
expect(result.warning).toBeUndefined();
});
const referenceCatalog = [
referenceModel("anthropic", "claude-sonnet-4-5"),
referenceModel("anthropic", "claude-sonnet-4-5-20250929"),
referenceModel("anthropic", "claude-opus-4-8"),
referenceModel("anthropic", "exact"),
// Fuzzy-matches "shared"; an ambiguous exact "shared" must not fall through to it.
referenceModel("anthropic", "shared-pro"),
referenceModel("openai", "gpt-4o"),
referenceModel("openrouter", "qwen/qwen3-coder:exacto"),
referenceModel("openrouter", "openai/gpt-4o:extended"),
referenceModel("gw-a", "shared"),
referenceModel("gw-a", "anthropic/future"),
referenceModel("gw-a", "anthropic/exact"),
referenceModel("gw-a", "anthropic/claude-opus-4.8"),
referenceModel("gw-a", "anthropic/claude-opus"),
referenceModel("gw-b", "shared"),
referenceModel("gw-b", "anthropic/future"),
referenceModel("gw-b", "claude-opus-4-8"),
referenceModel("gw-b", "gw-b/free"),
referenceModel("gw-b", "zz:free"),
];
test("no match returns undefined model and thinking level", () => {
const result = parseModelPattern("nonexistent", allModels);
expect(result.model).toBeUndefined();
expect(result.thinkingLevel).toBeUndefined();
expect(result.warning).toBeUndefined();
});
});
type ReferenceExpected =
| { model: string; thinkingLevel?: string; warning?: string }
| { ambiguous: string[]; usableCount: number }
| "not-found";
describe("patterns with valid thinking levels", () => {
test("sonnet:high returns sonnet with high thinking level", () => {
const result = parseModelPattern("sonnet:high", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe("high");
expect(result.warning).toBeUndefined();
});
interface ReferenceCase {
name: string;
text: string;
expected: ReferenceExpected;
usable?: string[];
provider?: string;
thinkingSuffix?: ModelReferenceRequest["thinkingSuffix"];
fuzzy?: boolean;
}
test("gpt-4o:medium returns gpt-4o with medium thinking level", () => {
const result = parseModelPattern("gpt-4o:medium", allModels);
expect(result.model?.id).toBe("gpt-4o");
expect(result.thinkingLevel).toBe("medium");
expect(result.warning).toBeUndefined();
});
const referenceCases: ReferenceCase[] = [
// Reference syntax
{ name: "exact id", text: "claude-sonnet-4-5", expected: { model: "anthropic/claude-sonnet-4-5" } },
{ name: "exact id is case-insensitive", text: "GPT-4O", expected: { model: "openai/gpt-4o" } },
{ name: "fuzzy prefers aliases over dated ids", text: "sonnet", expected: { model: "anthropic/claude-sonnet-4-5" } },
{
name: "thinking suffix",
text: "sonnet:high",
expected: { model: "anthropic/claude-sonnet-4-5", thinkingLevel: "high" },
},
{
name: "colon in a literal id is not a thinking suffix",
text: "qwen/qwen3-coder:exacto",
expected: { model: "openrouter/qwen/qwen3-coder:exacto" },
},
{
name: "provider prefix, colon id, and thinking suffix",
text: "openrouter/qwen/qwen3-coder:exacto:high",
expected: { model: "openrouter/qwen/qwen3-coder:exacto", thinkingLevel: "high" },
},
{
name: "raw id wins when the provider prefix names no matching model",
text: "openai/gpt-4o:extended",
expected: { model: "openrouter/openai/gpt-4o:extended" },
},
{
name: "fuzzy searches the prefixed provider first",
text: "openrouter/qwen",
expected: { model: "openrouter/qwen/qwen3-coder:exacto" },
},
{ name: "strict keeps an invalid suffix in the id", text: "gpt-4o:random", expected: "not-found" },
{
name: "lenient drops an invalid suffix with a warning",
text: "gpt-4o:random",
thinkingSuffix: "lenient",
expected: { model: "openai/gpt-4o", warning: 'Invalid thinking level "random"' },
},
{
name: "lenient invalid outer suffix discards the inner thinking level",
text: "qwen/qwen3-coder:exacto:high:random",
thinkingSuffix: "lenient",
expected: { model: "openrouter/qwen/qwen3-coder:exacto", warning: 'Invalid thinking level "random"' },
},
{ name: "none keeps the suffix in the id", text: "gpt-4o:high", thinkingSuffix: "none", expected: "not-found" },
{ name: "exact-only skips fuzzy matches", text: "sonnet", fuzzy: false, expected: "not-found" },
{ name: "empty text", text: " ", expected: "not-found" },
test("all valid thinking levels work", () => {
for (const level of ["off", "minimal", "low", "medium", "high", "xhigh", "max"]) {
const result = parseModelPattern(`sonnet:${level}`, allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe(level);
expect(result.warning).toBeUndefined();
// Exact matches across providers
// #7327
{ name: "sole usable provider wins", text: "shared", usable: ["gw-b"], expected: { model: "gw-b/shared" } },
{
name: "no usable provider is ambiguous instead of falling through to fuzzy",
text: "shared",
expected: { ambiguous: ["gw-a/shared", "gw-b/shared"], usableCount: 0 },
},
{
name: "several usable providers are ambiguous",
text: "shared",
usable: ["gw-a", "gw-b"],
expected: { ambiguous: ["gw-a/shared", "gw-b/shared"], usableCount: 2 },
},
{
name: "thinking suffix does not change provider selection",
text: "shared:high",
usable: ["gw-b"],
expected: { model: "gw-b/shared", thinkingLevel: "high" },
},
{
name: "thinking suffix does not bypass ambiguity",
text: "shared:high",
usable: ["gw-a", "gw-b"],
expected: { ambiguous: ["gw-a/shared", "gw-b/shared"], usableCount: 2 },
},
{
// #10160
name: "provider prefix without a direct model selects the usable raw id",
text: "anthropic/future",
usable: ["gw-b"],
expected: { model: "gw-b/anthropic/future" },
},
{
// #5823
name: "usable qualified match beats a usable raw id",
text: "anthropic/exact",
usable: ["anthropic", "gw-a"],
expected: { model: "anthropic/exact" },
},
{
// #5643
name: "usable raw id beats an unusable qualified match",
text: "anthropic/exact",
usable: ["gw-a"],
expected: { model: "gw-a/anthropic/exact" },
},
{
name: "unusable qualified match is kept when nothing is usable",
text: "anthropic/exact",
expected: { model: "anthropic/exact" },
},
{
// Like `mistral/codestral` -> mistral/codestral-latest despite gateway id `mistral/codestral`
name: "named provider's usable fuzzy match beats a raw id elsewhere",
text: "anthropic/claude-opus",
usable: ["anthropic"],
expected: { model: "anthropic/claude-opus-4-8" },
},
{
name: "usable raw id beats the named provider's unusable fuzzy match",
text: "anthropic/claude-opus",
usable: ["gw-a"],
expected: { model: "gw-a/anthropic/claude-opus" },
},
{
name: "named provider's fuzzy match is kept when nothing is usable",
text: "anthropic/claude-opus",
expected: { model: "anthropic/claude-opus-4-8" },
},
{
name: "named provider's literal id beats its fuzzy matches",
text: "gw-b/free",
expected: { model: "gw-b/gw-b/free" },
},
// Fuzzy matches across providers
{
name: "fuzzy prefers usable providers",
text: "opus",
usable: ["gw-a"],
expected: { model: "gw-a/anthropic/claude-opus-4.8" },
},
{
name: "fuzzy id tie across usable providers is ambiguous",
text: "opus",
usable: ["anthropic", "gw-a", "gw-b"],
expected: { ambiguous: ["anthropic/claude-opus-4-8", "gw-b/claude-opus-4-8"], usableCount: 2 },
},
{
name: "fuzzy id tie resolves to the sole usable provider",
text: "opus",
usable: ["anthropic"],
expected: { model: "anthropic/claude-opus-4-8" },
},
// Explicit provider constraint
{
name: "explicit provider ignores usable providers elsewhere",
text: "shared",
provider: "gw-a",
usable: ["gw-b"],
expected: { model: "gw-a/shared" },
},
{
name: "explicit provider accepts its own prefix",
text: "GW-B/shared",
provider: "gw-b",
expected: { model: "gw-b/shared" },
},
{
name: "explicit provider allows fuzzy matches",
text: "4o",
provider: "openai",
expected: { model: "openai/gpt-4o" },
},
{ name: "explicit provider never leaves the provider", text: "gpt-4o", provider: "gw-a", expected: "not-found" },
];
function modelRef(m: Model<Api>): string {
return `${m.provider}/${m.id}`;
}
describe("resolveModelReference", () => {
test.each(referenceCases)("$name", (testCase) => {
const usable = new Set(testCase.usable);
const request: ModelReferenceRequest = {
text: testCase.text,
provider: testCase.provider,
thinkingSuffix: testCase.thinkingSuffix ?? "strict",
fuzzy: testCase.fuzzy ?? true,
};
// Catalog order must never decide the result.
for (const models of [referenceCatalog, [...referenceCatalog].reverse()]) {
const result = resolveModelReference(request, models, (provider) => usable.has(provider));
const expected = testCase.expected;
if (expected === "not-found") {
expect(result.status).toBe("not-found");
} else if ("ambiguous" in expected) {
expect(result.status).toBe("ambiguous");
if (result.status !== "ambiguous") continue;
expect(result.candidates.map(modelRef).sort()).toEqual(expected.ambiguous);
expect(result.usableCount).toBe(expected.usableCount);
} else {
expect(result.status).toBe("resolved");
if (result.status !== "resolved") continue;
expect(modelRef(result.model)).toBe(expected.model);
expect(result.thinkingLevel).toBe(expected.thinkingLevel);
if (expected.warning) expect(result.warning).toContain(expected.warning);
else expect(result.warning).toBeUndefined();
}
});
});
describe("patterns with invalid thinking levels", () => {
test("sonnet:random returns sonnet with undefined thinking level and warning", () => {
const result = parseModelPattern("sonnet:random", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBeUndefined();
expect(result.warning).toContain("Invalid thinking level");
expect(result.warning).toContain("random");
});
test("gpt-4o:invalid returns gpt-4o with undefined thinking level and warning", () => {
const result = parseModelPattern("gpt-4o:invalid", allModels);
expect(result.model?.id).toBe("gpt-4o");
expect(result.thinkingLevel).toBeUndefined();
expect(result.warning).toContain("Invalid thinking level");
});
});
describe("OpenRouter models with colons in IDs", () => {
test("qwen3-coder:exacto matches the model with undefined thinking level", () => {
const result = parseModelPattern("qwen/qwen3-coder:exacto", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBeUndefined();
expect(result.warning).toBeUndefined();
});
test("openrouter/qwen/qwen3-coder:exacto matches with provider prefix", () => {
const result = parseModelPattern("openrouter/qwen/qwen3-coder:exacto", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.model?.provider).toBe("openrouter");
expect(result.thinkingLevel).toBeUndefined();
expect(result.warning).toBeUndefined();
});
test("qwen3-coder:exacto:high matches model with high thinking level", () => {
const result = parseModelPattern("qwen/qwen3-coder:exacto:high", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBe("high");
expect(result.warning).toBeUndefined();
});
test("openrouter/qwen/qwen3-coder:exacto:high matches with provider and thinking level", () => {
const result = parseModelPattern("openrouter/qwen/qwen3-coder:exacto:high", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.model?.provider).toBe("openrouter");
expect(result.thinkingLevel).toBe("high");
expect(result.warning).toBeUndefined();
});
test("gpt-4o:extended matches the extended model with undefined thinking level", () => {
const result = parseModelPattern("openai/gpt-4o:extended", allModels);
expect(result.model?.id).toBe("openai/gpt-4o:extended");
expect(result.thinkingLevel).toBeUndefined();
expect(result.warning).toBeUndefined();
});
});
describe("invalid thinking levels with OpenRouter models", () => {
test("qwen3-coder:exacto:random returns model with undefined thinking level and warning", () => {
const result = parseModelPattern("qwen/qwen3-coder:exacto:random", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBeUndefined();
expect(result.warning).toContain("Invalid thinking level");
expect(result.warning).toContain("random");
});
test("qwen3-coder:exacto:high:random returns model with undefined thinking level and warning", () => {
const result = parseModelPattern("qwen/qwen3-coder:exacto:high:random", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBeUndefined();
expect(result.warning).toContain("Invalid thinking level");
expect(result.warning).toContain("random");
});
});
describe("edge cases", () => {
test("empty pattern matches via partial matching", () => {
// Empty string is included in all model IDs, so partial matching finds a match
const result = parseModelPattern("", allModels);
expect(result.model).not.toBeNull();
expect(result.thinkingLevel).toBeUndefined();
});
test("pattern ending with colon treats empty suffix as invalid", () => {
const result = parseModelPattern("sonnet:", allModels);
// Empty string after colon is not a valid thinking level
// So it tries to match "sonnet:" which won't match, then tries "sonnet"
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.warning).toContain("Invalid thinking level");
});
}
});
});
@ -270,6 +385,41 @@ describe("resolveModelScopeWithDiagnostics", () => {
}
});
test("adds every provider for a pattern matching the same id on several providers, with a warning", async () => {
const registry = {
getAvailable: () => [
...allModels,
{ ...mockModels[1], provider: "azure" },
{ ...mockModels[1], provider: "openai-codex", id: "gpt-4o-mini" },
],
} as unknown as Parameters<typeof resolveModelScopeWithDiagnostics>[1];
for (const [pattern, thinkingLevel] of [
["gpt-4o:high", "high"],
["GPT-4o", undefined],
] as const) {
const result = await resolveModelScopeWithDiagnostics([pattern], registry);
expect(
result.scopedModels.map((scoped) => [`${scoped.model.provider}/${scoped.model.id}`, scoped.thinkingLevel]),
).toEqual(
expect.arrayContaining([
["azure/gpt-4o", thinkingLevel],
["openai/gpt-4o", thinkingLevel],
]),
);
expect(result.scopedModels).toHaveLength(2);
expect(result.diagnostics).toEqual([
{
type: "warning",
code: "ambiguous",
message: `Pattern "${pattern}" matches several providers: azure, openai. Use provider/id to choose one.`,
pattern,
},
]);
}
});
test("resolves bracketed model ids as exact references before glob matching", async () => {
const bracketedModel: Model<"anthropic-messages"> = {
id: "bracketed-model[1m]",
@ -319,67 +469,6 @@ describe("resolveModelScopeWithDiagnostics", () => {
});
describe("resolveCliModel", () => {
test("resolves --model provider/id without --provider", () => {
const registry = {
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "openai/gpt-4o",
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openai");
expect(result.model?.id).toBe("gpt-4o");
});
test("resolves fuzzy patterns within an explicit provider", () => {
const registry = {
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliProvider: "openai",
cliModel: "4o",
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openai");
expect(result.model?.id).toBe("gpt-4o");
});
test("supports --model <pattern>:<thinking> (without explicit --thinking)", () => {
const registry = {
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "sonnet:high",
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe("high");
});
test("prefers exact model id match over provider inference (OpenRouter-style ids)", () => {
const registry = {
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "openai/gpt-4o:extended",
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("openai/gpt-4o:extended");
});
test("does not strip invalid :suffix as thinking level in --model (treat as raw id)", () => {
const registry = {
getModels: () => allModels,
@ -427,159 +516,49 @@ describe("resolveCliModel", () => {
expect(result.error).toContain("No models available");
});
test("prefers the sole authenticated provider for an ambiguous bare exact model id", () => {
const azureModel: Model<"anthropic-messages"> = {
...mockModels[1],
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
provider: "azure",
};
const codexModel: Model<"anthropic-messages"> = {
...mockModels[1],
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
provider: "openai-codex",
};
const registry = {
getModels: () => [azureModel, codexModel],
hasConfiguredAuth: (provider: string) => provider === "openai-codex",
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
describe("duplicate raw ids behind a known provider prefix (#10160)", () => {
const direct = { ...mockModels[0], id: "claude-sonnet-4-5", provider: "anthropic" };
const openrouter = { ...mockModels[0], id: "anthropic/claude-opus-5.5", provider: "openrouter" };
const vercel = { ...mockModels[0], id: "anthropic/claude-opus-5.5", provider: "vercel-ai-gateway" };
const registry = (configured: string[]) =>
({
getModels: () => [direct, openrouter, vercel],
hasConfiguredAuth: (provider: string) => configured.includes(provider),
}) as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "gpt-5.6-sol",
modelRuntime: registry,
test("selects the authenticated gateway, with or without a thinking suffix", () => {
for (const [cliModel, thinkingLevel] of [
["anthropic/claude-opus-5.5", undefined],
["anthropic/claude-opus-5.5:high", "high"],
] as const) {
const result = resolveCliModel({ cliModel, modelRuntime: registry(["vercel-ai-gateway"]) });
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("vercel-ai-gateway");
expect(result.thinkingLevel).toBe(thinkingLevel);
}
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openai-codex");
expect(result.model?.id).toBe("gpt-5.6-sol");
});
test("reports ambiguity instead of choosing by catalog order", () => {
const result = resolveCliModel({ cliModel: "anthropic/claude-opus-5.5", modelRuntime: registry([]) });
test("requires an explicit provider for an ambiguous bare exact model id without a unique authenticated provider", () => {
const azureModel: Model<"anthropic-messages"> = {
...mockModels[1],
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
provider: "azure",
};
const codexModel: Model<"anthropic-messages"> = {
...mockModels[1],
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
provider: "openai-codex",
};
const registry = {
getModels: () => [azureModel, codexModel],
hasConfiguredAuth: () => false,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "gpt-5.6-sol",
modelRuntime: registry,
expect(result.model).toBeUndefined();
expect(result.error).toContain(
"openrouter/anthropic/claude-opus-5.5, vercel-ai-gateway/anthropic/claude-opus-5.5",
);
expect(result.error).toContain("No matching provider is authenticated. Use --provider or provider/model.");
});
expect(result.model).toBeUndefined();
expect(result.error).toContain('Model "gpt-5.6-sol" is ambiguous across providers');
expect(result.error).toContain("azure/gpt-5.6-sol");
expect(result.error).toContain("openai-codex/gpt-5.6-sol");
expect(result.error).toContain("Use --provider or provider/model");
});
test("does not let stored credentials choose the provider for --api-key", () => {
const result = resolveCliModel({
cliModel: "anthropic/claude-opus-5.5",
inlineApiKey: true,
modelRuntime: registry(["vercel-ai-gateway"]),
});
test("prefers provider/model split over gateway model with matching id", () => {
// When a user writes "zai/glm-5", and both a zai provider model (id: "glm-5")
// and a gateway model (id: "zai/glm-5") exist, prefer the zai provider model.
const zaiModel: Model<"anthropic-messages"> = {
id: "glm-5",
name: "GLM-5",
api: "anthropic-messages",
provider: "zai",
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const gatewayModel: Model<"anthropic-messages"> = {
id: "zai/glm-5",
name: "GLM-5",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const registry = {
getModels: () => [...allModels, zaiModel, gatewayModel],
hasConfiguredAuth: () => true,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "zai/glm-5",
modelRuntime: registry,
expect(result.model).toBeUndefined();
expect(result.error).toContain("--api-key does not choose a provider.");
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("zai");
expect(result.model?.id).toBe("glm-5");
});
test("prefers an authenticated exact raw model id over an unauthenticated inferred provider", () => {
const commandcodeModel: Model<"anthropic-messages"> = {
id: "xiaomi/mimo-v2.5-pro",
name: "Xiaomi MiMo via Commandcode",
api: "anthropic-messages",
provider: "commandcode",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const xiaomiModel: Model<"anthropic-messages"> = {
id: "mimo-v2.5-pro",
name: "Xiaomi MiMo",
api: "anthropic-messages",
provider: "xiaomi",
baseUrl: "https://api.xiaomimimo.com",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const registry = {
getModels: () => [...allModels, commandcodeModel, xiaomiModel],
hasConfiguredAuth: (provider: string) => provider === "commandcode",
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "xiaomi/mimo-v2.5-pro",
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("commandcode");
expect(result.model?.id).toBe("xiaomi/mimo-v2.5-pro");
});
test("resolves provider-prefixed fuzzy patterns (openrouter/qwen -> openrouter model)", () => {
const registry = {
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "openrouter/qwen",
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
});
describe("custom model fallback with :thinking suffix (#5552)", () => {
@ -747,23 +726,6 @@ describe("default model selection", () => {
expect(defaultModelPerProvider["qwen-token-plan-individual"]).toBe("qwen3.8-max");
});
test("findInitialModel accepts explicit provider custom model ids", async () => {
const registry = {
getModels: () => allModels,
} as unknown as Parameters<typeof findInitialModel>[0]["modelRuntime"];
const result = await findInitialModel({
cliProvider: "openrouter",
cliModel: "openrouter/openai/ghost-model",
scopedModels: [],
isContinuing: false,
modelRuntime: registry,
});
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("openai/ghost-model");
});
test("findInitialModel selects ai-gateway default when available", async () => {
const aiGatewayModel: Model<"anthropic-messages"> = {
id: "anthropic/claude-opus-4-6",