fix(ai): normalize opencode go reasoning replay

closes #4251
This commit is contained in:
Mario Zechner
2026-05-18 01:08:08 +02:00
parent ceacacaa45
commit 21d80deda2
3 changed files with 120 additions and 3 deletions

View File

@@ -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

View File

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

View File

@@ -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 = [
{