Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/guides/sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 48 additions & 2 deletions src/vision/anthropic-describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down Expand Up @@ -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<string> {
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<DescribeOutcome> {
if (!res.body) return { text: "", error: "anthropic vision sidecar returned no response body" };
Expand All @@ -52,6 +82,7 @@ export async function parseAnthropicVisionSSE(res: Response): Promise<DescribeOu
const decoder = new TextDecoder();
const reader = res.body.getReader();
let buffer = "";
let responseBytes = 0;

const processFrame = (rawFrame: string): void => {
let dataLine = "";
Expand All @@ -76,12 +107,26 @@ export async function parseAnthropicVisionSSE(res: Response): Promise<DescribeOu
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, "\n");
// Frames only fold on a `\n\n` separator, so an upstream that never emits one would grow
// `buffer` for the whole response. Accept a bounded prefix instead.
const remaining = MAX_SIDECAR_RESPONSE_BYTES - responseBytes;
const accepted = value.byteLength <= remaining ? value : value.subarray(0, remaining);
responseBytes += accepted.byteLength;
buffer = (buffer + decoder.decode(accepted, { stream: true })).replace(/\r\n/g, "\n");
let separator: number;
while ((separator = buffer.indexOf("\n\n")) !== -1) {
processFrame(buffer.slice(0, separator));
buffer = buffer.slice(separator + 2);
}
if (responseBytes >= MAX_SIDECAR_RESPONSE_BYTES) {
// 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 = "";
break;
}
}
buffer = (buffer + decoder.decode()).replace(/\r\n/g, "\n");
if (buffer.trim()) processFrame(buffer);
Expand Down Expand Up @@ -164,7 +209,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))}` };
Expand Down
72 changes: 72 additions & 0 deletions tests/vision/vision-anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
describeImageAnthropic,
parseAnthropicVisionSSE,
planVisionSidecar,
resetVisionDescriptionCache,
type VisionPlan,
} from "../../src/vision";

Expand Down Expand Up @@ -225,6 +226,77 @@ 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<Uint8Array>({
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("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<Uint8Array>({
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<void>(() => {});
},
}));
}) 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("");
Expand Down
Loading