Skip to content

Commit 66ffad1

Browse files
fix(azure-openai-responses): bound SSE response reads via buildGuardedModelFetch (#97349)
Inject buildGuardedModelFetch with resolved Azure base URL into both SDK constructors (OpenAI and AzureOpenAI path) so Azure Responses requests route through OpenClaw's guarded transport (SSRF policy, timeout, retry limiting, SSE/JSON synthesis caps). The non-OK response body cap is applied lazily inside the shared sanitizeOpenAISdkSseResponse guard via TransformStream — closes the unbounded response.text() OOM path for hostile 4xx/5xx responses while preserving the OpenAI SDK's ability to cancel and retry. - src/llm/providers/azure-openai-responses.ts: pass guardedFetch into both OpenAI and AzureOpenAI SDK constructors (no per-provider wrapper) - src/agents/provider-transport-fetch.ts: add capNonOkResponseBodyLazily helper and wire into sanitizeOpenAISdkSseResponse for !response.ok - src/agents/provider-transport-fetch.test.ts: 2 inline tests (cap fires + SDK cancel preserves source) Co-authored-by: Claude <[email protected]>
1 parent c026546 commit 66ffad1

3 files changed

Lines changed: 119 additions & 1 deletion

File tree

src/agents/provider-transport-fetch.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,6 +1465,90 @@ describe("buildGuardedModelFetch", () => {
14651465
expect(String(caught)).toMatch(/exceeded.*bytes while synthesizing SSE/i);
14661466
});
14671467

1468+
it("caps non-OK response body lazily so SDK can still cancel retryable responses", async () => {
1469+
// Regression: a 429/5xx non-OK body used to be returned unchanged by the
1470+
// shared sanitizer, so a hostile endpoint could OOM the SDK when it called
1471+
// response.text(). The cap is applied lazily via TransformStream so the SDK
1472+
// can still cancel the response before reading any body.
1473+
const OVER_LIMIT = 100 * 1024;
1474+
fetchWithSsrFGuardMock.mockResolvedValue({
1475+
response: new Response(
1476+
new ReadableStream({
1477+
start(controller) {
1478+
controller.enqueue(new Uint8Array(OVER_LIMIT));
1479+
controller.close();
1480+
},
1481+
}),
1482+
{ status: 429, statusText: "Too Many Requests" },
1483+
),
1484+
finalUrl: "https://custom-azure.openai.azure.com/openai/v1/responses",
1485+
release: vi.fn(async () => undefined),
1486+
});
1487+
const model = {
1488+
id: "gpt-5.5",
1489+
provider: "azure",
1490+
api: "azure-openai-responses",
1491+
baseUrl: "https://custom-azure.openai.azure.com/openai/v1",
1492+
} as unknown as Model<"azure-openai-responses">;
1493+
1494+
const response = await buildGuardedModelFetch(model)(
1495+
"https://custom-azure.openai.azure.com/openai/v1/responses",
1496+
{ method: "POST" },
1497+
);
1498+
1499+
expect(response.status).toBe(429);
1500+
expect(response.ok).toBe(false);
1501+
1502+
const text = await response.text();
1503+
expect(text.length).toBeLessThanOrEqual(64 * 1024);
1504+
expect(text.length).toBeLessThan(OVER_LIMIT);
1505+
});
1506+
1507+
it("preserves SDK ability to cancel retryable non-OK responses before reading body", async () => {
1508+
// Regression: a non-OK body wrapper must be lazy. The OpenAI SDK may decide
1509+
// to cancel a 429/5xx response and retry before reading the body. If the
1510+
// wrapper eagerly reads the body, the SDK loses that capability.
1511+
const TOTAL_BYTES = 80 * 1024;
1512+
let bytesPulled = 0;
1513+
fetchWithSsrFGuardMock.mockResolvedValue({
1514+
response: new Response(
1515+
new ReadableStream({
1516+
pull(controller) {
1517+
if (bytesPulled < TOTAL_BYTES) {
1518+
const chunk = new Uint8Array(8 * 1024);
1519+
bytesPulled += chunk.byteLength;
1520+
controller.enqueue(chunk);
1521+
} else {
1522+
controller.close();
1523+
}
1524+
},
1525+
}),
1526+
{ status: 503, statusText: "Service Unavailable" },
1527+
),
1528+
finalUrl: "https://custom-azure.openai.azure.com/openai/v1/responses",
1529+
release: vi.fn(async () => undefined),
1530+
});
1531+
const model = {
1532+
id: "gpt-5.5",
1533+
provider: "azure",
1534+
api: "azure-openai-responses",
1535+
baseUrl: "https://custom-azure.openai.azure.com/openai/v1",
1536+
} as unknown as Model<"azure-openai-responses">;
1537+
1538+
const response = await buildGuardedModelFetch(model)(
1539+
"https://custom-azure.openai.azure.com/openai/v1/responses",
1540+
{ method: "POST" },
1541+
);
1542+
1543+
expect(response.status).toBe(503);
1544+
// SDK cancels the response body before reading anything. The lazy cap must
1545+
// not pull the full body from the source — a small pre-buffered chunk is
1546+
// allowed (ReadableStream default high-water-mark), but the full payload
1547+
// must remain unconsumed so the SDK can still retry.
1548+
await response.body?.cancel();
1549+
expect(bytesPulled).toBeLessThan(TOTAL_BYTES);
1550+
});
1551+
14681552
describe("long retry-after handling", () => {
14691553
const anthropicModel = {
14701554
id: "sonnet-4.6",

src/agents/provider-transport-fetch.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,44 @@ function findSseEventBoundary(buffer: string): { index: number; length: number }
9696
return best;
9797
}
9898

99+
function capNonOkResponseBodyLazily(response: Response, maxBytes: number): Response {
100+
const source = response.body;
101+
if (!source) {
102+
return response;
103+
}
104+
let total = 0;
105+
const capped = source.pipeThrough(
106+
new TransformStream<Uint8Array, Uint8Array>({
107+
transform(chunk, controller) {
108+
const nextTotal = total + chunk.byteLength;
109+
if (nextTotal > maxBytes) {
110+
const remaining = maxBytes - total;
111+
if (remaining > 0) {
112+
controller.enqueue(chunk.subarray(0, remaining));
113+
}
114+
total = maxBytes;
115+
controller.terminate();
116+
return;
117+
}
118+
total = nextTotal;
119+
controller.enqueue(chunk);
120+
},
121+
}),
122+
);
123+
return new Response(capped, response);
124+
}
125+
99126
function sanitizeOpenAISdkSseResponse(
100127
response: Response,
101128
options?: { synthesizeJsonAsSse?: boolean },
102129
): Response {
103130
const contentType = response.headers.get("content-type") ?? "";
104-
if (!response.ok || !response.body) {
131+
if (!response.body) {
105132
return response;
106133
}
134+
if (!response.ok) {
135+
return capNonOkResponseBodyLazily(response, SSE_SANITIZE_BUFFER_MAX_BYTES);
136+
}
107137
if (
108138
options?.synthesizeJsonAsSse === true &&
109139
(/\bapplication\/json\b/i.test(contentType) || /\+json\b/i.test(contentType))

src/llm/providers/azure-openai-responses.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Azure OpenAI Responses provider adapts Azure deployments to Responses API streams.
22
import OpenAI, { AzureOpenAI } from "openai";
33
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
4+
import { buildGuardedModelFetch } from "../../agents/provider-transport-fetch.js";
45
import { isOpenAICompatibleAzureResponsesBaseUrl } from "../../shared/azure-openai-responses-client-compat.js";
56
import { getEnvApiKey } from "../env-api-keys.js";
67
import type {
@@ -198,13 +199,15 @@ function createClient(
198199
}
199200

200201
const { baseUrl, apiVersion } = resolveAzureConfig(model, options);
202+
const guardedFetch = buildGuardedModelFetch({ ...model, baseUrl });
201203

202204
if (isOpenAICompatibleAzureResponsesBaseUrl(baseUrl)) {
203205
return new OpenAI({
204206
apiKey,
205207
dangerouslyAllowBrowser: true,
206208
defaultHeaders: headers,
207209
baseURL: baseUrl,
210+
fetch: guardedFetch,
208211
});
209212
}
210213

@@ -214,6 +217,7 @@ function createClient(
214217
dangerouslyAllowBrowser: true,
215218
defaultHeaders: headers,
216219
baseURL: baseUrl,
220+
fetch: guardedFetch,
217221
});
218222
}
219223

0 commit comments

Comments
 (0)