- Coalesce Qoder's empty finish-in-delta frame with the later choices:[] usage frame so OpenAI and Claude clients receive prompt_tokens, completion_tokens and cache-hit tokens (the dashboard already saw them) - Upload inlined images through /api/v2/image/upload like qodercli, and stub oversized non-image files instead of stuffing 30MB+ data URIs into agent_chat_generation - Emit response.completed -> response.usage for chat-native upstreams so /v1/responses clients (Codex CLI, sub2api) no longer log 0/0/0 - Keep Claude message_delta.usage working when usage arrives without choices[0] - Escalate to the smallest advertised Qoder context tier (200K/400K/1M) when the estimated prompt no longer fits max_input_tokens - Pass apiKey for PAT connections and list hidden enable:false catalog keys from /v1/models
183 lines
7.2 KiB
JavaScript
183 lines
7.2 KiB
JavaScript
/**
|
|
* 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 },
|
|
});
|
|
});
|
|
});
|