fix(anthropic): recover orphan text deltas

This commit is contained in:
Peter Steinberger
2026-05-02 10:34:04 +01:00
parent 64fcc8a1aa
commit 9b11248c5f
3 changed files with 73 additions and 2 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
- Plugins: reuse gateway-bindable plugin loader cache entries for later default-mode loads without serving default-built registries to gateway-bound requests, reducing repeated plugin registration during dispatch. Refs #61756. Thanks @DmitryPogodaev.
- Gateway/secrets: include the caught error message in `secrets.reload` and `secrets.resolve` warning logs while keeping RPC errors generic, so operators can diagnose reload and permission failures. Thanks @davidangularme.
- Anthropic-compatible streams: recover text deltas that arrive before their matching content block, so Kimi Code and similar providers do not finish as empty `incomplete_result` replies. Fixes #76007. Thanks @vliuyt.
- fix(infra): block workspace state-directory env override [AI]. (#75940) Thanks @pgondhi987.
- MCP/OpenAI: normalize parameter-free tool schemas whose top-level object `properties` is missing, null, or invalid before sending tools to OpenAI, so MCP tools without params stay usable. Fixes #75362. Thanks @tolkonepiu and @SymbolStar.
- TTS: honor explicit short `[[tts:text]]...[[/tts:text]]` blocks while keeping untagged short auto-TTS suppressed, so tagged voice replies are synthesized instead of being dropped as empty voice-only payloads. Fixes #73758. Thanks @yfge.

View File

@@ -533,6 +533,65 @@ describe("anthropic transport stream", () => {
expect(result.usage.output).toBe(9);
});
it("recovers orphan text deltas when an Anthropic-compatible provider omits block start", async () => {
guardedFetchMock.mockResolvedValueOnce(
createSseResponse([
{
type: "message_start",
message: { id: "msg_1", usage: { input_tokens: 6, output_tokens: 0 } },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "你好" },
},
{
type: "content_block_stop",
index: 0,
},
{
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { input_tokens: 6, output_tokens: 1 },
},
]),
);
const streamFn = createAnthropicMessagesTransportStreamFn();
const stream = await Promise.resolve(
streamFn(
makeAnthropicTransportModel({
provider: "kimi-coding",
baseUrl: "https://api.kimi.com/coding/",
}),
{
messages: [{ role: "user", content: "hello" }],
} as Parameters<typeof streamFn>[1],
{
apiKey: "kimi-key",
} as Parameters<typeof streamFn>[2],
),
);
const events: Array<{ type?: string; delta?: string; content?: string }> = [];
for await (const event of stream as AsyncIterable<{
type?: string;
delta?: string;
content?: string;
}>) {
events.push(event);
}
const result = await stream.result();
expect(result.content).toEqual([{ type: "text", text: "你好" }]);
expect(result.stopReason).toBe("stop");
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "text_start" }),
expect.objectContaining({ type: "text_delta", delta: "你好" }),
expect.objectContaining({ type: "text_end", content: "你好" }),
]),
);
});
it("skips malformed tools when building Anthropic payloads", async () => {
await runTransportStream(
makeAnthropicTransportModel(),

View File

@@ -1018,9 +1018,20 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
continue;
}
if (event.type === "content_block_delta") {
const index = blocks.findIndex((block) => block.index === event.index);
const block = blocks[index];
const delta = event.delta as Record<string, unknown> | undefined;
let index = blocks.findIndex((block) => block.index === event.index);
let block = blocks[index];
if (!block && delta?.type === "text_delta" && typeof delta.text === "string") {
const recoveredIndex = typeof event.index === "number" ? event.index : blocks.length;
block = { type: "text", text: "", index: recoveredIndex };
output.content.push(block);
index = output.content.length - 1;
stream.push({
type: "text_start",
contentIndex: index,
partial: output as never,
});
}
if (
block?.type === "text" &&
delta?.type === "text_delta" &&