Skip to content

Commit 4797cdb

Browse files
committed
Support Qwen thinking format config
1 parent dc64b99 commit 4797cdb

10 files changed

Lines changed: 151 additions & 28 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
4848
- Codex app-server: pin the managed Codex harness and Codex CLI smoke package to `@openai/[email protected]`, defer OpenClaw integration dynamic tools behind Codex tool search by default, and accept current Codex service-tier values so legacy `fast` settings survive the stable harness upgrade as `priority`.
4949
- Codex app-server: annotate message-tool-only direct chat turns in the dynamic `message` tool spec so visible replies are sent through `message(action="send")` instead of staying private. (#79704)
5050
- Agents/PI: route explicit OpenAI Codex Responses runs through PI's native WebSocket-capable transport and remove OpenClaw's custom OpenAI Responses WebSocket stack while preserving auth injection, run abort signals, and prompt cache boundary stripping.
51+
- Models/config: allow `compat.thinkingFormat` values `qwen` and `qwen-chat-template` for configured OpenAI-compatible Qwen models, preserving them through catalog normalization and mapping `/think` levels to `enable_thinking` or `chat_template_kwargs.enable_thinking`. Fixes #79677.
5152
- Codex app-server: default implicit local stdio app-server permissions to guardian when Codex system requirements disallow the YOLO approval, reviewer, or sandbox value, including hostname-scoped remote sandbox entries, avoiding turn-start failures on managed hosts that permit only reviewed approval or narrower sandboxes.
5253
- Plugins/install: run managed npm-root install, uninstall, prune, and repair commands from the managed root without a redundant `--prefix .`, avoiding npm 10.9.3 Arborist crashes on native Windows WhatsApp plugin installs. Fixes #78514. (#78902) Thanks @melihselamett-stack.
5354
- Discord/voice: stream ElevenLabs TTS directly into Discord playback and send ElevenLabs latency optimization as the documented query parameter so spoken replies can start sooner.

docs/gateway/config-agents.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ Time format in system prompt. Default: `auto` (OS preference).
393393
- `params` merge precedence (config): `agents.defaults.params` (global base) is overridden by `agents.defaults.models["provider/model"].params` (per-model), then `agents.list[].params` (matching agent id) overrides by key. See [Prompt Caching](/reference/prompt-caching) for details.
394394
- `params.extra_body`/`params.extraBody`: advanced pass-through JSON merged into `api: "openai-completions"` request bodies for OpenAI-compatible proxies. If it collides with generated request keys, the extra body wins; non-native completions routes still strip OpenAI-only `store` afterward.
395395
- `params.chat_template_kwargs`: vLLM/OpenAI-compatible chat-template arguments merged into top-level `api: "openai-completions"` request bodies. For `vllm/nemotron-3-*` with thinking off, the bundled vLLM plugin automatically sends `enable_thinking: false` and `force_nonempty_content: true`; explicit `chat_template_kwargs` override generated defaults, and `extra_body.chat_template_kwargs` still has final precedence. For vLLM Qwen thinking controls, set `params.qwenThinkingFormat` to `"chat-template"` or `"top-level"` on that model entry.
396+
- `compat.thinkingFormat`: OpenAI-compatible thinking payload style. Use `"qwen"` for Qwen-style top-level `enable_thinking`, or `"qwen-chat-template"` for `chat_template_kwargs.enable_thinking` on local Qwen-family backends such as llama.cpp/llama-server. OpenClaw maps disabled thinking to `false` and enabled thinking to `true`.
396397
- `compat.supportedReasoningEfforts`: per-model OpenAI-compatible reasoning effort list. Include `"xhigh"` for custom endpoints that truly accept it; OpenClaw then exposes `/think xhigh` in command menus, Gateway session rows, session patch validation, agent CLI validation, and `llm-task` validation for that configured provider/model. Use `compat.reasoningEffortMap` when the backend wants a provider-specific value for a canonical level.
397398
- `params.preserveThinking`: Z.AI-only opt-in for preserved thinking. When enabled and thinking is on, OpenClaw sends `thinking.clear_thinking: false` and replays prior `reasoning_content`; see [Z.AI thinking and preserved thinking](/providers/zai#thinking-and-preserved-thinking).
398399
- Runtime policy belongs on providers or models, not on `agents.defaults`. Use `models.providers.<provider>.agentRuntime` for provider-wide rules or `agents.defaults.models["provider/model"].agentRuntime` / `agents.list[].models["provider/model"].agentRuntime` for model-specific rules. OpenAI agent models on the official OpenAI provider select Codex by default.

docs/gateway/config-tools.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
474474
- `models.providers.*.models.*.contextTokens`: optional runtime context cap. This overrides provider-level `contextTokens`; use it when you want a smaller effective context budget than the model's native `contextWindow`; `openclaw models list` shows both values when they differ.
475475
- `models.providers.*.models.*.compat.supportsDeveloperRole`: optional compatibility hint. For `api: "openai-completions"` with a non-empty non-native `baseUrl` (host not `api.openai.com`), OpenClaw forces this to `false` at runtime. Empty/omitted `baseUrl` keeps default OpenAI behavior.
476476
- `models.providers.*.models.*.compat.requiresStringContent`: optional compatibility hint for string-only OpenAI-compatible chat endpoints. When `true`, OpenClaw flattens pure text `messages[].content` arrays into plain strings before sending the request.
477+
- `models.providers.*.models.*.compat.thinkingFormat`: optional thinking payload hint. Use `"qwen"` for top-level `enable_thinking`, or `"qwen-chat-template"` for `chat_template_kwargs.enable_thinking` on local Qwen-family OpenAI-compatible servers.
477478

478479
</Accordion>
479480
<Accordion title="Amazon Bedrock discovery">

src/agents/openai-transport-stream.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2597,6 +2597,72 @@ describe("openai transport stream", () => {
25972597
expect(disabled.reasoning_effort).toBe("none");
25982598
});
25992599

2600+
it("maps qwen thinking format to top-level enable_thinking", () => {
2601+
const baseModel = {
2602+
id: "qwen3.5-32b",
2603+
name: "Qwen 3.5 32B",
2604+
api: "openai-completions",
2605+
provider: "llama-cpp",
2606+
baseUrl: "http://127.0.0.1:8080/v1",
2607+
reasoning: true,
2608+
input: ["text"],
2609+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2610+
contextWindow: 131072,
2611+
maxTokens: 8192,
2612+
compat: {
2613+
thinkingFormat: "qwen",
2614+
},
2615+
} as unknown as Model<"openai-completions">;
2616+
const context = {
2617+
systemPrompt: "system",
2618+
messages: [],
2619+
tools: [],
2620+
} as never;
2621+
2622+
const enabled = buildOpenAICompletionsParams(baseModel, context, {
2623+
reasoning: "medium",
2624+
} as never) as { enable_thinking?: unknown; reasoning_effort?: unknown };
2625+
const disabled = buildOpenAICompletionsParams(baseModel, context, {
2626+
reasoning: "off",
2627+
} as never) as { enable_thinking?: unknown; reasoning_effort?: unknown };
2628+
2629+
expect(enabled.enable_thinking).toBe(true);
2630+
expect(disabled.enable_thinking).toBe(false);
2631+
expect(enabled).not.toHaveProperty("reasoning_effort");
2632+
expect(disabled).not.toHaveProperty("reasoning_effort");
2633+
});
2634+
2635+
it("maps qwen-chat-template thinking format to chat_template_kwargs", () => {
2636+
const params = buildOpenAICompletionsParams(
2637+
{
2638+
id: "qwen3.5-32b",
2639+
name: "Qwen 3.5 32B",
2640+
api: "openai-completions",
2641+
provider: "llama-cpp",
2642+
baseUrl: "http://127.0.0.1:8080/v1",
2643+
reasoning: true,
2644+
input: ["text"],
2645+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2646+
contextWindow: 131072,
2647+
maxTokens: 8192,
2648+
compat: {
2649+
thinkingFormat: "qwen-chat-template",
2650+
},
2651+
} as unknown as Model<"openai-completions">,
2652+
{
2653+
systemPrompt: "system",
2654+
messages: [],
2655+
tools: [],
2656+
} as never,
2657+
{
2658+
reasoning: "off",
2659+
} as never,
2660+
) as { chat_template_kwargs?: Record<string, unknown>; reasoning_effort?: unknown };
2661+
2662+
expect(params.chat_template_kwargs).toEqual({ enable_thinking: false });
2663+
expect(params).not.toHaveProperty("reasoning_effort");
2664+
});
2665+
26002666
it("omits unsupported disabled reasoning for completions providers", () => {
26012667
const params = buildOpenAICompletionsParams(
26022668
{

src/agents/openai-transport-stream.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1831,6 +1831,44 @@ function resolveOpenAICompletionsReasoningEffort(options: OpenAICompletionsOptio
18311831
return options?.reasoningEffort ?? options?.reasoning ?? "high";
18321832
}
18331833

1834+
function isQwenOpenAICompletionsThinkingFormat(format: string): boolean {
1835+
return format === "qwen" || format === "qwen-chat-template";
1836+
}
1837+
1838+
function isOpenAICompletionsThinkingEnabled(effort: OpenAIReasoningEffort): boolean {
1839+
const normalized = effort.trim().toLowerCase();
1840+
return normalized !== "off" && normalized !== "none";
1841+
}
1842+
1843+
function setQwenChatTemplateThinking(params: Record<string, unknown>, enabled: boolean): void {
1844+
const existing = params.chat_template_kwargs;
1845+
params.chat_template_kwargs =
1846+
existing && typeof existing === "object" && !Array.isArray(existing)
1847+
? { ...(existing as Record<string, unknown>), enable_thinking: enabled }
1848+
: { enable_thinking: enabled };
1849+
}
1850+
1851+
function applyQwenOpenAICompletionsThinkingParams(params: {
1852+
compatThinkingFormat: string;
1853+
modelReasoning: boolean;
1854+
payload: Record<string, unknown>;
1855+
requestedEffort: OpenAIReasoningEffort;
1856+
}): boolean {
1857+
if (
1858+
!params.modelReasoning ||
1859+
!isQwenOpenAICompletionsThinkingFormat(params.compatThinkingFormat)
1860+
) {
1861+
return false;
1862+
}
1863+
const enabled = isOpenAICompletionsThinkingEnabled(params.requestedEffort);
1864+
if (params.compatThinkingFormat === "qwen-chat-template") {
1865+
setQwenChatTemplateThinking(params.payload, enabled);
1866+
} else {
1867+
params.payload.enable_thinking = enabled;
1868+
}
1869+
return true;
1870+
}
1871+
18341872
function convertTools(
18351873
tools: NonNullable<Context["tools"]>,
18361874
compat: ReturnType<typeof getCompat>,
@@ -2030,6 +2068,12 @@ export function buildOpenAICompletionsParams(
20302068
: undefined;
20312069
const omitGpt54MiniToolReasoningEffort =
20322070
isOpenAIGpt54MiniModel(model) && Array.isArray(params.tools) && params.tools.length > 0;
2071+
const handledQwenThinkingFormat = applyQwenOpenAICompletionsThinkingParams({
2072+
compatThinkingFormat: compat.thinkingFormat,
2073+
modelReasoning: model.reasoning,
2074+
payload: params,
2075+
requestedEffort: completionsReasoningEffort,
2076+
});
20332077
if (
20342078
compat.thinkingFormat === "openrouter" &&
20352079
model.reasoning &&
@@ -2042,6 +2086,7 @@ export function buildOpenAICompletionsParams(
20422086
resolvedCompletionsReasoningEffort &&
20432087
model.reasoning &&
20442088
compat.supportsReasoningEffort &&
2089+
!handledQwenThinkingFormat &&
20452090
!omitGpt54MiniToolReasoningEffort
20462091
) {
20472092
params.reasoning_effort = resolvedCompletionsReasoningEffort;

src/config/config-misc.test.ts

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -888,37 +888,40 @@ describe("broadcast", () => {
888888
});
889889

890890
describe("model compat config schema", () => {
891-
it("accepts full openai-completions compat fields", () => {
892-
const res = OpenClawSchema.safeParse({
893-
models: {
894-
providers: {
895-
local: {
896-
baseUrl: "http://127.0.0.1:1234/v1",
897-
api: "openai-completions",
898-
models: [
899-
{
900-
id: "qwen3-32b",
901-
name: "Qwen3 32B",
902-
compat: {
903-
supportsUsageInStreaming: true,
904-
supportsStrictMode: false,
905-
requiresStringContent: true,
906-
thinkingFormat: "zai",
907-
requiresToolResultName: true,
908-
requiresAssistantAfterToolResult: false,
909-
requiresThinkingAsText: false,
910-
requiresMistralToolIds: false,
911-
requiresOpenAiAnthropicToolPayload: true,
891+
it.each(["zai", "qwen", "qwen-chat-template"] as const)(
892+
"accepts full openai-completions compat fields with %s thinking format",
893+
(thinkingFormat) => {
894+
const res = OpenClawSchema.safeParse({
895+
models: {
896+
providers: {
897+
local: {
898+
baseUrl: "http://127.0.0.1:1234/v1",
899+
api: "openai-completions",
900+
models: [
901+
{
902+
id: "qwen3-32b",
903+
name: "Qwen3 32B",
904+
compat: {
905+
supportsUsageInStreaming: true,
906+
supportsStrictMode: false,
907+
requiresStringContent: true,
908+
thinkingFormat,
909+
requiresToolResultName: true,
910+
requiresAssistantAfterToolResult: false,
911+
requiresThinkingAsText: false,
912+
requiresMistralToolIds: false,
913+
requiresOpenAiAnthropicToolPayload: true,
914+
},
912915
},
913-
},
914-
],
916+
],
917+
},
915918
},
916919
},
917-
},
918-
});
920+
});
919921

920-
expect(res.success).toBe(true);
921-
});
922+
expect(res.success).toBe(true);
923+
},
924+
);
922925
});
923926

924927
describe("config paths", () => {

src/config/types.models.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ type SupportedAnthropicMessagesCompatFields = Pick<
5151
>;
5252

5353
type SupportedThinkingFormat =
54-
| Exclude<NonNullable<OpenAICompletionsCompat["thinkingFormat"]>, "qwen" | "qwen-chat-template">
54+
| NonNullable<OpenAICompletionsCompat["thinkingFormat"]>
5555
| "deepseek"
5656
| "openrouter";
5757

src/config/zod-schema.core.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ const ModelCompatSchema = z
205205
z.literal("openai"),
206206
z.literal("openrouter"),
207207
z.literal("deepseek"),
208+
z.literal("qwen"),
209+
z.literal("qwen-chat-template"),
208210
z.literal("zai"),
209211
])
210212
.optional(),

src/model-catalog/normalize.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe("model catalog normalization", () => {
5353
compat: {
5454
supportsTools: true,
5555
supportsStore: "yes",
56+
thinkingFormat: "qwen-chat-template",
5657
unknownFlag: true,
5758
},
5859
status: "preview",
@@ -137,6 +138,7 @@ describe("model catalog normalization", () => {
137138
},
138139
compat: {
139140
supportsTools: true,
141+
thinkingFormat: "qwen-chat-template",
140142
},
141143
status: "preview",
142144
statusReason: "rolling out",

src/model-catalog/normalize.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ function normalizeModelCatalogCompat(value: unknown): ModelCompatConfig | undefi
223223
thinkingFormat === "openai" ||
224224
thinkingFormat === "openrouter" ||
225225
thinkingFormat === "deepseek" ||
226+
thinkingFormat === "qwen" ||
227+
thinkingFormat === "qwen-chat-template" ||
226228
thinkingFormat === "zai"
227229
) {
228230
compat.thinkingFormat = thinkingFormat;

0 commit comments

Comments
 (0)