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 () => {
|
it("drops empty blocks that carry no metadata", async () => {
|
||||||
const registry = makeRegistry(
|
const registry = makeRegistry(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,12 @@ export class RealModelRegistry implements IModelRegistry {
|
||||||
let finishReason = "unknown";
|
let finishReason = "unknown";
|
||||||
let usage: z.infer<typeof TurnUsage> = {};
|
let usage: z.infer<typeof TurnUsage> = {};
|
||||||
let providerMetadata: JsonValue | undefined;
|
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 = (
|
const tagPart = (
|
||||||
part: TextContentPart | ReasoningContentPart,
|
part: TextContentPart | ReasoningContentPart,
|
||||||
|
|
@ -302,6 +308,10 @@ export class RealModelRegistry implements IModelRegistry {
|
||||||
finishReason = event.finishReason ?? "unknown";
|
finishReason = event.finishReason ?? "unknown";
|
||||||
usage = mapUsage(event.usage);
|
usage = mapUsage(event.usage);
|
||||||
providerMetadata = toJsonValue(event.providerMetadata);
|
providerMetadata = toJsonValue(event.providerMetadata);
|
||||||
|
messageProviderOptions = mergeProviderOptions(
|
||||||
|
messageProviderOptions,
|
||||||
|
event.providerMetadata,
|
||||||
|
);
|
||||||
yield {
|
yield {
|
||||||
type: "step_event",
|
type: "step_event",
|
||||||
event: {
|
event: {
|
||||||
|
|
@ -339,6 +349,9 @@ export class RealModelRegistry implements IModelRegistry {
|
||||||
message: {
|
message: {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: content.length > 0 ? content : "",
|
content: content.length > 0 ? content : "",
|
||||||
|
...(messageProviderOptions === undefined
|
||||||
|
? {}
|
||||||
|
: { providerOptions: messageProviderOptions }),
|
||||||
},
|
},
|
||||||
finishReason,
|
finishReason,
|
||||||
usage,
|
usage,
|
||||||
|
|
|
||||||
|
|
@ -1299,7 +1299,22 @@ function parseToolAdditions(
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
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 {
|
function outcomeFromTerminal(state: TurnState): TurnOutcome {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue