Skip to content

Commit f359bc3

Browse files
fix(models): resolve provider-qualified aliases (openclaw#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>
1 parent 3135437 commit f359bc3

5 files changed

Lines changed: 144 additions & 41 deletions

File tree

src/agents/model-selection-shared.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type ModelManifestPlugins = ModelManifestNormalizationContext["manifestPlugins"]
4545

4646
export type ModelAliasIndex = {
4747
byAlias: Map<string, { alias: string; ref: ModelRef }>;
48+
byProviderAlias?: Map<string, { alias: string; ref: ModelRef }>;
4849
byKey: Map<string, string[]>;
4950
};
5051

@@ -63,6 +64,10 @@ type ExactConfiguredProviderRefParts = {
6364
modelRaw: string;
6465
};
6566

67+
function providerAliasKey(provider: string, alias: string): string {
68+
return `${normalizeProviderId(provider)}/${normalizeLowercaseStringOrEmpty(alias)}`;
69+
}
70+
6671
function hasSlashFormModelRef(raw: string): boolean {
6772
const trimmed = raw.trim();
6873
const slash = trimmed.indexOf("/");
@@ -554,10 +559,11 @@ function buildModelAliasIndexWithManifestContext(
554559
},
555560
): ModelAliasIndex {
556561
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
562+
const byProviderAlias = new Map<string, { alias: string; ref: ModelRef }>();
557563
const byKey = new Map<string, string[]>();
558564
const aliasCandidates = listModelAliasCandidates(params.cfg);
559565
if (aliasCandidates.length === 0) {
560-
return { byAlias, byKey };
566+
return { byAlias, byProviderAlias, byKey };
561567
}
562568
const manifestPlugins = params.manifestPluginContext.get();
563569

@@ -576,14 +582,18 @@ function buildModelAliasIndexWithManifestContext(
576582
continue;
577583
}
578584
const aliasKey = normalizeLowercaseStringOrEmpty(alias);
579-
byAlias.set(aliasKey, { alias, ref: parsed });
585+
const match = { alias, ref: parsed };
586+
byAlias.set(aliasKey, match);
587+
// Bare aliases retain their existing last-wins behavior. Provider-qualified
588+
// aliases stay scoped so duplicate display names cannot select another provider.
589+
byProviderAlias.set(providerAliasKey(parsed.provider, alias), match);
580590
const key = modelKey(parsed.provider, parsed.model);
581591
const existing = byKey.get(key) ?? [];
582592
existing.push(alias);
583593
byKey.set(key, existing);
584594
}
585595

586-
return { byAlias, byKey };
596+
return { byAlias, byProviderAlias, byKey };
587597
}
588598

589599
/** Build lookup maps from user-facing aliases to normalized model refs. */
@@ -728,6 +738,15 @@ export function resolveModelRefFromString(
728738
if (aliasMatch) {
729739
return { ref: aliasMatch.ref, alias: aliasMatch.alias };
730740
}
741+
const slash = model.indexOf("/");
742+
if (slash > 0) {
743+
const providerAliasMatch = params.aliasIndex?.byProviderAlias?.get(
744+
providerAliasKey(model.slice(0, slash), model.slice(slash + 1)),
745+
);
746+
if (providerAliasMatch) {
747+
return { ref: providerAliasMatch.ref, alias: providerAliasMatch.alias };
748+
}
749+
}
731750
const parsed = parseModelRefWithCompatAlias({
732751
cfg: params.cfg,
733752
raw: model,

src/agents/model-selection.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,30 @@ describe("model-selection", () => {
992992
expect(index.byKey.get(modelKey("anthropic", "claude-3-5-sonnet"))).toEqual(["fast"]);
993993
});
994994

995+
it("indexes duplicate aliases by provider", () => {
996+
const cfg = {
997+
agents: {
998+
defaults: {
999+
models: {
1000+
"lmstudio-moe/qwen3.6-35b-a3b": { alias: "Local" },
1001+
"lmstudio-dense/qwen3.6-27b": { alias: "Local" },
1002+
},
1003+
},
1004+
},
1005+
} as OpenClawConfig;
1006+
1007+
const index = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
1008+
1009+
expect(index.byProviderAlias?.get("lmstudio-moe/local")?.ref).toEqual({
1010+
provider: "lmstudio-moe",
1011+
model: "qwen3.6-35b-a3b",
1012+
});
1013+
expect(index.byProviderAlias?.get("lmstudio-dense/local")?.ref).toEqual({
1014+
provider: "lmstudio-dense",
1015+
model: "qwen3.6-27b",
1016+
});
1017+
});
1018+
9951019
it("does not normalize configured model keys that have no alias", () => {
9961020
providerModelNormalizationMock.normalizeProviderModelIdWithRuntime.mockClear();
9971021
const models = Object.fromEntries(
@@ -1655,6 +1679,43 @@ describe("model-selection", () => {
16551679
expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" });
16561680
});
16571681

1682+
it("resolves provider-qualified aliases without cross-provider collisions", () => {
1683+
const index = buildModelAliasIndex({
1684+
cfg: {
1685+
agents: {
1686+
defaults: {
1687+
models: {
1688+
"lmstudio-moe/qwen3.6-35b-a3b": { alias: "Local" },
1689+
"lmstudio-dense/qwen3.6-27b": { alias: "Local" },
1690+
},
1691+
},
1692+
},
1693+
} as OpenClawConfig,
1694+
defaultProvider: "openai",
1695+
});
1696+
1697+
expect(
1698+
resolveModelRefFromString({
1699+
raw: "lmstudio-moe/Local",
1700+
defaultProvider: "openai",
1701+
aliasIndex: index,
1702+
}),
1703+
).toEqual({
1704+
ref: { provider: "lmstudio-moe", model: "qwen3.6-35b-a3b" },
1705+
alias: "Local",
1706+
});
1707+
expect(
1708+
resolveModelRefFromString({
1709+
raw: "lmstudio-dense/LOCAL",
1710+
defaultProvider: "openai",
1711+
aliasIndex: index,
1712+
}),
1713+
).toEqual({
1714+
ref: { provider: "lmstudio-dense", model: "qwen3.6-27b" },
1715+
alias: "Local",
1716+
});
1717+
});
1718+
16581719
it("prefers slash-form aliases over direct provider/model parsing", () => {
16591720
const index = {
16601721
byAlias: new Map([
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildModelAliasIndex } from "../../agents/model-selection-shared.js";
3+
import type { OpenClawConfig } from "../../config/config.js";
4+
import { resolveModelRefFromDirectiveString } from "./model-selection-directive.js";
5+
6+
describe("resolveModelRefFromDirectiveString", () => {
7+
it("resolves duplicate display aliases within the requested provider", () => {
8+
const cfg = {
9+
agents: {
10+
defaults: {
11+
models: {
12+
"local-a/model-a": { alias: "Local" },
13+
"local-b/model-b": { alias: "Local" },
14+
},
15+
},
16+
},
17+
} as OpenClawConfig;
18+
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
19+
20+
expect(
21+
resolveModelRefFromDirectiveString({
22+
raw: "local-a/Local",
23+
defaultProvider: "openai",
24+
aliasIndex,
25+
}),
26+
).toEqual({ ref: { provider: "local-a", model: "model-a" }, alias: "Local" });
27+
});
28+
});

src/auto-reply/reply/model-selection-directive.ts

Lines changed: 7 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
// Normalizes model selection directives into provider and model ids.
22
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
33
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
4-
import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js";
54
import { modelKey } from "../../agents/model-ref-shared.js";
6-
import { isModelKeyAllowedBySet } from "../../agents/model-selection-shared.js";
5+
import {
6+
isModelKeyAllowedBySet,
7+
type ModelAliasIndex,
8+
resolveModelRefFromString,
9+
} from "../../agents/model-selection-shared.js";
710
export { modelKey };
8-
9-
/** Alias lookup tables used by `/model` directive resolution. */
10-
export type ModelAliasIndex = {
11-
byAlias: Map<
12-
string,
13-
{
14-
alias: string;
15-
ref: { provider: string; model: string };
16-
}
17-
>;
18-
byKey: Map<string, string[]>;
19-
};
11+
export type { ModelAliasIndex };
2012

2113
/** Resolved model choice from a `/model` directive. */
2214
export type ModelDirectiveSelection = {
@@ -67,30 +59,7 @@ export function resolveModelRefFromDirectiveString(params: {
6759
defaultProvider: string;
6860
aliasIndex: ModelAliasIndex;
6961
}): { ref: { provider: string; model: string }; alias?: string } | null {
70-
const { model } = splitTrailingAuthProfile(params.raw);
71-
if (!model) {
72-
return null;
73-
}
74-
if (!model.includes("/")) {
75-
const aliasKey = normalizeLowercaseStringOrEmpty(model);
76-
const aliasMatch = params.aliasIndex.byAlias.get(aliasKey);
77-
if (aliasMatch) {
78-
return { ref: aliasMatch.ref, alias: aliasMatch.alias };
79-
}
80-
}
81-
const trimmed = model.trim();
82-
const slash = trimmed.indexOf("/");
83-
const providerRaw = slash === -1 ? params.defaultProvider : trimmed.slice(0, slash).trim();
84-
const modelRaw = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
85-
if (!providerRaw || !modelRaw) {
86-
return null;
87-
}
88-
return {
89-
ref: {
90-
provider: normalizeProviderId(providerRaw),
91-
model: modelRaw,
92-
},
93-
};
62+
return resolveModelRefFromString(params);
9463
}
9564

9665
function boundedLevenshteinDistance(a: string, b: string, maxDistance: number): number | null {

src/gateway/sessions-patch.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,32 @@ describe("gateway sessions patch", () => {
598598
expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID);
599599
});
600600

601+
test("persists provider-qualified aliases without cross-provider collisions", async () => {
602+
const entry = expectPatchOk(
603+
await runPatch({
604+
cfg: {
605+
agents: {
606+
defaults: {
607+
model: { primary: OPENAI_GPT_MODEL },
608+
models: {
609+
"lmstudio-moe/qwen3.6-35b-a3b": { alias: "Local" },
610+
"lmstudio-dense/qwen3.6-27b": { alias: "Local" },
611+
},
612+
},
613+
},
614+
} as OpenClawConfig,
615+
patch: { key: MAIN_SESSION_KEY, model: "lmstudio-moe/Local" },
616+
loadGatewayModelCatalog: loadCatalog(
617+
"lmstudio-moe/qwen3.6-35b-a3b",
618+
"lmstudio-dense/qwen3.6-27b",
619+
),
620+
}),
621+
);
622+
623+
expectModelSelection(entry, "lmstudio-moe", "qwen3.6-35b-a3b");
624+
expect(entry.modelOverrideSource).toBe("user");
625+
});
626+
601627
test("sets spawnDepth for subagent sessions", async () => {
602628
const entry = expectPatchOk(
603629
await runPatch({

0 commit comments

Comments
 (0)