Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/wire/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from "./demoted-thinking.js";
export * from "./mirror.js";
export * from "./util.js";
export * from "./compress-detect.js";
export * from "./strip-images.js";
104 changes: 104 additions & 0 deletions src/wire/strip-images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Wire-level removal of HISTORICAL image payloads. Image bytes ride along
// verbatim on every request even after compression folds the surrounding text
// (the codecs move images out of CoreMessage.text into sidecars, so the raw
// payload is forwarded regardless of what got summarized). When a host opts in,
// every message EXCEPT the most recent `keepRecent` has its image parts dropped
// before the wire rebuild; an image-only message collapses to a "[image]" text
// placeholder so message count / role ordering stay stable. Pure over the RAW
// parsed body; returns the input reference unchanged when nothing changed.
// Content-hash message ids shift once per message when it ages out of the
// recent-N window (self-healing downstream via orphan-GC). Shared across hosts
// (proxy + in-process adapters) so "which field carries an image" has one home.

export type StripProtocol = "anthropic" | "openai" | "responses" | null;

export interface StripResult {
body: unknown;
removed: number;
}

const IMAGE_PLACEHOLDER = "[image]";

function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null;
}

function isImagePart(
protocol: Exclude<StripProtocol, null>,
part: unknown,
): boolean {
if (!isObj(part)) return false;
if (protocol === "responses") return part.type === "input_image";
if (protocol === "openai") return part.type === "image_url";
return part.type === "image";
}

function placeholderContent(
protocol: Exclude<StripProtocol, null>,
): Record<string, unknown>[] {
const type = protocol === "responses" ? "input_text" : "text";
return [{ type, text: IMAGE_PLACEHOLDER }];
}

/** Drop image parts from every message older than the most recent `keepRecent`.
* `protocol` selects the wire dialect; `null` (unparseable) is a no-op. Returns
* `{ body, removed }` where `body` is the input reference untouched (and
* `removed` is 0) when there was nothing to strip. */
export function stripHistoricalImages(
body: unknown,
protocol: StripProtocol,
keepRecent: number,
): StripResult {
if (!protocol || !isObj(body)) return { body, removed: 0 };
const recentCount = Math.max(0, Math.floor(keepRecent));

if (protocol === "responses") {
const input = body.input;
if (!Array.isArray(input)) return { body, removed: 0 };
const cutoff = input.length - recentCount;
let removed = 0;
let touched = false;
const nextInput = input.map((item, i) => {
if (i < cutoff && isObj(item) && Array.isArray(item.content)) {
const content = item.content as unknown[];
const imgs = content.filter((p) => isImagePart("responses", p)).length;
if (imgs > 0) {
removed += imgs;
touched = true;
const kept = content.filter((p) => !isImagePart("responses", p));
return {
...item,
content: kept.length > 0 ? kept : placeholderContent("responses"),
};
}
}
return item;
});
if (!touched) return { body, removed: 0 };
return { body: { ...body, input: nextInput }, removed };
}

const messages = body.messages;
if (!Array.isArray(messages)) return { body, removed: 0 };
const cutoff = messages.length - recentCount;
let removed = 0;
let touched = false;
const nextMessages = messages.map((m, i) => {
if (i < cutoff && isObj(m) && Array.isArray(m.content)) {
const content = m.content as unknown[];
const imgs = content.filter((p) => isImagePart(protocol, p)).length;
if (imgs > 0) {
removed += imgs;
touched = true;
const kept = content.filter((p) => !isImagePart(protocol, p));
return {
...m,
content: kept.length > 0 ? kept : placeholderContent(protocol),
};
}
}
return m;
});
if (!touched) return { body, removed: 0 };
return { body: { ...body, messages: nextMessages }, removed };
}
172 changes: 172 additions & 0 deletions tests/wire-strip-images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { stripHistoricalImages } from "../src/wire/strip-images.js";

const DATA_URL = "data:image/png;base64,AAAA";
const REMOTE_URL = "https://example.com/img.png";

const oaiImg = (url: string) => ({ type: "image_url", image_url: { url } });
const oaiTxt = (t: string) => ({ type: "text", text: t });
const antImgB64 = () => ({
type: "image",
source: { type: "base64", media_type: "image/png", data: "AAAA" },
});
const antImgUrl = () => ({
type: "image",
source: { type: "url", url: REMOTE_URL },
});
const rspImg = (url: string) => ({ type: "input_image", image_url: url });
const rspTxt = (t: string) => ({ type: "input_text", text: t });

test("openai: drops image parts from old messages, keeps the most recent N", () => {
const body = {
model: "gpt",
messages: [
{ role: "user", content: [oaiTxt("look"), oaiImg(DATA_URL)] },
{ role: "assistant", content: [oaiImg(DATA_URL)] },
{ role: "user", content: [oaiTxt("hi"), oaiImg(REMOTE_URL)] },
],
};
const r = stripHistoricalImages(body, "openai", 1);
assert.equal(r.removed, 2);
assert.notEqual(r.body, body);
const m = (r.body as { messages: unknown[] }).messages;
assert.deepEqual(m[0], { role: "user", content: [oaiTxt("look")] });
assert.deepEqual(m[1], {
role: "assistant",
content: [{ type: "text", text: "[image]" }],
});
assert.equal(m[2], body.messages[2]);
});

test("openai: no images returns the input reference unchanged", () => {
const body = {
messages: [
{ role: "user", content: [oaiTxt("only text")] },
{ role: "assistant", content: [oaiTxt("still text")] },
],
};
const r = stripHistoricalImages(body, "openai", 1);
assert.equal(r.removed, 0);
assert.equal(r.body, body);
});

test("openai: keepRecent >= length strips nothing", () => {
const body = {
messages: [
{ role: "user", content: [oaiImg(DATA_URL)] },
{ role: "assistant", content: [oaiImg(DATA_URL)] },
],
};
const r = stripHistoricalImages(body, "openai", 5);
assert.equal(r.removed, 0);
assert.equal(r.body, body);
});

test("openai: keepRecent 0 strips every message", () => {
const body = {
messages: [
{ role: "user", content: [oaiImg(DATA_URL)] },
{ role: "assistant", content: [oaiTxt("x"), oaiImg(DATA_URL)] },
],
};
const r = stripHistoricalImages(body, "openai", 0);
assert.equal(r.removed, 2);
const m = (r.body as { messages: unknown[] }).messages;
assert.deepEqual(m[0].content, [{ type: "text", text: "[image]" }]);
assert.deepEqual(m[1].content, [oaiTxt("x")]);
});

test("openai: remote-URL images are stripped too (type-based, not data-URL-only)", () => {
const body = { messages: [{ role: "user", content: [oaiImg(REMOTE_URL)] }] };
const r = stripHistoricalImages(body, "openai", 0);
assert.equal(r.removed, 1);
assert.deepEqual((r.body as { messages: unknown[] }).messages[0].content, [
{ type: "text", text: "[image]" },
]);
});

test("openai: non-image fields on a message are preserved", () => {
const body = {
messages: [
{
role: "tool",
tool_call_id: "t1",
name: "search",
content: [oaiImg(DATA_URL)],
},
],
};
const r = stripHistoricalImages(body, "openai", 0);
const m = (r.body as { messages: Array<Record<string, unknown>> })
.messages[0];
assert.equal(m.role, "tool");
assert.equal(m.tool_call_id, "t1");
assert.equal(m.name, "search");
assert.deepEqual(m.content, [{ type: "text", text: "[image]" }]);
});

test("anthropic: strips base64 and url image parts, keeps recent", () => {
const body = {
model: "claude",
messages: [
{ role: "user", content: [antImgB64()] },
{ role: "assistant", content: [oaiTxt("ok"), antImgUrl()] },
],
};
const r = stripHistoricalImages(body, "anthropic", 1);
assert.equal(r.removed, 1);
const m = (r.body as { messages: unknown[] }).messages;
assert.deepEqual(m[0], {
role: "user",
content: [{ type: "text", text: "[image]" }],
});
assert.equal(m[1], body.messages[1]);
});

test("responses: strips input_image, uses input_text placeholder, ignores non-content items", () => {
const body = {
model: "gpt",
input: [
{ type: "message", role: "user", content: [rspImg(DATA_URL)] },
{ type: "function_call", name: "f", call_id: "c1", arguments: "{}" },
{
type: "message",
role: "user",
content: [rspTxt("q"), rspImg(REMOTE_URL)],
},
],
};
const r = stripHistoricalImages(body, "responses", 1);
assert.equal(r.removed, 1);
const inp = (r.body as { input: unknown[] }).input;
assert.deepEqual(inp[0], {
type: "message",
role: "user",
content: [{ type: "input_text", text: "[image]" }],
});
assert.equal(inp[1], body.input[1]);
assert.equal(inp[2], body.input[2]);
});

test("null protocol is a no-op even when the body carries images", () => {
const body = { messages: [{ role: "user", content: [oaiImg(DATA_URL)] }] };
const r = stripHistoricalImages(body, null, 0);
assert.equal(r.removed, 0);
assert.equal(r.body, body);
});

test("non-object or missing container bodies are no-ops returning the same reference", () => {
assert.deepEqual(stripHistoricalImages(null, "openai", 5), {
body: null,
removed: 0,
});
assert.deepEqual(stripHistoricalImages(42, "openai", 5), {
body: 42,
removed: 0,
});
const bare = { foo: 1 };
const r = stripHistoricalImages(bare, "openai", 5);
assert.equal(r.body, bare);
assert.equal(r.removed, 0);
});
Loading