openclaw/src/agents/models-config.ts
lbo728 b863316e7b fix(models): preserve user reasoning override when merging with built-in catalog
When a built-in provider model has reasoning:true (e.g. MiniMax-M2.5) and
the user explicitly sets reasoning:false in their config, mergeProviderModels
unconditionally overwrote the user's value with the built-in catalog value.

The merge code refreshes capability metadata (input, contextWindow, maxTokens,
reasoning) from the implicit catalog. This is correct for fields like
contextWindow and maxTokens — the catalog has authoritative values that
shouldn't be stale. But reasoning is a user preference, not just a
capability descriptor: users may need to disable it to avoid 'Message
ordering conflict' errors with certain models or backends.

Fix: check whether 'reasoning' is present in the explicit (user-supplied)
model entry. If the user has set it (even to false), honour that value.
If the user hasn't set it, fall back to the built-in catalog default.

This allows users to configure tools.models.providers.minimax.models with
reasoning:false for MiniMax-M2.5 without being silently overridden.

Fixes #25244
2026-02-24 14:22:02 +00:00

168 lines
5.4 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { type OpenClawConfig, loadConfig } from "../config/config.js";
import { isRecord } from "../utils.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import {
normalizeProviders,
type ProviderConfig,
resolveImplicitBedrockProvider,
resolveImplicitCopilotProvider,
resolveImplicitProviders,
} from "./models-config.providers.js";
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "merge";
function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig): ProviderConfig {
const implicitModels = Array.isArray(implicit.models) ? implicit.models : [];
const explicitModels = Array.isArray(explicit.models) ? explicit.models : [];
if (implicitModels.length === 0) {
return { ...implicit, ...explicit };
}
const getId = (model: unknown): string => {
if (!model || typeof model !== "object") {
return "";
}
const id = (model as { id?: unknown }).id;
return typeof id === "string" ? id.trim() : "";
};
const implicitById = new Map(
implicitModels.map((model) => [getId(model), model] as const).filter(([id]) => Boolean(id)),
);
const seen = new Set<string>();
const mergedModels = explicitModels.map((explicitModel) => {
const id = getId(explicitModel);
if (!id) {
return explicitModel;
}
seen.add(id);
const implicitModel = implicitById.get(id);
if (!implicitModel) {
return explicitModel;
}
// Refresh capability metadata from the implicit catalog while preserving
// user-specific fields (cost, headers, compat, etc.) on explicit entries.
// reasoning is treated as user-overridable: if the user has explicitly set
// it in their config (key present), honour that value; otherwise fall back
// to the built-in catalog default so new reasoning models work out of the
// box without requiring every user to configure it.
return {
...explicitModel,
input: implicitModel.input,
reasoning: "reasoning" in explicitModel ? explicitModel.reasoning : implicitModel.reasoning,
contextWindow: implicitModel.contextWindow,
maxTokens: implicitModel.maxTokens,
};
});
for (const implicitModel of implicitModels) {
const id = getId(implicitModel);
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
mergedModels.push(implicitModel);
}
return {
...implicit,
...explicit,
models: mergedModels,
};
}
function mergeProviders(params: {
implicit?: Record<string, ProviderConfig> | null;
explicit?: Record<string, ProviderConfig> | null;
}): Record<string, ProviderConfig> {
const out: Record<string, ProviderConfig> = params.implicit ? { ...params.implicit } : {};
for (const [key, explicit] of Object.entries(params.explicit ?? {})) {
const providerKey = key.trim();
if (!providerKey) {
continue;
}
const implicit = out[providerKey];
out[providerKey] = implicit ? mergeProviderModels(implicit, explicit) : explicit;
}
return out;
}
async function readJson(pathname: string): Promise<unknown> {
try {
const raw = await fs.readFile(pathname, "utf8");
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}
export async function ensureOpenClawModelsJson(
config?: OpenClawConfig,
agentDirOverride?: string,
): Promise<{ agentDir: string; wrote: boolean }> {
const cfg = config ?? loadConfig();
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir();
const explicitProviders = cfg.models?.providers ?? {};
const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders });
const providers: Record<string, ProviderConfig> = mergeProviders({
implicit: implicitProviders,
explicit: explicitProviders,
});
const implicitBedrock = await resolveImplicitBedrockProvider({ agentDir, config: cfg });
if (implicitBedrock) {
const existing = providers["amazon-bedrock"];
providers["amazon-bedrock"] = existing
? mergeProviderModels(implicitBedrock, existing)
: implicitBedrock;
}
const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir });
if (implicitCopilot && !providers["github-copilot"]) {
providers["github-copilot"] = implicitCopilot;
}
if (Object.keys(providers).length === 0) {
return { agentDir, wrote: false };
}
const mode = cfg.models?.mode ?? DEFAULT_MODE;
const targetPath = path.join(agentDir, "models.json");
let mergedProviders = providers;
let existingRaw = "";
if (mode === "merge") {
const existing = await readJson(targetPath);
if (isRecord(existing) && isRecord(existing.providers)) {
const existingProviders = existing.providers as Record<
string,
NonNullable<ModelsConfig["providers"]>[string]
>;
mergedProviders = { ...existingProviders, ...providers };
}
}
const normalizedProviders = normalizeProviders({
providers: mergedProviders,
agentDir,
});
const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`;
try {
existingRaw = await fs.readFile(targetPath, "utf8");
} catch {
existingRaw = "";
}
if (existingRaw === next) {
return { agentDir, wrote: false };
}
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
await fs.writeFile(targetPath, next, { mode: 0o600 });
return { agentDir, wrote: true };
}