Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion open-sse/executors/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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 });
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/default-executor-stream-usage.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});