Skip to content

Commit 61a6f73

Browse files
giodl73-repoGio Della-Libera
authored andcommitted
fix(doctor): migrate invalid thinking formats
1 parent d5cc0d5 commit 61a6f73

8 files changed

Lines changed: 240 additions & 23 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818

1919
- Dependencies: update `@openclaw/fs-safe` to `0.2.7` so OpenClaw's default Python-helper-off policy keeps best-effort Node write fallbacks for private stores, secret writes, run logs, and media attachments on Linux/macOS.
2020
- Browser: honor the configured image sanitization limit for screenshots and labeled snapshots so browser-captured images follow the same resize policy as other image results. (#84595)
21+
- Doctor: remove unrecognized `models.providers.*.models[*].compat.thinkingFormat` values during `doctor --fix` so stale provider model config can validate after upgrade. Fixes #77803.
2122
- Status: show the configured default, session-selected model, reason, clear hint, and docs link when a session remains pinned to a model that differs from `agents.defaults.model.primary`.
2223
- Mac app: keep local packaging signed with a stable app identity for permission testing and fix Control UI production builds under current Vite/Highlight.js exports.
2324
- macOS app: update the embedded Peekaboo bridge to 3.2.1 so OpenClaw-hosted UI automation works with current Peekaboo CLI capture flows.

src/commands/doctor/shared/legacy-config-migrate.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,3 +1136,98 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => {
11361136
expect(res.changes).toStrictEqual(['Normalized gateway.bind "localhost" → "loopback".']);
11371137
});
11381138
});
1139+
1140+
describe("legacy model compat migrate", () => {
1141+
it("removes unrecognized model compat thinkingFormat values", () => {
1142+
const res = migrateLegacyConfigForTest({
1143+
models: {
1144+
providers: {
1145+
bailian: {
1146+
models: [
1147+
{
1148+
id: "qwen-legacy",
1149+
name: "Qwen Legacy",
1150+
compat: {
1151+
thinkingFormat: "bailian-legacy",
1152+
supportsTools: true,
1153+
},
1154+
},
1155+
],
1156+
},
1157+
},
1158+
},
1159+
});
1160+
1161+
expect(res.config?.models?.providers?.bailian?.models?.[0]?.compat).toEqual({
1162+
supportsTools: true,
1163+
});
1164+
expect(res.changes).toStrictEqual([
1165+
'Removed models.providers.bailian.models.0.compat.thinkingFormat (unrecognized value "bailian-legacy"; runtime default applies).',
1166+
]);
1167+
});
1168+
1169+
it("preserves recognized model compat thinkingFormat values", () => {
1170+
const res = migrateLegacyConfigForTest({
1171+
models: {
1172+
providers: {
1173+
bailian: {
1174+
models: [
1175+
{
1176+
id: "qwen3",
1177+
name: "Qwen3",
1178+
compat: {
1179+
thinkingFormat: "qwen",
1180+
},
1181+
},
1182+
],
1183+
},
1184+
},
1185+
},
1186+
});
1187+
1188+
expect(res.config).toBeNull();
1189+
expect(res.changes).toStrictEqual([]);
1190+
});
1191+
1192+
it("selectively removes invalid thinkingFormat values across providers", () => {
1193+
const res = migrateLegacyConfigForTest({
1194+
models: {
1195+
providers: {
1196+
bailian: {
1197+
models: [
1198+
{
1199+
id: "valid",
1200+
name: "Valid",
1201+
compat: { thinkingFormat: "qwen-chat-template" },
1202+
},
1203+
{
1204+
id: "legacy",
1205+
name: "Legacy",
1206+
compat: { thinkingFormat: "old-bailian" },
1207+
},
1208+
],
1209+
},
1210+
openrouter: {
1211+
models: [
1212+
{
1213+
id: "legacy-router",
1214+
name: "Legacy Router",
1215+
compat: { thinkingFormat: "openrouter-v0" },
1216+
},
1217+
],
1218+
},
1219+
},
1220+
},
1221+
});
1222+
1223+
expect(res.config?.models?.providers?.bailian?.models?.[0]?.compat).toEqual({
1224+
thinkingFormat: "qwen-chat-template",
1225+
});
1226+
expect(res.config?.models?.providers?.bailian?.models?.[1]?.compat).toEqual({});
1227+
expect(res.config?.models?.providers?.openrouter?.models?.[0]?.compat).toEqual({});
1228+
expect(res.changes).toStrictEqual([
1229+
'Removed models.providers.bailian.models.1.compat.thinkingFormat (unrecognized value "old-bailian"; runtime default applies).',
1230+
'Removed models.providers.openrouter.models.0.compat.thinkingFormat (unrecognized value "openrouter-v0"; runtime default applies).',
1231+
]);
1232+
});
1233+
});

src/commands/doctor/shared/legacy-config-migrate.validation.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ describe("legacy config migrate validation", () => {
55
let groupChatRoutingResult: ReturnType<typeof migrateLegacyConfig>;
66
let partialValidationResult: ReturnType<typeof migrateLegacyConfig>;
77
let agentModelTimeoutResult: ReturnType<typeof migrateLegacyConfig>;
8+
let modelThinkingFormatResult: ReturnType<typeof migrateLegacyConfig>;
89

910
beforeAll(() => {
1011
groupChatRoutingResult = migrateLegacyConfig({
@@ -61,6 +62,33 @@ describe("legacy config migrate validation", () => {
6162
],
6263
},
6364
});
65+
modelThinkingFormatResult = migrateLegacyConfig({
66+
models: {
67+
providers: {
68+
bailian: {
69+
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
70+
api: "openai-completions",
71+
models: [
72+
{
73+
id: "qwen-legacy",
74+
name: "Qwen Legacy",
75+
compat: {
76+
thinkingFormat: "bailian-legacy",
77+
supportsTools: true,
78+
},
79+
},
80+
{
81+
id: "qwen-valid",
82+
name: "Qwen Valid",
83+
compat: {
84+
thinkingFormat: "qwen",
85+
},
86+
},
87+
],
88+
},
89+
},
90+
},
91+
});
6492
});
6593

6694
it("returns valid migrated config for legacy group chat routing drift", () => {
@@ -125,4 +153,19 @@ describe("legacy config migrate validation", () => {
125153
primary: "openai/gpt-5.4-mini",
126154
});
127155
});
156+
157+
it("returns valid config after removing invalid model compat thinkingFormat", () => {
158+
const res = modelThinkingFormatResult;
159+
160+
expect(res.partiallyValid).toBeUndefined();
161+
expect(res.changes).toStrictEqual([
162+
'Removed models.providers.bailian.models.0.compat.thinkingFormat (unrecognized value "bailian-legacy"; runtime default applies).',
163+
]);
164+
expect(res.config?.models?.providers?.bailian?.models?.[0]?.compat).toEqual({
165+
supportsTools: true,
166+
});
167+
expect(res.config?.models?.providers?.bailian?.models?.[1]?.compat).toEqual({
168+
thinkingFormat: "qwen",
169+
});
170+
});
128171
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {
2+
defineLegacyConfigMigration,
3+
getRecord,
4+
type LegacyConfigMigrationSpec,
5+
type LegacyConfigRule,
6+
} from "../../../config/legacy.shared.js";
7+
import { isModelThinkingFormat } from "../../../config/types.models.js";
8+
9+
function hasInvalidThinkingFormat(providers: unknown): boolean {
10+
const providersRecord = getRecord(providers);
11+
if (!providersRecord) {
12+
return false;
13+
}
14+
15+
for (const provider of Object.values(providersRecord)) {
16+
const models = getRecord(provider)?.models;
17+
if (!Array.isArray(models)) {
18+
continue;
19+
}
20+
21+
for (const model of models) {
22+
const compat = getRecord(getRecord(model)?.compat);
23+
const thinkingFormat = compat?.thinkingFormat;
24+
if (typeof thinkingFormat === "string" && !isModelThinkingFormat(thinkingFormat)) {
25+
return true;
26+
}
27+
}
28+
}
29+
30+
return false;
31+
}
32+
33+
const INVALID_THINKING_FORMAT_RULE: LegacyConfigRule = {
34+
path: ["models", "providers"],
35+
message:
36+
'models.providers.<id>.models[*].compat.thinkingFormat has an unrecognized value; run "openclaw doctor --fix" to remove it and restore the runtime default.',
37+
match: (value) => hasInvalidThinkingFormat(value),
38+
};
39+
40+
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS: LegacyConfigMigrationSpec[] = [
41+
defineLegacyConfigMigration({
42+
id: "models.providers.*.models.*.compat.thinkingFormat-invalid",
43+
describe: "Remove unrecognized compat.thinkingFormat values from provider model entries",
44+
legacyRules: [INVALID_THINKING_FORMAT_RULE],
45+
apply: (raw, changes) => {
46+
const providers = getRecord(getRecord(raw.models)?.providers);
47+
if (!providers) {
48+
return;
49+
}
50+
51+
for (const [providerId, provider] of Object.entries(providers)) {
52+
const models = getRecord(provider)?.models;
53+
if (!Array.isArray(models)) {
54+
continue;
55+
}
56+
57+
for (const [index, model] of models.entries()) {
58+
const compat = getRecord(getRecord(model)?.compat);
59+
if (!compat) {
60+
continue;
61+
}
62+
const thinkingFormat = compat.thinkingFormat;
63+
if (typeof thinkingFormat !== "string" || isModelThinkingFormat(thinkingFormat)) {
64+
continue;
65+
}
66+
67+
delete compat.thinkingFormat;
68+
changes.push(
69+
`Removed models.providers.${providerId}.models.${index}.compat.thinkingFormat (unrecognized value ${JSON.stringify(thinkingFormat)}; runtime default applies).`,
70+
);
71+
}
72+
}
73+
},
74+
}),
75+
];

src/commands/doctor/shared/legacy-config-migrations.runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS } from "./legacy-config-migrati
33
import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_DIAGNOSTICS } from "./legacy-config-migrations.runtime.diagnostics.js";
44
import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY } from "./legacy-config-migrations.runtime.gateway.js";
55
import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_MCP } from "./legacy-config-migrations.runtime.mcp.js";
6+
import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS } from "./legacy-config-migrations.runtime.models.js";
67
import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS } from "./legacy-config-migrations.runtime.providers.js";
78
import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_SESSION } from "./legacy-config-migrations.runtime.session.js";
89
import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS } from "./legacy-config-migrations.runtime.tts.js";
@@ -12,6 +13,7 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [
1213
...LEGACY_CONFIG_MIGRATIONS_RUNTIME_DIAGNOSTICS,
1314
...LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY,
1415
...LEGACY_CONFIG_MIGRATIONS_RUNTIME_MCP,
16+
...LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS,
1517
...LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS,
1618
...LEGACY_CONFIG_MIGRATIONS_RUNTIME_SESSION,
1719
...LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS,

src/config/types.models.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,26 @@ type SupportedAnthropicMessagesCompatFields = Pick<
5050
"supportsEagerToolInputStreaming" | "supportsLongCacheRetention"
5151
>;
5252

53-
type SupportedThinkingFormat =
53+
export type SupportedThinkingFormat =
5454
| NonNullable<OpenAICompletionsCompat["thinkingFormat"]>
5555
| "deepseek"
5656
| "openrouter"
5757
| "together";
5858

59+
export const MODEL_THINKING_FORMATS = [
60+
"openai",
61+
"openrouter",
62+
"deepseek",
63+
"together",
64+
"qwen",
65+
"qwen-chat-template",
66+
"zai",
67+
] as const satisfies readonly SupportedThinkingFormat[];
68+
69+
export function isModelThinkingFormat(value: string): value is SupportedThinkingFormat {
70+
return (MODEL_THINKING_FORMATS as readonly string[]).includes(value);
71+
}
72+
5973
export type ModelCompatConfig = SupportedOpenAICompatFields &
6074
SupportedOpenAIResponsesCompatFields &
6175
SupportedAnthropicMessagesCompatFields & {

src/config/zod-schema.core.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from "../secrets/ref-contract.js";
1010
import { normalizeStringEntries } from "../shared/string-normalization.js";
1111
import type { ModelCompatConfig } from "./types.models.js";
12-
import { MODEL_APIS } from "./types.models.js";
12+
import { MODEL_APIS, MODEL_THINKING_FORMATS } from "./types.models.js";
1313
import type { MediaToolsConfig } from "./types.tools.js";
1414
import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js";
1515
import { sensitive } from "./zod-schema.sensitive.js";
@@ -202,17 +202,7 @@ const ModelCompatSchema = z
202202
maxTokensField: z
203203
.union([z.literal("max_completion_tokens"), z.literal("max_tokens")])
204204
.optional(),
205-
thinkingFormat: z
206-
.union([
207-
z.literal("openai"),
208-
z.literal("openrouter"),
209-
z.literal("deepseek"),
210-
z.literal("together"),
211-
z.literal("qwen"),
212-
z.literal("qwen-chat-template"),
213-
z.literal("zai"),
214-
])
215-
.optional(),
205+
thinkingFormat: z.enum(MODEL_THINKING_FORMATS).optional(),
216206
requiresToolResultName: z.boolean().optional(),
217207
requiresAssistantAfterToolResult: z.boolean().optional(),
218208
requiresThinkingAsText: z.boolean().optional(),

src/model-catalog/normalize.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { MODEL_APIS, type ModelApi, type ModelCompatConfig } from "../config/types.models.js";
1+
import {
2+
MODEL_APIS,
3+
isModelThinkingFormat,
4+
type ModelApi,
5+
type ModelCompatConfig,
6+
} from "../config/types.models.js";
27
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
38
import { normalizeOptionalString } from "../shared/string-coerce.js";
49
import { normalizeTrimmedStringList } from "../shared/string-normalization.js";
@@ -220,15 +225,7 @@ function normalizeModelCatalogCompat(value: unknown): ModelCompatConfig | undefi
220225
}
221226

222227
const thinkingFormat = normalizeOptionalString(value.thinkingFormat) ?? "";
223-
if (
224-
thinkingFormat === "openai" ||
225-
thinkingFormat === "openrouter" ||
226-
thinkingFormat === "deepseek" ||
227-
thinkingFormat === "together" ||
228-
thinkingFormat === "qwen" ||
229-
thinkingFormat === "qwen-chat-template" ||
230-
thinkingFormat === "zai"
231-
) {
228+
if (isModelThinkingFormat(thinkingFormat)) {
232229
compat.thinkingFormat = thinkingFormat;
233230
}
234231

0 commit comments

Comments
 (0)