From 14c8118412bffc8f8df7a088fde51d2ffa74fd8f Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 23 Oct 2026 10:54:18 +0200 Subject: [PATCH] feat(ai): carry responseModel on pi-messages terminal events (#11265) This commit makes it so we store the responseModel (i.e. the "phyiscal" model used) on each PiMessage. This allows e.g. Radius to know which physical model was used by virtual models (like `cheap`). It's especially important after we merged https://github.com/earendil-works/pi/pull/11000 (911309429175254550837167baee245d5f9dbcb6), where Pi will drop thinking traces when the model name doesn't match - with this change we can check the physical model instead. --- packages/ai/src/api/pi-messages.ts | 8 ++++++++ packages/ai/src/types.ts | 2 +- packages/ai/test/pi-messages.test.ts | 15 ++++++++++++++- packages/coding-agent/docs/message-types.md | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/api/pi-messages.ts b/packages/ai/src/api/pi-messages.ts index 605194937..83ddc06c7 100644 --- a/packages/ai/src/api/pi-messages.ts +++ b/packages/ai/src/api/pi-messages.ts @@ -74,6 +74,7 @@ export type PiMessagesEvent = reason: Extract; usage: PiMessagesUsage; responseId?: string; + responseModel?: string; providerThinkingLevel?: string; rewrite?: PiMessagesRewriteImpact; } @@ -83,6 +84,7 @@ export type PiMessagesEvent = usage: PiMessagesUsage; errorMessage?: string; responseId?: string; + responseModel?: string; providerThinkingLevel?: string; rewrite?: PiMessagesRewriteImpact; }; @@ -199,6 +201,9 @@ function createEventConverter(model: Model<"pi-messages">) { usage: event.usage, responseId: event.responseId, }); + if (event.responseModel !== undefined) { + partial.responseModel = event.responseModel; + } if (event.providerThinkingLevel !== undefined) { partial.providerThinkingLevel = event.providerThinkingLevel; } @@ -211,6 +216,9 @@ function createEventConverter(model: Model<"pi-messages">) { errorMessage: event.errorMessage, responseId: event.responseId, }); + if (event.responseModel !== undefined) { + partial.responseModel = event.responseModel; + } if (event.providerThinkingLevel !== undefined) { partial.providerThinkingLevel = event.providerThinkingLevel; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index d1fcee314..32b5a5f31 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -588,7 +588,7 @@ export interface AssistantMessage { api: Api; provider: ProviderId; model: string; - responseModel?: string; // Concrete model reported by the provider when different from the requested `model` + responseModel?: string; // Concrete model reported by the provider, when reported. May equal 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; diff --git a/packages/ai/test/pi-messages.test.ts b/packages/ai/test/pi-messages.test.ts index 211c24cb0..54b3d4d9e 100644 --- a/packages/ai/test/pi-messages.test.ts +++ b/packages/ai/test/pi-messages.test.ts @@ -116,6 +116,7 @@ describe("pi-messages", () => { reason: "toolUse", usage, responseId: "resp_1", + responseModel: "claude-opus-5-5", providerThinkingLevel: "high", }, ], @@ -145,6 +146,7 @@ describe("pi-messages", () => { expect(message.responseId).toBe("resp_1"); expect(message.providerThinkingLevel).toBe("high"); expect(message.model).toBe("auto"); + expect(message.responseModel).toBe("claude-opus-5-5"); expect(message.provider).toBe("radius"); expect(message.content).toEqual([ { type: "text", text: "Hello", textSignature: undefined }, @@ -190,6 +192,7 @@ describe("pi-messages", () => { expect(eventModels).toEqual(wireEvents.map(() => model)); expect(message.stopReason).toBe("stop"); expect(message.responseId).toBe("resp_1"); + expect(message).not.toHaveProperty("responseModel"); expect(message.content).toEqual([{ type: "text", text: "Hello", textSignature: undefined }]); }); @@ -234,7 +237,16 @@ describe("pi-messages", () => { it("propagates server-sent error events", async () => { const { baseUrl } = await startServer({ - events: [{ type: "start" }, { type: "error", reason: "error", usage, errorMessage: "Upstream failed" }], + events: [ + { type: "start" }, + { + type: "error", + reason: "error", + usage, + errorMessage: "Upstream failed", + responseModel: "claude-opus-5-5", + }, + ], }); const model = createModel(baseUrl); @@ -243,6 +255,7 @@ describe("pi-messages", () => { expect(message.stopReason).toBe("error"); expect(message.errorMessage).toBe("Upstream failed"); expect(message.usage).toEqual(usage); + expect(message.responseModel).toBe("claude-opus-5-5"); }); it("errors when no API key is provided", async () => { diff --git a/packages/coding-agent/docs/message-types.md b/packages/coding-agent/docs/message-types.md index dfdafd505..6c5d95c63 100644 --- a/packages/coding-agent/docs/message-types.md +++ b/packages/coding-agent/docs/message-types.md @@ -140,7 +140,7 @@ interface AssistantMessage { } ``` -`responseModel` records a concrete provider response model when it differs from the requested model. `responseId`, `providerThinkingLevel`, `thinkingLevel`, `diagnostics`, and `rawStopReason` preserve provider or runtime details. +`responseModel` records the concrete model reported by the provider, when the provider reports one. It may equal the requested model. `responseId`, `providerThinkingLevel`, `thinkingLevel`, `diagnostics`, and `rawStopReason` preserve provider or runtime details. `"pending"` is used for a partial assistant message while it streams. The completed message in `message_end` has a terminal stop reason, and Pi does not persist `"pending"` assistant messages in session JSONL.