diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb1d150fc..aaa3a1403f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(deps): bump undici to 7.28.0 and dompurify to 3.4.11 (security)** — addresses the undici SOCKS5-TLS / cache advisories and the dompurify advisory. ([#4306](https://github.com/diegosouzapw/OmniRoute/pull/4306)) - **chore(deps): bump actions/checkout from 4 to 7** — CI checkout-action update. ([#4297](https://github.com/diegosouzapw/OmniRoute/pull/4297)) +- **fix(executors): strip `stream_options` for qwen non-streaming / thinking-mode Claude Code requests** — Claude-Code-compatible providers force the executor-level `stream` flag on via `upstreamStream = stream || isClaudeCodeCompatible` (`open-sse/handlers/chatCore.ts`), but the outgoing body keeps the caller's original `stream: false`. The shared `stream && targetFormat === "openai"` branch in `DefaultExecutor.transformRequest` then injected `stream_options: { include_usage: true }` onto a body that still said `stream: false`, and qwen upstream rejected it with `400 "'stream_options' only set this when you set stream: true"`. Same rejection when the body carries `thinking` / `enable_thinking`. The qwen branch now skips the injection (and strips any client-sent `stream_options`) when the body explicitly says `stream: false` or requests thinking, leaving regular qwen streaming requests with the usage injection intact. (thanks @anuragg-saxenaa) --- diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index cc2444602a..034477ec48 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -571,11 +571,31 @@ export class DefaultExecutor extends BaseExecutor { withDefaults = withoutStreamOptions; } } else if (stream && targetFormat === "openai" && requestFormat !== "openai-responses") { - if (!credentials?.providerSpecificData?.disableStreamOptions) { + // Port of decolua/9router#663 (closes upstream #557): Qwen rejects with + // 400 "'stream_options' only set this when you set stream: true" when the + // outgoing body carries `stream: false` (Claude Code / Claude-Code- + // compatible callers force the executor-level stream flag on via + // `upstreamStream = stream || isClaudeCodeCompatible`, but the body keeps + // the caller's original `stream: false`). Same upstream also rejects the + // injection when `thinking` / `enable_thinking` is set. Skip injection in + // those cases instead of unconditionally adding `stream_options`. + const defaultsRecord = withDefaults as Record; + const qwenBlocksStreamOptions = + this.provider === "qwen" && + (defaultsRecord.stream === false || + Boolean(defaultsRecord.thinking) || + Boolean(defaultsRecord.enable_thinking)); + if (qwenBlocksStreamOptions) { + if (Object.prototype.hasOwnProperty.call(defaultsRecord, "stream_options")) { + const withoutStreamOptions = { ...defaultsRecord }; + delete withoutStreamOptions.stream_options; + withDefaults = withoutStreamOptions; + } + } else if (!credentials?.providerSpecificData?.disableStreamOptions) { withDefaults = { ...withDefaults, stream_options: { - ...(((withDefaults as Record).stream_options as object) || {}), + ...((defaultsRecord.stream_options as object) || {}), include_usage: true, }, }; diff --git a/tests/unit/qwen-strip-stream-options-claude-code-port663.test.ts b/tests/unit/qwen-strip-stream-options-claude-code-port663.test.ts new file mode 100644 index 0000000000..f4effdacba --- /dev/null +++ b/tests/unit/qwen-strip-stream-options-claude-code-port663.test.ts @@ -0,0 +1,100 @@ +/** + * Port of upstream decolua/9router#663 (closes upstream #557). + * + * Scenario: Claude Code (or any caller) hits a Qwen model with an OpenAI body + * that carries `stream: false`. OmniRoute, however, sets the executor-level + * `stream` flag to `true` for Claude-Code-compatible providers via + * `upstreamStream = stream || isClaudeCodeCompatible` + * (`open-sse/handlers/chatCore.ts`). DefaultExecutor.transformRequest then runs + * its `if (stream && targetFormat === "openai")` branch and injects + * `stream_options: { include_usage: true }` onto a body that still carries + * `stream: false`. Qwen upstream rejects with: + * 400 "'stream_options' only set this when you set stream: true" + * + * Fix mirrors upstream: when the OUTGOING body explicitly says `stream: false`, + * do NOT inject `stream_options` regardless of the executor-level `stream` arg. + * Same defensive treatment when the body carries `thinking` / + * `enable_thinking`, since the upstream PR also exempts those. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; + +test("port#663 qwen: body.stream===false → no stream_options even when executor stream=true", () => { + const executor = new DefaultExecutor("qwen"); + const body = { + model: "qwen3-coder-plus", + messages: [{ role: "user", content: "hi" }], + stream: false, + }; + const result = executor.transformRequest( + "qwen3-coder-plus", + body, + /* stream */ true, + {} + ) as Record; + assert.equal( + result.stream_options, + undefined, + "stream_options must not be injected when body.stream === false" + ); +}); + +test("port#663 qwen: body.thinking truthy → no stream_options injection", () => { + const executor = new DefaultExecutor("qwen"); + const body = { + model: "qwen3-coder-plus", + messages: [{ role: "user", content: "hi" }], + thinking: { type: "enabled" }, + }; + const result = executor.transformRequest( + "qwen3-coder-plus", + body, + true, + {} + ) as Record; + assert.equal( + result.stream_options, + undefined, + "stream_options must not be injected when thinking mode is requested" + ); +}); + +test("port#663 qwen: body.enable_thinking truthy → no stream_options injection", () => { + const executor = new DefaultExecutor("qwen"); + const body = { + model: "qwen3-coder-plus", + messages: [{ role: "user", content: "hi" }], + enable_thinking: true, + }; + const result = executor.transformRequest( + "qwen3-coder-plus", + body, + true, + {} + ) as Record; + assert.equal( + result.stream_options, + undefined, + "stream_options must not be injected when enable_thinking is true" + ); +}); + +test("port#663 qwen: normal streaming request still injects stream_options.include_usage", () => { + const executor = new DefaultExecutor("qwen"); + const body = { + model: "qwen3-coder-plus", + messages: [{ role: "user", content: "hi" }], + }; + const result = executor.transformRequest( + "qwen3-coder-plus", + body, + true, + {} + ) as Record; + assert.deepEqual( + result.stream_options, + { include_usage: true }, + "regular qwen streaming requests must keep the include_usage injection" + ); +});