From aa9fcc5cea6d200d862e99175b388312fbd73b27 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Oct 2026 16:39:22 -0700 Subject: [PATCH] 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 --- docs/concepts/models.md | 1 + scripts/pr-lib/wrapper-components.txt | 1 + .../model-selection-alias-scope.test.ts | 164 ++++++++++++++++++ src/agents/model-selection-alias-scope.ts | 82 +++++++++ src/agents/model-selection-shared.ts | 125 ++++++------- src/agents/model-selection.test.ts | 50 +++--- .../manifest-model-id-normalization.ts | 8 +- 7 files changed, 335 insertions(+), 96 deletions(-) create mode 100644 src/agents/model-selection-alias-scope.test.ts create mode 100644 src/agents/model-selection-alias-scope.ts diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 6003b6395c8e..cc66c6f84004 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -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. diff --git a/scripts/pr-lib/wrapper-components.txt b/scripts/pr-lib/wrapper-components.txt index a7a2177beef0..8d2077e42139 100644 --- a/scripts/pr-lib/wrapper-components.txt +++ b/scripts/pr-lib/wrapper-components.txt @@ -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 diff --git a/src/agents/model-selection-alias-scope.test.ts b/src/agents/model-selection-alias-scope.test.ts new file mode 100644 index 000000000000..d4010d0a8d69 --- /dev/null +++ b/src/agents/model-selection-alias-scope.test.ts @@ -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" }); +}); diff --git a/src/agents/model-selection-alias-scope.ts b/src/agents/model-selection-alias-scope.ts new file mode 100644 index 000000000000..85b9e406c3fa --- /dev/null +++ b/src/agents/model-selection-alias-scope.ts @@ -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 | 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); + }; +} diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index 1fb46e90c5c7..718af8dd08bd 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -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; 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 } { const aliasesByKey = new Map(); + 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(); const byKey = new Map(); 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; diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 9df1d7c96bda..3c0d4b718434 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -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 }); diff --git a/src/plugins/manifest-model-id-normalization.ts b/src/plugins/manifest-model-id-normalization.ts index 0ef14867c6d9..ad8c73989d0d 100644 --- a/src/plugins/manifest-model-id-normalization.ts +++ b/src/plugins/manifest-model-id-normalization.ts @@ -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[] - | { owners: Pick }; + | readonly (Pick & + Partial>)[] + | { + owners: Pick & + Partial>; + }; type ManifestModelIdNormalizationLookupParams = { config?: OpenClawConfig;