From ba821e7220ffbfa809d23dc7a4f81b4ae0914ea4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 06:44:54 -0700 Subject: [PATCH] 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> --- src/agents/model-selection-shared.ts | 25 +++++++- src/agents/model-selection.test.ts | 61 +++++++++++++++++++ .../reply/model-selection-directive.test.ts | 28 +++++++++ .../reply/model-selection-directive.ts | 45 +++----------- src/gateway/sessions-patch.test.ts | 26 ++++++++ 5 files changed, 144 insertions(+), 41 deletions(-) create mode 100644 src/auto-reply/reply/model-selection-directive.test.ts diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index d0b3a0eaea66..a69b83d70f31 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -45,6 +45,7 @@ type ModelManifestPlugins = ModelManifestNormalizationContext["manifestPlugins"] export type ModelAliasIndex = { byAlias: Map; + byProviderAlias?: Map; byKey: Map; }; @@ -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(); + const byProviderAlias = new Map(); const byKey = new Map(); 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, diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index de4a22bec722..d0eb74ecd74a 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -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([ diff --git a/src/auto-reply/reply/model-selection-directive.test.ts b/src/auto-reply/reply/model-selection-directive.test.ts new file mode 100644 index 000000000000..e5d557ab8fa6 --- /dev/null +++ b/src/auto-reply/reply/model-selection-directive.test.ts @@ -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" }); + }); +}); diff --git a/src/auto-reply/reply/model-selection-directive.ts b/src/auto-reply/reply/model-selection-directive.ts index e37bd0cc36a6..78d00acd1704 100644 --- a/src/auto-reply/reply/model-selection-directive.ts +++ b/src/auto-reply/reply/model-selection-directive.ts @@ -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; -}; +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 { diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 6262f5ac7647..31b9add4c4fa 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -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({