fix(qoder): report usage to all clients and stop inlining large attachments
- 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
This commit is contained in:
182
tests/unit/openai-responses-usage.test.js
Normal file
182
tests/unit/openai-responses-usage.test.js
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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,4 +203,31 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
193
tests/unit/qoder-context-tier.test.js
Normal file
193
tests/unit/qoder-context-tier.test.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Qoder context-window tiers + routable model listing.
|
||||
*
|
||||
* The Qoder IDE lets a user pick 200K / 400K / 1M for a model; qodercli-style
|
||||
* requests (what 9router sends) only carry the default max_input_tokens. These
|
||||
* tests pin the escalation policy and the payload fields the IDE writes.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
parseTierTokenCount,
|
||||
getQoderContextTiers,
|
||||
estimateQoderPromptTokens,
|
||||
resolveQoderContextTier,
|
||||
applyQoderContextTier,
|
||||
} from "../../open-sse/shared/qoder/contextTier.js";
|
||||
import { routableQoderModels } from "../../open-sse/services/qoderModels.js";
|
||||
|
||||
// Shape mirrors the live /algo/api/v2/model/list entry for qmodel_38max.
|
||||
const MODEL_CONFIG = {
|
||||
key: "qmodel_38max",
|
||||
display_name: "Qwen3.8-Max",
|
||||
is_reasoning: true,
|
||||
max_input_tokens: 180_000,
|
||||
max_output_tokens: 32_768,
|
||||
context_config: [
|
||||
{ name: "200K", tokenCount: 200_000, isDefault: true },
|
||||
{ name: "400K", tokenCount: 400_000, isDefault: false },
|
||||
{ name: "1M", tokenCount: 1_000_000, isDefault: false },
|
||||
],
|
||||
};
|
||||
|
||||
function promptOfTokens(n) {
|
||||
// ~4 ASCII chars per token
|
||||
return { system: "", messages: [{ role: "user", content: "abcd".repeat(n) }], tools: [] };
|
||||
}
|
||||
|
||||
describe("parseTierTokenCount", () => {
|
||||
it("accepts numbers and K/M suffixed strings", () => {
|
||||
expect(parseTierTokenCount(204800)).toBe(204800);
|
||||
expect(parseTierTokenCount("200K")).toBe(200_000);
|
||||
expect(parseTierTokenCount("1M")).toBe(1_000_000);
|
||||
expect(parseTierTokenCount("1.5m")).toBe(1_500_000);
|
||||
expect(parseTierTokenCount("131072")).toBe(131072);
|
||||
});
|
||||
|
||||
it("returns 0 for garbage", () => {
|
||||
expect(parseTierTokenCount(null)).toBe(0);
|
||||
expect(parseTierTokenCount("big")).toBe(0);
|
||||
expect(parseTierTokenCount(-5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQoderContextTiers", () => {
|
||||
it("sorts tiers ascending and keeps the default flag", () => {
|
||||
const tiers = getQoderContextTiers({
|
||||
context_config: [
|
||||
{ name: "1M", tokenCount: 1_000_000 },
|
||||
{ name: "200K", tokenCount: 200_000, isDefault: true },
|
||||
],
|
||||
});
|
||||
expect(tiers.map((t) => t.tokenCount)).toEqual([200_000, 1_000_000]);
|
||||
expect(tiers[0].isDefault).toBe(true);
|
||||
expect(tiers[1].isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it("understands camelCase / snake_case variants and derives names", () => {
|
||||
const tiers = getQoderContextTiers({
|
||||
contextConfig: [{ token_count: "400K", is_default: true }, { max_input_tokens: 1_000_000 }],
|
||||
});
|
||||
expect(tiers).toEqual([
|
||||
{ name: "400K", tokenCount: 400_000, isDefault: true },
|
||||
{ name: "1M", tokenCount: 1_000_000, isDefault: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] when the model has no tiers", () => {
|
||||
expect(getQoderContextTiers({ max_input_tokens: 131072 })).toEqual([]);
|
||||
expect(getQoderContextTiers(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateQoderPromptTokens", () => {
|
||||
it("counts CJK characters as ~1 token each instead of chars/4", () => {
|
||||
const ascii = estimateQoderPromptTokens({ messages: [{ role: "user", content: "a".repeat(4000) }] });
|
||||
const cjk = estimateQoderPromptTokens({ messages: [{ role: "user", content: "中".repeat(4000) }] });
|
||||
expect(ascii).toBeLessThan(1_200);
|
||||
expect(cjk).toBeGreaterThan(4_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveQoderContextTier (auto)", () => {
|
||||
it("leaves the payload untouched while the prompt fits the current max_input_tokens", () => {
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(50_000))).toBeNull();
|
||||
});
|
||||
|
||||
it("escalates to the smallest tier that fits once the prompt outgrows the default", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(250_000));
|
||||
expect(choice).not.toBeNull();
|
||||
expect(choice.tier.name).toBe("400K");
|
||||
expect(choice.reason).toBe("auto:fits");
|
||||
expect(choice.estimatedTokens).toBeGreaterThan(240_000);
|
||||
});
|
||||
|
||||
it("falls back to the largest tier when nothing fits (upstream decides)", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(1_200_000));
|
||||
expect(choice.tier.name).toBe("1M");
|
||||
expect(choice.reason).toBe("auto:largest");
|
||||
});
|
||||
|
||||
it("applies headroom so a prompt just under the limit still escalates", () => {
|
||||
// 170K estimated * 1.15 = 195.5K > 180K current → smallest tier above the current limit (200K)
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(170_000))?.tier.name).toBe("200K");
|
||||
// 190K * 1.15 = 218.5K → 200K no longer fits → 400K
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(190_000))?.tier.name).toBe("400K");
|
||||
});
|
||||
|
||||
it("returns null for models without context_config", () => {
|
||||
expect(resolveQoderContextTier({ max_input_tokens: 131072 }, promptOfTokens(500_000))).toBeNull();
|
||||
});
|
||||
|
||||
it("never escalates when the current limit is already the largest tier", () => {
|
||||
const cfg = { ...MODEL_CONFIG, max_input_tokens: 1_000_000 };
|
||||
expect(resolveQoderContextTier(cfg, promptOfTokens(1_500_000))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveQoderContextTier (forced via QODER_CONTEXT_TIER)", () => {
|
||||
it("max picks the largest tier regardless of prompt size", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "max" });
|
||||
expect(choice.tier.name).toBe("1M");
|
||||
expect(choice.reason).toBe("forced:max");
|
||||
});
|
||||
|
||||
it("default picks the isDefault tier", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "default" });
|
||||
expect(choice.tier.name).toBe("200K");
|
||||
});
|
||||
|
||||
it("a tier name or token count selects that tier", () => {
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "400k" }).tier.tokenCount).toBe(400_000);
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "1000000" }).tier.name).toBe("1M");
|
||||
});
|
||||
|
||||
it("an unknown tier name falls back to auto", () => {
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "9M" })).toBeNull();
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(250_000), { preference: "9M" }).tier.name).toBe("400K");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyQoderContextTier", () => {
|
||||
it("mirrors the tier into the three places the IDE writes", () => {
|
||||
const payload = {
|
||||
parameters: { max_tokens: 32_768 },
|
||||
chat_context: { extra: { context: [], modelConfig: { key: "qmodel_38max" } } },
|
||||
model_config: { ...MODEL_CONFIG },
|
||||
};
|
||||
applyQoderContextTier(payload, { name: "1M", tokenCount: 1_000_000 });
|
||||
expect(payload.parameters).toEqual({ max_tokens: 32_768, context_length: 1_000_000 });
|
||||
expect(payload.chat_context.extra.ideModelConfigOverride).toEqual({ max_input_tokens: 1_000_000 });
|
||||
expect(payload.chat_context.extra.modelConfig).toEqual({ key: "qmodel_38max" });
|
||||
expect(payload.model_config.max_input_tokens).toBe(1_000_000);
|
||||
expect(payload.model_config.context_config).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("is a no-op without a tier", () => {
|
||||
const payload = { parameters: { max_tokens: 1 } };
|
||||
expect(applyQoderContextTier(payload, null)).toBe(payload);
|
||||
expect(payload).toEqual({ parameters: { max_tokens: 1 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("routableQoderModels", () => {
|
||||
it("lists visible models first, then hidden (enable:false) catalog keys", () => {
|
||||
const catalog = {
|
||||
models: [{ id: "qmodel_38max", name: "Qwen3.8-Max" }],
|
||||
rawConfigs: new Map([
|
||||
["qmodel_38max", { key: "qmodel_38max", enable: true }],
|
||||
["qfmodel", { key: "qfmodel", enable: false, display_name: "Qwen Fast" }],
|
||||
["dmodel", { key: "dmodel", enable: false }],
|
||||
]),
|
||||
};
|
||||
expect(routableQoderModels(catalog)).toEqual([
|
||||
{ id: "qmodel_38max", name: "Qwen3.8-Max", hidden: false },
|
||||
{ id: "qfmodel", name: "Qwen Fast", hidden: true },
|
||||
{ id: "dmodel", name: "dmodel", hidden: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] for a failed catalog fetch", () => {
|
||||
expect(routableQoderModels(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
* - device flow URL construction
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import crypto from "crypto";
|
||||
|
||||
import { qoderEncodeBody } from "../../src/lib/qoder/encoding.js";
|
||||
@@ -22,6 +22,13 @@ import {
|
||||
} from "../../src/lib/qoder/constants.js";
|
||||
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js";
|
||||
import { __test__ as qoderExecutorInternals } from "../../open-sse/executors/qoder.js";
|
||||
import { canonicalizeQoderUsage } from "../../open-sse/shared/qoder/sse.js";
|
||||
import {
|
||||
rewriteQoderMessageAttachments,
|
||||
clearQoderUploadCache,
|
||||
buildMultipartFile,
|
||||
} from "../../open-sse/shared/qoder/attachments.js";
|
||||
import { qoderInferenceBase } from "../../open-sse/shared/qoder/constants.js";
|
||||
|
||||
// Convenience aliases — tests were originally written against module-level
|
||||
// helpers; the QoderService class wraps them so each test creates its own
|
||||
@@ -431,6 +438,21 @@ describe("normalizeMessages", () => {
|
||||
]);
|
||||
expect(result.messages[0].content).toBe("hi");
|
||||
});
|
||||
|
||||
it("turns leftover file/document blocks into short stubs instead of dropping them", () => {
|
||||
const result = normalizeMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "see" },
|
||||
{ type: "file", file: { filename: "big.pdf", file_data: "data:application/pdf;base64,AAA" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(result.messages[0].content).toContain("see");
|
||||
expect(result.messages[0].content).toContain("big.pdf");
|
||||
expect(result.messages[0].content).not.toContain("AAA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapQoderSSE", () => {
|
||||
@@ -530,4 +552,190 @@ describe("wrapQoderSSE", () => {
|
||||
const wrapped = await wrapQoderSSE(r, "qoder/auto");
|
||||
expect(wrapped).toBe(r);
|
||||
});
|
||||
|
||||
function envelope(body) {
|
||||
return `data: ${JSON.stringify({ statusCodeValue: 200, body })}\n\n`;
|
||||
}
|
||||
|
||||
function parseForwardedChunks(out) {
|
||||
return out
|
||||
.split("\n\n")
|
||||
.map((block) => block.trim())
|
||||
.filter((block) => block.startsWith("data:") && !block.includes("[DONE]"))
|
||||
.map((block) => JSON.parse(block.slice("data:".length).trim()));
|
||||
}
|
||||
|
||||
it("coalesces empty finish-in-delta + usage-only into one OpenAI usage chunk", async () => {
|
||||
const content = JSON.stringify({
|
||||
id: "chatcmpl-qoder-1",
|
||||
created: 1700000000,
|
||||
model: "auto",
|
||||
choices: [{ index: 0, delta: { content: "hi" } }],
|
||||
});
|
||||
const finish = JSON.stringify({
|
||||
id: "chatcmpl-qoder-1",
|
||||
choices: [{ index: 0, delta: { content: "", finish_reason: "stop" } }],
|
||||
});
|
||||
const usage = JSON.stringify({
|
||||
id: "chatcmpl-qoder-1",
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
prompt_tokens_details: { cached_tokens: 40 },
|
||||
},
|
||||
});
|
||||
const wrapped = await wrapQoderSSE(
|
||||
makeResponse([envelope(content) + envelope(finish) + envelope(usage) + envelope("[DONE]")]),
|
||||
"qoder/auto",
|
||||
);
|
||||
const out = await drain(wrapped);
|
||||
expect(out).toContain(`data: ${content}\n\n`);
|
||||
const chunks = parseForwardedChunks(out);
|
||||
const usageChunk = chunks.find((c) => c.usage);
|
||||
expect(usageChunk).toBeDefined();
|
||||
expect(usageChunk.choices[0].finish_reason).toBe("stop");
|
||||
expect(usageChunk.usage.prompt_tokens).toBe(100);
|
||||
expect(usageChunk.usage.completion_tokens).toBe(20);
|
||||
expect(usageChunk.usage.prompt_tokens_details.cached_tokens).toBe(40);
|
||||
expect(chunks.some((c) => Array.isArray(c.choices) && c.choices.length === 0)).toBe(false);
|
||||
expect((out.match(/data: \[DONE\]/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("maps Qoder input_tokens aliases onto prompt_tokens in the coalesced usage chunk", async () => {
|
||||
const finish = JSON.stringify({
|
||||
choices: [{ index: 0, delta: { finish_reason: "stop" } }],
|
||||
});
|
||||
const usage = JSON.stringify({
|
||||
choices: [],
|
||||
usage: {
|
||||
input_tokens: 80,
|
||||
output_tokens: 10,
|
||||
cache_read_input_tokens: 25,
|
||||
},
|
||||
});
|
||||
const wrapped = await wrapQoderSSE(
|
||||
makeResponse([envelope(finish) + envelope(usage)]),
|
||||
"qoder/lite",
|
||||
);
|
||||
const chunks = parseForwardedChunks(await drain(wrapped));
|
||||
const usageChunk = chunks.find((c) => c.usage);
|
||||
expect(usageChunk.usage.prompt_tokens).toBe(80);
|
||||
expect(usageChunk.usage.completion_tokens).toBe(10);
|
||||
expect(usageChunk.usage.prompt_tokens_details.cached_tokens).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalizeQoderUsage", () => {
|
||||
it("returns null for missing or empty usage", () => {
|
||||
expect(canonicalizeQoderUsage(null)).toBeNull();
|
||||
expect(canonicalizeQoderUsage({})).toBeNull();
|
||||
});
|
||||
|
||||
it("copies prompt_tokens_details.cached_tokens through", () => {
|
||||
const out = canonicalizeQoderUsage({
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 5,
|
||||
prompt_tokens_details: { cached_tokens: 12 },
|
||||
});
|
||||
expect(out.prompt_tokens).toBe(50);
|
||||
expect(out.cached_tokens).toBe(12);
|
||||
expect(out.prompt_tokens_details.cached_tokens).toBe(12);
|
||||
expect(out.total_tokens).toBe(55);
|
||||
});
|
||||
});
|
||||
|
||||
describe("qoderInferenceBase", () => {
|
||||
it("sends job tokens to api2 and device tokens to api3", () => {
|
||||
expect(qoderInferenceBase({ accessToken: "jt-abc" })).toContain("api2.qoder.sh");
|
||||
expect(qoderInferenceBase({ accessToken: "dt-abc" })).toContain("api3.qoder.sh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteQoderMessageAttachments", () => {
|
||||
beforeEach(() => clearQoderUploadCache());
|
||||
|
||||
it("uploads data-URI images and keeps only the OSS URL in the message", async () => {
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "see this" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
|
||||
],
|
||||
}];
|
||||
const stats = await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async ({ buffer, mediaType }) => {
|
||||
expect(Buffer.isBuffer(buffer)).toBe(true);
|
||||
expect(mediaType).toBe("image/png");
|
||||
return "https://cdn.qoder.example/img.png";
|
||||
},
|
||||
});
|
||||
expect(messages[0].content).toEqual([
|
||||
{ type: "text", text: "see this" },
|
||||
{ type: "image_url", image_url: { url: "https://cdn.qoder.example/img.png" } },
|
||||
]);
|
||||
expect(JSON.stringify(messages)).not.toContain("AAAA");
|
||||
expect(stats.imageUrls).toEqual(["https://cdn.qoder.example/img.png"]);
|
||||
});
|
||||
|
||||
it("does not re-upload already-hosted http(s) image URLs", async () => {
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }],
|
||||
}];
|
||||
await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async () => {
|
||||
throw new Error("should not upload remote URLs");
|
||||
},
|
||||
});
|
||||
expect(messages[0].content[0].image_url.url).toBe("https://example.com/a.png");
|
||||
});
|
||||
|
||||
it("stubs non-image file blocks instead of inlining bytes", async () => {
|
||||
const pdfB64 = "A".repeat(200);
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "read this" },
|
||||
{ type: "file", file: { filename: "big.pdf", file_data: `data:application/pdf;base64,${pdfB64}` } },
|
||||
],
|
||||
}];
|
||||
await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async () => {
|
||||
throw new Error("should not upload PDFs as images");
|
||||
},
|
||||
});
|
||||
const wire = JSON.stringify(messages);
|
||||
expect(wire).not.toContain(pdfB64);
|
||||
expect(wire).toContain("[file omitted: big.pdf");
|
||||
});
|
||||
|
||||
it("stubs oversized images when OSS upload fails instead of keeping a huge data URI", async () => {
|
||||
const big = "A".repeat(700_000);
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: { url: `data:image/png;base64,${big}` } }],
|
||||
}];
|
||||
await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async () => {
|
||||
throw new Error("upstream 413");
|
||||
},
|
||||
});
|
||||
const wire = JSON.stringify(messages);
|
||||
expect(wire).not.toContain(big);
|
||||
expect(wire).toContain("[file omitted:");
|
||||
expect(Buffer.byteLength(wire, "utf8")).toBeLessThan(4096);
|
||||
});
|
||||
|
||||
it("buildMultipartFile uses the file field name qodercli sends", () => {
|
||||
const { boundary, body } = buildMultipartFile(Buffer.from("hi"), {
|
||||
fileName: "image.png",
|
||||
mediaType: "image/png",
|
||||
});
|
||||
const text = body.toString("latin1");
|
||||
expect(text).toContain(`name="file"`);
|
||||
expect(text).toContain("filename=\"image.png\"");
|
||||
expect(text).toContain(`--${boundary}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user