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:
LLL
2026-09-10 22:06:49 +07:00
committed by decolua
parent 832a34659e
commit 1f10f9e5c4
19 changed files with 1615 additions and 121 deletions

View File

@@ -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}`);
});
});