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 {