mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
8f9b234c21
commit
aa9fcc5cea
7 changed files with 335 additions and 96 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
164
src/agents/model-selection-alias-scope.test.ts
Normal file
164
src/agents/model-selection-alias-scope.test.ts
Normal 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" });
|
||||
});
|
||||
82
src/agents/model-selection-alias-scope.ts
Normal file
82
src/agents/model-selection-alias-scope.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue