mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): drop thinking from other models instead of sending it as text (#11000)
When switching models, thinking blocks from earlier assistant messages produced by a different model were sent as plain text. The new model saw the old model's reasoning as part of its replies and could start writing its own reasoning into the visible answer. These blocks are now dropped; text and tool calls are kept. closes #10995
This commit is contained in:
parent
5ab7fe74c4
commit
9113094291
8 changed files with 116 additions and 74 deletions
|
|
@ -13,6 +13,7 @@
|
|||
### Changed
|
||||
|
||||
- Changed the `kimi-coding` provider to use OpenAI Chat Completions at `https://api.kimi.com/coding/v1`, the protocol used by the official Kimi Code client. K3, K3-256K, and Kimi For Coding now accept tools added mid-conversation without invalidating the prompt cache. `models.json` overrides of the `kimi-coding` base URL must now include `/v1` ([#10720](https://github.com/earendil-works/pi/issues/10720))
|
||||
- Changed model switching to drop thinking blocks from earlier assistant messages produced by a different model, instead of sending them as plain text. The new model previously saw the old model's reasoning as part of its replies and could start writing its own reasoning into the visible answer ([#10995](https://github.com/earendil-works/pi/issues/10995))
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -1498,13 +1498,13 @@ Notes:
|
|||
|
||||
## Cross-Provider Handoffs
|
||||
|
||||
The library supports seamless handoffs between different LLM providers within the same conversation. This allows you to switch models mid-conversation while preserving context, including thinking blocks, tool calls, and tool results.
|
||||
The library supports seamless handoffs between different LLM providers within the same conversation. This allows you to switch models mid-conversation while preserving context, including text, tool calls, and tool results.
|
||||
|
||||
When messages from one provider are sent to a different provider, the library automatically transforms them for compatibility:
|
||||
|
||||
- **User and tool result messages** are passed through unchanged
|
||||
- **Assistant messages from the same provider/API** are preserved as-is
|
||||
- **Assistant messages from different providers** have their thinking blocks converted to text with `<thinking>` tags
|
||||
- **Assistant messages from the same provider, API, and model** are preserved as-is, including thinking blocks
|
||||
- **Assistant messages from a different model** have their thinking blocks dropped. Signatures and encrypted reasoning only work with the model that produced them, and readable thinking sent as plain text makes the new model treat it as part of the reply
|
||||
- **Tool calls and regular text** are preserved unchanged
|
||||
|
||||
```typescript
|
||||
|
|
@ -1525,7 +1525,7 @@ const claude = models.getModel('anthropic', 'claude-sonnet-4-5')!;
|
|||
context.messages.push({ role: 'user', content: 'What is 25 * 18?', timestamp: Date.now() });
|
||||
context.messages.push(await models.completeSimple(claude, context, { reasoning: 'medium' }));
|
||||
|
||||
// Switch to GPT-5 - it will see Claude's thinking as <thinking> tagged text
|
||||
// Switch to GPT-5 - it sees Claude's answer, but not Claude's thinking
|
||||
const gpt5 = models.getModel('openai', 'gpt-5-mini')!;
|
||||
context.messages.push({ role: 'user', content: 'Is that calculation correct?', timestamp: Date.now() });
|
||||
context.messages.push(await models.complete(gpt5, context));
|
||||
|
|
@ -1536,7 +1536,7 @@ context.messages.push({ role: 'user', content: 'What was the original question?'
|
|||
const geminiResponse = await models.complete(gemini, context);
|
||||
```
|
||||
|
||||
All providers can handle messages from other providers — text, tool calls and results (including images), thinking blocks (transformed to tagged text), and aborted messages with partial content. This enables flexible workflows: start with a fast model, switch to a more capable one for complex reasoning, or maintain continuity across provider outages.
|
||||
All providers can handle messages from other providers — text, tool calls and results (including images), thinking blocks (dropped when the model changes), and aborted messages with partial content. This enables flexible workflows: start with a fast model, switch to a more capable one for complex reasoning, or maintain continuity across provider outages.
|
||||
|
||||
## System Messages
|
||||
|
||||
|
|
|
|||
|
|
@ -126,21 +126,15 @@ export function transformMessages<TApi extends Api>(
|
|||
|
||||
const transformedContent = assistantMsg.content.flatMap((block) => {
|
||||
if (block.type === "thinking") {
|
||||
// Redacted thinking is opaque encrypted content, only valid for the same model.
|
||||
// Drop it for cross-model to avoid API errors.
|
||||
if (block.redacted) {
|
||||
return isSameModel ? block : [];
|
||||
}
|
||||
// For same model: keep thinking blocks with signatures (needed for replay)
|
||||
// even if the thinking text is empty (OpenAI encrypted reasoning)
|
||||
if (isSameModel && block.thinkingSignature) return block;
|
||||
// Skip empty thinking blocks, convert others to plain text
|
||||
// Thinking from another model is dropped. Signatures and encrypted reasoning are
|
||||
// only valid for the model that produced them, and sending readable thinking as
|
||||
// plain text makes the new model treat it as part of the visible reply.
|
||||
if (!isSameModel) return [];
|
||||
// Keep redacted and signed blocks even if the thinking text is empty
|
||||
// (OpenAI encrypted reasoning); they are needed for replay.
|
||||
if (block.redacted || block.thinkingSignature) return block;
|
||||
if (!block.thinking || block.thinking.trim() === "") return [];
|
||||
if (isSameModel) return block;
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: block.thinking,
|
||||
};
|
||||
return block;
|
||||
}
|
||||
|
||||
if (block.type === "text") {
|
||||
|
|
|
|||
|
|
@ -122,16 +122,4 @@ describe("Anthropic empty thinking signature compat", () => {
|
|||
{ type: "text", text: "answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression for #9323: opting into unsigned replay must not change cross-model conversion.
|
||||
it("still converts cross-model Fireworks thinking to text", async () => {
|
||||
const model = getModel("fireworks", "accounts/fireworks/models/deepseek-v4p1-flash");
|
||||
const payload = await capturePayload(
|
||||
model,
|
||||
makeContext("", "internal reasoning", "fireworks", "accounts/fireworks/models/nemotron-3-ultra-nvfp4"),
|
||||
);
|
||||
expect(payload.messages?.find((message) => message.role === "assistant")?.content).toEqual([
|
||||
{ type: "text", text: "internal reasoning" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ describe("OpenAI Responses message ID conversion", () => {
|
|||
const assistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "private reasoning" },
|
||||
{ type: "text", text: "first answer" },
|
||||
{ type: "text", text: "visible answer" },
|
||||
],
|
||||
api: "anthropic-messages",
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)(
|
|||
// This tests the scenario where:
|
||||
// 1. Model A (gpt-5-mini) generates reasoning + function_call
|
||||
// 2. User switches to Model B (gpt-5.2-codex) - same provider, different model
|
||||
// 3. transform-messages: isSameModel=false, thinking converted to text
|
||||
// 3. transform-messages: isSameModel=false, thinking dropped
|
||||
// 4. But tool call ID still has OpenAI pairing history (fc_xxx paired with rs_xxx)
|
||||
// 5. Without fix: OpenAI returns 400 "function_call without required reasoning item"
|
||||
// 6. With fix: tool calls/results converted to text, conversation continues
|
||||
|
|
@ -184,7 +184,7 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)(
|
|||
// This tests cross-provider handoff:
|
||||
// 1. Anthropic model generates thinking + function_call (toolu_xxx ID)
|
||||
// 2. User switches to OpenAI Codex
|
||||
// 3. transform-messages: isSameModel=false, thinking converted to text
|
||||
// 3. transform-messages: isSameModel=false, thinking dropped
|
||||
// 4. Tool call ID is Anthropic format (toolu_xxx), no OpenAI pairing history
|
||||
// 5. Should work because foreign IDs have no pairing expectation
|
||||
|
||||
|
|
|
|||
|
|
@ -47,46 +47,6 @@ function makeAssistantMessage(content: AssistantMessage["content"]): AssistantMe
|
|||
}
|
||||
|
||||
describe("OpenAI to Anthropic session migration for Copilot Claude", () => {
|
||||
it("converts thinking blocks to plain text when source model differs", () => {
|
||||
const model = makeCopilotClaudeModel();
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: "hello", timestamp: Date.now() },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "Let me think about this...",
|
||||
thinkingSignature: "reasoning_content",
|
||||
},
|
||||
{ type: "text", text: "Hi there!" },
|
||||
],
|
||||
api: "openai-completions",
|
||||
provider: "github-copilot",
|
||||
model: "gpt-4o",
|
||||
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(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformMessages(messages, model, anthropicNormalizeToolCallId);
|
||||
const assistantMsg = result.find((m) => m.role === "assistant") as AssistantMessage;
|
||||
|
||||
// Thinking block should be converted to text since models differ
|
||||
const textBlocks = assistantMsg.content.filter((b) => b.type === "text");
|
||||
const thinkingBlocks = assistantMsg.content.filter((b) => b.type === "thinking");
|
||||
expect(thinkingBlocks).toHaveLength(0);
|
||||
expect(textBlocks.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("removes thoughtSignature from tool calls when migrating between models", () => {
|
||||
const model = makeCopilotClaudeModel();
|
||||
const messages: Message[] = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { transformMessages } from "../src/api/transform-messages.ts";
|
||||
import type { AssistantMessage, Message, Model } from "../src/types.ts";
|
||||
|
||||
const kimi: Model<"openai-completions"> = {
|
||||
id: "moonshotai/Kimi-K3",
|
||||
name: "Kimi K3",
|
||||
api: "openai-completions",
|
||||
provider: "sference",
|
||||
baseUrl: "https://api.sference.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 32768,
|
||||
};
|
||||
|
||||
function assistant(model: string, content: AssistantMessage["content"]): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
api: "openai-completions",
|
||||
provider: "sference",
|
||||
model,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("thinking from other models", () => {
|
||||
// https://github.com/earendil-works/pi/issues/10995
|
||||
it("does not send another model's readable thinking as text", () => {
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: "Why does curl work but node fails?", timestamp: Date.now() },
|
||||
assistant("zai-org/GLM-5.3", [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "The user asks about ECONNRESET. Candidates: proxy, IPv6, TLS.",
|
||||
thinkingSignature: "reasoning_content",
|
||||
},
|
||||
{ type: "text", text: "Let me check the TLS versions." },
|
||||
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "curl -v" } },
|
||||
]),
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "bash",
|
||||
content: [{ type: "text", text: "TLSv1.2" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformMessages(messages, kimi);
|
||||
const assistantMsg = result.find((m) => m.role === "assistant") as AssistantMessage;
|
||||
|
||||
expect(assistantMsg.content).toEqual([
|
||||
{ type: "text", text: "Let me check the TLS versions." },
|
||||
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "curl -v" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps thinking only from the target model when models alternate", () => {
|
||||
const thinking = (text: string) => ({
|
||||
type: "thinking" as const,
|
||||
thinking: text,
|
||||
thinkingSignature: "reasoning_content",
|
||||
});
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: "1", timestamp: Date.now() },
|
||||
{ ...assistant("moonshotai/Kimi-K3", [thinking("kimi 1"), { type: "text", text: "a" }]), stopReason: "stop" },
|
||||
{ role: "user", content: "2", timestamp: Date.now() },
|
||||
{ ...assistant("zai-org/GLM-5.3", [thinking("glm 1"), { type: "text", text: "b" }]), stopReason: "stop" },
|
||||
{ role: "user", content: "3", timestamp: Date.now() },
|
||||
{ ...assistant("moonshotai/Kimi-K3", [thinking("kimi 2"), { type: "text", text: "c" }]), stopReason: "stop" },
|
||||
{ role: "user", content: "4", timestamp: Date.now() },
|
||||
{ ...assistant("zai-org/GLM-5.3", [thinking("glm 2"), { type: "text", text: "d" }]), stopReason: "stop" },
|
||||
{ role: "user", content: "hi", timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const result = transformMessages(messages, kimi);
|
||||
const contents = result.filter((m) => m.role === "assistant").map((m) => (m as AssistantMessage).content);
|
||||
|
||||
expect(contents).toEqual([
|
||||
[thinking("kimi 1"), { type: "text", text: "a" }],
|
||||
[{ type: "text", text: "b" }],
|
||||
[thinking("kimi 2"), { type: "text", text: "c" }],
|
||||
[{ type: "text", text: "d" }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue