mirror of
https://github.com/fosrl/pangolin.git
synced 2026-11-09 10:51:53 -06:00
extractOpenAiResponses only took response.usage from response.completed, so a stream cut by max_output_tokens (response.incomplete) lost its usage and fell back to estimateUsage over the whole SSE text, over-counting cost and budget. Read response.usage from any event that carries it.
137 lines
4.6 KiB
TypeScript
137 lines
4.6 KiB
TypeScript
import { extractUsage } from "./aiUsageExtraction";
|
|
import { assertEqualsObj } from "@test/assert";
|
|
|
|
// Bedrock reports `inputTokens` as the NON-cached input tokens when prompt
|
|
// caching is on; cache reads/writes are separate counters. So `promptTokens`
|
|
// (input billed at the normal rate) must equal `inputTokens` as-is.
|
|
|
|
const headers = new Headers();
|
|
|
|
function testConverseJsonWithCache() {
|
|
console.log("Running Bedrock Converse JSON cache usage test...");
|
|
const body = JSON.stringify({
|
|
usage: {
|
|
inputTokens: 100,
|
|
outputTokens: 20,
|
|
totalTokens: 1120,
|
|
cacheReadInputTokens: 800,
|
|
cacheWriteInputTokens: 200
|
|
}
|
|
});
|
|
assertEqualsObj(
|
|
extractUsage("bedrock_converse", body, false, headers),
|
|
{
|
|
promptTokens: 100,
|
|
cacheReadTokens: 800,
|
|
cacheWriteTokens: 200,
|
|
completionTokens: 20,
|
|
reasoningTokens: 0,
|
|
estimated: false
|
|
},
|
|
"cache reads must not be subtracted from inputTokens"
|
|
);
|
|
}
|
|
|
|
function testConverseStreamWithCache() {
|
|
console.log("Running Bedrock Converse stream cache usage test...");
|
|
// converse-stream frames the metadata event in binary envelope; the JSON
|
|
// text survives inside it, so it is not valid JSON as a whole.
|
|
const body =
|
|
'\u0000\u0001{"metrics":{"latencyMs":5},"usage":{"cacheReadInputTokens":800,' +
|
|
'"cacheWriteInputTokens":0,"inputTokens":100,"outputTokens":20,"totalTokens":920}}\u0002';
|
|
assertEqualsObj(
|
|
extractUsage("bedrock_converse", body, true, headers),
|
|
{
|
|
promptTokens: 100,
|
|
cacheReadTokens: 800,
|
|
cacheWriteTokens: 0,
|
|
completionTokens: 20,
|
|
reasoningTokens: 0,
|
|
estimated: false
|
|
},
|
|
"streamed cache reads must not be subtracted from inputTokens"
|
|
);
|
|
}
|
|
|
|
function testConverseWithoutCache() {
|
|
console.log("Running Bedrock Converse no-cache usage test...");
|
|
const body = JSON.stringify({
|
|
usage: { inputTokens: 50, outputTokens: 7, totalTokens: 57 }
|
|
});
|
|
assertEqualsObj(
|
|
extractUsage("bedrock_converse", body, false, headers),
|
|
{
|
|
promptTokens: 50,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
completionTokens: 7,
|
|
reasoningTokens: 0,
|
|
estimated: false
|
|
},
|
|
"uncached usage is unchanged"
|
|
);
|
|
}
|
|
|
|
// Gemini reports tool-result tokens (Google Search, URL context) in
|
|
// `toolUsePromptTokenCount`, separately from `promptTokenCount`; they are
|
|
// billed as input.
|
|
function testGeminiToolUsePromptTokens() {
|
|
console.log("Running Gemini tool-use prompt tokens test...");
|
|
const body = JSON.stringify({
|
|
usageMetadata: {
|
|
promptTokenCount: 100,
|
|
cachedContentTokenCount: 20,
|
|
toolUsePromptTokenCount: 40,
|
|
candidatesTokenCount: 15,
|
|
thoughtsTokenCount: 5,
|
|
totalTokenCount: 160
|
|
}
|
|
});
|
|
assertEqualsObj(
|
|
extractUsage("gemini_generate_content", body, false, headers),
|
|
{
|
|
promptTokens: 120,
|
|
cacheReadTokens: 20,
|
|
cacheWriteTokens: 0,
|
|
completionTokens: 15,
|
|
reasoningTokens: 5,
|
|
estimated: false
|
|
},
|
|
"tool-use prompt tokens are added to promptTokens"
|
|
);
|
|
}
|
|
|
|
// A Responses API stream that stops early (max_output_tokens) ends with
|
|
// `response.incomplete`, which carries the same `response.usage` as
|
|
// `response.completed`.
|
|
function testOpenAiResponsesIncompleteStream() {
|
|
console.log("Running OpenAI Responses incomplete stream test...");
|
|
const body =
|
|
'data: {"type":"response.output_text.delta","delta":"Hi"}\n\n' +
|
|
'data: {"type":"response.incomplete","response":{"status":"incomplete","usage":{"input_tokens":50,"input_tokens_details":{"cached_tokens":10},"output_tokens":30,"output_tokens_details":{"reasoning_tokens":5}}}}\n\n';
|
|
assertEqualsObj(
|
|
extractUsage("openai_responses", body, true, headers),
|
|
{
|
|
promptTokens: 40,
|
|
cacheReadTokens: 10,
|
|
cacheWriteTokens: 0,
|
|
completionTokens: 25,
|
|
reasoningTokens: 5,
|
|
estimated: false
|
|
},
|
|
"usage is read from response.incomplete"
|
|
);
|
|
}
|
|
|
|
try {
|
|
testConverseJsonWithCache();
|
|
testConverseStreamWithCache();
|
|
testConverseWithoutCache();
|
|
testGeminiToolUsePromptTokens();
|
|
testOpenAiResponsesIncompleteStream();
|
|
console.log("All aiUsageExtraction tests passed!");
|
|
} catch (error) {
|
|
console.error("Test failed:", error);
|
|
process.exit(1);
|
|
}
|