Skip to content

Commit bc68fbf

Browse files
committed
fix(agents): bound Anthropic error streams
1 parent b073d7c commit bc68fbf

2 files changed

Lines changed: 109 additions & 2 deletions

File tree

src/agents/anthropic-transport-stream.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* provider transport hooks.
55
*/
66
import type { Model } from "openclaw/plugin-sdk/llm";
7-
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
7+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
88
import { attachModelProviderRequestTransport } from "./provider-request-config.js";
99

1010
const { buildGuardedModelFetchMock, guardedFetchMock } = vi.hoisted(() => ({
@@ -196,6 +196,10 @@ describe("anthropic transport stream", () => {
196196
guardedFetchMock.mockResolvedValue(createSseResponse());
197197
});
198198

199+
afterEach(() => {
200+
vi.useRealTimers();
201+
});
202+
199203
it("uses the guarded fetch transport for api-key Anthropic requests", async () => {
200204
const model = makeAnthropicTransportModel({
201205
headers: { "X-Provider": "anthropic" },
@@ -266,6 +270,81 @@ describe("anthropic transport stream", () => {
266270
expect(headers.get("X-Provider")).toBe("foundry");
267271
});
268272

273+
it("bounds streamed Anthropic error responses without content-length", async () => {
274+
const encoder = new TextEncoder();
275+
let pullCount = 0;
276+
let cancelCount = 0;
277+
guardedFetchMock.mockResolvedValueOnce(
278+
new Response(
279+
new ReadableStream<Uint8Array>({
280+
pull(controller) {
281+
pullCount += 1;
282+
if (pullCount === 1) {
283+
controller.enqueue(encoder.encode("x".repeat(8 * 1024)));
284+
return;
285+
}
286+
controller.enqueue(encoder.encode("y"));
287+
},
288+
cancel() {
289+
cancelCount += 1;
290+
},
291+
}),
292+
{ status: 500 },
293+
),
294+
);
295+
296+
const result = await runTransportStream(
297+
makeAnthropicTransportModel(),
298+
{
299+
messages: [{ role: "user", content: "hello" }],
300+
} as AnthropicStreamContext,
301+
{ apiKey: "sk-ant-api" } as AnthropicStreamOptions,
302+
);
303+
304+
expect(result.stopReason).toBe("error");
305+
expect(result.errorMessage).toBe(`${"x".repeat(400)}…`);
306+
expect(pullCount).toBeGreaterThanOrEqual(2);
307+
expect(cancelCount).toBe(1);
308+
});
309+
310+
it("aborts stalled streamed Anthropic error responses", async () => {
311+
vi.useFakeTimers();
312+
const encoder = new TextEncoder();
313+
let cancelReason: unknown;
314+
guardedFetchMock.mockResolvedValueOnce(
315+
new Response(
316+
new ReadableStream<Uint8Array>({
317+
start(controller) {
318+
controller.enqueue(encoder.encode("partial failure detail"));
319+
},
320+
cancel(reason) {
321+
cancelReason = reason;
322+
},
323+
}),
324+
{ status: 500 },
325+
),
326+
);
327+
328+
const resultPromise = runTransportStream(
329+
makeAnthropicTransportModel(),
330+
{
331+
messages: [{ role: "user", content: "hello" }],
332+
} as AnthropicStreamContext,
333+
{ apiKey: "sk-ant-api" } as AnthropicStreamOptions,
334+
);
335+
336+
await vi.advanceTimersByTimeAsync(0);
337+
await vi.advanceTimersByTimeAsync(10_000);
338+
const result = await resultPromise;
339+
340+
expect(result.stopReason).toBe("error");
341+
expect(result.errorMessage).toBe(
342+
"Anthropic Messages error response stalled: no data received for 10000ms",
343+
);
344+
expect(cancelReason).toBeInstanceOf(Error);
345+
expect((cancelReason as Error).message).toBe(result.errorMessage);
346+
});
347+
269348
it("honors ANTHROPIC_BASE_URL when model base URL is blank", async () => {
270349
vi.stubEnv("ANTHROPIC_BASE_URL", " https://anthropic-proxy.example/v1 ");
271350

src/agents/anthropic-transport-stream.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Converts OpenClaw contexts/tools into Anthropic payloads, streams SSE events
44
* back into runtime output blocks, and applies provider request policy.
55
*/
6+
import { readResponseTextSnippet } from "@openclaw/media-core/read-response-with-limit";
67
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
78
import { getEnvApiKey } from "../llm/env-api-keys.js";
89
import { calculateCost, clampThinkingLevel } from "../llm/model-utils.js";
@@ -60,6 +61,9 @@ import {
6061
} from "./transport-stream-shared.js";
6162

6263
const CLAUDE_CODE_VERSION = "2.1.75";
64+
const ANTHROPIC_MESSAGES_ERROR_BODY_MAX_BYTES = 8 * 1024;
65+
const ANTHROPIC_MESSAGES_ERROR_BODY_MAX_CHARS = 400;
66+
const ANTHROPIC_MESSAGES_ERROR_BODY_READ_IDLE_TIMEOUT_MS = 10_000;
6367
const CLAUDE_CODE_TOOLS = [
6468
"Read",
6569
"Write",
@@ -771,7 +775,7 @@ function createAnthropicMessagesClient(params: {
771775
signal: options?.signal,
772776
});
773777
if (!response.ok) {
774-
const detail = await response.text().catch(() => "");
778+
const detail = await readAnthropicMessagesErrorBodySnippet(response);
775779
throw new Error(
776780
detail || `Anthropic Messages request failed with HTTP ${response.status}`,
777781
);
@@ -785,6 +789,30 @@ function createAnthropicMessagesClient(params: {
785789
};
786790
}
787791

792+
async function readAnthropicMessagesErrorBodySnippet(response: Response): Promise<string> {
793+
try {
794+
return (
795+
(await readResponseTextSnippet(response, {
796+
maxBytes: ANTHROPIC_MESSAGES_ERROR_BODY_MAX_BYTES,
797+
maxChars: ANTHROPIC_MESSAGES_ERROR_BODY_MAX_CHARS,
798+
chunkTimeoutMs: ANTHROPIC_MESSAGES_ERROR_BODY_READ_IDLE_TIMEOUT_MS,
799+
onIdleTimeout: ({ chunkTimeoutMs }) =>
800+
new Error(
801+
`Anthropic Messages error response stalled: no data received for ${chunkTimeoutMs}ms`,
802+
),
803+
})) ?? ""
804+
);
805+
} catch (error: unknown) {
806+
if (
807+
error instanceof Error &&
808+
error.message.startsWith("Anthropic Messages error response stalled:")
809+
) {
810+
return error.message;
811+
}
812+
return "";
813+
}
814+
}
815+
788816
function createAnthropicTransportClient(params: {
789817
model: AnthropicTransportModel;
790818
context: Context;

0 commit comments

Comments
 (0)