From 16f32e7255a8b15574e016020025203284f3c158 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:50:46 +0900 Subject: [PATCH 1/2] fix(vision): bound the Anthropic vision sidecar SSE and error bodies --- src/vision/anthropic-describe.ts | 48 +++++++++++++++++++++++++-- tests/vision/vision-anthropic.test.ts | 21 ++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/vision/anthropic-describe.ts b/src/vision/anthropic-describe.ts index 4f41017ef5..280096f033 100644 --- a/src/vision/anthropic-describe.ts +++ b/src/vision/anthropic-describe.ts @@ -10,6 +10,8 @@ import type { DescribeOutcome, VisionSettings } from "./describe"; const ANTHROPIC_VISION_MAX_TOKENS = 1024; const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]); const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +/** Bound the sidecar SSE stream and its untrusted error body; the description is clamped downstream. */ +const MAX_SIDECAR_RESPONSE_BYTES = 64 * 1024; const DESCRIBE_INSTRUCTION = "You are a vision describer for a text-only model that cannot see the image. Describe the image " + "thoroughly and factually so that model can fully reason about it: transcribe any visible text " + @@ -43,6 +45,34 @@ function buildImageBlock(imageUrl: string): { block?: AnthropicImageBlock; error return { error: "unsupported image URL scheme (expected data: or https:)" }; } +/** Read at most `MAX_SIDECAR_RESPONSE_BYTES` of an untrusted upstream body, then stop reading. */ +async function readBoundedText(res: Response): Promise { + if (!res.body) return ""; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let out = ""; + let seen = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const remaining = MAX_SIDECAR_RESPONSE_BYTES - seen; + const accepted = value.byteLength <= remaining ? value : value.subarray(0, remaining); + seen += accepted.byteLength; + out += decoder.decode(accepted, { stream: true }); + if (seen >= MAX_SIDECAR_RESPONSE_BYTES) { + try { void reader.cancel("vision sidecar error body byte limit reached").catch(() => undefined); } + catch { /* best-effort body teardown */ } + break; + } + } + out += decoder.decode(); + } catch { + /* a failed error-body read must not mask the HTTP status we are about to report */ + } + return out; +} + /** Fold Anthropic Messages text deltas into one description. Malformed frames are ignored. */ export async function parseAnthropicVisionSSE(res: Response): Promise { if (!res.body) return { text: "", error: "anthropic vision sidecar returned no response body" }; @@ -52,6 +82,7 @@ export async function parseAnthropicVisionSSE(res: Response): Promise { let dataLine = ""; @@ -76,12 +107,24 @@ export async function parseAnthropicVisionSSE(res: Response): Promise= MAX_SIDECAR_RESPONSE_BYTES) { + // Keep the frames folded above, drop the unterminated tail, and do not wait on teardown. + try { void reader.cancel("vision sidecar response byte limit reached").catch(() => undefined); } + catch { /* best-effort body teardown */ } + buffer = ""; + break; + } } buffer = (buffer + decoder.decode()).replace(/\r\n/g, "\n"); if (buffer.trim()) processFrame(buffer); @@ -164,7 +207,8 @@ export async function describeImageAnthropic( { abortSignal: linkedSignal.signal, label: "vision-sidecar-anthropic" }, ); if (!res.ok) { - const responseText = await res.text().catch(() => ""); + // The body is untrusted and only feeds one auth-failure message, so read a bounded prefix. + const responseText = await readBoundedText(res); console.warn(`[vision] anthropic sidecar HTTP ${res.status} (${Date.now() - startedAt}ms)`); if (res.status === 401) { return { text: "", error: `anthropic vision sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(new Error(responseText))}` }; diff --git a/tests/vision/vision-anthropic.test.ts b/tests/vision/vision-anthropic.test.ts index 086e5df7bb..05df35662e 100644 --- a/tests/vision/vision-anthropic.test.ts +++ b/tests/vision/vision-anthropic.test.ts @@ -225,6 +225,27 @@ describe("Anthropic vision executor", () => { expect(result).toEqual({ text: "first second" }); }); + test("an unterminated frame cannot buffer the stream without bound", async () => { + // A sidecar that never emits a frame separator: without a cap the parser accumulates the + // whole response in memory before it can fold anything. + let produced = 0; + let cancelled = false; + const chunk = new TextEncoder().encode(`data: {"filler":"${"x".repeat(64 * 1024)}"}`); + const body = new ReadableStream({ + pull(c) { + if (produced > 8 * 1024 * 1024) { c.close(); return; } + produced += chunk.byteLength; + c.enqueue(chunk); + }, + cancel() { cancelled = true; }, + }); + const out = await parseAnthropicVisionSSE(new Response(body, { status: 200 })); + expect(cancelled).toBe(true); + // The cap stops the read long before the producer would have finished on its own. + expect(produced).toBeLessThan(1024 * 1024); + expect(out.text).toBe(""); + }); + test("malformed and terminal-error streams degrade to explicit errors", async () => { const malformed = await parseAnthropicVisionSSE(sseResponse(["{not-json", { type: "message_stop" }])); expect(malformed.text).toBe(""); From 8baeb8af913d04a02855ba9550f05fb92bcb258f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:58:32 +0900 Subject: [PATCH 2/2] fix(vision): reject byte-limited partial descriptions --- docs-site/src/content/docs/guides/sidecars.md | 2 + src/vision/anthropic-describe.ts | 4 +- tests/vision/vision-anthropic.test.ts | 51 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index d0c79d272e..e1ffaafbde 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -154,6 +154,8 @@ model field. `gpt-oss:120b`. - If description fails, the model receives a short processing-error marker. (Without an available sidecar plan, no description is attempted — the raw image is stripped, as described above.) + Anthropic responses are limited to 64 KiB; reaching that limit rejects the partial description + and leaves it uncached so a later request can try again. - `maxDescriptionsPerTurn` (default 8) limits new descriptions per main-model turn. Cache hits and same-turn duplicates do not consume it. Successful `data:` image descriptions are cached by backend, model, detail, image bytes, and message context — plus the reasoning effort on OpenAI diff --git a/src/vision/anthropic-describe.ts b/src/vision/anthropic-describe.ts index 280096f033..bc56531f21 100644 --- a/src/vision/anthropic-describe.ts +++ b/src/vision/anthropic-describe.ts @@ -119,7 +119,9 @@ export async function parseAnthropicVisionSSE(res: Response): Promise= MAX_SIDECAR_RESPONSE_BYTES) { - // Keep the frames folded above, drop the unterminated tail, and do not wait on teardown. + // A bounded prefix is not a complete description. Reject it so callers cannot display + // or cache partial image facts as a successful result, and do not wait on teardown. + if (!terminalError) terminalError = "anthropic vision sidecar response byte limit reached"; try { void reader.cancel("vision sidecar response byte limit reached").catch(() => undefined); } catch { /* best-effort body teardown */ } buffer = ""; diff --git a/tests/vision/vision-anthropic.test.ts b/tests/vision/vision-anthropic.test.ts index 05df35662e..9b5047028b 100644 --- a/tests/vision/vision-anthropic.test.ts +++ b/tests/vision/vision-anthropic.test.ts @@ -23,6 +23,7 @@ import { describeImageAnthropic, parseAnthropicVisionSSE, planVisionSidecar, + resetVisionDescriptionCache, type VisionPlan, } from "../../src/vision"; @@ -246,6 +247,56 @@ describe("Anthropic vision executor", () => { expect(out.text).toBe(""); }); + test("a byte-limited partial description is rejected and never cached", async () => { + let calls = 0; + let cancelled = false; + const frame = `data: ${JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text: "incomplete description" } })}\n\n`; + globalThis.fetch = (async () => { + calls += 1; + if (calls > 1) return successSse("complete description"); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(frame + `data: ${"x".repeat(64 * 1024)}`)); + }, + cancel() { + cancelled = true; + // Provider teardown must not hold up the error outcome. + return new Promise(() => {}); + }, + })); + }) as typeof fetch; + const request = () => parseRequest({ + model: "routed/text-only", + input: [{ type: "message", role: "user", content: [ + { type: "input_image", image_url: DATA_IMAGE }, + ] }], + }); + const plan: VisionPlan = { + backend: "anthropic", + anthropicSidecar: { providerName: "anthropic-vision-test", provider: anthropicProvider }, + settings, + maxDescriptionsPerTurn: 1, + }; + resetVisionDescriptionCache(); + try { + const first = request(); + await describeImagesInPlace(first, plan, new Headers()); + expect(cancelled).toBe(true); + expect(JSON.stringify(first.context.messages)).toContain("anthropic vision sidecar response byte limit reached"); + expect(JSON.stringify(first.context.messages)).not.toContain("incomplete description"); + const second = request(); + await describeImagesInPlace(second, plan, new Headers()); + expect(calls).toBe(2); + expect(JSON.stringify(second.context.messages)).toContain("complete description"); + const third = request(); + await describeImagesInPlace(third, plan, new Headers()); + expect(calls).toBe(2); + expect(JSON.stringify(third.context.messages)).toContain("complete description"); + } finally { + resetVisionDescriptionCache(); + } + }); + test("malformed and terminal-error streams degrade to explicit errors", async () => { const malformed = await parseAnthropicVisionSSE(sseResponse(["{not-json", { type: "message_stop" }])); expect(malformed.text).toBe("");