Files
gbrain/test/voyage-response-cap.test.ts
T
182a144272 v0.33.1.1 fix: Voyage output_dimension + flexible-dim guard + OOM-cap rethrow (#962)
* fix: send Voyage output_dimension on embedding requests

* fixup: drop voyage-4-nano from flexible-dim set

Voyage's hosted /embeddings endpoint accepts `output_dimension` only for
the seven flexible-dim models (voyage-4-large, voyage-4, voyage-4-lite,
voyage-3-large, voyage-3.5, voyage-3.5-lite, voyage-code-3). voyage-4-nano
is an open-weight variant Voyage lists separately as fixed 1024-dim — the
hosted API rejects the parameter for it.

The recipe docstring previously claimed "all v4 variants" have flexible
dims, which is what led to nano being added to the allowlist in the first
place. Tighten the comment to name the hosted trio explicitly and call out
nano-as-open-weight.

Convert the test case at test/ai/gateway.test.ts from a positive assertion
(voyage-4-nano returns { dimensions: 512 }) to a negative regression pin
(voyage-4-nano returns undefined), so a future contributor can't silently
re-add nano without breaking this test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: Voyage OOM-cap rethrow + flexible-dim runtime validation (Codex P3 follow-ups)

Two follow-ups from Codex's adversarial review of PR #962, both Voyage-adjacent
correctness fixes that the original PR scope had filed as TODOs.

1. gateway.ts:619 Voyage OOM cap was theatrical
-------------------------------------------------
voyageCompatFetch's inbound response rewriter is wrapped in a try/catch that
falls back to the original response on parse failure — correct for "Voyage
returned JSON I can't reshape, let the SDK handle it." But the per-embedding
Layer 2 OOM cap at line 619 threw a bare `new Error(...)`, which the same
catch silently swallowed. Net result: an oversized base64 response (Layer 1
skipped because no Content-Length header) returned through to the AI SDK and
could OOM the worker on JSON.parse.

Fix: introduce `VoyageResponseTooLargeError`, throw it at both cap sites
(Content-Length Layer 1 at line 595 and per-embedding Layer 2 at line 619),
and rethrow it from the inbound try/catch via `if (err instanceof
VoyageResponseTooLargeError) throw err`. Pre-existing fall-back-on-parse-error
behavior for other thrown errors is preserved.

Regression-pinned by 2 new behavioral tests (mock fetch returns oversized
Content-Length / oversized base64; embed() throws with the expected message)
and a structural assertion in test/voyage-response-cap.test.ts that the
`instanceof VoyageResponseTooLargeError ⇒ throw` line stays put.

2. Voyage flexible-dim runtime validation + doctor check
-------------------------------------------------------
A brain configured for a Voyage flexible-dim model (voyage-4-large,
voyage-3-large, voyage-3.5, voyage-3.5-lite, voyage-4, voyage-4-lite,
voyage-code-3) without an explicit `embedding_dimensions` would fall back to
DEFAULT_EMBEDDING_DIMENSIONS=1536 — an OpenAI default that Voyage rejects.
Voyage's only accepted values are {256, 512, 1024, 2048}. Pre-fix the failure
surfaced as an HTTP 400 from Voyage that often got misclassified as a
transient network error.

Fix:
- `dims.ts` exports `VOYAGE_VALID_OUTPUT_DIMS` and `isValidVoyageOutputDim`.
- `dimsProviderOptions` throws `AIConfigError` with a paste-ready fix command
  (`gbrain config set embedding_dimensions ...`) when a Voyage flexible-dim
  model is configured with an invalid dim value.
- `gbrain models doctor` gets a new `embedding_config` probe that runs first
  (zero tokens) and surfaces the misconfiguration before any chat/expansion
  probes spend a single token. New probe status `config` + optional `fix`
  hint rendered in human output.

Regression-pinned by 6 new unit tests covering the AIConfigError throw,
exact valid-values set, the bypass path for fixed-dim Voyage models, and
the fix-hint contents.

* chore: bump version and changelog (v0.33.1.1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.33.1.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Eva <eva@100yen.org>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:21:21 -04:00

99 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v0.31.8 — voyage adapter Content-Length pre-check + per-item cap (D2 + D10).
*
* Pre-fix: voyageCompatFetch in src/core/ai/gateway.ts called
* `await resp.clone().json()` BEFORE iterating embeddings. A malicious or
* compromised Voyage response of arbitrary size was fully parsed into the
* JS heap before any cap could fire. The fix adds two layers:
*
* Layer 1 (PRIMARY): Content-Length header pre-check, fires BEFORE
* `resp.clone().json()` so the JSON.parse OOM vector is gated.
* Layer 2 (defense-in-depth): per-embedding base64 length check inside
* the iteration; catches the rare case where Layer 1 was skipped
* because the response uses chunked encoding (no Content-Length).
*
* The cap is 256 MB — sized as "unambiguously not legitimate" (the
* realistic upper bound is voyage-3-large × 16K embeddings ≈ 200 MB raw).
*
* Tests are structural source-string assertions (matches the doctor.test.ts
* style) because voyageCompatFetch is a closed-over helper inside the
* recipe instantiation path and isn't exported. Behavioral coverage of the
* voyage adapter lives in the existing E2E tests that hit a live Voyage
* endpoint; these regression guards pin the SHAPE so a silent revert
* (e.g. moving the cap below the resp.clone().json() call) fails loudly.
*/
import { describe, test, expect } from 'bun:test';
describe('v0.31.8 — voyage Content-Length pre-check + per-item cap', () => {
test('MAX_VOYAGE_RESPONSE_BYTES constant is declared at 256 MB (D2 sizing)', async () => {
const source = await Bun.file(new URL('../src/core/ai/gateway.ts', import.meta.url)).text();
// 256 * 1024 * 1024 == 268435456. Either form is acceptable; assert
// both so a silent re-tighten to 64 MB or 128 MB fails this test.
expect(source).toContain('MAX_VOYAGE_RESPONSE_BYTES');
expect(source).toMatch(/MAX_VOYAGE_RESPONSE_BYTES\s*=\s*256\s*\*\s*1024\s*\*\s*1024/);
});
test('Layer 1: Content-Length pre-check fires BEFORE resp.clone().json() (D10 OOM defense)', async () => {
const source = await Bun.file(new URL('../src/core/ai/gateway.ts', import.meta.url)).text();
// Anchor relative to the post-fetch handler block. The function declaration
// contains an OUTBOUND request body section earlier; we want to verify
// the INBOUND ordering (everything after `await fetch(input, init)`).
const fetchCallIdx = source.indexOf('const resp = await fetch(input, init);');
expect(fetchCallIdx).toBeGreaterThan(0);
const inboundBlock = source.slice(fetchCallIdx, fetchCallIdx + 6000);
// Pre-check string. Use the actual code shape from gateway.ts so the test
// doesn't pin to comment text.
const preCheckIdx = inboundBlock.indexOf("resp.headers.get('content-length')");
// Use the full lvalue assignment so the match doesn't accidentally hit
// comment text that mentions `await resp.clone().json()` for context.
const jsonParseIdx = inboundBlock.indexOf('const json: any = await resp.clone().json()');
expect(preCheckIdx).toBeGreaterThan(0);
expect(jsonParseIdx).toBeGreaterThan(0);
// The pre-check MUST appear before the JSON parse — otherwise the OOM
// defense is theatrical (the original codex OV8 finding). This is the
// critical structural invariant for D10.
expect(preCheckIdx).toBeLessThan(jsonParseIdx);
});
test('Layer 1 throws on Content-Length over the cap (not silent return)', async () => {
const source = await Bun.file(new URL('../src/core/ai/gateway.ts', import.meta.url)).text();
// The cap check must throw a VoyageResponseTooLargeError so the inbound
// try/catch at the bottom of voyageCompatFetch rethrows it (instead of
// the pre-fix bare `catch {}` that swallowed `throw new Error(...)` and
// returned the original response — making the cap theatrical).
expect(source).toMatch(/exceeds[^`]*MAX_VOYAGE_RESPONSE_BYTES[^`]*bytes/);
expect(source).toMatch(/throw new VoyageResponseTooLargeError\([\s\S]{0,200}Voyage response Content-Length/);
});
test('Layer 2: per-embedding base64 cap fires inside the json.data iteration', async () => {
const source = await Bun.file(new URL('../src/core/ai/gateway.ts', import.meta.url)).text();
// Defense-in-depth: even when Content-Length header is missing
// (chunked encoding), each embedding string is bounded.
expect(source).toMatch(/item\.embedding\.length\s*\*\s*0\.75/);
expect(source).toMatch(/throw new VoyageResponseTooLargeError\([\s\S]{0,200}Voyage embedding base64/);
});
test('inbound try/catch rethrows VoyageResponseTooLargeError (Codex P3 follow-up)', async () => {
const source = await Bun.file(new URL('../src/core/ai/gateway.ts', import.meta.url)).text();
// Critical structural invariant after the Codex P3 fix: the catch
// around the inbound JSON-rewrite block MUST rethrow OOM-cap errors.
// Pre-fix, a bare `catch {}` swallowed the throw and returned the
// original (oversized) response, making Layer 2 ineffective. The
// tagged class + instanceof check restores fail-loud behavior.
expect(source).toContain('VoyageResponseTooLargeError');
expect(source).toMatch(/if\s*\(\s*err\s+instanceof\s+VoyageResponseTooLargeError\s*\)\s*throw\s+err/);
});
test('comment thread documents both layers + the cap-sizing decision', async () => {
const source = await Bun.file(new URL('../src/core/ai/gateway.ts', import.meta.url)).text();
// Anti-regression: the comment thread is part of the contract. If a
// refactor strips them, future maintainers won't know why the order
// matters or why the cap is 256 MB.
expect(source).toContain('Layer 1');
expect(source).toContain('Layer 2');
expect(source).toMatch(/16K embeddings/);
});
});