fix(models): resolve provider-qualified aliases (#100209)

* fix(models): resolve provider-qualified aliases

Co-authored-by: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com>

* docs(changelog): defer TUI batch entries

---------

Co-authored-by: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-07-05 06:44:54 -07:00
committed by GitHub
parent 245092dc3a
commit ba821e7220
5 changed files with 144 additions and 41 deletions

View File

@@ -45,6 +45,7 @@ type ModelManifestPlugins = ModelManifestNormalizationContext["manifestPlugins"]
export type ModelAliasIndex = {
byAlias: Map<string, { alias: string; ref: ModelRef }>;
byProviderAlias?: Map<string, { alias: string; ref: ModelRef }>;
byKey: Map<string, string[]>;
};
@@ -63,6 +64,10 @@ 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("/");
@@ -554,10 +559,11 @@ function buildModelAliasIndexWithManifestContext(
},
): ModelAliasIndex {
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byProviderAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byKey = new Map<string, string[]>();
const aliasCandidates = listModelAliasCandidates(params.cfg);
if (aliasCandidates.length === 0) {
return { byAlias, byKey };
return { byAlias, byProviderAlias, byKey };
}
const manifestPlugins = params.manifestPluginContext.get();
@@ -576,14 +582,18 @@ function buildModelAliasIndexWithManifestContext(
continue;
}
const aliasKey = normalizeLowercaseStringOrEmpty(alias);
byAlias.set(aliasKey, { alias, ref: parsed });
const match = { alias, ref: parsed };
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(parsed.provider, alias), match);
const key = modelKey(parsed.provider, parsed.model);
const existing = byKey.get(key) ?? [];
existing.push(alias);
byKey.set(key, existing);
}
return { byAlias, byKey };
return { byAlias, byProviderAlias, byKey };
}
/** Build lookup maps from user-facing aliases to normalized model refs. */
@@ -728,6 +738,15 @@ export function resolveModelRefFromString(
if (aliasMatch) {
return { ref: aliasMatch.ref, alias: aliasMatch.alias };
}
const slash = model.indexOf("/");
if (slash > 0) {
const providerAliasMatch = params.aliasIndex?.byProviderAlias?.get(
providerAliasKey(model.slice(0, slash), model.slice(slash + 1)),
);
if (providerAliasMatch) {
return { ref: providerAliasMatch.ref, alias: providerAliasMatch.alias };
}
}
const parsed = parseModelRefWithCompatAlias({
cfg: params.cfg,
raw: model,

View File

@@ -992,6 +992,30 @@ describe("model-selection", () => {
expect(index.byKey.get(modelKey("anthropic", "claude-3-5-sonnet"))).toEqual(["fast"]);
});
it("indexes duplicate aliases by provider", () => {
const cfg = {
agents: {
defaults: {
models: {
"lmstudio-moe/qwen3.6-35b-a3b": { alias: "Local" },
"lmstudio-dense/qwen3.6-27b": { alias: "Local" },
},
},
},
} as OpenClawConfig;
const index = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
expect(index.byProviderAlias?.get("lmstudio-moe/local")?.ref).toEqual({
provider: "lmstudio-moe",
model: "qwen3.6-35b-a3b",
});
expect(index.byProviderAlias?.get("lmstudio-dense/local")?.ref).toEqual({
provider: "lmstudio-dense",
model: "qwen3.6-27b",
});
});
it("does not normalize configured model keys that have no alias", () => {
providerModelNormalizationMock.normalizeProviderModelIdWithRuntime.mockClear();
const models = Object.fromEntries(
@@ -1655,6 +1679,43 @@ describe("model-selection", () => {
expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" });
});
it("resolves provider-qualified aliases without cross-provider collisions", () => {
const index = buildModelAliasIndex({
cfg: {
agents: {
defaults: {
models: {
"lmstudio-moe/qwen3.6-35b-a3b": { alias: "Local" },
"lmstudio-dense/qwen3.6-27b": { alias: "Local" },
},
},
},
} as OpenClawConfig,
defaultProvider: "openai",
});
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("prefers slash-form aliases over direct provider/model parsing", () => {
const index = {
byAlias: new Map([

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { buildModelAliasIndex } from "../../agents/model-selection-shared.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveModelRefFromDirectiveString } from "./model-selection-directive.js";
describe("resolveModelRefFromDirectiveString", () => {
it("resolves duplicate display aliases within the requested provider", () => {
const cfg = {
agents: {
defaults: {
models: {
"local-a/model-a": { alias: "Local" },
"local-b/model-b": { alias: "Local" },
},
},
},
} as OpenClawConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
expect(
resolveModelRefFromDirectiveString({
raw: "local-a/Local",
defaultProvider: "openai",
aliasIndex,
}),
).toEqual({ ref: { provider: "local-a", model: "model-a" }, alias: "Local" });
});
});

View File

@@ -1,22 +1,14 @@
// Normalizes model selection directives into provider and model ids.
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js";
import { modelKey } from "../../agents/model-ref-shared.js";
import { isModelKeyAllowedBySet } from "../../agents/model-selection-shared.js";
import {
isModelKeyAllowedBySet,
type ModelAliasIndex,
resolveModelRefFromString,
} from "../../agents/model-selection-shared.js";
export { modelKey };
/** Alias lookup tables used by `/model` directive resolution. */
export type ModelAliasIndex = {
byAlias: Map<
string,
{
alias: string;
ref: { provider: string; model: string };
}
>;
byKey: Map<string, string[]>;
};
export type { ModelAliasIndex };
/** Resolved model choice from a `/model` directive. */
export type ModelDirectiveSelection = {
@@ -67,30 +59,7 @@ export function resolveModelRefFromDirectiveString(params: {
defaultProvider: string;
aliasIndex: ModelAliasIndex;
}): { ref: { provider: string; model: string }; alias?: string } | null {
const { model } = splitTrailingAuthProfile(params.raw);
if (!model) {
return null;
}
if (!model.includes("/")) {
const aliasKey = normalizeLowercaseStringOrEmpty(model);
const aliasMatch = params.aliasIndex.byAlias.get(aliasKey);
if (aliasMatch) {
return { ref: aliasMatch.ref, alias: aliasMatch.alias };
}
}
const trimmed = model.trim();
const slash = trimmed.indexOf("/");
const providerRaw = slash === -1 ? params.defaultProvider : trimmed.slice(0, slash).trim();
const modelRaw = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
if (!providerRaw || !modelRaw) {
return null;
}
return {
ref: {
provider: normalizeProviderId(providerRaw),
model: modelRaw,
},
};
return resolveModelRefFromString(params);
}
function boundedLevenshteinDistance(a: string, b: string, maxDistance: number): number | null {

View File

@@ -598,6 +598,32 @@ describe("gateway sessions patch", () => {
expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID);
});
test("persists provider-qualified aliases without cross-provider collisions", async () => {
const entry = expectPatchOk(
await runPatch({
cfg: {
agents: {
defaults: {
model: { primary: OPENAI_GPT_MODEL },
models: {
"lmstudio-moe/qwen3.6-35b-a3b": { alias: "Local" },
"lmstudio-dense/qwen3.6-27b": { alias: "Local" },
},
},
},
} as OpenClawConfig,
patch: { key: MAIN_SESSION_KEY, model: "lmstudio-moe/Local" },
loadGatewayModelCatalog: loadCatalog(
"lmstudio-moe/qwen3.6-35b-a3b",
"lmstudio-dense/qwen3.6-27b",
),
}),
);
expectModelSelection(entry, "lmstudio-moe", "qwen3.6-35b-a3b");
expect(entry.modelOverrideSource).toBe("user");
});
test("sets spawnDepth for subagent sessions", async () => {
const entry = expectPatchOk(
await runPatch({