fix(models): preserve explicit providers when aliases collide (#162973)

When a model alias collided across providers, explicit provider/model selections were rewritten to the colliding provider, so operators could not route explicit picks such as openai/gpt-6.1-sol on 2026.9.7. Shared alias preparation now owns the collision decision and preserves an explicit registered or configured provider; indexed and configured-primary resolution both consume it. Credited replacement for #162621 (thanks @RXQ6).

Refs #162621
This commit is contained in:
Peter Steinberger 2026-10-01 16:39:22 -07:00 • committed by GitHub
parent 8f9b234c21
commit aa9fcc5cea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 335 additions and 96 deletions

View file

@ -400,6 +400,7 @@ Without a scope flag, selections change only the current session. `agents.defaul
- A user-selected `/model` ref is strict for that session: if it becomes unreachable, the reply fails visibly instead of silently falling back through `agents.defaults.model.fallbacks`. Configured defaults and cron job primaries still use fallback chains.
- `/model status` is the detailed view: auth candidates per provider, and (when configured) the provider endpoint `baseUrl` plus `api` mode.
- Model refs are parsed by splitting on the first `/`. Type `provider/model`. If the model ID itself contains `/` (OpenRouter-style), include the provider prefix, for example `/model openrouter/moonshotai/kimi-k2`. If you omit the provider, OpenClaw tries an alias match first. It then tries a unique configured-provider match for that exact unprefixed model id. It then tries the configured default provider, which is a deprecated fallback. If that provider no longer exposes the configured default model, OpenClaw uses the first configured provider and model instead. This avoids surfacing a stale removed-provider default.
- An alias cannot redirect an explicit registered or configured provider ref to another provider, including when the ref has an auth-profile suffix. A colliding alias remains usable with its own provider prefix. Slash-form aliases whose leading segment is a model namespace still work.
- When inferring a provider, exact model ID case takes precedence over case-insensitive matches within the same configuration scope. A case-insensitive match is used only when it identifies one provider. Per-agent model entries take precedence over global entries and configured provider catalogs.
- Provider IDs are normalized to lowercase. Model IDs follow the provider's normalization rules. Use the spelling advertised by the plugin.
- Configured primary models also accept `provider/alias`. The alias resolves within that provider before inference, while an exact model ID configured for that provider keeps its literal identity. An optional auth-profile suffix such as `@work` stays separate from the model identity.

View file

@ -216,6 +216,7 @@ src/agents/model-extra-params.ts
src/agents/model-ref-profile.ts
src/agents/model-ref-shared.ts
src/agents/model-runtime-policy.ts
src/agents/model-selection-alias-scope.ts
src/agents/model-selection-config.ts
src/agents/model-selection-normalize.ts
src/agents/model-selection-shared.ts

View file

@ -0,0 +1,164 @@
import { expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
buildModelAliasIndex,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "./model-selection-resolve.js";
const context = {
manifestPlugins: [{ providers: ["anthropic", "openai", "openrouter"] }],
allowPluginNormalization: false,
};
function resolveConfiguredRefForTest(cfg: OpenClawConfig) {
return resolveConfiguredModelRef({
...context,
cfg,
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
});
}
it.each([
{
defaultProvider: "openai",
raw: "openai/gpt-4o-mini",
expected: { provider: "openai", model: "gpt-4o-mini" },
},
{
defaultProvider: "anthropic",
raw: "openai/gpt-4o-mini",
expected: { provider: "openai", model: "gpt-4o-mini" },
},
{
defaultProvider: "openai",
raw: "anthropic/claude-sonnet-4-6",
expected: { provider: "anthropic", model: "claude-sonnet-4-6" },
},
])(
"keeps explicit $raw authoritative with default provider $defaultProvider",
({ defaultProvider, raw, expected }) => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
models: { [`openrouter/${raw}`]: { alias: raw } },
},
},
};
const aliasIndex = buildModelAliasIndex({ ...context, cfg, defaultProvider });
expect(
resolveModelRefFromString({ ...context, raw, defaultProvider, aliasIndex })?.ref,
).toEqual(expected);
expect(
resolveModelRefFromString({
...context,
raw: `openrouter/${raw}`,
defaultProvider,
aliasIndex,
})?.ref,
).toEqual({ provider: "openrouter", model: raw });
},
);
it("retains configured provider authority in an alias index used without config", () => {
const cfg: OpenClawConfig = {
models: { providers: { acme: { baseUrl: "https://acme.example/v1", models: [] } } },
agents: { defaults: { models: { "openai/gpt-4o-mini": { alias: "acme/small" } } } },
};
const aliasIndex = buildModelAliasIndex({ ...context, cfg, defaultProvider: "openai" });
expect(
resolveModelRefFromString({
...context,
raw: "acme/small",
defaultProvider: "openai",
aliasIndex,
})?.ref,
).toEqual({ provider: "acme", model: "small" });
});
it("uses the supplied manifest generation to scope model aliases", () => {
const cfg: OpenClawConfig = {
agents: { defaults: { models: { "openai/gpt-4o-mini": { alias: "acme/small" } } } },
};
const aliasIndex = buildModelAliasIndex({
...context,
cfg,
defaultProvider: "openai",
manifestPlugins: [{ providers: ["acme"] }],
});
expect(
resolveModelRefFromString({
...context,
raw: "acme/small",
defaultProvider: "openai",
aliasIndex,
})?.ref,
).toEqual({ provider: "acme", model: "small" });
});
it("resolves a profile-suffixed colliding alias through its own provider", () => {
const raw = "openai/anthropic/small@work";
const cfg: OpenClawConfig = {
agents: {
defaults: {
model: raw,
models: { "openai/gpt-4o-mini": { alias: "anthropic/small@work" } },
},
},
};
const aliasIndex = buildModelAliasIndex({ ...context, cfg, defaultProvider: "openai" });
expect(
resolveModelRefFromString({ ...context, raw, defaultProvider: "openai", aliasIndex })?.ref,
).toEqual({
provider: "openai",
model: "gpt-4o-mini",
});
expect(resolveConfiguredRefForTest(cfg)).toEqual({ provider: "openai", model: "gpt-4o-mini" });
});
it("keeps an explicit profile-qualified primary on its named provider", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
model: "openai/gpt-4o-mini@work",
models: { "openrouter/openai/gpt-4o-mini": { alias: "openai/gpt-4o-mini" } },
},
},
};
expect(resolveConfiguredRefForTest(cfg)).toEqual({ provider: "openai", model: "gpt-4o-mini" });
});
it("resolves provider-qualified aliases without cross-provider collisions", () => {
const index = buildModelAliasIndex({
...context,
cfg: {
agents: {
defaults: {
models: {
"lmstudio-moe/qwen3.6-35b-a3b": { alias: "Local" },
"lmstudio-dense/qwen3.6-27b": { alias: "Local" },
},
},
},
},
defaultProvider: "openai",
});
expect(
resolveModelRefFromString({
...context,
raw: "lmstudio-moe/Local",
defaultProvider: "openai",
aliasIndex: index,
}),
).toEqual({ ref: { provider: "lmstudio-moe", model: "qwen3.6-35b-a3b" }, alias: "Local" });
expect(
resolveModelRefFromString({
...context,
raw: "lmstudio-dense/LOCAL",
defaultProvider: "openai",
aliasIndex: index,
}),
).toEqual({ ref: { provider: "lmstudio-dense", model: "qwen3.6-27b" }, alias: "Local" });
});

View file

@ -0,0 +1,82 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ManifestModelIdNormalizationSource } from "../plugins/manifest-model-id-normalization.js";
import { DEFAULT_PROVIDER } from "./defaults.js";
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
import type { ModelRef } from "./model-ref-shared.js";
export type EffectiveModelAlias = {
keyRaw: string;
alias: string;
ref: ModelRef;
qualifiedOnly: boolean;
};
export function providerAliasKey(provider: string, alias: string): string {
return `${normalizeProviderId(provider)}/${normalizeLowercaseStringOrEmpty(alias)}`;
}
export function findModelAliasCandidate(
candidates: readonly EffectiveModelAlias[],
raw: string,
provider?: string,
): EffectiveModelAlias | undefined {
const aliasKey = normalizeLowercaseStringOrEmpty(raw);
const scopedProvider = provider ? normalizeProviderId(provider) : undefined;
return candidates.findLast(
(candidate) =>
(!candidate.qualifiedOnly || scopedProvider !== undefined) &&
normalizeLowercaseStringOrEmpty(candidate.alias) === aliasKey &&
(!scopedProvider || normalizeProviderId(candidate.ref.provider) === scopedProvider),
);
}
export function preferLiteralPrimary(primary: string, aliasKey: string): boolean {
const hasSlashRef = (raw: string) => {
const trimmed = raw.trim();
const slash = trimmed.indexOf("/");
return slash > 0 && slash < trimmed.length - 1;
};
return hasSlashRef(primary) && !hasSlashRef(aliasKey);
}
/** Capture provider authority while preparing aliases, including config-free index consumers. */
export function createModelAliasScope(params: {
cfg: OpenClawConfig;
defaultProvider: string;
manifestPlugins: () => ManifestModelIdNormalizationSource | undefined;
}): (alias: string, ref: ModelRef) => boolean {
const configuredProviders = new Set(
[
DEFAULT_PROVIDER,
params.defaultProvider,
...Object.keys(params.cfg.models?.providers ?? {}),
].map(normalizeProviderId),
);
let declaredProviders: ReadonlySet<string> | undefined;
return (alias, ref) => {
const { model } = splitTrailingAuthProfile(alias);
const slash = model.indexOf("/");
if (slash <= 0) {
return false;
}
const provider = normalizeProviderId(model.slice(0, slash));
if (provider === normalizeProviderId(ref.provider)) {
return false;
}
if (configuredProviders.has(provider)) {
return true;
}
if (!declaredProviders) {
const source = params.manifestPlugins();
const providers = source
? "owners" in source
? [...(source.owners.providers?.keys() ?? [])]
: source.flatMap((plugin) => plugin.providers ?? [])
: [];
declaredProviders = new Set(providers.map(normalizeProviderId));
}
return declaredProviders.has(provider);
};
}

View file

@ -38,6 +38,13 @@ import {
normalizeModelRef,
normalizeProviderId,
} from "./model-ref-shared.js";
import {
createModelAliasScope,
type EffectiveModelAlias,
findModelAliasCandidate,
preferLiteralPrimary,
providerAliasKey,
} from "./model-selection-alias-scope.js";
import { findNormalizedProviderValue, parseModelRef } from "./model-selection-normalize.js";
import { createModelCatalogIdentityKeyResolver } from "./openai-model-routes.js";
import { readUtilityModelSetting } from "./utility-model-setting.js";
@ -72,16 +79,10 @@ export type ModelAliasIndex = {
type ModelManifestPluginContext = {
peek: () => ModelManifestPlugins;
get: () => ModelManifestPlugins;
getAliasScope: () => ModelManifestPlugins;
};
type ModelAliasCandidate = {
keyRaw: string;
alias: string;
};
type EffectiveModelAlias = ModelAliasCandidate & {
ref: ModelRef;
};
type ModelAliasCandidate = Pick<EffectiveModelAlias, "keyRaw" | "alias">;
function isStaticDefaultProviderAliasCandidate(
candidate: ModelAliasCandidate,
@ -102,23 +103,13 @@ type ExactConfiguredProviderRefParts = {
modelRaw: string;
};
function providerAliasKey(provider: string, alias: string): string {
return `${normalizeProviderId(provider)}/${normalizeLowercaseStringOrEmpty(alias)}`;
}
function hasSlashFormModelRef(raw: string): boolean {
const trimmed = raw.trim();
const slash = trimmed.indexOf("/");
return slash > 0 && slash < trimmed.length - 1;
}
function resolveManifestPluginsForModelIdNormalization(params: {
cfg: OpenClawConfig;
workspaceDir?: string;
manifestPlugins?: ModelManifestPlugins;
allowManifestNormalization?: boolean;
}): ModelManifestPlugins {
if (params.allowManifestNormalization === false || params.manifestPlugins !== undefined) {
if (params.manifestPlugins !== undefined) {
return params.manifestPlugins;
}
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
@ -145,19 +136,19 @@ function createModelManifestPluginContext(params: {
allowManifestNormalization?: boolean;
}): ModelManifestPluginContext {
let manifestPlugins = params.manifestPlugins;
let resolved =
params.allowManifestNormalization === false || params.manifestPlugins !== undefined;
let resolved = params.manifestPlugins !== undefined;
const get = () => {
if (!resolved) {
manifestPlugins = resolveManifestPluginsForModelIdNormalization(params);
resolved = true;
}
return manifestPlugins;
};
return {
peek: () => manifestPlugins,
get: () => {
// Manifest metadata can touch plugin registries. Defer that work until a
// path actually needs plugin/provider normalization.
if (!resolved) {
manifestPlugins = resolveManifestPluginsForModelIdNormalization(params);
resolved = true;
}
return manifestPlugins;
},
get: () => (params.allowManifestNormalization === false ? params.manifestPlugins : get()),
// Disabling model-ID transforms does not let aliases rewrite a declared provider.
getAliasScope: get,
};
}
@ -189,6 +180,10 @@ function buildEffectiveModelAliases(
},
): { aliases: EffectiveModelAlias[]; disabledKeys: Set<string> } {
const aliasesByKey = new Map<string, EffectiveModelAlias | null>();
const isQualifiedOnly = createModelAliasScope({
...params,
manifestPlugins: params.manifestPluginContext.getAliasScope,
});
const candidates = listModelAliasCandidates(params.cfg, params.agentId);
if (candidates.length === 0) {
return { aliases: [], disabledKeys: new Set() };
@ -225,7 +220,12 @@ function buildEffectiveModelAliases(
// Reinsert replacements so agent-owned aliases win duplicate-alias lookup
// while an omitted agent alias leaves the inherited record untouched.
aliasesByKey.delete(key);
aliasesByKey.set(key, candidate.alias ? { ...candidate, ref } : null);
aliasesByKey.set(
key,
candidate.alias
? { ...candidate, ref, qualifiedOnly: isQualifiedOnly(candidate.alias, ref) }
: null,
);
}
return {
aliases: [...aliasesByKey.values()].filter(
@ -237,20 +237,6 @@ function buildEffectiveModelAliases(
};
}
function findModelAliasCandidate(
candidates: readonly EffectiveModelAlias[],
raw: string,
provider?: string,
): EffectiveModelAlias | undefined {
const aliasKey = normalizeLowercaseStringOrEmpty(raw);
const scopedProvider = provider ? normalizeProviderId(provider) : undefined;
return candidates.findLast(
(candidate) =>
normalizeLowercaseStringOrEmpty(candidate.alias) === aliasKey &&
(!scopedProvider || normalizeProviderId(candidate.ref.provider) === scopedProvider),
);
}
function sanitizeModelWarningValue(value: string): string {
const stripped = value ? stripAnsi(value) : "";
let controlBoundary = -1;
@ -533,11 +519,13 @@ function buildModelAliasIndexWithManifestContext(
const byProviderAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byKey = new Map<string, string[]>();
const { aliases, disabledKeys } = buildEffectiveModelAliases(params);
for (const { alias, ref } of aliases) {
for (const { alias, ref, qualifiedOnly } of aliases) {
const aliasKey = normalizeLowercaseStringOrEmpty(alias);
const match = { alias, ref };
const key = modelKey(ref.provider, ref.model);
byAlias.set(aliasKey, match);
if (!qualifiedOnly) {
byAlias.set(aliasKey, match);
}
// Bare aliases retain their existing last-wins behavior. Provider-qualified
// aliases stay scoped so duplicate display names cannot select another provider.
byProviderAlias.set(providerAliasKey(ref.provider, alias), match);
@ -613,9 +601,13 @@ export function resolveModelRefFromString(
}
const slash = model.indexOf("/");
if (slash > 0) {
const providerAliasMatch = params.aliasIndex?.byProviderAlias?.get(
providerAliasKey(model.slice(0, slash), model.slice(slash + 1)),
);
const providerAliasMatch =
params.aliasIndex?.byProviderAlias?.get(
providerAliasKey(model.slice(0, slash), params.raw.trim().slice(slash + 1)),
) ??
params.aliasIndex?.byProviderAlias?.get(
providerAliasKey(model.slice(0, slash), model.slice(slash + 1)),
);
if (providerAliasMatch) {
return { ref: providerAliasMatch.ref, alias: providerAliasMatch.alias };
}
@ -668,6 +660,7 @@ export function resolveConfiguredModelRef(
[
trimmed,
...(profileStripped ? [modelWithoutProfile] : []),
...(qualifiedProvider ? [trimmed.slice(providerSeparator + 1)] : []),
...(qualifiedModel ? [qualifiedModel] : []),
].map(normalizeLowercaseStringOrEmpty),
);
@ -695,11 +688,12 @@ export function resolveConfiguredModelRef(
return profileAliasCandidate.ref;
}
if (!exactAliasCandidate && qualifiedProvider && qualifiedModel) {
const qualifiedAliasCandidate = findModelAliasCandidate(
aliasCandidates,
qualifiedModel,
qualifiedProvider,
);
const qualifiedAliasCandidate =
findModelAliasCandidate(
aliasCandidates,
trimmed.slice(providerSeparator + 1),
qualifiedProvider,
) ?? findModelAliasCandidate(aliasCandidates, qualifiedModel, qualifiedProvider);
if (
qualifiedAliasCandidate &&
!hasExactConfiguredProviderModel({
@ -724,19 +718,14 @@ export function resolveConfiguredModelRef(
}
const aliasCandidate = profileStripped ? undefined : exactAliasCandidate;
const manifestPlugins = manifestPluginContext.peek();
if (
aliasCandidate &&
hasSlashFormModelRef(primaryWithoutProfile) &&
!hasSlashFormModelRef(aliasCandidate.keyRaw)
) {
const primaryRef = parseModelRefWithCompatAlias({
...params,
raw: primaryWithoutProfile,
manifestPlugins: manifestPluginContext.get(),
});
if (primaryRef) {
return primaryRef;
}
if (aliasCandidate && preferLiteralPrimary(primaryWithoutProfile, aliasCandidate.keyRaw)) {
return (
parseModelRefWithCompatAlias({
...params,
raw: primaryWithoutProfile,
manifestPlugins: manifestPluginContext.get(),
}) ?? aliasCandidate.ref
);
}
if (aliasCandidate) {
return aliasCandidate.ref;

View file

@ -26,8 +26,12 @@ const manifestNormalizationSnapshot = createPluginMetadataSnapshotFixture({
plugins: [
{
id: "model-selection-test-normalizers",
providers: ["nvidia", "fixture-route"],
modelIdNormalization: {
providers: { nvidia: { aliases: { "llama-fast": "nvidia/canonical-fast" } } },
providers: {
nvidia: { aliases: { "llama-fast": "nvidia/canonical-fast" } },
"fixture-route": { aliases: { raw: "canonical" } },
},
},
},
],
@ -645,31 +649,19 @@ it.each([
expect(resolveAllowedModelRef(params)).toEqual(expected);
});
it("resolves provider-qualified aliases without cross-provider collisions", () => {
const index = buildModelAliasIndex({
it.each([false, true])("preserves provider identity with manifest normalization %s", (enabled) => {
const params = {
cfg: createConfiguredModelRefConfig({
modelEntries: {
"lmstudio-moe/qwen3.6-35b-a3b": { alias: "Local" },
"lmstudio-dense/qwen3.6-27b": { alias: "Local" },
},
modelEntries: { "openai/gpt-4o-mini": { alias: "fixture-route/raw" } },
}),
defaultProvider: "openai",
allowManifestNormalization: enabled,
allowPluginNormalization: false,
};
const aliasIndex = buildModelAliasIndex(params);
expect(resolveModelRefFromString({ ...params, raw: "fixture-route/raw", aliasIndex })).toEqual({
ref: { provider: "fixture-route", model: enabled ? "canonical" : "raw" },
});
expect(
resolveModelRefFromString({
raw: "lmstudio-moe/Local",
defaultProvider: "openai",
aliasIndex: index,
}),
).toEqual({ ref: { provider: "lmstudio-moe", model: "qwen3.6-35b-a3b" }, alias: "Local" });
expect(
resolveModelRefFromString({
raw: "lmstudio-dense/LOCAL",
defaultProvider: "openai",
aliasIndex: index,
}),
).toEqual({ ref: { provider: "lmstudio-dense", model: "qwen3.6-27b" }, alias: "Local" });
});
it("strips profile suffix before alias resolution", () => {
@ -784,6 +776,12 @@ it.each([
modelEntries: { opus: { alias: "anthropic/claude-opus-4-6" } },
expected: { provider: "anthropic", model: "claude-opus-4-6" },
},
{
name: "keeps a literal primary before a same-provider alias backed by a bare key",
primary: "openai/friendly",
modelEntries: { base: { alias: "openai/friendly" } },
expected: { provider: "openai", model: "friendly" },
},
{
name: "prefers slash-form aliases for configured default models",
primary: "xiaomi/mimo-v2-pro-mit",
@ -791,16 +789,16 @@ it.each([
expected: { provider: "openai", model: "xiaomi/mimo-v2-pro-mit" },
},
{
name: "prefers exact auth-profile aliases before configured-provider stripping",
name: "keeps a configured provider ahead of an exact auth-profile alias collision",
primary: "nemotron-bolt/nemotron-3-super-120b@prod",
modelEntries: {
"openai/gpt-5.5": { alias: "nemotron-bolt/nemotron-3-super-120b@prod" },
},
providers: nemotronProvider,
expected: { provider: "openai", model: "gpt-5.5" },
expected: { provider: "nemotron-bolt", model: "nemotron-3-super-120b" },
},
{
name: "prefers stripped auth-profile aliases before configured-provider stripping",
name: "keeps a configured provider ahead of a stripped auth-profile alias collision",
primary: "nemotron-bolt/nemotron-3-super-120b@prod",
modelEntries: {
"openai/nemotron-bolt/nemotron-3-super-120b": {
@ -808,7 +806,7 @@ it.each([
},
},
providers: nemotronProvider,
expected: { provider: "openai", model: "nemotron-bolt/nemotron-3-super-120b" },
expected: { provider: "nemotron-bolt", model: "nemotron-3-super-120b" },
},
])("$name", ({ primary, modelEntries, providers, expected }) => {
const cfg = createConfiguredModelRefConfig({ primary, modelEntries, providers });

View file

@ -17,8 +17,12 @@ import { getActivePluginRegistryWorkspaceDirFromStateCore } from "./runtime-work
/** Caller-owned declarations or facts from an already selected metadata snapshot. */
export type ManifestModelIdNormalizationSource =
| readonly Pick<PluginManifestRecord, "modelIdNormalization">[]
| { owners: Pick<PluginMetadataSnapshot["owners"], "modelIdNormalizationPolicies"> };
| readonly (Pick<PluginManifestRecord, "modelIdNormalization"> &
Partial<Pick<PluginManifestRecord, "providers">>)[]
| {
owners: Pick<PluginMetadataSnapshot["owners"], "modelIdNormalizationPolicies"> &
Partial<Pick<PluginMetadataSnapshot["owners"], "providers">>;
};
type ManifestModelIdNormalizationLookupParams = {
config?: OpenClawConfig;