diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5619050e..709ae712 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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`. diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 2c48b195..e9bc6514 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -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, }; } diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 4c22b84d..d1645aa5 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -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 = [ {