fix(x): round-trip signed reasoning via message-level providerOptions

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 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-10 11:34:16 +05:30
parent 3b447d07b9
commit 7ab7143372
3 changed files with 93 additions and 1 deletions

View file

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

View file

@ -184,6 +184,12 @@ export class RealModelRegistry implements IModelRegistry {
let finishReason = "unknown";
let usage: z.infer<typeof TurnUsage> = {};
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,

View file

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