From 21d80deda2b0e97c63adacad4dce683514292229 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 18 May 2026 01:08:08 +0200 Subject: [PATCH] fix(ai): normalize opencode go reasoning replay closes #4251 --- packages/ai/CHANGELOG.md | 1 + .../ai/src/providers/openai-completions.ts | 11 +- .../openai-completions-tool-choice.test.ts | 111 +++++++++++++++++- 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 23ff630a..787a2898 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed Xiaomi MiMo model metadata to use the OpenAI-compatible endpoints and `openai-completions` API, restoring multi-turn thinking/tool-call sessions ([#4505](https://github.com/earendil-works/pi/issues/4505)). +- Fixed OpenCode Go Kimi reasoning replay by normalizing streamed `reasoning` fields back to `reasoning_content` for OpenCode Go only ([#4251](https://github.com/earendil-works/pi/issues/4251)). ## [0.75.0] - 2026-05-17 diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index e60b43e8..a89b6dd9 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -326,7 +326,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA if (foundReasoningField) { const delta = deltaFields[foundReasoningField]; if (typeof delta === "string" && delta.length > 0) { - const block = ensureThinkingBlock(foundReasoningField); + const thinkingSignature = + model.provider === "opencode-go" && foundReasoningField === "reasoning" + ? "reasoning_content" + : foundReasoningField; + const block = ensureThinkingBlock(thinkingSignature); block.thinking += delta; stream.push({ type: "thinking_delta", @@ -844,7 +848,10 @@ export function convertMessages( } // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) - const signature = nonEmptyThinkingBlocks[0].thinkingSignature; + let signature = nonEmptyThinkingBlocks[0].thinkingSignature; + if (model.provider === "opencode-go" && signature === "reasoning") { + signature = "reasoning_content"; + } if (signature && signature.length > 0) { (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); } diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 22b7242a..21b5c0d5 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1,8 +1,9 @@ import { Type } from "typebox"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getModel } from "../src/models.js"; +import { convertMessages } from "../src/providers/openai-completions.js"; import { streamSimple } from "../src/stream.js"; -import type { Tool } from "../src/types.js"; +import type { Model, Tool } from "../src/types.js"; const mockState = vi.hoisted(() => ({ lastParams: undefined as unknown, @@ -816,6 +817,114 @@ describe("openai-completions tool_choice", () => { expect(writeCall).not.toHaveProperty("partialArgs"); }); + it("normalizes OpenCode Go reasoning deltas to reasoning_content for replay", async () => { + mockState.chunks = [ + { + id: "chatcmpl-opencode-go-reasoning", + choices: [{ delta: { reasoning: "think" }, finish_reason: "stop" }], + }, + ]; + + const { compat: _compat, ...baseModel } = getModel("opencode-go", "kimi-k2.6")!; + 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.content).toEqual([ + { + type: "thinking", + thinking: "think", + thinkingSignature: "reasoning_content", + }, + ]); + }); + + it("keeps non-OpenCode Go reasoning deltas on the original reasoning field", async () => { + mockState.chunks = [ + { + id: "chatcmpl-reasoning", + choices: [{ delta: { reasoning: "think" }, finish_reason: "stop" }], + }, + ]; + + 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.content).toEqual([ + { + type: "thinking", + thinking: "think", + thinkingSignature: "reasoning", + }, + ]); + }); + + it("replays OpenCode Go reasoning thinking blocks as reasoning_content", () => { + const { compat: _compat, ...baseModel } = getModel("opencode-go", "kimi-k2.6")!; + const model = { ...baseModel, api: "openai-completions" } as Model<"openai-completions">; + const messages = convertMessages( + model, + { + messages: [ + { + role: "assistant", + api: "openai-completions", + provider: "opencode-go", + model: "kimi-k2.6", + content: [ + { type: "thinking", thinking: "think", thinkingSignature: "reasoning" }, + { type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" } }, + ], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ], + }, + { + ...model.compat, + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: true, + supportsUsageInStreaming: true, + maxTokensField: "max_completion_tokens", + requiresToolResultName: false, + requiresAssistantAfterToolResult: false, + requiresThinkingAsText: false, + requiresReasoningContentOnAssistantMessages: false, + thinkingFormat: "openai", + openRouterRouting: {}, + vercelGatewayRouting: {}, + zaiToolStream: false, + supportsStrictMode: true, + sendSessionAffinityHeaders: false, + supportsLongCacheRetention: true, + }, + ); + + expect(messages[0]).toMatchObject({ role: "assistant", reasoning_content: "think" }); + expect(messages[0]).not.toHaveProperty("reasoning"); + }); + it("does not double-count reasoning tokens in completion usage", async () => { mockState.chunks = [ {