mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +02:00
fix(x): preserve block-level provider metadata in the turns bridge
The bridge dropped providerMetadata from content-block stream events,
so reasoning parts persisted as bare {type, text}. Anthropic thinking
signatures (which arrive on a reasoning-delta with an empty text delta),
Gemini thoughtSignatures (on text-end / reasoning-end / tool-call), and
OpenAI encrypted reasoning were all lost — breaking multi-step tool
turns whenever extended thinking is enabled, since providers require
signed blocks to be echoed back verbatim within the tool loop.
Now every -start event opens a new part (distinct blocks keep distinct
signatures) and metadata from each event of a block is merged opaquely
onto that part's providerOptions, which convertFromMessages already
echoes verbatim. The bridge never interprets the contents; each
provider reads back only its own keys. Empty blocks with no metadata
are dropped, matching the previous lazy-creation behavior. finish-step
metadata stays top-level as before.
Schemas already allowed providerOptions on all part types, so persisted
files remain readable in both directions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7a0e729703
commit
091f3e3acd
2 changed files with 251 additions and 13 deletions
|
|
@ -138,6 +138,166 @@ describe("RealModelRegistry", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("captures block-level provider metadata opaquely onto parts (Anthropic-style signatures)", async () => {
|
||||||
|
// Anthropic delivers the thinking signature on a reasoning-delta with
|
||||||
|
// an EMPTY text delta, and redacted thinking as metadata on
|
||||||
|
// reasoning-start. Both must land on the right reasoning part, and
|
||||||
|
// distinct blocks must stay distinct parts.
|
||||||
|
const registry = makeRegistry(
|
||||||
|
[
|
||||||
|
{ type: "reasoning-start" },
|
||||||
|
{ type: "reasoning-delta", text: "let me think" },
|
||||||
|
{ type: "reasoning-delta", text: "", providerMetadata: { anthropic: { signature: "sig-1" } } },
|
||||||
|
{ type: "reasoning-end" },
|
||||||
|
{
|
||||||
|
type: "reasoning-start",
|
||||||
|
providerMetadata: { anthropic: { redactedData: "opaque-blob" } },
|
||||||
|
},
|
||||||
|
{ type: "reasoning-end" },
|
||||||
|
{ type: "text-start" },
|
||||||
|
{ type: "text-delta", text: "answer" },
|
||||||
|
{ type: "text-end" },
|
||||||
|
{ type: "finish-step", finishReason: "stop", usage: {} },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const events = await collect(registry, request());
|
||||||
|
const completed = events[events.length - 1];
|
||||||
|
expect(
|
||||||
|
completed.type === "completed" ? completed.message.content : undefined,
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "let me think",
|
||||||
|
providerOptions: { anthropic: { signature: "sig-1" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "",
|
||||||
|
providerOptions: { anthropic: { redactedData: "opaque-blob" } },
|
||||||
|
},
|
||||||
|
{ type: "text", text: "answer" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures metadata on text and tool-call parts (Gemini-style thoughtSignatures)", async () => {
|
||||||
|
const registry = makeRegistry(
|
||||||
|
[
|
||||||
|
{ type: "text-start" },
|
||||||
|
{ type: "text-delta", text: "calling a tool" },
|
||||||
|
{ type: "text-end", providerMetadata: { google: { thoughtSignature: "ts-text" } } },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "tc1",
|
||||||
|
toolName: "echo",
|
||||||
|
input: { x: 1 },
|
||||||
|
providerMetadata: { google: { thoughtSignature: "ts-call" } },
|
||||||
|
},
|
||||||
|
{ type: "finish-step", finishReason: "tool-calls", usage: {} },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const events = await collect(registry, request());
|
||||||
|
const completed = events[events.length - 1];
|
||||||
|
expect(
|
||||||
|
completed.type === "completed" ? completed.message.content : undefined,
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "calling a tool",
|
||||||
|
providerOptions: { google: { thoughtSignature: "ts-text" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "tc1",
|
||||||
|
toolName: "echo",
|
||||||
|
arguments: { x: 1 },
|
||||||
|
providerOptions: { google: { thoughtSignature: "ts-call" } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges metadata from multiple events of one block, later fields winning", async () => {
|
||||||
|
const registry = makeRegistry(
|
||||||
|
[
|
||||||
|
{ type: "reasoning-start", providerMetadata: { openai: { itemId: "r-1" } } },
|
||||||
|
{ type: "reasoning-delta", text: "…" },
|
||||||
|
{
|
||||||
|
type: "reasoning-end",
|
||||||
|
providerMetadata: { openai: { itemId: "r-1", reasoningEncryptedContent: "enc" } },
|
||||||
|
},
|
||||||
|
{ type: "finish-step", finishReason: "stop", usage: {} },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const events = await collect(registry, request());
|
||||||
|
const completed = events[events.length - 1];
|
||||||
|
expect(
|
||||||
|
completed.type === "completed" ? completed.message.content : undefined,
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "…",
|
||||||
|
providerOptions: { openai: { itemId: "r-1", reasoningEncryptedContent: "enc" } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty blocks that carry no metadata", async () => {
|
||||||
|
const registry = makeRegistry(
|
||||||
|
[
|
||||||
|
{ type: "reasoning-start" },
|
||||||
|
{ type: "reasoning-end" },
|
||||||
|
{ type: "text-start" },
|
||||||
|
{ type: "text-delta", text: "hi" },
|
||||||
|
{ type: "text-end" },
|
||||||
|
{ type: "finish-step", finishReason: "stop", usage: {} },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const events = await collect(registry, request());
|
||||||
|
const completed = events[events.length - 1];
|
||||||
|
expect(
|
||||||
|
completed.type === "completed" ? completed.message.content : undefined,
|
||||||
|
).toEqual([{ type: "text", text: "hi" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips part-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: "reasoning",
|
||||||
|
text: "thought",
|
||||||
|
providerOptions: { anthropic: { signature: "sig-1" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "tc1",
|
||||||
|
toolName: "echo",
|
||||||
|
arguments: { x: 1 },
|
||||||
|
providerOptions: { google: { thoughtSignature: "ts-call" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as never) as Array<{ role: string; content: Array<Record<string, unknown>> }>;
|
||||||
|
|
||||||
|
expect(encoded[0].content[0]).toMatchObject({
|
||||||
|
type: "reasoning",
|
||||||
|
text: "thought",
|
||||||
|
providerOptions: { anthropic: { signature: "sig-1" } },
|
||||||
|
});
|
||||||
|
expect(encoded[0].content[1]).toMatchObject({
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "tc1",
|
||||||
|
input: { x: 1 },
|
||||||
|
providerOptions: { google: { thoughtSignature: "ts-call" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("throws on provider error parts (a model failure, not a completion)", async () => {
|
it("throws on provider error parts (a model failure, not a completion)", async () => {
|
||||||
const registry = makeRegistry(
|
const registry = makeRegistry(
|
||||||
[{ type: "error", error: new Error("rate limited") }],
|
[{ type: "error", error: new Error("rate limited") }],
|
||||||
|
|
|
||||||
|
|
@ -121,12 +121,33 @@ export class RealModelRegistry implements IModelRegistry {
|
||||||
};
|
};
|
||||||
|
|
||||||
const parts: Array<z.infer<typeof AssistantContentPart>> = [];
|
const parts: Array<z.infer<typeof AssistantContentPart>> = [];
|
||||||
|
// Per-block accumulation: providers attach round-trip metadata to
|
||||||
|
// individual content blocks (Anthropic thinking signatures arrive on
|
||||||
|
// reasoning-delta, redacted thinking on reasoning-start; Gemini
|
||||||
|
// thoughtSignatures on text-end / reasoning-end / tool-call; OpenAI
|
||||||
|
// encrypted reasoning on reasoning events). Each -start opens a new
|
||||||
|
// part so distinct blocks keep distinct signatures, and metadata from
|
||||||
|
// every event of a block is merged onto that part's providerOptions —
|
||||||
|
// which is exactly what the AI SDK providers read back when the
|
||||||
|
// message is echoed in later steps.
|
||||||
|
let currentText: TextContentPart | null = null;
|
||||||
|
let currentReasoning: ReasoningContentPart | null = null;
|
||||||
let textBuffer = "";
|
let textBuffer = "";
|
||||||
let reasoningBuffer = "";
|
let reasoningBuffer = "";
|
||||||
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;
|
||||||
|
|
||||||
|
const tagPart = (
|
||||||
|
part: TextContentPart | ReasoningContentPart,
|
||||||
|
metadata: unknown,
|
||||||
|
) => {
|
||||||
|
const merged = mergeProviderOptions(part.providerOptions, metadata);
|
||||||
|
if (merged !== undefined) {
|
||||||
|
part.providerOptions = merged;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Anthropic-family models get cache_control breakpoints; everything
|
// Anthropic-family models get cache_control breakpoints; everything
|
||||||
// else passes through byte-identical (transport-only, not persisted).
|
// else passes through byte-identical (transport-only, not persisted).
|
||||||
const prompt = applyPromptCaching(flavor, modelId, {
|
const prompt = applyPromptCaching(flavor, modelId, {
|
||||||
|
|
@ -157,56 +178,74 @@ export class RealModelRegistry implements IModelRegistry {
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
};
|
};
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "text-start":
|
case "text-start": {
|
||||||
textBuffer = "";
|
textBuffer = "";
|
||||||
|
currentText = { type: "text", text: "" };
|
||||||
|
tagPart(currentText, event.providerMetadata);
|
||||||
|
parts.push(currentText);
|
||||||
yield { type: "step_event", event: { type: "text_start" } };
|
yield { type: "step_event", event: { type: "text_start" } };
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "text-delta": {
|
case "text-delta": {
|
||||||
const delta = event.text ?? "";
|
const delta = event.text ?? "";
|
||||||
textBuffer += delta;
|
textBuffer += delta;
|
||||||
const last = parts[parts.length - 1];
|
if (currentText === null) {
|
||||||
if (last?.type === "text") {
|
currentText = { type: "text", text: "" };
|
||||||
last.text += delta;
|
parts.push(currentText);
|
||||||
} else {
|
|
||||||
parts.push({ type: "text", text: delta });
|
|
||||||
}
|
}
|
||||||
|
currentText.text += delta;
|
||||||
|
tagPart(currentText, event.providerMetadata);
|
||||||
yield { type: "text_delta", delta };
|
yield { type: "text_delta", delta };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "text-end":
|
case "text-end":
|
||||||
|
if (currentText !== null) {
|
||||||
|
tagPart(currentText, event.providerMetadata);
|
||||||
|
currentText = null;
|
||||||
|
}
|
||||||
yield {
|
yield {
|
||||||
type: "step_event",
|
type: "step_event",
|
||||||
event: { type: "text_end", text: textBuffer },
|
event: { type: "text_end", text: textBuffer },
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "reasoning-start":
|
case "reasoning-start": {
|
||||||
reasoningBuffer = "";
|
reasoningBuffer = "";
|
||||||
|
currentReasoning = { type: "reasoning", text: "" };
|
||||||
|
tagPart(currentReasoning, event.providerMetadata);
|
||||||
|
parts.push(currentReasoning);
|
||||||
yield { type: "step_event", event: { type: "reasoning_start" } };
|
yield { type: "step_event", event: { type: "reasoning_start" } };
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "reasoning-delta": {
|
case "reasoning-delta": {
|
||||||
const delta = event.text ?? "";
|
const delta = event.text ?? "";
|
||||||
reasoningBuffer += delta;
|
reasoningBuffer += delta;
|
||||||
const last = parts[parts.length - 1];
|
if (currentReasoning === null) {
|
||||||
if (last?.type === "reasoning") {
|
currentReasoning = { type: "reasoning", text: "" };
|
||||||
last.text += delta;
|
parts.push(currentReasoning);
|
||||||
} else {
|
|
||||||
parts.push({ type: "reasoning", text: delta });
|
|
||||||
}
|
}
|
||||||
|
currentReasoning.text += delta;
|
||||||
|
tagPart(currentReasoning, event.providerMetadata);
|
||||||
yield { type: "reasoning_delta", delta };
|
yield { type: "reasoning_delta", delta };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "reasoning-end":
|
case "reasoning-end":
|
||||||
|
if (currentReasoning !== null) {
|
||||||
|
tagPart(currentReasoning, event.providerMetadata);
|
||||||
|
currentReasoning = null;
|
||||||
|
}
|
||||||
yield {
|
yield {
|
||||||
type: "step_event",
|
type: "step_event",
|
||||||
event: { type: "reasoning_end", text: reasoningBuffer },
|
event: { type: "reasoning_end", text: reasoningBuffer },
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "tool-call": {
|
case "tool-call": {
|
||||||
|
const partOptions = mergeProviderOptions(undefined, event.providerMetadata);
|
||||||
const toolCall = {
|
const toolCall = {
|
||||||
type: "tool-call" as const,
|
type: "tool-call" as const,
|
||||||
toolCallId: String(event.toolCallId),
|
toolCallId: String(event.toolCallId),
|
||||||
toolName: String(event.toolName),
|
toolName: String(event.toolName),
|
||||||
arguments: event.input,
|
arguments: event.input,
|
||||||
|
...(partOptions === undefined ? {} : { providerOptions: partOptions }),
|
||||||
};
|
};
|
||||||
parts.push(toolCall);
|
parts.push(toolCall);
|
||||||
yield { type: "step_event", event: { type: "tool_call", toolCall } };
|
yield { type: "step_event", event: { type: "tool_call", toolCall } };
|
||||||
|
|
@ -238,11 +277,21 @@ export class RealModelRegistry implements IModelRegistry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blocks that ended up with no text and no round-trip metadata carry
|
||||||
|
// no information; dropping them matches the pre-block-tracking
|
||||||
|
// behavior (parts used to be created lazily on first delta).
|
||||||
|
const content = parts.filter(
|
||||||
|
(part) =>
|
||||||
|
part.type === "tool-call"
|
||||||
|
|| part.text !== ""
|
||||||
|
|| part.providerOptions !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
type: "completed",
|
type: "completed",
|
||||||
message: {
|
message: {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: parts.length > 0 ? parts : "",
|
content: content.length > 0 ? content : "",
|
||||||
},
|
},
|
||||||
finishReason,
|
finishReason,
|
||||||
usage,
|
usage,
|
||||||
|
|
@ -251,6 +300,35 @@ export class RealModelRegistry implements IModelRegistry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TextContentPart = Extract<z.infer<typeof AssistantContentPart>, { type: "text" }>;
|
||||||
|
type ReasoningContentPart = Extract<z.infer<typeof AssistantContentPart>, { type: "reasoning" }>;
|
||||||
|
type PartProviderOptions = NonNullable<TextContentPart["providerOptions"]>;
|
||||||
|
|
||||||
|
// Round-trip metadata is relayed opaquely: whatever JSON-safe, per-provider
|
||||||
|
// record a stream event carries is merged onto the part (later events win
|
||||||
|
// per field within a provider's namespace). The bridge never interprets the
|
||||||
|
// contents — Anthropic signatures, Gemini thoughtSignatures, and OpenAI
|
||||||
|
// encrypted reasoning all ride the same channel, and each provider reads
|
||||||
|
// back only its own keys.
|
||||||
|
function mergeProviderOptions(
|
||||||
|
base: PartProviderOptions | undefined,
|
||||||
|
incoming: unknown,
|
||||||
|
): PartProviderOptions | undefined {
|
||||||
|
const sanitized = toJsonValue(incoming);
|
||||||
|
if (sanitized === undefined || sanitized === null || typeof sanitized !== "object" || Array.isArray(sanitized)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
let merged: PartProviderOptions | undefined;
|
||||||
|
for (const [provider, fields] of Object.entries(sanitized)) {
|
||||||
|
if (fields === null || typeof fields !== "object" || Array.isArray(fields)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
merged = merged ?? { ...(base ?? {}) };
|
||||||
|
merged[provider] = { ...(merged[provider] ?? {}), ...fields };
|
||||||
|
}
|
||||||
|
return merged ?? base;
|
||||||
|
}
|
||||||
|
|
||||||
function mapUsage(
|
function mapUsage(
|
||||||
usage: Record<string, number | undefined> | undefined,
|
usage: Record<string, number | undefined> | undefined,
|
||||||
): z.infer<typeof TurnUsage> {
|
): z.infer<typeof TurnUsage> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue