From 7ab7143372c18bdaf128af2cf64ea7d3528f007b Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:34:16 +0530 Subject: [PATCH] fix(x): round-trip signed reasoning via message-level providerOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thorough-effort Anthropic runs through the gateway failed on the tool loop: Bedrock rejected the echoed thinking block ("Invalid `signature` in `thinking` block"). OpenRouter streams reasoning_details as per-delta FRAGMENTS on reasoning events — the part-level capture kept only the last fragment (unsigned) — while the fully accumulated, signed array rides the finish event's providerMetadata, and OpenRouter gives message-level reasoning_details precedence on read-back. The bridge now attaches finish-step providerMetadata to the assistant message as message-level providerOptions (restoring parity with the legacy StreamStepMessageBuilder, which always did this); convertFromMessages already echoes it. The authoritative signed array wins over the per-part fragments, which providers then ignore. Also persist provider failure detail on turn_failed: errorMessage() appends the API error's status code and responseBody (bounded to 2KB) — "Failed after 3 attempts" alone was undebuggable from the turn log. Co-Authored-By: Claude Fable 5 --- .../turns/bridges/real-model-registry.test.ts | 64 +++++++++++++++++++ .../src/turns/bridges/real-model-registry.ts | 13 ++++ apps/x/packages/core/src/turns/runtime.ts | 17 ++++- 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts b/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts index 0f3ef353..26de134c 100644 --- a/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts +++ b/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts @@ -243,6 +243,70 @@ describe("RealModelRegistry", () => { ]); }); + it("attaches finish-step metadata as message-level providerOptions (OpenRouter signed reasoning)", async () => { + // OpenRouter streams per-delta reasoning_details FRAGMENTS on + // reasoning events, but puts the fully accumulated, signed array on + // the finish event's providerMetadata — and its read-back gives + // message-level reasoning_details precedence over per-part ones. + // The message-level attachment is what round-trips thinking + // signatures through tool loops (Bedrock rejects unsigned blocks). + const signedDetails = [ + { + type: "reasoning.text", + text: "full thought", + format: "anthropic-claude-v1", + index: 0, + signature: "sig-full", + }, + ]; + const registry = makeRegistry( + [ + { type: "reasoning-start" }, + { + type: "reasoning-delta", + text: "full ", + providerMetadata: { openrouter: { reasoning_details: [{ type: "reasoning.text", text: "full " }] } }, + }, + { + type: "reasoning-delta", + text: "thought", + providerMetadata: { openrouter: { reasoning_details: [{ type: "reasoning.text", text: "thought" }] } }, + }, + { type: "reasoning-end" }, + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { + type: "finish-step", + finishReason: "tool-calls", + usage: {}, + providerMetadata: { openrouter: { reasoning_details: signedDetails, usage: { cost: 1 } } }, + }, + ], + [], + ); + const events = await collect(registry, request()); + const completed = events[events.length - 1]; + expect( + completed.type === "completed" ? completed.message.providerOptions : undefined, + ).toEqual({ + openrouter: { reasoning_details: signedDetails, usage: { cost: 1 } }, + }); + }); + + it("echoes message-level providerOptions through encodeMessages", async () => { + const registry = makeRegistry([], []); + const model = await registry.resolve({ provider: "openai", model: "gpt-test" }); + const encoded = model.encodeMessages([ + { + role: "assistant", + content: [{ type: "text", text: "done" }], + providerOptions: { openrouter: { reasoning_details: [{ type: "reasoning.text", text: "t", signature: "s" }] } }, + }, + ] as never) as Array<{ role: string; providerOptions?: unknown }>; + expect(encoded[0].providerOptions).toEqual({ + openrouter: { reasoning_details: [{ type: "reasoning.text", text: "t", signature: "s" }] }, + }); + }); + it("drops empty blocks that carry no metadata", async () => { const registry = makeRegistry( [ diff --git a/apps/x/packages/core/src/turns/bridges/real-model-registry.ts b/apps/x/packages/core/src/turns/bridges/real-model-registry.ts index c7fa5348..11897908 100644 --- a/apps/x/packages/core/src/turns/bridges/real-model-registry.ts +++ b/apps/x/packages/core/src/turns/bridges/real-model-registry.ts @@ -184,6 +184,12 @@ export class RealModelRegistry implements IModelRegistry { let finishReason = "unknown"; let usage: z.infer = {}; let providerMetadata: JsonValue | undefined; + // finish-step metadata also rides the assistant message as + // message-level providerOptions: providers put whole-response + // round-trip state there (OpenRouter's accumulated reasoning_details + // with thinking signatures — message-level wins over per-part + // fragments on read-back), and convertFromMessages echoes it. + let messageProviderOptions: PartProviderOptions | undefined; const tagPart = ( part: TextContentPart | ReasoningContentPart, @@ -302,6 +308,10 @@ export class RealModelRegistry implements IModelRegistry { finishReason = event.finishReason ?? "unknown"; usage = mapUsage(event.usage); providerMetadata = toJsonValue(event.providerMetadata); + messageProviderOptions = mergeProviderOptions( + messageProviderOptions, + event.providerMetadata, + ); yield { type: "step_event", event: { @@ -339,6 +349,9 @@ export class RealModelRegistry implements IModelRegistry { message: { role: "assistant", content: content.length > 0 ? content : "", + ...(messageProviderOptions === undefined + ? {} + : { providerOptions: messageProviderOptions }), }, finishReason, usage, diff --git a/apps/x/packages/core/src/turns/runtime.ts b/apps/x/packages/core/src/turns/runtime.ts index 08d9120e..d027e385 100644 --- a/apps/x/packages/core/src/turns/runtime.ts +++ b/apps/x/packages/core/src/turns/runtime.ts @@ -1299,7 +1299,22 @@ function parseToolAdditions( } function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); + const message = error instanceof Error ? error.message : String(error); + // Provider API errors carry the actual upstream failure in responseBody + // (AI SDK APICallError; RetryError wraps the last one as lastError). + // "Failed after 3 attempts" alone is undebuggable — persist the payload, + // bounded so request events stay reference-sized. + const source = (error as { lastError?: unknown }).lastError ?? error; + const statusCode = (source as { statusCode?: unknown }).statusCode; + const responseBody = (source as { responseBody?: unknown }).responseBody; + const details: string[] = []; + if (typeof statusCode === "number") { + details.push(`status ${statusCode}`); + } + if (typeof responseBody === "string" && responseBody.trim().length > 0) { + details.push(responseBody.slice(0, 2000)); + } + return details.length > 0 ? `${message} [${details.join(" — ")}]` : message; } function outcomeFromTerminal(state: TurnState): TurnOutcome {