mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
3b447d07b9
commit
7ab7143372
3 changed files with 93 additions and 1 deletions
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue