Files
openclaw/src/agents/model-selection.ts
JK 323030594e fix(agents): resolve model aliases in sessions_spawn (#59681)
* fix(agents): resolve model aliases in sessions_spawn

normalizeModelSelection() only trims the input — it never resolves
aliases through the model alias index. When a user passes an alias
like 'opus' to sessions_spawn, the child session gets patched with
the raw string, which the gateway cannot match to any provider.

Add resolveModelThroughAliases() to check bare strings against the
configured alias map before returning from
resolveSubagentSpawnModelSelection().

Fixes #57532
Refs #50736

* refactor: address review feedback on alias resolution

- Accept pre-built ModelAliasIndex instead of rebuilding per call
- Narrow helper signature to (string, ModelAliasIndex) → string
- Remove unreachable ?? raw fallback

Co-Authored-By: greptile-apps[bot]

* fix(agents): resolve sessions_spawn model aliases

---------

Co-authored-by: HowdyDooToYou <HowdyDooToYou@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
2026-04-27 15:44:56 -07:00

413 lines
12 KiB
TypeScript

import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
toAgentModelListLike,
} from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
resolveAgentConfig,
resolveAgentEffectiveModelPrimary,
resolveAgentModelFallbacksOverride,
} from "./agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import type { ModelCatalogEntry } from "./model-catalog.types.js";
export { resolveThinkingDefault } from "./model-thinking-default.js";
import {
type ModelRef,
findNormalizedProviderKey,
findNormalizedProviderValue,
legacyModelKey,
modelKey,
normalizeModelRef,
normalizeProviderId,
normalizeProviderIdForAuth,
parseModelRef,
} from "./model-selection-normalize.js";
import {
buildAllowedModelSetWithFallbacks,
buildConfiguredAllowlistKeys,
buildConfiguredModelCatalog,
buildModelAliasIndex,
getModelRefStatusWithFallbackModels,
inferUniqueProviderFromCatalog,
inferUniqueProviderFromConfiguredModels,
normalizeModelSelection,
resolveBareModelDefaultProvider,
resolveAllowedModelRefFromAliasIndex,
resolveAllowlistModelKey as resolveAllowlistModelKeyFromShared,
resolveConfiguredModelRef,
resolveConfiguredOpenRouterCompatAlias,
resolveHooksGmailModel,
resolveModelRefFromString,
type ModelAliasIndex,
type ModelRefStatus,
} from "./model-selection-shared.js";
export type { ModelAliasIndex, ModelRef, ModelRefStatus };
export type ThinkLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "adaptive"
| "max";
export {
buildConfiguredAllowlistKeys,
buildConfiguredModelCatalog,
buildModelAliasIndex,
findNormalizedProviderKey,
findNormalizedProviderValue,
inferUniqueProviderFromConfiguredModels,
inferUniqueProviderFromCatalog,
legacyModelKey,
modelKey,
normalizeModelRef,
normalizeModelSelection,
normalizeProviderId,
normalizeProviderIdForAuth,
parseModelRef,
resolveBareModelDefaultProvider,
resolveConfiguredModelRef,
resolveHooksGmailModel,
resolveModelRefFromString,
};
export { isCliProvider } from "./model-selection-cli.js";
export function resolvePersistedOverrideModelRef(params: {
defaultProvider: string;
overrideProvider?: string;
overrideModel?: string;
allowPluginNormalization?: boolean;
}): ModelRef | null {
const defaultProvider = params.defaultProvider.trim();
const overrideProvider = params.overrideProvider?.trim();
const overrideModel = params.overrideModel?.trim();
if (!overrideModel) {
return null;
}
const encodedOverride = overrideProvider ? `${overrideProvider}/${overrideModel}` : overrideModel;
return (
parseModelRef(encodedOverride, defaultProvider, {
allowPluginNormalization: params.allowPluginNormalization,
}) ?? {
provider: overrideProvider || defaultProvider,
model: overrideModel,
}
);
}
/**
* Runtime-first resolver for persisted model metadata.
* Use this when callers intentionally want the last executed model identity.
*/
export function resolvePersistedModelRef(params: {
defaultProvider: string;
runtimeProvider?: string;
runtimeModel?: string;
overrideProvider?: string;
overrideModel?: string;
allowPluginNormalization?: boolean;
}): ModelRef | null {
const defaultProvider = params.defaultProvider.trim();
const runtimeProvider = params.runtimeProvider?.trim();
const runtimeModel = params.runtimeModel?.trim();
if (runtimeModel) {
if (runtimeProvider) {
return { provider: runtimeProvider, model: runtimeModel };
}
return (
parseModelRef(runtimeModel, defaultProvider, {
allowPluginNormalization: params.allowPluginNormalization,
}) ?? {
provider: defaultProvider,
model: runtimeModel,
}
);
}
return resolvePersistedOverrideModelRef({
defaultProvider,
overrideProvider: params.overrideProvider,
overrideModel: params.overrideModel,
allowPluginNormalization: params.allowPluginNormalization,
});
}
/**
* Selected-model resolver for persisted model metadata.
* Use this for control/status/UI surfaces that should honor explicit session
* overrides before falling back to runtime identity.
*/
export function resolvePersistedSelectedModelRef(params: {
defaultProvider: string;
runtimeProvider?: string;
runtimeModel?: string;
overrideProvider?: string;
overrideModel?: string;
allowPluginNormalization?: boolean;
}): ModelRef | null {
const override = resolvePersistedOverrideModelRef({
defaultProvider: params.defaultProvider,
overrideProvider: params.overrideProvider,
overrideModel: params.overrideModel,
allowPluginNormalization: params.allowPluginNormalization,
});
if (override) {
return override;
}
return resolvePersistedModelRef({
defaultProvider: params.defaultProvider,
runtimeProvider: params.runtimeProvider,
runtimeModel: params.runtimeModel,
allowPluginNormalization: params.allowPluginNormalization,
});
}
export function normalizeStoredOverrideModel(params: {
providerOverride?: string | null;
modelOverride?: string | null;
}): { providerOverride?: string; modelOverride?: string } {
const providerOverride = params.providerOverride?.trim();
const modelOverride = params.modelOverride?.trim();
if (!providerOverride || !modelOverride) {
return {
providerOverride,
modelOverride,
};
}
const providerPrefix = `${providerOverride.toLowerCase()}/`;
return {
providerOverride,
modelOverride: modelOverride.toLowerCase().startsWith(providerPrefix)
? modelOverride.slice(providerOverride.length + 1).trim() || modelOverride
: modelOverride,
};
}
export function resolveAllowlistModelKey(
raw: string,
defaultProvider: string,
cfg?: OpenClawConfig,
): string | null {
return resolveAllowlistModelKeyFromShared({ cfg, raw, defaultProvider });
}
export function resolveDefaultModelForAgent(params: {
cfg: OpenClawConfig;
agentId?: string;
}): ModelRef {
const agentModelOverride = params.agentId
? resolveAgentEffectiveModelPrimary(params.cfg, params.agentId)
: undefined;
const cfg =
agentModelOverride && agentModelOverride.length > 0
? {
...params.cfg,
agents: {
...params.cfg.agents,
defaults: {
...params.cfg.agents?.defaults,
model: {
...toAgentModelListLike(params.cfg.agents?.defaults?.model),
primary: agentModelOverride,
},
},
},
}
: params.cfg;
return resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
}
function resolveAllowedFallbacks(params: { cfg: OpenClawConfig; agentId?: string }): string[] {
if (params.agentId) {
const override = resolveAgentModelFallbacksOverride(params.cfg, params.agentId);
if (override !== undefined) {
return override;
}
}
return resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model);
}
export function resolveSubagentConfiguredModelSelection(params: {
cfg: OpenClawConfig;
agentId: string;
}): string | undefined {
const agentConfig = resolveAgentConfig(params.cfg, params.agentId);
return (
normalizeModelSelection(agentConfig?.subagents?.model) ??
normalizeModelSelection(agentConfig?.model) ??
normalizeModelSelection(params.cfg.agents?.defaults?.subagents?.model)
);
}
/**
* Resolve a normalized model string through a pre-built alias index, returning
* a fully qualified `provider/model` string. If the value is already qualified
* or not a known alias, returns it unchanged.
*/
function resolveModelThroughAliases(value: string, aliasIndex: ModelAliasIndex): string {
// Already a provider/model ref — no alias resolution needed.
if (value.includes("/")) {
return value;
}
// Check if the value is a known alias; if so, resolve to provider/model.
// Unknown bare strings are returned as-is (don't guess the provider).
const aliasKey = normalizeLowercaseStringOrEmpty(value);
const aliasMatch = aliasIndex.byAlias.get(aliasKey);
if (aliasMatch) {
return `${aliasMatch.ref.provider}/${aliasMatch.ref.model}`;
}
return value;
}
export function resolveSubagentSpawnModelSelection(params: {
cfg: OpenClawConfig;
agentId: string;
modelOverride?: unknown;
}): string {
const runtimeDefault = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: params.agentId,
});
const raw =
normalizeModelSelection(params.modelOverride) ??
resolveSubagentConfiguredModelSelection({
cfg: params.cfg,
agentId: params.agentId,
}) ??
normalizeModelSelection(resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model)) ??
`${runtimeDefault.provider}/${runtimeDefault.model}`;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: runtimeDefault.provider,
});
return resolveModelThroughAliases(raw, aliasIndex);
}
export function buildAllowedModelSet(params: {
cfg: OpenClawConfig;
catalog: ModelCatalogEntry[];
defaultProvider: string;
defaultModel?: string;
agentId?: string;
}): {
allowAny: boolean;
allowedCatalog: ModelCatalogEntry[];
allowedKeys: Set<string>;
} {
return buildAllowedModelSetWithFallbacks({
cfg: params.cfg,
catalog: params.catalog,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
fallbackModels: resolveAllowedFallbacks({
cfg: params.cfg,
agentId: params.agentId,
}),
});
}
export function getModelRefStatus(params: {
cfg: OpenClawConfig;
catalog: ModelCatalogEntry[];
ref: ModelRef;
defaultProvider: string;
defaultModel?: string;
}): ModelRefStatus {
return getModelRefStatusWithFallbackModels({
cfg: params.cfg,
catalog: params.catalog,
ref: params.ref,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
fallbackModels: resolveAllowedFallbacks({
cfg: params.cfg,
}),
});
}
function getModelRefStatusForResolve(
params: {
cfg: OpenClawConfig;
catalog: ModelCatalogEntry[];
defaultProvider: string;
defaultModel?: string;
},
ref: ModelRef,
): ModelRefStatus {
return getModelRefStatus({
cfg: params.cfg,
catalog: params.catalog,
ref,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
});
}
export function resolveAllowedModelRef(params: {
cfg: OpenClawConfig;
catalog: ModelCatalogEntry[];
raw: string;
defaultProvider: string;
defaultModel?: string;
}):
| { ref: ModelRef; key: string }
| {
error: string;
} {
const trimmed = params.raw.trim();
if (!trimmed) {
return { error: "invalid model: empty" };
}
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: params.defaultProvider,
});
const openrouterCompatRef = resolveConfiguredOpenRouterCompatAlias({
cfg: params.cfg,
raw: trimmed,
defaultProvider: params.defaultProvider,
});
if (openrouterCompatRef) {
const status = getModelRefStatusForResolve(params, openrouterCompatRef);
if (!status.allowed) {
return { error: `model not allowed: ${status.key}` };
}
return { ref: openrouterCompatRef, key: status.key };
}
return resolveAllowedModelRefFromAliasIndex({
cfg: params.cfg,
raw: params.raw,
defaultProvider: params.defaultProvider,
aliasIndex,
getStatus: (ref) => getModelRefStatusForResolve(params, ref),
});
}
/** Default reasoning level when session/directive do not set it: "on" if model supports reasoning, else "off". */
export function resolveReasoningDefault(params: {
provider: string;
model: string;
catalog?: ModelCatalogEntry[];
}): "on" | "off" {
const key = modelKey(params.provider, params.model);
const candidate = params.catalog?.find(
(entry) =>
(entry.provider === params.provider && entry.id === params.model) ||
(entry.provider === key && entry.id === params.model),
);
return candidate?.reasoning === true ? "on" : "off";
}