From b863316e7b9d03c6ba55aa06ac9119f007eb0fef Mon Sep 17 00:00:00 2001 From: lbo728 Date: Tue, 24 Feb 2026 18:57:37 +0900 Subject: [PATCH] fix(models): preserve user reasoning override when merging with built-in catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...serves-explicit-reasoning-override.test.ts | 119 ++++++++++++++++++ src/agents/models-config.ts | 6 +- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/agents/models-config.preserves-explicit-reasoning-override.test.ts diff --git a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts new file mode 100644 index 00000000000..455d43b4e7b --- /dev/null +++ b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { + installModelsConfigTestHooks, + withModelsTempHome as withTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; + +installModelsConfigTestHooks(); + +type ModelEntry = { + id: string; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; +}; + +type ModelsJson = { + providers: Record; +}; + +describe("models-config: explicit reasoning override", () => { + it("preserves user reasoning:false when built-in catalog has reasoning:true (MiniMax-M2.5)", async () => { + // MiniMax-M2.5 has reasoning:true in the built-in catalog. + // User explicitly sets reasoning:false to avoid message-ordering conflicts. + await withTempHome(async () => { + const prevKey = process.env.MINIMAX_API_KEY; + process.env.MINIMAX_API_KEY = "sk-minimax-test"; + try { + const cfg: OpenClawConfig = { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + models: [ + { + id: "MiniMax-M2.5", + name: "MiniMax M2.5", + reasoning: false, // explicit override: user wants to disable reasoning + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 8192, + }, + ], + }, + }, + }, + }; + + await ensureOpenClawModelsJson(cfg); + + const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8"); + const parsed = JSON.parse(raw) as ModelsJson; + const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5"); + expect(m25).toBeDefined(); + // Must honour the explicit false — built-in true must NOT win. + expect(m25?.reasoning).toBe(false); + } finally { + if (prevKey === undefined) { + delete process.env.MINIMAX_API_KEY; + } else { + process.env.MINIMAX_API_KEY = prevKey; + } + } + }); + }); + + it("falls back to built-in reasoning:true when user omits the field (MiniMax-M2.5)", async () => { + // When the user does not set reasoning at all, the built-in catalog value + // (true for MiniMax-M2.5) should be used so the model works out of the box. + await withTempHome(async () => { + const prevKey = process.env.MINIMAX_API_KEY; + process.env.MINIMAX_API_KEY = "sk-minimax-test"; + try { + // Omit 'reasoning' to simulate a user config that doesn't set it. + const modelWithoutReasoning = { + id: "MiniMax-M2.5", + name: "MiniMax M2.5", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 8192, + }; + const cfg: OpenClawConfig = { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + models: [modelWithoutReasoning as any], + }, + }, + }, + }; + + await ensureOpenClawModelsJson(cfg); + + const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8"); + const parsed = JSON.parse(raw) as ModelsJson; + const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5"); + expect(m25).toBeDefined(); + // Built-in catalog has reasoning:true — should be applied as default. + expect(m25?.reasoning).toBe(true); + } finally { + if (prevKey === undefined) { + delete process.env.MINIMAX_API_KEY; + } else { + process.env.MINIMAX_API_KEY = prevKey; + } + } + }); + }); +}); diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 5ca971646e1..4b38b824398 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -47,10 +47,14 @@ function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig) // 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: implicitModel.reasoning, + reasoning: "reasoning" in explicitModel ? explicitModel.reasoning : implicitModel.reasoning, contextWindow: implicitModel.contextWindow, maxTokens: implicitModel.maxTokens, };