revert(qoder): drop the Responses usage plumbing from shared code
The merged Qoder work also rewrote shared translator/handler code so that /v1/responses clients got token usage on response.completed. That changed behaviour for every provider, not just Qoder: proxies saw input tokens rise by the 2000-token context buffer, and the plain token mapping was replaced by one that always adds input_tokens_details. A probe confirms the Qoder benefit does not depend on those edits: the executor's coalescer already emits one include_usage-style finish chunk, so a Claude client receives input_tokens and cache_read_input_tokens with every shared file at its original state. Only the Responses path relies on the shared translator, and that path has no Qoder-owned seam to put it in. Reverts the shared files to their pre-PR state and drops the Responses usage test. The Cline envelope unwrap in nonStreamingHandler.js, which landed after the PR in the same file, is kept.
This commit is contained in:
@@ -11,7 +11,6 @@ import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, sav
|
||||
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { decloakToolNames } from "../../utils/claudeCloaking.js";
|
||||
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
|
||||
import { toResponsesUsage } from "../../translator/concerns/usage.js";
|
||||
|
||||
function parseToolArguments(value) {
|
||||
if (!value) return {};
|
||||
@@ -132,8 +131,11 @@ function openAICompletionToResponses(responseBody, customToolNames = null) {
|
||||
background: false,
|
||||
error: null,
|
||||
output,
|
||||
// Keep cached/reasoning details (input_tokens_details) — proxies bill cache hits from them
|
||||
usage: toResponsesUsage(usage) || { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
usage: {
|
||||
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
|
||||
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
|
||||
total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { FORMATS } from "../../translator/formats.js";
|
||||
import { PROVIDERS } from "../../config/providers.js";
|
||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
|
||||
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
|
||||
import { toResponsesUsage } from "../../translator/concerns/usage.js";
|
||||
|
||||
// Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape
|
||||
const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
|
||||
@@ -98,8 +97,11 @@ function chatCompletionToResponses(responseBody, customToolNames = null) {
|
||||
background: false,
|
||||
error: null,
|
||||
output,
|
||||
// Keep cached/reasoning details (input_tokens_details) — proxies bill cache hits from them
|
||||
usage: toResponsesUsage(usage) || { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
usage: {
|
||||
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
|
||||
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
|
||||
total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { toResponsesUsage } from "../translator/concerns/usage.js";
|
||||
|
||||
// Create log directory for responses (Node.js only)
|
||||
export function createResponsesLogger(model, logsDir = null) {
|
||||
@@ -74,7 +73,6 @@ export function createResponsesApiTransformStream(logger = null) {
|
||||
funcArgsDone: {},
|
||||
funcItemDone: {},
|
||||
buffer: "",
|
||||
usage: null,
|
||||
completedSent: false
|
||||
};
|
||||
|
||||
@@ -227,17 +225,17 @@ export function createResponsesApiTransformStream(logger = null) {
|
||||
const sendCompleted = (controller) => {
|
||||
if (!state.completedSent) {
|
||||
state.completedSent = true;
|
||||
const response = {
|
||||
id: state.responseId,
|
||||
object: "response",
|
||||
created_at: state.created,
|
||||
status: "completed",
|
||||
background: false,
|
||||
error: null
|
||||
};
|
||||
const usage = toResponsesUsage(state.usage);
|
||||
if (usage) response.usage = usage;
|
||||
emit(controller, "response.completed", { type: "response.completed", response });
|
||||
emit(controller, "response.completed", {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: state.responseId,
|
||||
object: "response",
|
||||
created_at: state.created,
|
||||
status: "completed",
|
||||
background: false,
|
||||
error: null
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -266,9 +264,6 @@ export function createResponsesApiTransformStream(logger = null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remember usage (finish chunk or trailing include_usage frame) for response.completed
|
||||
if (parsed.usage && typeof parsed.usage === "object") state.usage = parsed.usage;
|
||||
|
||||
if (!parsed.choices?.length) continue;
|
||||
|
||||
const choice = parsed.choices[0];
|
||||
|
||||
@@ -67,34 +67,3 @@ export function toOpenAIUsage(raw, kind) {
|
||||
if (!extract || !raw || typeof raw !== "object") return null;
|
||||
return buildUsage(extract(raw));
|
||||
}
|
||||
|
||||
// Convert an OpenAI-shaped (or already-canonical / Claude-shaped) usage object into the
|
||||
// Responses API shape emitted by `response.completed`. Details objects are always present
|
||||
// (like the real API) so proxies that read `input_tokens_details.cached_tokens` never see undefined.
|
||||
// Returns null when there is nothing countable.
|
||||
export function toResponsesUsage(usage) {
|
||||
if (!usage || typeof usage !== "object") return null;
|
||||
const input = n(usage.prompt_tokens ?? usage.input_tokens);
|
||||
const output = n(usage.completion_tokens ?? usage.output_tokens);
|
||||
if (input === 0 && output === 0) return null;
|
||||
const cached = n(
|
||||
usage.input_tokens_details?.cached_tokens ??
|
||||
usage.prompt_tokens_details?.cached_tokens ??
|
||||
usage.cached_tokens ??
|
||||
usage.cache_read_input_tokens
|
||||
);
|
||||
const reasoning = n(
|
||||
usage.output_tokens_details?.reasoning_tokens ??
|
||||
usage.completion_tokens_details?.reasoning_tokens ??
|
||||
usage.reasoning_tokens
|
||||
);
|
||||
const out = {
|
||||
input_tokens: input,
|
||||
output_tokens: output,
|
||||
total_tokens: typeof usage.total_tokens === "number" ? usage.total_tokens : input + output,
|
||||
input_tokens_details: { cached_tokens: cached },
|
||||
output_tokens_details: { reasoning_tokens: reasoning },
|
||||
};
|
||||
if (usage.estimated) out.estimated = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { buildChunk } from "../concerns/chunk.js";
|
||||
import { buildUsage, toResponsesUsage } from "../concerns/usage.js";
|
||||
import { buildUsage } from "../concerns/usage.js";
|
||||
import { fallbackToolCallId } from "../concerns/toolCall.js";
|
||||
import { reasoningDelta, extractReasoningText } from "../concerns/reasoning.js";
|
||||
import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM, OPENAI_FINISH, MODEL_FALLBACK } from "../schema/index.js";
|
||||
@@ -18,13 +18,7 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
|
||||
if (!chunk) {
|
||||
return flushEvents(state);
|
||||
}
|
||||
|
||||
// Usage riding on the finish chunk (include_usage style, e.g. coalesced Qoder frames):
|
||||
// remember it so response.completed can report tokens even outside stream.js.
|
||||
if (chunk.usage && typeof chunk.usage === "object" && !state.usage) {
|
||||
state.usage = chunk.usage;
|
||||
}
|
||||
|
||||
|
||||
if (!chunk.choices?.length) return [];
|
||||
|
||||
const events = [];
|
||||
@@ -374,19 +368,17 @@ function closeToolCall(state, emit, idx) {
|
||||
function sendCompleted(state, emit) {
|
||||
if (!state.completedSent) {
|
||||
state.completedSent = true;
|
||||
const response = {
|
||||
id: state.responseId,
|
||||
object: "response",
|
||||
created_at: state.created,
|
||||
status: "completed",
|
||||
background: false,
|
||||
error: null
|
||||
};
|
||||
// Carry provider usage (recorded by stream.js or from the finish chunk itself) in the
|
||||
// Responses shape; proxies such as sub2api/Codex read tokens only from here.
|
||||
const usage = toResponsesUsage(state.usage);
|
||||
if (usage) response.usage = usage;
|
||||
emit("response.completed", { type: "response.completed", response });
|
||||
emit("response.completed", {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: state.responseId,
|
||||
object: "response",
|
||||
created_at: state.created,
|
||||
status: "completed",
|
||||
background: false,
|
||||
error: null
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,50 +67,48 @@ function stopTextBlock(state, results) {
|
||||
state.textBlockStarted = false;
|
||||
}
|
||||
|
||||
function recordOpenAIUsage(chunk, state) {
|
||||
if (!chunk?.usage || typeof chunk.usage !== "object") return;
|
||||
|
||||
const promptTokens = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0;
|
||||
const outputTokens = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0;
|
||||
|
||||
// Extract cache tokens from prompt_tokens_details
|
||||
const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens;
|
||||
const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens;
|
||||
const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0;
|
||||
const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0;
|
||||
|
||||
// input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens
|
||||
// Because OpenAI's prompt_tokens includes all prompt-side tokens
|
||||
const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens;
|
||||
|
||||
state.usage = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens
|
||||
};
|
||||
|
||||
if (cacheReadTokens > 0) {
|
||||
state.usage.cache_read_input_tokens = cacheReadTokens;
|
||||
}
|
||||
if (cacheCreateTokens > 0) {
|
||||
state.usage.cache_creation_input_tokens = cacheCreateTokens;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert OpenAI stream chunk to Claude format
|
||||
export function openaiToClaudeResponse(chunk, state) {
|
||||
if (!chunk) return null;
|
||||
|
||||
// Track usage from OpenAI chunk if available
|
||||
if (chunk.usage && typeof chunk.usage === "object") {
|
||||
recordOpenAIUsage(chunk, state);
|
||||
}
|
||||
|
||||
if (!chunk.choices?.[0]) return null;
|
||||
if (!chunk || !chunk.choices?.[0]) return null;
|
||||
|
||||
const results = [];
|
||||
const choice = chunk.choices[0];
|
||||
const delta = choice.delta;
|
||||
|
||||
// Track usage from OpenAI chunk if available
|
||||
if (chunk.usage && typeof chunk.usage === "object") {
|
||||
const promptTokens = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0;
|
||||
const outputTokens = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0;
|
||||
|
||||
// Extract cache tokens from prompt_tokens_details
|
||||
const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens;
|
||||
const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens;
|
||||
const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0;
|
||||
const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0;
|
||||
|
||||
// input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens
|
||||
// Because OpenAI's prompt_tokens includes all prompt-side tokens
|
||||
const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens;
|
||||
|
||||
state.usage = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens
|
||||
};
|
||||
|
||||
// Add cache_read_input_tokens if present
|
||||
if (cacheReadTokens > 0) {
|
||||
state.usage.cache_read_input_tokens = cacheReadTokens;
|
||||
}
|
||||
|
||||
// Add cache_creation_input_tokens if present
|
||||
if (cacheCreateTokens > 0) {
|
||||
state.usage.cache_creation_input_tokens = cacheCreateTokens;
|
||||
}
|
||||
|
||||
// Note: completion_tokens_details.reasoning_tokens is already included in output_tokens
|
||||
// No need to add separately as Claude expects total output_tokens
|
||||
}
|
||||
|
||||
// First chunk - ALWAYS send message_start first
|
||||
if (!state.messageStartSent) {
|
||||
state.messageStartSent = true;
|
||||
@@ -223,9 +221,8 @@ export function openaiToClaudeResponse(chunk, state) {
|
||||
}
|
||||
}
|
||||
|
||||
// Finish (OpenAI puts this on the choice; Qoder often puts it on delta)
|
||||
const finishReason = choice.finish_reason || delta?.finish_reason;
|
||||
if (finishReason) {
|
||||
// Finish
|
||||
if (choice.finish_reason) {
|
||||
stopThinkingBlock(state, results);
|
||||
stopTextBlock(state, results);
|
||||
|
||||
@@ -247,13 +244,13 @@ export function openaiToClaudeResponse(chunk, state) {
|
||||
}
|
||||
|
||||
// Mark finish for later usage injection in stream.js
|
||||
state.finishReason = finishReason;
|
||||
state.finishReason = choice.finish_reason;
|
||||
|
||||
// Use tracked usage (will be estimated in stream.js if not valid)
|
||||
const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 };
|
||||
results.push({
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: convertFinishReason(finishReason) },
|
||||
delta: { stop_reason: convertFinishReason(choice.finish_reason) },
|
||||
usage: finalUsage
|
||||
});
|
||||
results.push({ type: "message_stop" });
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js";
|
||||
import { toResponsesUsage } from "../translator/concerns/usage.js";
|
||||
import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js";
|
||||
import { dbg, isDebugEnabled } from "./debugLog.js";
|
||||
|
||||
@@ -202,8 +201,7 @@ export function createSSEStream(options = {}) {
|
||||
|
||||
responsesTerminal = isOpenAIResponsesTerminalEvent(currentOpenAIResponsesEvent, parsed);
|
||||
|
||||
const isFinishChunk = parsed.choices?.[0]?.finish_reason
|
||||
|| parsed.choices?.[0]?.delta?.finish_reason;
|
||||
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
|
||||
if (isFinishChunk && !hasValidUsage(parsed.usage)) {
|
||||
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
|
||||
parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI);
|
||||
@@ -367,19 +365,6 @@ export function createSSEStream(options = {}) {
|
||||
item.usage = filterUsageForFormat(buffered, sourceFormat);
|
||||
}
|
||||
|
||||
// Responses API clients (Codex, sub2api /v1/responses): usage lives on
|
||||
// response.completed → response.usage. Same buffer/estimate policy as above.
|
||||
const completedResponse = item.event === "response.completed" ? item.data?.response : null;
|
||||
if (completedResponse && typeof completedResponse === "object") {
|
||||
if (state.usage) {
|
||||
completedResponse.usage = toResponsesUsage(addBufferToUsage(state.usage)) ?? completedResponse.usage;
|
||||
} else if (!completedResponse.usage && totalContentLength > 0) {
|
||||
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
|
||||
completedResponse.usage = toResponsesUsage(estimated);
|
||||
state.usage = estimated;
|
||||
}
|
||||
}
|
||||
|
||||
const output = formatSSE(item, sourceFormat);
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
controller.enqueue(sharedEncoder.encode(output));
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* Responses API clients (Codex, sub2api /v1/responses) read token usage only from
|
||||
* `response.completed → response.usage`. For chat-native upstreams (Qoder, most
|
||||
* OpenAI-compatible providers) the translator used to emit that event without usage,
|
||||
* so proxies logged 0 input / 0 output / 0 cached tokens.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/usageDb.js", () => ({
|
||||
appendRequestLog: vi.fn(async () => {}),
|
||||
saveRequestDetail: vi.fn(async () => {}),
|
||||
saveRequestUsage: vi.fn(async () => {}),
|
||||
trackPendingRequest: vi.fn(() => {}),
|
||||
}));
|
||||
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.js");
|
||||
const { initState } = await import("../../open-sse/translator/index.js");
|
||||
const { toResponsesUsage } = await import("../../open-sse/translator/concerns/usage.js");
|
||||
const { openaiToOpenAIResponsesResponse } = await import("../../open-sse/translator/response/openai-responses.js");
|
||||
const { createSSETransformStreamWithLogger } = await import("../../open-sse/utils/stream.js");
|
||||
const { createResponsesApiTransformStream } = await import("../../open-sse/transformer/responsesTransformer.js");
|
||||
const { addBufferToUsage } = await import("../../open-sse/utils/usageTracking.js");
|
||||
// stream.js adds the same context-safety buffer it applies to chat/claude clients
|
||||
const BUFFER_TOKENS = addBufferToUsage({ prompt_tokens: 0 }).prompt_tokens;
|
||||
|
||||
const QODER_FINISH_CHUNK = {
|
||||
id: "chatcmpl-qoder-1",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1_700_000_000,
|
||||
model: "qmodel_38max",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
usage: {
|
||||
prompt_tokens: 27_339,
|
||||
completion_tokens: 437,
|
||||
total_tokens: 27_776,
|
||||
prompt_tokens_details: { cached_tokens: 27_200 },
|
||||
},
|
||||
};
|
||||
|
||||
function sse(chunks) {
|
||||
return chunks.map((c) => `data: ${typeof c === "string" ? c : JSON.stringify(c)}\n\n`).join("");
|
||||
}
|
||||
|
||||
async function pipe(input, transform) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(input));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = stream.pipeThrough(transform).getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return text + decoder.decode();
|
||||
}
|
||||
|
||||
function completedEvent(text) {
|
||||
const m = text.match(/event: response\.completed\ndata: (.+)\n/);
|
||||
return m ? JSON.parse(m[1]) : null;
|
||||
}
|
||||
|
||||
describe("toResponsesUsage", () => {
|
||||
it("maps OpenAI usage (nested cached_tokens) to the Responses shape", () => {
|
||||
expect(toResponsesUsage(QODER_FINISH_CHUNK.usage)).toEqual({
|
||||
input_tokens: 27_339,
|
||||
output_tokens: 437,
|
||||
total_tokens: 27_776,
|
||||
input_tokens_details: { cached_tokens: 27_200 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts canonical flat fields and Claude-style cache fields", () => {
|
||||
expect(toResponsesUsage({ prompt_tokens: 10, completion_tokens: 2, cached_tokens: 4, reasoning_tokens: 1 })).toMatchObject({
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
total_tokens: 12,
|
||||
input_tokens_details: { cached_tokens: 4 },
|
||||
output_tokens_details: { reasoning_tokens: 1 },
|
||||
});
|
||||
expect(toResponsesUsage({ input_tokens: 5, output_tokens: 1, cache_read_input_tokens: 3 }).input_tokens_details.cached_tokens).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps the estimated marker and returns null for empty usage", () => {
|
||||
expect(toResponsesUsage({ prompt_tokens: 1, completion_tokens: 1, estimated: true }).estimated).toBe(true);
|
||||
expect(toResponsesUsage({})).toBeNull();
|
||||
expect(toResponsesUsage(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai → openai-responses translator", () => {
|
||||
it("puts usage from the finish chunk on response.completed", () => {
|
||||
const state = initState(FORMATS.OPENAI_RESPONSES);
|
||||
const events = openaiToOpenAIResponsesResponse(QODER_FINISH_CHUNK, state);
|
||||
const completed = events.find((e) => e.event === "response.completed");
|
||||
expect(completed).toBeTruthy();
|
||||
expect(completed.data.response.usage).toEqual({
|
||||
input_tokens: 27_339,
|
||||
output_tokens: 437,
|
||||
total_tokens: 27_776,
|
||||
input_tokens_details: { cached_tokens: 27_200 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits usage when the upstream never reported any", () => {
|
||||
const state = initState(FORMATS.OPENAI_RESPONSES);
|
||||
const events = openaiToOpenAIResponsesResponse({ ...QODER_FINISH_CHUNK, usage: undefined }, state);
|
||||
const completed = events.find((e) => e.event === "response.completed");
|
||||
expect(completed.data.response.usage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stream.js translate mode: chat upstream → Responses client", () => {
|
||||
const transform = () => createSSETransformStreamWithLogger(
|
||||
FORMATS.OPENAI, // provider (Qoder executor emits OpenAI chunks)
|
||||
FORMATS.OPENAI_RESPONSES, // client
|
||||
"qoder",
|
||||
null,
|
||||
null,
|
||||
"qmodel_38max",
|
||||
null,
|
||||
{ model: "qd/qmodel_38max", messages: [{ role: "user", content: "hi" }] },
|
||||
);
|
||||
|
||||
it("emits provider usage (+buffer) with cached tokens on response.completed", async () => {
|
||||
const out = await pipe(sse([
|
||||
{ ...QODER_FINISH_CHUNK, choices: [{ index: 0, delta: { role: "assistant", content: "Hello" }, finish_reason: null }], usage: undefined },
|
||||
QODER_FINISH_CHUNK,
|
||||
"[DONE]",
|
||||
]), transform());
|
||||
|
||||
const completed = completedEvent(out);
|
||||
expect(completed).toBeTruthy();
|
||||
expect(completed.response.usage).toEqual({
|
||||
input_tokens: 27_339 + BUFFER_TOKENS,
|
||||
output_tokens: 437,
|
||||
total_tokens: 27_776 + BUFFER_TOKENS,
|
||||
input_tokens_details: { cached_tokens: 27_200 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
});
|
||||
// Responses clients terminate on response.completed (no [DONE] sentinel in translate mode)
|
||||
expect(out.indexOf("event: response.completed")).toBeGreaterThan(out.indexOf("event: response.output_item.done"));
|
||||
});
|
||||
|
||||
it("injects estimated usage when the upstream reports none", async () => {
|
||||
const out = await pipe(sse([
|
||||
{ ...QODER_FINISH_CHUNK, choices: [{ index: 0, delta: { role: "assistant", content: "Hello world" }, finish_reason: null }], usage: undefined },
|
||||
{ ...QODER_FINISH_CHUNK, usage: undefined },
|
||||
"[DONE]",
|
||||
]), transform());
|
||||
|
||||
const completed = completedEvent(out);
|
||||
expect(completed.response.usage).toBeTruthy();
|
||||
expect(completed.response.usage.estimated).toBe(true);
|
||||
expect(completed.response.usage.input_tokens).toBeGreaterThan(0);
|
||||
expect(completed.response.usage.output_tokens).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("responsesTransformer (Chat SSE → Codex Responses SSE)", () => {
|
||||
it("forwards finish-chunk usage on response.completed", async () => {
|
||||
const out = await pipe(sse([
|
||||
{ ...QODER_FINISH_CHUNK, choices: [{ index: 0, delta: { role: "assistant", content: "Hello" }, finish_reason: null }], usage: undefined },
|
||||
QODER_FINISH_CHUNK,
|
||||
"[DONE]",
|
||||
]), createResponsesApiTransformStream());
|
||||
|
||||
const completed = completedEvent(out);
|
||||
expect(completed.response.usage).toMatchObject({
|
||||
input_tokens: 27_339,
|
||||
output_tokens: 437,
|
||||
input_tokens_details: { cached_tokens: 27_200 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -203,31 +203,4 @@ describe("openaiToClaudeResponse", () => {
|
||||
limit: 120
|
||||
});
|
||||
});
|
||||
|
||||
it("records usage from a choices:[] frame so the finish chunk can emit it", () => {
|
||||
const state = { toolCalls: new Map() };
|
||||
expect(openaiToClaudeResponse({
|
||||
usage: {
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 30 },
|
||||
},
|
||||
choices: [],
|
||||
}, state)).toBeNull();
|
||||
expect(state.usage).toEqual({
|
||||
input_tokens: 60,
|
||||
output_tokens: 7,
|
||||
cache_read_input_tokens: 30,
|
||||
});
|
||||
|
||||
const events = openaiToClaudeResponse({
|
||||
id: "chatcmpl-qoder-finish",
|
||||
model: "qoder/auto",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
}, state);
|
||||
const delta = events.find((e) => e.type === "message_delta");
|
||||
expect(delta.usage.input_tokens).toBe(60);
|
||||
expect(delta.usage.output_tokens).toBe(7);
|
||||
expect(delta.usage.cache_read_input_tokens).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user