fix(ai): avoid double-counting reasoning tokens

closes #3581
This commit is contained in:
Mario Zechner
2026-04-23 23:28:16 +02:00
parent ffa0f31239
commit c681d35d76
3 changed files with 48 additions and 10 deletions

View File

@@ -8,6 +8,8 @@
### Fixed
- Fixed OpenAI-compatible completion usage parsing to stop double-counting reasoning tokens already included in `completion_tokens` ([#3581](https://github.com/badlogic/pi-mono/issues/3581))
- Fixed long cache retention compatibility by adding `compat.supportsLongCacheRetention`, allowing Anthropic Messages and OpenAI-compatible proxies to explicitly disable long-retention fields while enabling long retention by default when requested ([#3543](https://github.com/badlogic/pi-mono/issues/3543))
- Fixed `openai-responses` compatibility by adding `compat.sendSessionIdHeader: false`, allowing strict OpenAI-compatible proxies to omit the underscore-containing `session_id` header while still sending other session-affinity headers ([#3579](https://github.com/badlogic/pi-mono/issues/3579))
- Fixed `anthropic-messages` tool streaming compatibility by adding `compat.supportsEagerToolInputStreaming`, allowing Anthropic-compatible providers to omit per-tool `eager_input_streaming` and use the legacy fine-grained tool streaming beta header instead ([#3575](https://github.com/badlogic/pi-mono/issues/3575))
- Fixed `supportsXhigh()` to recognize `openai-codex` `gpt-5.5`, preserving `xhigh` reasoning requests instead of clamping them to `high`.

View File

@@ -465,15 +465,18 @@ function buildParams(
cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention),
) {
const messages = convertMessages(model, context, compat);
const cacheControl = getCompatCacheControl(model, compat, cacheRetention);
const cacheControl = getCompatCacheControl(compat, cacheRetention);
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: model.id,
messages,
stream: true,
prompt_cache_key:
model.baseUrl.includes("api.openai.com") && cacheRetention !== "none" ? options?.sessionId : undefined,
prompt_cache_retention: model.baseUrl.includes("api.openai.com") && cacheRetention === "long" ? "24h" : undefined,
(model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") ||
(cacheRetention === "long" && compat.supportsLongCacheRetention)
? options?.sessionId
: undefined,
prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined,
};
if (compat.supportsUsageInStreaming !== false) {
@@ -565,7 +568,6 @@ function mapReasoningEffort(
}
function getCompatCacheControl(
model: Model<"openai-completions">,
compat: ResolvedOpenAICompletionsCompat,
cacheRetention: CacheRetention,
): OpenAICompatCacheControl | undefined {
@@ -573,7 +575,7 @@ function getCompatCacheControl(
return undefined;
}
const ttl = cacheRetention === "long" && model.baseUrl.includes("api.anthropic.com") ? "1h" : undefined;
const ttl = cacheRetention === "long" && compat.supportsLongCacheRetention ? "1h" : undefined;
return { type: "ephemeral", ...(ttl ? { ttl } : {}) };
}
@@ -937,14 +939,12 @@ function parseChunkUsage(
prompt_tokens?: number;
completion_tokens?: number;
prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
completion_tokens_details?: { reasoning_tokens?: number };
},
model: Model<"openai-completions">,
): AssistantMessage["usage"] {
const promptTokens = rawUsage.prompt_tokens || 0;
const reportedCachedTokens = rawUsage.prompt_tokens_details?.cached_tokens || 0;
const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0;
const reasoningTokens = rawUsage.completion_tokens_details?.reasoning_tokens || 0;
// Normalize to pi-ai semantics:
// - cacheRead: hits from cache created by previous requests only
@@ -955,9 +955,8 @@ function parseChunkUsage(
cacheWriteTokens > 0 ? Math.max(0, reportedCachedTokens - cacheWriteTokens) : reportedCachedTokens;
const input = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens);
// Compute totalTokens ourselves since we add reasoning_tokens to output
// and some providers (e.g., Groq) don't include them in total_tokens
const outputTokens = (rawUsage.completion_tokens || 0) + reasoningTokens;
// OpenAI completion_tokens already includes reasoning_tokens.
const outputTokens = rawUsage.completion_tokens || 0;
const usage: AssistantMessage["usage"] = {
input,
output: outputTokens,
@@ -1055,6 +1054,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
supportsStrictMode: true,
cacheControlFormat,
sendSessionAffinityHeaders: false,
supportsLongCacheRetention: true,
};
}
@@ -1084,5 +1084,6 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat,
sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders,
supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention,
};
}

View File

@@ -552,6 +552,41 @@ describe("openai-completions tool_choice", () => {
expect(toolCall).not.toHaveProperty("partialArgs");
});
it("does not double-count reasoning tokens in completion usage", async () => {
mockState.chunks = [
{
id: "chatcmpl-reasoning-usage",
choices: [{ delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 10,
completion_tokens: 33,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 21 },
},
},
];
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions" } as const;
const response = await streamSimple(
model,
{
messages: [
{
role: "user",
content: "Use reasoning.",
timestamp: Date.now(),
},
],
},
{ apiKey: "test" },
).result();
expect(response.usage.input).toBe(10);
expect(response.usage.output).toBe(33);
expect(response.usage.totalTokens).toBe(43);
});
it("preserves prompt_tokens_details.cache_write_tokens from chunk usage", async () => {
mockState.chunks = [
{