Skip to content
Open
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
32 changes: 32 additions & 0 deletions open-sse/handlers/chatCore/sseToJsonHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,43 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel, validToolNames =
for (const chunk of chunks) {
const choice = chunk?.choices?.[0];
const delta = choice?.delta || {};

// OpenAI format: content in delta + usage at chunk root
if (typeof delta.content === "string" && delta.content.length > 0) contentParts.push(delta.content);
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) reasoningParts.push(delta.reasoning_content);
if (choice?.finish_reason) finishReason = choice.finish_reason;
if (chunk?.usage && typeof chunk.usage === "object") usage = chunk.usage;

// Gemini / Antigravity format: content in response.candidates[*].content.parts
// and usageMetadata inside response envelope
// data: {"response":{"candidates":[{...}],"usageMetadata":{"promptTokenCount":...,...}}}
if (!choice && chunk.response) {
const resp = chunk.response;
const candidate = resp.candidates?.[0];
if (candidate) {
const parts = candidate.content?.parts || [];
for (const part of parts) {
if (part.text !== undefined && part.text) contentParts.push(part.text);
if (part.thought && part.text) reasoningParts.push(part.text);
}
if (candidate.finishReason) finishReason = candidate.finishReason.toLowerCase();
}
// Extract usage from Gemini AG envelope
const usageMeta = resp.usageMetadata || chunk.usageMetadata;
if (usageMeta && !usage) {
const prompt = usageMeta.promptTokenCount || 0;
const completion = usageMeta.candidatesTokenCount || 0;
usage = {
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: usageMeta.totalTokenCount || (prompt + completion),
completion_tokens_details: {
reasoning_tokens: usageMeta.thoughtsTokenCount || 0
}
};
}
}

// Accumulate tool_calls from streaming deltas
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
Expand Down
52 changes: 51 additions & 1 deletion open-sse/utils/stream.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { translateResponse, initState } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js";
import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
import { extractUsage, mergeUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js";
import { extractUsage, mergeUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, hasZeroCompletionWithContent, fixZeroCompletionUsage, COLORS } from "./usageTracking.js";
import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js";
import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js";
import { dbg, isDebugEnabled } from "./debugLog.js";
Expand Down Expand Up @@ -230,6 +230,17 @@ export function createSSEStream(options = {}) {
totalContentLength += reasoning.length;
accumulatedThinking += reasoning;
}
// Tool-call-only responses (e.g. agentic coding tools with large
// tool arrays) produce real output entirely via delta.tool_calls,
// with content/reasoning empty. Count function name + streamed
// argument chunk length so totalContentLength isn't stuck at 0
// and the zero-completion / estimation fallbacks below can fire.
if (Array.isArray(delta?.tool_calls)) {
for (const tc of delta.tool_calls) {
if (typeof tc?.function?.name === "string") totalContentLength += tc.function.name.length;
if (typeof tc?.function?.arguments === "string") totalContentLength += tc.function.arguments.length;
}
}

const extracted = extractUsage(parsed);
if (extracted) {
Expand All @@ -243,6 +254,15 @@ export function createSSEStream(options = {}) {
output = `data: ${JSON.stringify(parsed)}\n`;
usage = estimated;
injectedUsage = true;
} else if (isFinishChunk && hasZeroCompletionWithContent(parsed.usage, totalContentLength)) {
// Provider reported completion_tokens: 0 (e.g. Shiteru "estimated"
// finish chunk on a large prompt) despite real streamed content.
// Keep the provider's prompt_tokens, patch only the output side.
const fixed = fixZeroCompletionUsage(parsed.usage, totalContentLength);
parsed.usage = filterUsageForFormat(fixed, FORMATS.OPENAI);
output = `data: ${JSON.stringify(parsed)}\n`;
usage = fixed;
injectedUsage = true;
} else if (isFinishChunk && usage) {
const buffered = addBufferToUsage(usage);
parsed.usage = filterUsageForFormat(buffered, FORMATS.OPENAI);
Expand Down Expand Up @@ -322,12 +342,26 @@ export function createSSEStream(options = {}) {
totalContentLength += parsed.delta.thinking.length;
accumulatedThinking += parsed.delta.thinking;
}
// Claude format - tool_use input streamed as partial_json deltas.
// Tool-call-only turns would otherwise leave totalContentLength at 0.
if (typeof parsed.delta?.partial_json === "string") {
totalContentLength += parsed.delta.partial_json.length;
}

// OpenAI format - content
if (parsed.choices?.[0]?.delta?.content) {
totalContentLength += parsed.choices[0].delta.content.length;
accumulatedContent += parsed.choices[0].delta.content;
}
// OpenAI format - tool calls (name + streamed argument chunks). Without
// this, tool-call-only turns leave totalContentLength at 0 and the
// zero-completion/estimation usage fallbacks never fire.
if (Array.isArray(parsed.choices?.[0]?.delta?.tool_calls)) {
for (const tc of parsed.choices[0].delta.tool_calls) {
if (typeof tc?.function?.name === "string") totalContentLength += tc.function.name.length;
if (typeof tc?.function?.arguments === "string") totalContentLength += tc.function.arguments.length;
}
}

// Detect and correct native Kimi tool-call markup that leaks into the
// content stream instead of being emitted as structured tool_calls.
Expand Down Expand Up @@ -429,6 +463,13 @@ export function createSSEStream(options = {}) {
const estimated = estimateUsage(body, totalContentLength, sourceFormat);
item.usage = filterUsageForFormat(estimated, sourceFormat); // Filter + already has buffer
state.usage = estimated;
} else if (state.finishReason && isFinishChunk && hasZeroCompletionWithContent(item.usage, totalContentLength)) {
// Provider reported zero completion tokens despite real streamed
// content (e.g. Shiteru-style "estimated" usage on large prompts).
// Patch just the output side, keep the provider's prompt_tokens.
const fixed = fixZeroCompletionUsage(item.usage, totalContentLength);
item.usage = filterUsageForFormat(fixed, sourceFormat);
state.usage = fixed;
} else if (state.finishReason && isFinishChunk && state.usage) {
// Add buffer and filter usage for client (but keep original in state.usage for logging)
const buffered = addBufferToUsage(state.usage);
Expand Down Expand Up @@ -464,6 +505,11 @@ export function createSSEStream(options = {}) {

if (!hasValidUsage(usage) && totalContentLength > 0) {
usage = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
} else if (hasZeroCompletionWithContent(usage, totalContentLength)) {
// Provider (e.g. Shiteru on large prompts) reported completion_tokens: 0
// despite real streamed content. hasValidUsage() above passes because
// prompt_tokens is non-zero, so patch just the output side here.
usage = fixZeroCompletionUsage(usage, totalContentLength);
}

if (hasValidUsage(usage)) {
Expand Down Expand Up @@ -570,6 +616,10 @@ export function createSSEStream(options = {}) {

if (!hasValidUsage(state?.usage) && totalContentLength > 0) {
state.usage = estimateUsage(body, totalContentLength, sourceFormat);
} else if (hasZeroCompletionWithContent(state?.usage, totalContentLength)) {
// Provider reported completion_tokens: 0 despite real streamed content.
// hasValidUsage() above passes because prompt_tokens is non-zero.
state.usage = fixZeroCompletionUsage(state.usage, totalContentLength);
}

if (hasValidUsage(state?.usage)) {
Expand Down
53 changes: 53 additions & 0 deletions open-sse/utils/usageTracking.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,59 @@ export function hasValidUsage(usage) {
return false;
}

/**
* Detect a usage object with a provider-reported zero completion count
* despite real streamed content having been produced.
*
* Some providers (e.g. Shiteru / OpenAI-compatible passthrough on large prompts)
* return a finish chunk like:
* {"usage": {"prompt_tokens": 182812, "completion_tokens": 0, "estimated": true}}
*
* hasValidUsage() returns true because prompt_tokens > 0, so the caller's
* "estimate the whole thing from scratch" fallback is skipped — yet
* completion_tokens is 0 even though the client received real content.
* Distinct from hasValidUsage(): this only flags the zero-completion-with-
* content case, so callers can patch just the output side and keep the
* provider's (usually more accurate) input token count intact.
*
* @param {object|null} usage - Usage object (any format)
* @param {number} totalContentLength - Total length of streamed response content
* @returns {boolean} true if completion_tokens/output_tokens is a zero that contradicts real content
*/
export function hasZeroCompletionWithContent(usage, totalContentLength = 0) {
if (!usage || typeof usage !== "object") return false;
if (!(totalContentLength > 0)) return false;

const outTokens = usage.completion_tokens ?? usage.output_tokens;
return typeof outTokens === "number" && outTokens === 0;
}

/**
* Patch a usage object whose completion/output tokens are zero despite real
* streamed content, replacing only the output side with an estimate derived
* from content length. Keeps the provider's prompt/input token count intact
* (it's usually accurate — only the provider's output estimate failed).
*
* @param {object} usage - Usage object with completion_tokens/output_tokens === 0
* @param {number} totalContentLength - Total length of streamed response content
* @returns {object} New usage object with corrected completion/total tokens
*/
export function fixZeroCompletionUsage(usage, totalContentLength) {
const estimatedOut = estimateOutputTokens(totalContentLength);
const fixed = { ...usage, estimated: true };

if (fixed.completion_tokens !== undefined) {
fixed.completion_tokens = estimatedOut;
const prompt = fixed.prompt_tokens || 0;
fixed.total_tokens = prompt + estimatedOut;
}
if (fixed.output_tokens !== undefined) {
fixed.output_tokens = estimatedOut;
}

return fixed;
}

/**
* Extract usage from any format (Claude, OpenAI, Gemini, Responses API)
*/
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/shiteru-zero-completion-fix.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Regression test: Shiteru / OpenAI-compatible passthrough on large prompts
// returns a finish chunk with completion_tokens: 0 despite real streamed
// content ("estimated": true, output estimation failed upstream).
// hasValidUsage() alone treats this as valid because prompt_tokens > 0, so
// the router must detect and patch the zero-completion case separately.
import { describe, it, expect } from "vitest";
import {
hasValidUsage,
hasZeroCompletionWithContent,
fixZeroCompletionUsage,
} from "../../open-sse/utils/usageTracking.js";

describe("hasZeroCompletionWithContent", () => {
it("flags OpenAI-shape usage with completion_tokens: 0 when content was streamed", () => {
const usage = { prompt_tokens: 182812, completion_tokens: 0, total_tokens: 182812, estimated: true };
expect(hasValidUsage(usage)).toBe(true); // this is exactly the trap: passes hasValidUsage
expect(hasZeroCompletionWithContent(usage, 1200)).toBe(true);
});

it("flags Claude-shape usage with output_tokens: 0 when content was streamed", () => {
const usage = { input_tokens: 50000, output_tokens: 0 };
expect(hasZeroCompletionWithContent(usage, 500)).toBe(true);
});

it("does not flag when completion_tokens is legitimately non-zero", () => {
const usage = { prompt_tokens: 1000, completion_tokens: 42, total_tokens: 1042 };
expect(hasZeroCompletionWithContent(usage, 200)).toBe(false);
});

it("does not flag zero completion_tokens when no content was actually streamed", () => {
const usage = { prompt_tokens: 1000, completion_tokens: 0 };
expect(hasZeroCompletionWithContent(usage, 0)).toBe(false);
});

it("returns false for null/undefined usage", () => {
expect(hasZeroCompletionWithContent(null, 500)).toBe(false);
expect(hasZeroCompletionWithContent(undefined, 500)).toBe(false);
});
});

describe("fixZeroCompletionUsage", () => {
it("patches OpenAI-shape usage: keeps prompt_tokens, estimates completion_tokens, recomputes total", () => {
const usage = { prompt_tokens: 182812, completion_tokens: 0, total_tokens: 182812, estimated: true };
const fixed = fixZeroCompletionUsage(usage, 1200); // ~300 estimated tokens (1200/4)
expect(fixed.prompt_tokens).toBe(182812); // provider's real input count preserved
expect(fixed.completion_tokens).toBe(300);
expect(fixed.total_tokens).toBe(183112);
expect(fixed.estimated).toBe(true);
expect(hasValidUsage(fixed)).toBe(true);
});

it("patches Claude-shape usage: keeps input_tokens, estimates output_tokens", () => {
const usage = { input_tokens: 50000, output_tokens: 0 };
const fixed = fixZeroCompletionUsage(usage, 400); // 100 estimated tokens
expect(fixed.input_tokens).toBe(50000);
expect(fixed.output_tokens).toBe(100);
});

it("minimum 1 estimated output token when content is non-empty", () => {
const usage = { prompt_tokens: 100, completion_tokens: 0 };
const fixed = fixZeroCompletionUsage(usage, 1); // 1 char -> floor(1/4)=0, but min 1
expect(fixed.completion_tokens).toBeGreaterThanOrEqual(1);
});
});
Loading
Loading