mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
feat(chat): forward OpenAI image_url parts to Claude (multimodal vision) (#154)
* feat(chat): forward OpenAI image_url parts to Claude (multimodal vision) `POST /v1/chat/completions` previously flattened every message to plain text via contentToText(), replacing image_url parts with "[non-text content omitted]" — so images were silently dropped (issue #110). This adds real multimodal support: OpenAI `image_url` content parts are translated to Anthropic image blocks and fed to the Claude CLI over `--input-format stream-json`, keeping subscription auth (the reason OCP routes through the CLI rather than the API). Class B.1 (OpenAI-compatibility surface), authorized by ADR 0006. Request shape follows OpenAI's published vision / chat-completions spec (https://platform.openai.com/docs/guides/vision and the chat/completions `content` image_url part) — no field is introduced beyond OpenAI's shape. The CLI's `--input-format stream-json` is the transport for this Class B endpoint, not a forwarded cli.js operation, so there is no Class A cli.js citation to make; scope is justified under the ALIGNMENT.md Class B mapping of Rule 2 (no invention beyond the cited OpenAI spec). Mechanism (verified empirically against the installed CLI, v2.1.206): a user message whose `content` is an Anthropic block array including `{type:"image",source:{type:"base64",media_type,data}}` fed to `claude -p --input-format stream-json` is correctly described by the model. Confirmed live end-to-end through this endpoint (a base64 PNG returns the correct color). Design: - New pure module `lib/multimodal.mjs` (mirrors the lib/*.mjs pattern; unit- testable without a live server): hasImageContent, buildImageBlocks, buildStreamJsonInput, MultimodalError. - server.mjs: text path is byte-for-byte unchanged. Only when a request carries an image_url part does spawnClaudeProcess switch stdin to a stream-json user envelope and buildCliArgs add `--input-format stream-json`. Image parsing runs before any stats mutation so a validation failure never leaks counters/slots. - Images bypass the text char budget (CLAUDE_MAX_PROMPT_CHARS) and are bounded by explicit byte/count caps with clear 4xx errors (413 for size/count, 400 for malformed/unsupported/disabled-remote), never a silent drop. Scope decisions (v1): - Base64 data URIs supported by default (image/jpeg,png,gif,webp). - Remote http(s) image URLs OFF by default behind CLAUDE_IMAGE_ALLOW_URL; when enabled they are passed through as an Anthropic url-source (OCP never fetches the URL itself, so no OCP-side SSRF surface). - Audio/file parts deferred: existing placeholder behavior preserved. - Images anywhere in multi-turn history, not just the last message. New env vars (documented in README Environment Variables table): CLAUDE_IMAGE_ALLOW_URL, CLAUDE_MAX_IMAGE_BYTES, CLAUDE_MAX_IMAGES, CLAUDE_MAX_IMAGE_TOTAL_BYTES, CLAUDE_MAX_BODY_SIZE (now configurable; default 5 MB unchanged). Tests: 26 unit tests in test-features.mjs covering data-URI parse, multiple images, text/image ordering, multi-turn history images, malformed/oversized/ too-many handling, remote-URL policy, and text-path parity. `npm test` green (289 passed). `node --check` clean. No alignment-blacklist tokens added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): address PR #154 review blockers — TUI guard, fail-closed caps, text budget Remediates the three merge-blocking findings from the maintainer's review of the multimodal vision PR. Class B.1 (OpenAI-compat surface): request shape per OpenAI vision spec (image_url content parts), authorized by ADR 0006. No new wire shape and no cli.js surface change — the stream-json image contract is the CLI's native input format (already cited in the base commit); these are correctness fixes on the OCP-owned validation/dispatch layer. F1 — TUI mode silently dropped images and returned 200. callClaudeTui() renders every non-text part as "[non-text content omitted]", so a vision request in CLAUDE_TUI_MODE=true was answered about an image the model never saw. Now handleChatCompletions fails loudly with 400 images_unsupported_in_tui_mode instead of a silent drop (ALIGNMENT.md forbids serving text the model did not mean). Documented in README § Images / Multimodal. F3 — NaN env parsing failed open. CLAUDE_MAX_BODY_SIZE=unlimited -> NaN -> `body.length > NaN` always false -> body cap gone (OOM DoS); =5MB -> 5 bytes -> proxy bricked; same on CLAUDE_MAX_IMAGES / _IMAGE_BYTES / _IMAGE_TOTAL_BYTES. Added lib/env.mjs parsePositiveInt (pure, fail-closed) + a thin parseIntEnv warn wrapper; a malformed cap now keeps the safe default and warns at startup. F2 — images let unbounded text bypass MAX_PROMPT_CHARS. buildImageBlocks only counted textChars and never truncated; the budget was never passed in. Threaded maxTextChars (= MAX_PROMPT_CHARS) into the multimodal transform, which now truncates text tail-first (mirroring messagesToPrompt) while preserving image blocks, and logs prompt_truncated. Tests: +11 in test-features.mjs (all pure-module, per the repo's no-server-import pattern) covering the text-budget enforcement and the fail-closed cap parsing, including the exact F2 (500k chars + 1 image) and F3 (unlimited/5MB/0/20.5) scenarios. 300 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): address PR #154 review round 2 — MAX_PROMPT_CHARS fail-closed + system-only image guard Closes the two residual gaps from the round-2 review, both traced to the same root as the already-fixed blockers. Class B.1 (OpenAI-compat vision): authorized by ADR 0006; request shape is the OpenAI `image_url` content part. No new wire shape — the Anthropic image block over `--input-format stream-json` is the CLI's native contract (cli.js buildStreamJsonInput path, verified live in round 1). MAX_PROMPT_CHARS is OCP's own truncation guard, not a cli.js operation. Gap (a) — MAX_PROMPT_CHARS was left on the raw parseInt while every other cap moved to the fail-closed helper. `let MAX_PROMPT_CHARS = parseInt(env||"150000",10)` sat five lines above the parseIntEnv helper this PR added, so CLAUDE_MAX_PROMPT_CHARS=unlimited → NaN → enforceTextBudget's `!(NaN > 0)` early-return → 500k chars passed unbounded, truncated:false, silently defeating F2's text-budget guarantee under a plausible operator config. Fix: hoist parseIntEnv above the declaration and derive MAX_PROMPT_CHARS through it (keeps `let` for the settings API). A misconfigured value now keeps the 150k default and warns, like the other caps. Gap (b) — an image present ONLY in a system message silently dropped in non-TUI mode. Detection runs on the full message list, but extraction/spawn filter role==="system" out, so a system-only image was detected as multimodal, survived no filter, fell to the text path, and rendered as "[non-text content omitted]" → 200 with a hallucinated answer — the one silent-drop outcome F1 exists to forbid. Fix: after filtering, if hasImageContent(full) is true but no image survives, return `400 images_unsupported_in_system_messages`. Narrow (OpenAI disallows images in the system role) so no legitimate request is rejected. Documented in README § Images. Tests: +4 (all pure-module) — parsePositiveInt('unlimited') keeps the 150k default (gap a) and a valid override is honored; hasImageContent proves the guard predicate fires for a system-only image (true on full list, false after the system filter) and does NOT fire for a user-message image. 304 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: vvlasy-openclaw <vvlasy-openclaw@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com> Co-authored-by: dtzp555 <dtzp555@gmail.com>
This commit is contained in:
@@ -3413,6 +3413,337 @@ test("contentToText: null returns empty string", () => {
|
||||
assert.equal(contentToText(null), "");
|
||||
});
|
||||
|
||||
// ── multimodal image transform (issue #110) ──────────────────────────────────
|
||||
// OpenAI image_url parts → Anthropic image blocks for `claude -p --input-format
|
||||
// stream-json`. lib/multimodal.mjs is a PURE module (no server.listen()), so it is
|
||||
// imported directly here. Class B.1: shape per OpenAI vision spec, authorized by
|
||||
// ADR 0006. Mechanism verified live: a base64 PNG fed as an Anthropic image block
|
||||
// via --input-format stream-json is correctly described by the model.
|
||||
import {
|
||||
hasImageContent as mmHasImageContent,
|
||||
buildImageBlocks as mmBuildImageBlocks,
|
||||
buildStreamJsonInput as mmBuildStreamJsonInput,
|
||||
MultimodalError as MmError,
|
||||
SUPPORTED_IMAGE_TYPES as MM_SUPPORTED,
|
||||
} from "./lib/multimodal.mjs";
|
||||
import { parsePositiveInt } from "./lib/env.mjs";
|
||||
|
||||
console.log("\nmultimodal image transform (issue #110):");
|
||||
|
||||
// A short, valid base64 string (charset-valid; not decoded by the transform).
|
||||
const MM_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGP4DwABAQEAG7buVgAAAABJRU5ErkJggg==";
|
||||
const dataUri = (mt = "image/png") => `data:${mt};base64,${MM_B64}`;
|
||||
const imgPart = (mt) => ({ type: "image_url", image_url: { url: dataUri(mt) } });
|
||||
const txtPart = (t) => ({ type: "text", text: t });
|
||||
|
||||
test("hasImageContent: plain string message → false (text path preserved)", () => {
|
||||
assert.equal(mmHasImageContent([{ role: "user", content: "hello" }]), false);
|
||||
});
|
||||
|
||||
test("hasImageContent: array of text-only parts → false", () => {
|
||||
assert.equal(mmHasImageContent([{ role: "user", content: [txtPart("a"), txtPart("b")] }]), false);
|
||||
});
|
||||
|
||||
test("hasImageContent: message with an image_url part → true", () => {
|
||||
assert.equal(mmHasImageContent([{ role: "user", content: [txtPart("q"), imgPart()] }]), true);
|
||||
});
|
||||
|
||||
test("hasImageContent: image anywhere in history (not just last) → true", () => {
|
||||
const msgs = [
|
||||
{ role: "user", content: [txtPart("look"), imgPart()] },
|
||||
{ role: "assistant", content: "ok" },
|
||||
{ role: "user", content: "and now?" },
|
||||
];
|
||||
assert.equal(mmHasImageContent(msgs), true);
|
||||
});
|
||||
|
||||
// ── PR #154 review round 2, gap (b): image ONLY in a system message must not silently drop ──
|
||||
// The handler detects multimodal on the FULL list but extraction/spawn filter system messages out.
|
||||
// The guard fires exactly when the full list has an image but the non-system list does not — proven
|
||||
// here against the same predicate the guard uses, so a system-only image is rejected (400) rather
|
||||
// than falling to the text path and returning a 200 hallucinated answer.
|
||||
test("hasImageContent: image ONLY in a system message → true on full list, false after system filter (guard fires)", () => {
|
||||
const msgs = [
|
||||
{ role: "system", content: [txtPart("context"), imgPart()] },
|
||||
{ role: "user", content: "describe it" },
|
||||
];
|
||||
assert.equal(mmHasImageContent(msgs), true, "detected as multimodal on the full list");
|
||||
assert.equal(mmHasImageContent(msgs.filter(m => m.role !== "system")), false, "no image survives the system filter → guard must 400");
|
||||
});
|
||||
test("hasImageContent: image in a USER message survives the system filter (legitimate request not rejected)", () => {
|
||||
const msgs = [
|
||||
{ role: "system", content: "you are helpful" },
|
||||
{ role: "user", content: [txtPart("describe it"), imgPart()] },
|
||||
];
|
||||
assert.equal(mmHasImageContent(msgs.filter(m => m.role !== "system")), true, "user image survives → normal multimodal path");
|
||||
});
|
||||
|
||||
test("buildImageBlocks: data-URI parsed into an Anthropic base64 image block", () => {
|
||||
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("what is this?"), imgPart("image/png")] }]);
|
||||
assert.equal(blocks.length, 2);
|
||||
assert.equal(blocks[0].type, "text");
|
||||
assert.equal(blocks[0].text, "what is this?");
|
||||
assert.deepEqual(blocks[1], { type: "image", source: { type: "base64", media_type: "image/png", data: MM_B64 } });
|
||||
assert.equal(stats.imageCount, 1);
|
||||
assert.ok(stats.totalImageBytes > 0);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: media_type carried through (jpeg/gif/webp)", () => {
|
||||
for (const mt of ["image/jpeg", "image/gif", "image/webp"]) {
|
||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [imgPart(mt)] }]);
|
||||
assert.equal(blocks.find(b => b.type === "image").source.media_type, mt);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildImageBlocks: multiple images in one message both emitted", () => {
|
||||
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("compare"), imgPart(), imgPart()] }]);
|
||||
const imgs = blocks.filter(b => b.type === "image");
|
||||
assert.equal(imgs.length, 2);
|
||||
assert.equal(stats.imageCount, 2);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: text/image/text ordering preserved", () => {
|
||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [txtPart("A"), imgPart(), txtPart("B")] }]);
|
||||
assert.deepEqual(blocks.map(b => (b.type === "text" ? b.text : "IMG")), ["A", "IMG", "B"]);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: image-first message keeps ordering (image before text)", () => {
|
||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [imgPart(), txtPart("caption")] }]);
|
||||
assert.deepEqual(blocks.map(b => (b.type === "text" ? b.text : "IMG")), ["IMG", "caption"]);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: multi-turn history — role prefixes + separators preserved", () => {
|
||||
const msgs = [
|
||||
{ role: "user", content: "first q" },
|
||||
{ role: "assistant", content: "prior answer" },
|
||||
{ role: "user", content: [txtPart("now this"), imgPart()] },
|
||||
];
|
||||
const { blocks } = mmBuildImageBlocks(msgs);
|
||||
assert.equal(blocks[0].text, "first q");
|
||||
assert.equal(blocks[1].text, "\n\n[Assistant] prior answer");
|
||||
assert.equal(blocks[2].text, "\n\nnow this");
|
||||
assert.equal(blocks[3].type, "image");
|
||||
});
|
||||
|
||||
test("buildImageBlocks: image in an EARLIER turn is carried (history image)", () => {
|
||||
const msgs = [
|
||||
{ role: "user", content: [txtPart("here"), imgPart()] },
|
||||
{ role: "assistant", content: "got it" },
|
||||
{ role: "user", content: "thanks" },
|
||||
];
|
||||
const { blocks, stats } = mmBuildImageBlocks(msgs);
|
||||
assert.equal(stats.imageCount, 1);
|
||||
assert.equal(blocks.filter(b => b.type === "image").length, 1);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: image_url as bare string is accepted (client leniency)", () => {
|
||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: dataUri() }] }]);
|
||||
assert.equal(blocks.find(b => b.type === "image").source.data, MM_B64);
|
||||
});
|
||||
|
||||
test("buildStreamJsonInput: emits one newline-terminated user envelope", () => {
|
||||
const { payload } = mmBuildStreamJsonInput([{ role: "user", content: [txtPart("hi"), imgPart()] }]);
|
||||
assert.ok(payload.endsWith("\n"));
|
||||
const env = JSON.parse(payload.trim());
|
||||
assert.equal(env.type, "user");
|
||||
assert.equal(env.message.role, "user");
|
||||
assert.equal(env.message.content[1].type, "image");
|
||||
});
|
||||
|
||||
// ── malformed / policy / oversized handling (clean 4xx, never a silent drop) ──
|
||||
test("buildImageBlocks: unsupported media type → 400 unsupported_image_type", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [imgPart("image/tiff")] }]),
|
||||
(e) => e instanceof MmError && e.code === "unsupported_image_type" && e.status === 400
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: non-base64 data URI → 400 invalid_data_uri", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png,notbase64" } }] }]),
|
||||
(e) => e instanceof MmError && e.code === "invalid_data_uri" && e.status === 400
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: malformed data URI (no comma) → 400 invalid_data_uri", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64" } }] }]),
|
||||
(e) => e instanceof MmError && e.code === "invalid_data_uri"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: image_url part missing a URL → 400 invalid_image_url", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: {} }] }]),
|
||||
(e) => e instanceof MmError && e.code === "invalid_image_url"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: oversized single image → 413 image_too_large", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [imgPart()] }], { maxImageBytes: 4 }),
|
||||
(e) => e instanceof MmError && e.code === "image_too_large" && e.status === 413
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: too many images → 413 too_many_images", () => {
|
||||
const many = Array.from({ length: 3 }, () => imgPart());
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: many }], { maxImages: 2 }),
|
||||
(e) => e instanceof MmError && e.code === "too_many_images" && e.status === 413
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: aggregate image bytes over cap → 413 images_too_large", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [imgPart(), imgPart()] }], { maxTotalImageBytes: 100, maxImageBytes: 1000 }),
|
||||
(e) => e instanceof MmError && e.code === "images_too_large" && e.status === 413
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: remote http(s) URL disabled by default → 400 remote_url_disabled", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }] }]),
|
||||
(e) => e instanceof MmError && e.code === "remote_url_disabled" && e.status === 400
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: remote URL passthrough when allowRemoteUrl=true (url source, OCP does not fetch)", () => {
|
||||
const { blocks } = mmBuildImageBlocks(
|
||||
[{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }] }],
|
||||
{ allowRemoteUrl: true }
|
||||
);
|
||||
assert.deepEqual(blocks.find(b => b.type === "image").source, { type: "url", url: "https://example.com/a.png" });
|
||||
});
|
||||
|
||||
test("buildImageBlocks: unsupported URL scheme → 400 unsupported_url_scheme", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "ftp://x/y.png" } }] }], { allowRemoteUrl: true }),
|
||||
(e) => e instanceof MmError && e.code === "unsupported_url_scheme"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: non-image parts (audio/file) fall back to placeholder text", () => {
|
||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [txtPart("hear this"), { type: "input_audio", input_audio: {} }] }]);
|
||||
assert.deepEqual(blocks.map(b => b.text), ["hear this", "[non-text content omitted]"]);
|
||||
});
|
||||
|
||||
test("SUPPORTED_IMAGE_TYPES: exactly the four Anthropic vision types", () => {
|
||||
assert.deepEqual([...MM_SUPPORTED].sort(), ["image/gif", "image/jpeg", "image/png", "image/webp"]);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: pure-text conversation still yields text blocks (untouched-path parity)", () => {
|
||||
// hasImageContent would be false for this input in server.mjs (text path taken),
|
||||
// but the transform must still be well-defined for a text-only turn.
|
||||
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: "just text" }]);
|
||||
assert.deepEqual(blocks, [{ type: "text", text: "just text" }]);
|
||||
assert.equal(stats.imageCount, 0);
|
||||
assert.equal(stats.truncated, false);
|
||||
});
|
||||
|
||||
// ── F2 (PR #154 review): text char budget is enforced on the multimodal path ──
|
||||
// Regression guard: without maxTextChars, attaching one tiny image let unbounded
|
||||
// text bypass MAX_PROMPT_CHARS entirely (the text path truncates; the image path
|
||||
// did not). server.mjs passes maxTextChars: MAX_PROMPT_CHARS into this transform.
|
||||
console.log("\nmultimodal text-budget enforcement (PR #154 F2):");
|
||||
|
||||
test("buildImageBlocks: text under budget → not truncated, blocks unchanged", () => {
|
||||
const { blocks, stats } = mmBuildImageBlocks(
|
||||
[{ role: "user", content: [txtPart("short"), imgPart()] }],
|
||||
{ maxTextChars: 1000 }
|
||||
);
|
||||
assert.equal(stats.truncated, false);
|
||||
assert.equal(stats.textChars, "short".length);
|
||||
assert.equal(blocks.filter(b => b.type === "image").length, 1);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: text over budget → truncated, keeps most-recent tail + note", () => {
|
||||
const big = "A".repeat(300) + "TAIL_MARKER";
|
||||
const { blocks, stats } = mmBuildImageBlocks(
|
||||
[{ role: "user", content: [txtPart(big)] }],
|
||||
{ maxTextChars: 50 }
|
||||
);
|
||||
assert.equal(stats.truncated, true);
|
||||
assert.equal(stats.originalTextChars, big.length);
|
||||
// The most recent characters (the tail) survive; the oldest 'A's are dropped.
|
||||
const joined = blocks.filter(b => b.type === "text").map(b => b.text).join("");
|
||||
assert.ok(joined.includes("TAIL_MARKER"), "tail text must be kept");
|
||||
assert.ok(joined.includes("truncated to fit"), "a truncation note must be present");
|
||||
assert.ok(stats.originalTextChars > stats.textChars, "post-truncation text is smaller");
|
||||
});
|
||||
|
||||
test("buildImageBlocks: F2 exact scenario — 500k chars + one image → text bounded, image preserved", () => {
|
||||
const { blocks, stats } = mmBuildImageBlocks(
|
||||
[{ role: "user", content: [txtPart("Z".repeat(500000)), imgPart()] }],
|
||||
{ maxTextChars: 150000 }
|
||||
);
|
||||
assert.equal(stats.truncated, true);
|
||||
assert.ok(stats.textChars <= 150000 + 200, "text char count is bounded by the budget (+note)");
|
||||
// The image bypasses the text budget and is NOT dropped by truncation.
|
||||
assert.equal(blocks.filter(b => b.type === "image").length, 1);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: default (no maxTextChars) never truncates — pure module standalone", () => {
|
||||
const { stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("x".repeat(10000))] }]);
|
||||
assert.equal(stats.truncated, false);
|
||||
assert.equal(stats.textChars, 10000);
|
||||
});
|
||||
|
||||
// ── F3 (PR #154 review): fail-closed positive-int env parsing ────────────────
|
||||
// A misconfigured numeric cap must NEVER silently disable a guard (`x > NaN` is
|
||||
// always false) or brick the proxy with a nonsense value. parsePositiveInt keeps
|
||||
// the default and reports ok:false so the caller can warn.
|
||||
console.log("\nfail-closed env-cap parsing (PR #154 F3):");
|
||||
|
||||
test("parsePositiveInt: missing/empty → default, ok", () => {
|
||||
assert.deepEqual(parsePositiveInt(undefined, 42), { value: 42, ok: true });
|
||||
assert.deepEqual(parsePositiveInt("", 42), { value: 42, ok: true });
|
||||
});
|
||||
|
||||
test("parsePositiveInt: valid positive integer → parsed value", () => {
|
||||
assert.equal(parsePositiveInt("5000000", 42).value, 5000000);
|
||||
assert.equal(parsePositiveInt("5000000", 42).ok, true);
|
||||
});
|
||||
|
||||
test("parsePositiveInt: 'unlimited' → NaN rejected, default kept (would drop the cap)", () => {
|
||||
const r = parsePositiveInt("unlimited", 5 * 1024 * 1024);
|
||||
assert.equal(r.value, 5 * 1024 * 1024);
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
|
||||
test("parsePositiveInt: '5MB' → unit suffix rejected (naive parseInt would give 5 bytes)", () => {
|
||||
const r = parsePositiveInt("5MB", 5 * 1024 * 1024);
|
||||
assert.equal(r.value, 5 * 1024 * 1024);
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
|
||||
test("parsePositiveInt: '0' and '-1' → non-positive rejected", () => {
|
||||
assert.equal(parsePositiveInt("0", 20).ok, false);
|
||||
assert.equal(parsePositiveInt("0", 20).value, 20);
|
||||
assert.equal(parsePositiveInt("-1", 20).ok, false);
|
||||
});
|
||||
|
||||
test("parsePositiveInt: '20.5' → fractional/ambiguous rejected", () => {
|
||||
assert.equal(parsePositiveInt("20.5", 20).ok, false);
|
||||
});
|
||||
|
||||
test("parsePositiveInt: surrounding whitespace tolerated", () => {
|
||||
assert.deepEqual(parsePositiveInt(" 20 ", 5), { value: 20, ok: true });
|
||||
});
|
||||
|
||||
// ── PR #154 review round 2, gap (a): MAX_PROMPT_CHARS must fail closed like the other caps ──
|
||||
// server.mjs now derives MAX_PROMPT_CHARS via parseIntEnv → parsePositiveInt (was a raw parseInt).
|
||||
// CLAUDE_MAX_PROMPT_CHARS=unlimited previously → NaN → enforceTextBudget's `!(NaN > 0)` early-return
|
||||
// → 500k chars passed unbounded, defeating F2's text-budget guarantee. The default must be kept.
|
||||
test("parsePositiveInt: CLAUDE_MAX_PROMPT_CHARS='unlimited' → default kept, cap not lost to NaN (gap a)", () => {
|
||||
const r = parsePositiveInt("unlimited", 150000);
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.value, 150000, "the 150k text budget must survive a bad config, not become NaN");
|
||||
});
|
||||
test("parsePositiveInt: CLAUDE_MAX_PROMPT_CHARS valid override honored", () => {
|
||||
assert.deepEqual(parsePositiveInt("200000", 150000), { value: 200000, ok: true });
|
||||
});
|
||||
|
||||
// ── messages guard predicate truth-table (issue #110) ────────────────────────
|
||||
// Mirrors the guard at server.mjs line ~1650: Array.isArray(x) && x.length > 0
|
||||
console.log("\nmessages guard predicate (issue #110):");
|
||||
|
||||
Reference in New Issue
Block a user