Files
openclaw/src/config/model-input-normalization.ts
T
Peter Steinberger b4d14d7848 refactor(config): move model normalization to input owners (#115965)
* refactor(config): move model normalization to input owners

* fix(config): merge canonical doctor model rows

* fix(config): align touched model map identities

* ci(env): lower production variable budget

* chore: leave changelog to release automation
2026-07-29 12:04:07 -04:00

217 lines
7.0 KiB
TypeScript

// Normalizes user-submitted model config at config mutation boundaries.
import {
normalizeConfiguredProviderCatalogModelId,
type ManifestModelIdNormalizationProvider,
} from "@openclaw/model-catalog-core/provider-model-id-normalization";
import { isRecord } from "../utils.js";
import { normalizeAgentModelMapForConfig, normalizeAgentModelRefForConfig } from "./model-input.js";
import type { OpenClawConfig } from "./types.openclaw.js";
const MODEL_SELECTION_KEYS = ["model", "imageModel", "voiceModel", "pdfModel"] as const;
const MEDIA_MODEL_KEYS = ["image", "video", "music"] as const;
function normalizeModelSelection(value: unknown): unknown {
if (typeof value === "string") {
return normalizeAgentModelRefForConfig(value);
}
if (!isRecord(value)) {
return value;
}
let next = value;
const assign = (key: string, candidate: unknown) => {
if (candidate === next[key]) {
return;
}
next = { ...next, [key]: candidate };
};
if (typeof value.primary === "string") {
assign("primary", normalizeAgentModelRefForConfig(value.primary));
}
if (Array.isArray(value.fallbacks)) {
const originalFallbacks = value.fallbacks;
const fallbacks = originalFallbacks.map((fallback) =>
typeof fallback === "string" ? normalizeAgentModelRefForConfig(fallback) : fallback,
);
if (fallbacks.some((fallback, index) => fallback !== originalFallbacks[index])) {
assign("fallbacks", fallbacks);
}
}
return next;
}
function normalizeStringModelRef(value: unknown): unknown {
return typeof value === "string" ? normalizeAgentModelRefForConfig(value) : value;
}
function normalizeNestedModelField(
value: unknown,
key: string,
normalize: (candidate: unknown) => unknown,
): unknown {
if (!isRecord(value) || !Object.hasOwn(value, key)) {
return value;
}
const normalized = normalize(value[key]);
return normalized === value[key] ? value : { ...value, [key]: normalized };
}
function normalizeAgentModelScope(value: unknown): unknown {
if (!isRecord(value)) {
return value;
}
let next = value;
const assign = (key: string, candidate: unknown) => {
if (candidate === next[key]) {
return;
}
next = { ...next, [key]: candidate };
};
for (const key of MODEL_SELECTION_KEYS) {
if (Object.hasOwn(value, key)) {
assign(key, normalizeModelSelection(value[key]));
}
}
if (Object.hasOwn(value, "utilityModel")) {
assign("utilityModel", normalizeStringModelRef(value.utilityModel));
}
const originalMediaModels = value.mediaModels;
if (isRecord(originalMediaModels)) {
let mediaModelsChanged = false;
const mediaModels = { ...originalMediaModels };
for (const key of MEDIA_MODEL_KEYS) {
if (!Object.hasOwn(originalMediaModels, key)) {
continue;
}
const normalized = normalizeModelSelection(originalMediaModels[key]);
if (normalized !== mediaModels[key]) {
mediaModels[key] = normalized;
mediaModelsChanged = true;
}
}
if (mediaModelsChanged) {
assign("mediaModels", mediaModels);
}
}
assign("heartbeat", normalizeNestedModelField(value.heartbeat, "model", normalizeStringModelRef));
assign("subagents", normalizeNestedModelField(value.subagents, "model", normalizeModelSelection));
if (isRecord(value.compaction)) {
let compaction = normalizeNestedModelField(value.compaction, "model", normalizeStringModelRef);
if (isRecord(compaction)) {
const memoryFlush = normalizeNestedModelField(
compaction.memoryFlush,
"model",
normalizeStringModelRef,
);
if (memoryFlush !== compaction.memoryFlush) {
compaction = { ...compaction, memoryFlush };
}
}
assign("compaction", compaction);
}
if (isRecord(value.models)) {
assign("models", normalizeAgentModelMapForConfig(value.models));
}
return next;
}
function normalizeAgentScopes(agents: unknown): unknown {
if (!isRecord(agents)) {
return agents;
}
let next = agents;
const assign = (key: string, candidate: unknown) => {
if (candidate === next[key]) {
return;
}
next = { ...next, [key]: candidate };
};
if (Object.hasOwn(agents, "defaults")) {
assign("defaults", normalizeAgentModelScope(agents.defaults));
}
if (isRecord(agents.entries)) {
let entriesChanged = false;
const entries = Object.fromEntries(
Object.entries(agents.entries).map(([agentId, entry]) => {
const normalized = normalizeAgentModelScope(entry);
entriesChanged ||= normalized !== entry;
return [agentId, normalized];
}),
);
if (entriesChanged) {
assign("entries", entries);
}
}
if (Array.isArray(agents.list)) {
const originalList = agents.list;
const list = originalList.map(normalizeAgentModelScope);
if (list.some((entry, index) => entry !== originalList[index])) {
assign("list", list);
}
}
return next;
}
function normalizeProviderCatalogs(
models: unknown,
modelIdNormalizationPolicies?: ReadonlyMap<string, ManifestModelIdNormalizationProvider>,
): unknown {
if (!isRecord(models) || !isRecord(models.providers)) {
return models;
}
let providersChanged = false;
const providers = Object.fromEntries(
Object.entries(models.providers).map(([providerId, providerValue]) => {
if (!isRecord(providerValue) || !Array.isArray(providerValue.models)) {
return [providerId, providerValue];
}
const originalModels = providerValue.models;
const providerModels = originalModels.map((model) => {
if (!isRecord(model) || typeof model.id !== "string") {
return model;
}
const trimmed = model.id.trim();
if (!trimmed) {
return model;
}
const id = normalizeConfiguredProviderCatalogModelId(
providerId,
trimmed,
modelIdNormalizationPolicies,
);
return id === model.id ? model : { ...model, id };
});
if (providerModels.every((model, index) => model === originalModels[index])) {
return [providerId, providerValue];
}
providersChanged = true;
return [providerId, { ...providerValue, models: providerModels }];
}),
);
return providersChanged ? { ...models, providers } : models;
}
/** Canonicalize model refs submitted through a config mutation API before persistence. */
export function normalizeSubmittedConfigModelRefs(
cfg: OpenClawConfig,
modelIdNormalizationPolicies?: ReadonlyMap<string, ManifestModelIdNormalizationProvider>,
): OpenClawConfig {
let next = cfg;
const agents = normalizeAgentScopes(cfg.agents);
if (agents !== cfg.agents) {
next = { ...next, agents: agents as OpenClawConfig["agents"] };
}
const models = normalizeProviderCatalogs(cfg.models, modelIdNormalizationPolicies);
if (models !== cfg.models) {
next = { ...next, models: models as OpenClawConfig["models"] };
}
// tools.subagents owns only tool policy; model selection moved to
// agents.defaults/entries.*.subagents.model and the schema rejects the old key.
return next;
}