fix(ai): preserve thinking replay through renamed Anthropic models

Keep the requested model ID on assistant messages and record the
provider-reported model separately, while retaining fallback pricing.

Fixes #9188
This commit is contained in:
David Brailovsky 2026-09-17 10:22:47 +02:00
parent 16235fd935
commit 1283afd0d0
4 changed files with 94 additions and 6 deletions

View file

@ -10,6 +10,7 @@
### Fixed
- Fixed Google Generative AI and Vertex AI using unsupported thinking levels when reasoning is omitted or when model capabilities differ within a Gemini family ([#9455](https://github.com/earendil-works/pi/issues/9455)).
- Fixed Anthropic-compatible relays breaking signed thinking replay when they report a different response model, while preserving fallback pricing ([#9188](https://github.com/earendil-works/pi/issues/9188)).
- Fixed Amazon Bedrock one-hour cache writes being priced at the five-minute rate ([#9457](https://github.com/earendil-works/pi/issues/9457)).
- Added `RetryPolicy.maxAgentDelayMs` support to cap shared assistant retry backoff for summarization calls ([#8826](https://github.com/earendil-works/pi/issues/8826)).
- Fixed quadratic CPU usage when draining buffered `EventStream` events ([#9055](https://github.com/earendil-works/pi/issues/9055)).

View file

@ -603,14 +603,15 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
output.responseId = event.message.id;
const transformations = event.message.input_transformations;
if (Array.isArray(transformations)) inputTransformations = transformations;
output.model = event.message.model;
const responseModel = event.message.model;
if (responseModel !== model.id) output.responseModel = responseModel;
const fallbackCost =
output.model === model.id
responseModel === model.id
? undefined
: model.compat?.allowedFallbackModels?.find(
(fallback) => fallback.provider === model.provider && fallback.model === output.model,
(fallback) => fallback.provider === model.provider && fallback.model === responseModel,
)?.cost;
usageModel = fallbackCost ? { ...model, id: output.model, cost: fallbackCost } : model;
usageModel = fallbackCost ? { ...model, id: responseModel, cost: fallbackCost } : model;
// Capture initial token usage from message_start event
// This ensures we have input token counts even if the stream is aborted early
output.usage.input = event.message.usage.input_tokens || 0;

View file

@ -472,7 +472,7 @@ export interface AssistantMessage {
api: Api;
provider: ProviderId;
model: string;
responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`)
responseModel?: string; // Concrete model reported by the provider when different from the requested `model`
responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one
/** Exact provider-native effort level used for this response. Absent for legacy or unmanaged responses. */
providerThinkingLevel?: string;

View file

@ -2,8 +2,9 @@ import type Anthropic from "@anthropic-ai/sdk";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { transformMessages } from "../src/api/transform-messages.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { ToolCall } from "../src/types.ts";
import type { Model, ToolCall } from "../src/types.ts";
function createSseResponse(events: Array<{ event: string; data: string }>): Response {
const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n");
@ -80,7 +81,92 @@ function createFakeAnthropicClient(response: Response): Anthropic {
} as unknown as Anthropic;
}
type ResponseContentBlock = { type: "thinking"; thinking: string; signature: string } | { type: "text"; text: string };
function createResponseModelSseResponse(model: string, contentBlock: ResponseContentBlock): Response {
return createSseResponse([
{
event: "message_start",
data: JSON.stringify({
type: "message_start",
message: { id: "msg_response_model", model, usage: { input_tokens: 100, output_tokens: 0 } },
}),
},
{
event: "content_block_start",
data: JSON.stringify({ type: "content_block_start", index: 0, content_block: contentBlock }),
},
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) },
{
event: "message_delta",
data: JSON.stringify({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { input_tokens: 100, output_tokens: 20 },
}),
},
{ event: "message_stop", data: JSON.stringify({ type: "message_stop" }) },
]);
}
describe("Anthropic raw SSE parsing", () => {
it("keeps signed thinking replayable when a proxy relabels the model", async () => {
// Regression test for earendil-works/pi#9188.
const model = getModel("anthropic", "claude-opus-5");
const responseModel = "kimi-for-coding";
const initialContext = normalizeContext({
messages: [{ role: "user", content: "Hello", timestamp: 1 }],
});
const first = await streamAnthropic(model, initialContext, {
client: createFakeAnthropicClient(
createResponseModelSseResponse(responseModel, {
type: "thinking",
thinking: "reasoning",
signature: "signature",
}),
),
}).result();
expect(first.model).toBe(model.id);
expect(first.responseModel).toBe(responseModel);
const transformed = transformMessages([...initialContext.messages, first], model);
const replayedAssistant = transformed.find((message) => message.role === "assistant");
expect(replayedAssistant?.content).toEqual([
{ type: "thinking", thinking: "reasoning", thinkingSignature: "signature" },
]);
});
it("uses a returned fallback model for cost attribution", async () => {
const fallbackModel = "fallback-model";
const model: Model<"anthropic-messages"> = {
...getModel("anthropic", "claude-opus-5"),
compat: {
allowedFallbackModels: [
{
provider: "anthropic",
model: fallbackModel,
cost: { input: 3, output: 5, cacheRead: 0, cacheWrite: 0 },
},
],
},
};
const result = await streamAnthropic(
model,
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
{
client: createFakeAnthropicClient(
createResponseModelSseResponse(fallbackModel, { type: "text", text: "done" }),
),
},
).result();
expect(result.model).toBe(model.id);
expect(result.responseModel).toBe(fallbackModel);
expect(result.usage.cost.input).toBeCloseTo(0.0003, 10);
expect(result.usage.cost.output).toBeCloseTo(0.0001, 10);
});
it("fails safely when Anthropic falls back after output begins", async () => {
const model = getModel("anthropic", "claude-opus-5");
const response = createSseResponse([