diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 92c78e92007..0f4c2bcca9f 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -67,7 +67,7 @@ export class DefaultExecutor extends BaseExecutor { super(provider, PROVIDERS[provider] || PROVIDERS.openai); } - transformRequest(model, body) { + transformRequest(model, body, stream) { const transformed = this.applyJsonSchemaFallback(body); if (transformed && typeof transformed === "object") { @@ -76,6 +76,17 @@ export class DefaultExecutor extends BaseExecutor { delete transformed.client_metadata; } stripUnsupportedParams(this.provider, model, transformed); + // Ask OpenAI-compatible upstreams to include usage in the final stream + // chunk so /v1 streaming requests record real token counts instead of + // IN 0 · OUT 0 (issue #3017). Same approach as the iflow executor. + // Only inject when the body itself streams: the executor-level stream + // flag can be true while the body omits stream (Responses->chat path, + // Accept: text/event-stream clients), and strict upstreams (deepseek) + // 400 with "stream_options should be set along with stream = true". + const bodyStream = transformed.stream === true; + if (stream && bodyStream && transformed.messages && !transformed.stream_options) { + transformed.stream_options = { include_usage: true }; + } } return injectReasoningContent({ provider: this.provider, model, body: transformed }); diff --git a/tests/unit/default-executor-stream-usage.test.js b/tests/unit/default-executor-stream-usage.test.js new file mode 100644 index 00000000000..ab3fb37ff0d --- /dev/null +++ b/tests/unit/default-executor-stream-usage.test.js @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { DefaultExecutor } from "../../open-sse/executors/default.js"; + +// Provider registry: opencode is a generic OpenAI-compatible provider → DefaultExecutor. +const executor = new DefaultExecutor("opencode"); + +describe("DefaultExecutor stream_options injection (#3017)", () => { + it("injects stream_options.include_usage for streaming requests", () => { + const body = { + model: "deepseek-v4-flash-free", + messages: [{ role: "user", content: "hi" }], + stream: true, + }; + const out = executor.transformRequest("deepseek-v4-flash-free", body, true); + expect(out.stream_options).toEqual({ include_usage: true }); + }); + + it("does not inject stream_options for non-streaming requests", () => { + const body = { + model: "deepseek-v4-flash-free", + messages: [{ role: "user", content: "hi" }], + stream: false, + }; + const out = executor.transformRequest("deepseek-v4-flash-free", body, false); + expect(out.stream_options).toBeUndefined(); + }); + + it("respects an existing stream_options from the client", () => { + const body = { + model: "deepseek-v4-flash-free", + messages: [{ role: "user", content: "hi" }], + stream: true, + stream_options: { include_usage: false }, + }; + const out = executor.transformRequest("deepseek-v4-flash-free", body, true); + expect(out.stream_options).toEqual({ include_usage: false }); + }); + + it("does not inject when the body omits stream (Responses->chat path)", () => { + // Responses-API clients convert to chat without a stream field while the + // executor-level stream flag is true (Accept: text/event-stream). Strict + // upstreams (deepseek) 400 on stream_options without stream: true. + const body = { + model: "deepseek-v4-flash-free", + messages: [{ role: "user", content: "hi" }], + }; + const out = executor.transformRequest("deepseek-v4-flash-free", body, true); + expect(out.stream_options).toBeUndefined(); + }); +});