mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-11 08:36:07 +00:00
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:
committed by
GitHub
parent
245092dc3a
commit
ba821e7220
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
28
src/auto-reply/reply/model-selection-directive.test.ts
Normal file
28
src/auto-reply/reply/model-selection-directive.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user