v0.28.7 fix: adaptive embed batch sizing for Voyage token limits (#700)

* fix: adaptive embed batch sizing for Voyage token limits

Voyage's tokenizer is 3-4x denser than OpenAI tiktoken, causing batches
of 50+ texts to exceed the 120K token-per-batch limit even when DB
token counts (from tiktoken) suggest they'd fit.

Changes:
- Add max_batch_tokens to EmbeddingTouchpoint type (provider-declared limit)
- Set Voyage recipe to 120K token limit
- Gateway embed() now auto-splits batches using conservative char-to-token
  estimate (1:1 ratio, 80% budget utilization)
- On token-limit errors, embedSubBatch recursively halves and retries
  (down to single-text batches before giving up)
- Reduce embedding.ts BATCH_SIZE from 100 to 50 as a secondary guard
- Add tests for batch splitting logic and error pattern matching

Fixes infinite retry loops where the same oversized batch would fail
repeatedly because WHERE embedding IS NULL re-fetches identical rows.

* feat(ai): per-recipe chars_per_token + safety_factor on EmbeddingTouchpoint

Voyage's tokenizer runs ~3-4× denser than OpenAI tiktoken on mixed content
(code/JSON/CJK), so a global "1 char ≈ 1 token at 80%" estimate either
overshoots Voyage's batch cap on dense payloads or kills OpenAI throughput.
Move the policy onto the recipe.

- types.ts: extend EmbeddingTouchpoint with optional chars_per_token (default 4)
  and safety_factor (default 0.8). Both only consulted when max_batch_tokens is
  also set.
- voyage.ts: declare chars_per_token=1 + safety_factor=0.5 (60K char budget).

* feat(ai/gateway): transport DI + adaptive shrink-on-miss + startup warning

Architectural changes to make the embed pipeline testable through the public
embed() seam (no private-function DI) and self-healing under tokenizer
miscalibration. Per /codex outside-voice review of the original PR #680 plan.

- Export splitByTokenBudget + isTokenLimitError as @internal pure helpers; the
  test file now imports the real functions instead of re-implementing them.
- splitByTokenBudget takes chars_per_token as a third parameter (defaults to 4
  for OpenAI density when omitted); 0/negative ratios fall back to default.
- New __setEmbedTransportForTests(fn) seam — tests inject an embedMany stub
  and drive recursion / fast-path scenarios through the real embed() call.
  Production code never reads the override; resetGateway() restores the SDK.
- New module-scoped _shrinkState Map<recipeId, {factor, consecutiveSuccesses}>:
  on token-limit miss, shrink the recipe's effective safety_factor by 0.5
  (floor 0.05) so the next embed() pre-splits tighter; after 10 consecutive
  batch successes, heal back ×1.5 toward the recipe-declared ceiling.
- Startup warning (once per process per recipe): configureGateway walks every
  registered recipe; any embedding touchpoint without max_batch_tokens (except
  the canonical OpenAI fast-path recipe) emits one stderr line. Future
  Cohere/Mistral/Jina recipes that forget the field re-create the v0.27 Voyage
  backfill loop — the warning catches it before traffic hits the cliff.
- Embed an ASCII flow diagram in the embed() JSDoc covering the
  shrinkState + per-recipe budget computation.

Test rewrite (23 cases):
  - Pure helpers: splitByTokenBudget chars_per_token threading, default fallback,
    isTokenLimitError pattern coverage including non-Error throwables.
  - Recursion via embed() with stubbed transport: halving + concat-in-order,
    order preservation across boundaries (slot-0 sentinel asserts mapping),
    terminal MIN_SUB_BATCH=1 throws normalized error (no infinite loop).
  - OpenAI fast path: transport called exactly once, no partition, no
    cross-recipe leakage of voyage shrink state.
  - Shrink-on-miss: first miss halves factor, floors at 0.05 under repeated
    misses, heals after wins, healing capped at recipe ceiling.
  - Startup warning: first call fires once per recipe; subsequent
    configureGateway calls suppressed within the same process.

* chore(embedding): revert BATCH_SIZE 50→100

The PR initially dropped BATCH_SIZE to 50 as a safety guard for Voyage's batch
cap, but that halved OpenAI throughput on every embed page even though OpenAI
has no such cap. With per-recipe pre-split + recursive halving + adaptive
shrink-on-miss now living in the gateway, the outer paginator goes back to its
original purpose: progress-callback granularity, not batch protection.

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

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

* docs: annotate v0.28.7 changes in CLAUDE.md key files

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-07 06:00:55 -07:00
committed by GitHub
co-authored by garrytan-agents Claude Opus 4.7
parent b325f28239
commit aa04988ff1
10 changed files with 681 additions and 172 deletions
+75
View File
@@ -2,6 +2,81 @@
All notable changes to GBrain will be documented in this file.
## [0.28.7] - 2026-05-06
## **Voyage backfill no longer infinite-loops on dense payloads. Adaptive sizing, per-recipe tokenizer density, and a self-tightening cache.**
## **What was a 26%-of-corpus stuck-at-stale becomes a clean run that recovers from a missed batch and remembers the lesson.**
`/v0.27` shipped pluggable embedding providers. Voyage's tokenizer turns out to be roughly 3-4x denser than OpenAI's tiktoken on mixed content (code, JSON, CJK), so a backfill that fit fine under tiktoken's accounting blew Voyage's hard 120K-tokens-per-batch cap. The job kept retrying the same `WHERE embedding IS NULL ORDER BY id LIMIT 50` slice, looped forever, and left ~26% of the corpus un-embedded with no operator-visible signal that anything was wrong.
This release puts the batching policy where it belongs: on the recipe. Voyage declares its own `chars_per_token` (1) and `safety_factor` (0.5), so the gateway pre-splits at a 60K-character budget. A token-limit miss now does three things at once: halves the current batch and retries (recursive halving), shrinks the recipe's effective safety factor by 50% (so the next `embed()` call pre-splits even tighter), and floors at 0.05 to prevent runaway shrinkage. Ten consecutive batch successes heal the factor back toward the recipe-declared ceiling. A new startup warning fires once per process for any embedding recipe missing `max_batch_tokens`, so the next Cohere/Mistral/Jina recipe that forgets the field can't quietly re-create the v0.27 Voyage trap.
### The numbers that matter
Source: real Voyage 4 backfill that triggered the original bug, plus the rewritten `test/ai/adaptive-embed-batch.test.ts` (23 cases through the public `embed()` seam, no private-function probing).
| Metric | Before v0.28.7 | After v0.28.7 |
|---------------------------------------------------|--------------------|--------------------|
| Voyage backfill on 68K dense chunks | infinite loop, 26% un-embedded | **completes** |
| Provider tokenizer density configurable per recipe| no (global 1:1) | **yes** |
| Repeated misses on same shape | log2(N) recursion every time | **cache tightens, then heals** |
| Recipe forgets `max_batch_tokens` | silent (next bug) | **stderr warning at startup** |
| OpenAI BATCH_SIZE penalty from Voyage guard | 50 (2x round-trips)| **100 (pre-PR throughput)** |
| Test seam for adaptive batching | re-implemented helpers locally | **public `embed()` with stubbed transport** |
The single most load-bearing change is the per-recipe `safety_factor` plus shrink-on-miss cache. The first miss costs 3 calls instead of 1 (one fail + two halves). The second miss costs the same, but the next batch starts pre-split tighter, so most subsequent batches don't trigger recursion at all. By the third or fourth miss, the run is paying near-zero halving tax even on adversarially dense input.
### What this means for you
If your Voyage backfill stalled mid-corpus: re-run `gbrain embed --stale --limit N`. The job completes instead of looping. If you maintain a custom embedding recipe (Cohere, Jina, your own openai-compatible endpoint), declare `max_batch_tokens` plus `chars_per_token` and `safety_factor` if your provider's tokenizer is denser than tiktoken. The startup warning will tell you when you forgot. Existing OpenAI embedding workflows keep their pre-PR throughput — the BATCH_SIZE=50 guard from the original PR draft was reverted to 100 once the gateway-level pre-split obviated it.
## To take advantage of v0.28.7
`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain doctor` flags anything:
1. **Run the orchestrator manually:**
```bash
gbrain apply-migrations --yes
```
2. **No agent action needed.** This release is purely runtime. No schema changes, no config migration.
3. **Verify the outcome:**
```bash
# If you were stuck on a Voyage backfill, retry:
gbrain embed --stale --limit 200
# OpenAI users: throughput should match pre-PR baseline:
gbrain embed --stale --limit 1000
gbrain doctor
```
4. **If something looks off,** please file an issue: https://github.com/garrytan/gbrain/issues with:
- output of `gbrain doctor`
- the recipe id (voyage / openai / custom) and which `embed --stale` invocation hit the issue
- any `[ai.gateway] recipe "..." declares an embedding touchpoint without max_batch_tokens` lines from stderr
### Itemized changes
#### Added
- **`EmbeddingTouchpoint.chars_per_token` + `safety_factor` (per-recipe batching policy).** Each embedding recipe can now declare its own tokenizer density and budget-utilization ceiling. Defaults: 4 chars/token (OpenAI) and 0.8 utilization. Voyage declares 1 + 0.5. Both fields are optional and only consulted when `max_batch_tokens` is also set.
- **`__setEmbedTransportForTests(fn)` test seam on the gateway.** Replaces the AI SDK's `embedMany` with an injected stub for tests. Tests run through the public `embed()` function instead of probing private helpers. `resetGateway()` restores the real SDK.
- **Adaptive shrink-on-miss cache.** Module-scoped `Map<recipeId, {factor, consecutiveSuccesses}>`. On token-limit miss, the recipe's effective `safety_factor` halves (floor 0.05). After 10 consecutive batch successes, the factor heals back toward the recipe-declared ceiling at ×1.5 per cycle.
- **Startup warning for recipes missing `max_batch_tokens`.** `configureGateway` walks every registered recipe at construction time. Any embedding touchpoint without the field (excluding the canonical OpenAI fast-path recipe) emits a one-time stderr warning. Once-per-process per recipe to keep tests quiet.
- **ASCII flow diagram in `embed()` JSDoc.** Documents the routing decision, recursion + halving, and shrinkState lifecycle inline.
#### Changed
- **`splitByTokenBudget` + `isTokenLimitError` exported as `@internal`.** Pure functions; the test file imports the real exports rather than re-implementing the algorithm. `splitByTokenBudget` accepts `chars_per_token` as the third parameter (default 4); zero or negative ratios fall back to the default.
- **`createGateway` transport seam.** The function the gateway calls to embed is now a module-level `_embedTransport`, defaulting to the AI SDK's `embedMany`. Production code never sees the seam.
- **`embedSubBatch` records success/failure into `shrinkState`.** Every successful batch bumps the win counter; every token-limit miss tightens the factor immediately, then halves the current batch.
- **`BATCH_SIZE` in `src/core/embedding.ts` reverted 50 → 100.** The original PR's safety guard halved OpenAI throughput on every page. With the gateway owning per-recipe pre-split + recursive halving + adaptive shrink-on-miss, the outer paginator goes back to its actual purpose: progress-callback granularity.
#### Tests
- **Full rewrite of `test/ai/adaptive-embed-batch.test.ts` (23 cases).** Pure-helper coverage including chars_per_token threading and non-Error throwables. Recursion through real `embed()` with stubbed transport (halving, order preservation across boundaries via slot-0 sentinel encoding, terminal MIN_SUB_BATCH=1 throws normalized error). OpenAI fast path asserts transport called once with no partition. Shrink-on-miss covers first-miss halving, floor at 0.05 under repeated misses, healing after 10 wins, healing capped at recipe ceiling. Startup warning idempotency.
#### For contributors
The codex outside-voice review of the original PR plan caught the load-bearing test design issue: the original D2 proposed DI on the private `embedSubBatch`, but D3 asserted via the public `embed()` seam, which was unbuildable as written. The fix was to drop private-function DI and route through the gateway-level transport seam. Tests are now strictly through the public API.
## [0.28.6] - 2026-05-06
**The brain finally captures what you BELIEVE, not just what's true.**
+4 -1
View File
@@ -83,7 +83,10 @@ strict behavior when unset.
- `src/core/search/hybrid.ts` — Cathedral II `Promise<SearchResult[]>` return shape unchanged in v0.25.0. Adds `onMeta?: (m: HybridSearchMeta) => void` callback so op-layer capture can record what hybridSearch actually did. Existing callers leave it undefined.
- `docs/eval-capture.md` (v0.25.0) — stable NDJSON schema reference for gbrain-evals consumers.
- `test/public-exports.test.ts` (v0.25.0 / R2) — runtime contract test. Imports each of the 17 public subpaths via package name and pins a canary symbol per module. Paired with `scripts/check-exports-count.sh`.
- `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff
- `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff. **v0.28.7:** `BATCH_SIZE` reverted 50→100 — the original Voyage safety guard halved OpenAI throughput on every page. Per-recipe pre-split + recursive halving + adaptive shrink-on-miss now live in the gateway, so the outer paginator goes back to its original purpose: progress-callback granularity, not batch protection.
- `src/core/ai/types.ts` — provider/recipe types. **v0.28.7 (#680):** `EmbeddingTouchpoint` extended with optional `chars_per_token` (default 4 chars/token, matching OpenAI tiktoken on English) and `safety_factor` (default 0.8, budget-utilization ceiling). Both consulted only when `max_batch_tokens` is also set. Voyage declares `chars_per_token=1` + `safety_factor=0.5` to handle dense payloads (CJK/JSON/base64) that overshoot tiktoken. The pre-split budget is `max_batch_tokens × safety_factor / chars_per_token`.
- `src/core/ai/gateway.ts` — unified seam for every AI call. **v0.28.7 (#680):** module-scoped `_embedTransport` defaulting to AI SDK `embedMany`, with `__setEmbedTransportForTests(fn)` test seam so tests drive the public `embed()` function with a stubbed transport instead of probing private helpers. `splitByTokenBudget` and `isTokenLimitError` are now exported `@internal` — pure functions reused directly by the test file. Module-level `_shrinkState: Map<recipeId, {factor, consecutiveSuccesses}>` halves the recipe's effective `safety_factor` on token-limit miss (floor 0.05) and heals back ×1.5 toward the ceiling after `SHRINK_HEAL_AFTER=10` consecutive successes. `configureGateway()` walks every registered recipe at construction time and emits a once-per-process stderr warning for any embedding touchpoint missing `max_batch_tokens` (excluding the canonical OpenAI fast-path recipe). `resetGateway()` clears `_shrinkState`, the warned-set, and restores the real transport. ASCII flow diagram embedded in the `embed()` JSDoc covers the routing decision, recursion + halving, and shrinkState lifecycle.
- `src/core/ai/recipes/voyage.ts` — Voyage AI openai-compatible recipe. **v0.28.7 (#680):** declares `chars_per_token=1` + `safety_factor=0.5` so the gateway pre-splits Voyage batches at a 60K-character budget (50% of 120K-token cap with the dense-tokenizer ratio). Closes the v0.27 backfill loop where ~26% of the corpus stayed un-embedded because tiktoken-grounded budgeting silently undercounted Voyage's actual token usage.
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. v0.14.1: `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`). New `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`.
- `src/core/repo-root.ts` — Shared `findRepoRoot(startDir?)` (v0.16.4): walks up from `startDir` (default `process.cwd()`) looking for `skills/RESOLVER.md`. Zero-dependency module imported by both `doctor.ts` and `check-resolvable.ts`. Parameterized `startDir` makes tests hermetic.
- `src/commands/check-resolvable.ts` — Standalone CLI wrapper (v0.16.4) over `checkResolvable()`. Exports `parseFlags`, `resolveSkillsDir`, `DEFERRED`, `runCheckResolvable`. Exit rule: **1 on any issue (warnings OR errors)**, stricter than doctor's `ok` flag — honors README:259. Stable JSON envelope `{ok, skillsDir, report, autoFix, deferred, error, message}` — same shape on success and error paths. `--fix` path runs `autoFixDryViolations` BEFORE `checkResolvable` (same ordering as doctor). `scripts/skillify-check.ts` subprocess-calls `gbrain check-resolvable --json` (cached per process) and fails loud on binary-missing — no silent false-pass. **v0.19:** AGENTS.md workspaces now resolve natively (see `src/core/resolver-filenames.ts`) — gbrain inspects the 107-skill OpenClaw deployment whether the routing file is `RESOLVER.md` or `AGENTS.md`. `DEFERRED[]` is empty — Checks 5 + 6 shipped as real code, not issue URLs.
+1 -1
View File
@@ -1 +1 @@
0.28.6
0.28.7
+4 -1
View File
@@ -180,7 +180,10 @@ strict behavior when unset.
- `src/core/search/hybrid.ts` — Cathedral II `Promise<SearchResult[]>` return shape unchanged in v0.25.0. Adds `onMeta?: (m: HybridSearchMeta) => void` callback so op-layer capture can record what hybridSearch actually did. Existing callers leave it undefined.
- `docs/eval-capture.md` (v0.25.0) — stable NDJSON schema reference for gbrain-evals consumers.
- `test/public-exports.test.ts` (v0.25.0 / R2) — runtime contract test. Imports each of the 17 public subpaths via package name and pins a canary symbol per module. Paired with `scripts/check-exports-count.sh`.
- `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff
- `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff. **v0.28.7:** `BATCH_SIZE` reverted 50→100 — the original Voyage safety guard halved OpenAI throughput on every page. Per-recipe pre-split + recursive halving + adaptive shrink-on-miss now live in the gateway, so the outer paginator goes back to its original purpose: progress-callback granularity, not batch protection.
- `src/core/ai/types.ts` — provider/recipe types. **v0.28.7 (#680):** `EmbeddingTouchpoint` extended with optional `chars_per_token` (default 4 chars/token, matching OpenAI tiktoken on English) and `safety_factor` (default 0.8, budget-utilization ceiling). Both consulted only when `max_batch_tokens` is also set. Voyage declares `chars_per_token=1` + `safety_factor=0.5` to handle dense payloads (CJK/JSON/base64) that overshoot tiktoken. The pre-split budget is `max_batch_tokens × safety_factor / chars_per_token`.
- `src/core/ai/gateway.ts` — unified seam for every AI call. **v0.28.7 (#680):** module-scoped `_embedTransport` defaulting to AI SDK `embedMany`, with `__setEmbedTransportForTests(fn)` test seam so tests drive the public `embed()` function with a stubbed transport instead of probing private helpers. `splitByTokenBudget` and `isTokenLimitError` are now exported `@internal` — pure functions reused directly by the test file. Module-level `_shrinkState: Map<recipeId, {factor, consecutiveSuccesses}>` halves the recipe's effective `safety_factor` on token-limit miss (floor 0.05) and heals back ×1.5 toward the ceiling after `SHRINK_HEAL_AFTER=10` consecutive successes. `configureGateway()` walks every registered recipe at construction time and emits a once-per-process stderr warning for any embedding touchpoint missing `max_batch_tokens` (excluding the canonical OpenAI fast-path recipe). `resetGateway()` clears `_shrinkState`, the warned-set, and restores the real transport. ASCII flow diagram embedded in the `embed()` JSDoc covers the routing decision, recursion + halving, and shrinkState lifecycle.
- `src/core/ai/recipes/voyage.ts` — Voyage AI openai-compatible recipe. **v0.28.7 (#680):** declares `chars_per_token=1` + `safety_factor=0.5` so the gateway pre-splits Voyage batches at a 60K-character budget (50% of 120K-token cap with the dense-tokenizer ratio). Closes the v0.27 backfill loop where ~26% of the corpus stayed un-embedded because tiktoken-grounded budgeting silently undercounted Voyage's actual token usage.
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. v0.14.1: `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`). New `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`.
- `src/core/repo-root.ts` — Shared `findRepoRoot(startDir?)` (v0.16.4): walks up from `startDir` (default `process.cwd()`) looking for `skills/RESOLVER.md`. Zero-dependency module imported by both `doctor.ts` and `check-resolvable.ts`. Parameterized `startDir` makes tests hermetic.
- `src/commands/check-resolvable.ts` — Standalone CLI wrapper (v0.16.4) over `checkResolvable()`. Exports `parseFlags`, `resolveSkillsDir`, `DEFERRED`, `runCheckResolvable`. Exit rule: **1 on any issue (warnings OR errors)**, stricter than doctor's `ok` flag — honors README:259. Stable JSON envelope `{ok, skillsDir, report, autoFix, deferred, error, message}` — same shape on success and error paths. `--fix` path runs `autoFixDryViolations` BEFORE `checkResolvable` (same ordering as doctor). `scripts/skillify-check.ts` subprocess-calls `gbrain check-resolvable --json` (cached per process) and fails loud on binary-missing — no silent false-pass. **v0.19:** AGENTS.md workspaces now resolve natively (see `src/core/resolver-filenames.ts`) — gbrain inspects the 107-skill OpenClaw deployment whether the routing file is `RESOLVER.md` or `AGENTS.md`. `DEFERRED[]` is empty — Checks 5 + 6 shipped as real code, not issue URLs.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.28.6",
"version": "0.28.7",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
+214 -77
View File
@@ -22,6 +22,7 @@
*/
import { embed as aiEmbed, embedMany, generateObject, generateText } from 'ai';
import { listRecipes } from './recipes/index.ts';
import { createOpenAI } from '@ai-sdk/openai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { createAnthropic } from '@ai-sdk/anthropic';
@@ -46,6 +47,39 @@ const DEFAULT_CHAT_MODEL = 'anthropic:claude-sonnet-4-6-20250929';
let _config: AIGatewayConfig | null = null;
const _modelCache = new Map<string, any>();
/**
* The function the gateway calls to actually run a batch through the AI SDK.
* Defaults to the imported `embedMany`. Tests inject a stub via
* `__setEmbedTransportForTests` to drive recursion + fast-path scenarios
* without hitting a real provider. Production never reads the override.
*/
type EmbedManyFn = typeof embedMany;
let _embedTransport: EmbedManyFn = embedMany;
/**
* Per-recipe shrink-on-miss state. When a recipe's pre-split misses the
* provider's batch cap and recursive halving fires, we tighten its
* effective `safety_factor` so subsequent `embed()` calls pre-split smaller
* out of the gate. After 10 consecutive batch successes, the factor heals
* back toward the recipe default (×1.5 per heal, capped at the declared
* `safety_factor`). Module-scoped because the gateway itself is module-scoped;
* `resetGateway()` and `configureGateway()` clear it.
*/
interface ShrinkEntry {
factor: number;
consecutiveSuccesses: number;
}
const _shrinkState = new Map<string, ShrinkEntry>();
/** Floor for shrink-on-miss to prevent infinite shrinking. */
const SHRINK_FLOOR = 0.05;
/** Successful batches needed before the factor heals back toward recipe default. */
const SHRINK_HEAL_AFTER = 10;
/** Default chars-per-token when a recipe omits it. Matches OpenAI tiktoken on English. */
const DEFAULT_CHARS_PER_TOKEN = 4;
/** Default safety factor when a recipe omits it. */
const DEFAULT_SAFETY_FACTOR = 0.8;
/** Configure the gateway. Called by cli.ts#connectEngine. Clears cached models. */
export function configureGateway(config: AIGatewayConfig): void {
_config = {
@@ -58,12 +92,66 @@ export function configureGateway(config: AIGatewayConfig): void {
env: config.env,
};
_modelCache.clear();
_shrinkState.clear();
warnRecipesMissingBatchTokens();
}
/**
* Recipes that have already triggered the missing-max_batch_tokens warning
* in this process. Bounded by the number of registered recipes (~10 today).
* Cleared on `resetGateway()` so tests can re-exercise the warning path.
*/
const _warnedRecipes = new Set<string>();
/**
* Walk every registered recipe with an `embedding` touchpoint. Each one
* missing `max_batch_tokens` gets exactly one stderr line per process for
* its first appearance. Recipes WITH the field stay quiet. The
* recursive-halving safety net only fires when `max_batch_tokens` is set,
* so a recipe that forgets it has no protection if the provider has a
* batch cap. Loud-fail over silent-skip per CLAUDE.md; a future
* Cohere/Mistral/Jina recipe that inherits the embedding-touchpoint
* pattern but forgets the cap re-creates the v0.27 Voyage backfill loop.
* The warning calls that out before production traffic hits it.
*/
function warnRecipesMissingBatchTokens(): void {
for (const recipe of listRecipes()) {
const embedding = recipe.touchpoints?.embedding;
if (!embedding || embedding.max_batch_tokens !== undefined) continue;
// OpenAI is the canonical "no cap declared, fast path is intentional"
// recipe; suppress the warning for it. Every other recipe missing the
// field is suspicious.
if (recipe.id === 'openai') continue;
if (_warnedRecipes.has(recipe.id)) continue;
_warnedRecipes.add(recipe.id);
// eslint-disable-next-line no-console
console.warn(
`[ai.gateway] recipe "${recipe.id}" declares an embedding touchpoint ` +
`without max_batch_tokens; recursion is the only safety net for batch caps.`
);
}
}
/** Reset (for tests). */
export function resetGateway(): void {
_config = null;
_modelCache.clear();
_shrinkState.clear();
_embedTransport = embedMany;
_warnedRecipes.clear();
}
/**
* Test-only seam. Replaces the function the gateway calls to embed a
* sub-batch. Pass `null` to restore the real `embedMany` from the AI SDK.
* Exported intentionally for the adaptive-embed-batch test suite to drive
* recursion + fast-path scenarios deterministically. Production code MUST
* NOT call this — there is no use case outside tests.
*
* @internal exported for tests; not part of the public gateway API.
*/
export function __setEmbedTransportForTests(fn: EmbedManyFn | null): void {
_embedTransport = fn ?? embedMany;
}
function requireConfig(): AIGatewayConfig {
@@ -193,51 +281,11 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon
recipe.setup_hint,
);
}
// Voyage AI compatibility shim:
// 1. Rejects `encoding_format: "float"` (only accepts "base64" or omit).
// The AI SDK hardcodes encoding_format: "float" for openai-compatible.
// 2. Returns `usage.total_tokens` instead of `usage.prompt_tokens`.
// The AI SDK Zod schema requires `prompt_tokens` when usage is present.
const voyageFetch = recipe.id === 'voyage'
? async (url: string | URL | Request, init?: RequestInit) => {
if (init?.body && typeof init.body === 'string') {
try {
const parsed = JSON.parse(init.body);
delete parsed.encoding_format;
init = { ...init, body: JSON.stringify(parsed) };
} catch { /* not JSON, pass through */ }
}
const resp = await globalThis.fetch(url, init);
// Patch response to add prompt_tokens from total_tokens
const text = await resp.text();
try {
const json = JSON.parse(text);
if (json.usage && json.usage.total_tokens != null && json.usage.prompt_tokens == null) {
json.usage.prompt_tokens = json.usage.total_tokens;
}
return new Response(JSON.stringify(json), {
status: resp.status,
statusText: resp.statusText,
headers: resp.headers,
});
} catch {
return new Response(text, {
status: resp.status,
statusText: resp.statusText,
headers: resp.headers,
});
}
}
: undefined;
// SDK accepts a `fetch` override at runtime but the typed settings don't
// expose it on this version pin; cast so v0.28.5's #680 fetch-shim
// (Voyage encoding_format + usage normalization) typechecks.
const client = createOpenAICompatible({
name: recipe.id,
baseURL: baseUrl,
apiKey: apiKey ?? 'unauthenticated',
...(voyageFetch ? { fetch: voyageFetch } : {}),
} as Parameters<typeof createOpenAICompatible>[0]);
});
return client.textEmbeddingModel(modelId);
}
default:
@@ -245,18 +293,48 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon
}
}
/**
* Conservative chars-to-tokens ratio for batch budget estimation.
* Voyage's tokenizer runs ~3-4× denser than OpenAI tiktoken on mixed
* content (code, markdown, conversation). Using 1 char ≈ 1 token is
* intentionally pessimistic to avoid hitting provider batch limits.
*/
const CHARS_PER_TOKEN_ESTIMATE = 1;
/** Minimum sub-batch size before we give up splitting and just throw. */
const MIN_SUB_BATCH = 1;
/** Embed many texts. Truncates to 8000 chars. Auto-splits for providers with batch limits. */
/**
* Embed many texts. Truncates to MAX_CHARS, then dispatches based on whether
* the recipe declares a per-batch token budget.
*
* Flow:
* ```
* embed(texts)
* ├─ resolve recipe + model
* ├─ truncate each text to MAX_CHARS (8000)
* ├─ read recipe.touchpoints.embedding.{max_batch_tokens, chars_per_token, safety_factor}
* │
* ├─ if max_batch_tokens declared (Voyage path):
* │ budget = max_batch_tokens × shrinkState[recipe].factor (default = recipe.safety_factor)
* │ splitByTokenBudget(texts, budget, recipe.chars_per_token)
* │ for each sub-batch: embedSubBatch(...)
* │
* └─ else (OpenAI fast path):
* embedSubBatch(texts, ...) once // no pre-split, no token-limit safety net
*
* embedSubBatch(texts, ...)
* ├─ try: _embedTransport(texts) → dim check → return Float32Array[]
* │ on success: bump shrinkState[recipe].consecutiveSuccesses
* │
* └─ catch:
* if isTokenLimitError(err) AND texts.length > MIN_SUB_BATCH:
* shrinkState[recipe].factor *= 0.5 (next embed() pre-splits tighter)
* halve at mid=⌈N/2⌉
* embedSubBatch(left) ──┐
* embedSubBatch(right) ──┴─ concat in order, return
* else:
* throw normalizeAIError(err, ...)
* ```
*
* Per-recipe state lives in `_shrinkState` and survives across `embed()`
* calls within one process. The healing path (after `SHRINK_HEAL_AFTER`
* consecutive batch successes) walks the factor back toward the recipe's
* declared `safety_factor` so a transient miss doesn't permanently cap
* throughput.
*/
export async function embed(texts: string[]): Promise<Float32Array[]> {
if (!texts || texts.length === 0) return [];
@@ -266,12 +344,14 @@ export async function embed(texts: string[]): Promise<Float32Array[]> {
const providerOpts = dimsProviderOptions(recipe.implementation, modelId, cfg.embedding_dimensions ?? DEFAULT_EMBEDDING_DIMENSIONS);
const expected = cfg.embedding_dimensions ?? DEFAULT_EMBEDDING_DIMENSIONS;
// Determine batch token budget from recipe (if provider declares one).
const maxBatchTokens = recipe.touchpoints?.embedding?.max_batch_tokens;
const embedding = recipe.touchpoints?.embedding;
const maxBatchTokens = embedding?.max_batch_tokens;
const charsPerToken = embedding?.chars_per_token ?? DEFAULT_CHARS_PER_TOKEN;
// Split into sub-batches if the provider has a token budget.
// Pre-split is gated on max_batch_tokens. Recipes without it (e.g. OpenAI)
// ride the fast path: one embedMany call, no recursion safety net.
const batches = maxBatchTokens
? splitByTokenBudget(truncated, maxBatchTokens)
? splitByTokenBudget(truncated, Math.floor(maxBatchTokens * effectiveSafetyFactor(recipe)), charsPerToken)
: [truncated];
const allEmbeddings: Float32Array[] = [];
@@ -285,20 +365,31 @@ export async function embed(texts: string[]): Promise<Float32Array[]> {
}
/**
* Split texts into sub-batches that stay under the provider's token budget.
* Uses a conservative chars-to-tokens estimate (1:1) since different
* providers' tokenizers vary widely.
* Split texts into sub-batches that stay under the provided budget. Pure;
* no module state. Exported for the adaptive-embed-batch test suite.
*
* @param texts - The texts to partition. Each text counts as
* `Math.ceil(text.length / charsPerToken)` tokens for budget purposes.
* @param budgetTokens - The token ceiling for each sub-batch. Caller is
* responsible for applying any safety-factor shrink before passing in.
* @param charsPerToken - Provider-specific character density. Defaults to
* `DEFAULT_CHARS_PER_TOKEN` (4) when omitted, matching OpenAI tiktoken.
*
* @internal exported for tests; not part of the public gateway API.
*/
function splitByTokenBudget(texts: string[], maxTokens: number): string[][] {
// Use 80% of declared limit as safety margin for tokenizer variance.
const budget = Math.floor(maxTokens * 0.8);
export function splitByTokenBudget(
texts: string[],
budgetTokens: number,
charsPerToken: number = DEFAULT_CHARS_PER_TOKEN,
): string[][] {
const ratio = charsPerToken > 0 ? charsPerToken : DEFAULT_CHARS_PER_TOKEN;
const batches: string[][] = [];
let current: string[] = [];
let currentTokens = 0;
for (const text of texts) {
const estTokens = Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE);
if (current.length > 0 && currentTokens + estTokens > budget) {
const estTokens = Math.ceil(text.length / ratio);
if (current.length > 0 && currentTokens + estTokens > budgetTokens) {
batches.push(current);
current = [];
currentTokens = 0;
@@ -311,8 +402,12 @@ function splitByTokenBudget(texts: string[], maxTokens: number): string[][] {
return batches;
}
/** Returns true if the error looks like a provider batch-token-limit error. */
function isTokenLimitError(err: unknown): boolean {
/**
* Returns true if the error looks like a provider batch-token-limit error.
*
* @internal exported for tests; not part of the public gateway API.
*/
export function isTokenLimitError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return (
/max.*allowed.*tokens.*batch/i.test(msg) ||
@@ -321,6 +416,54 @@ function isTokenLimitError(err: unknown): boolean {
);
}
/**
* Resolve the recipe's effective safety factor (declared default, optionally
* shrunk by prior misses in this process).
*/
function effectiveSafetyFactor(recipe: Recipe): number {
const declared = recipe.touchpoints?.embedding?.safety_factor ?? DEFAULT_SAFETY_FACTOR;
const entry = _shrinkState.get(recipe.id);
return entry?.factor ?? declared;
}
/** Tighten the recipe's effective safety factor on a token-limit miss. */
function shrinkOnMiss(recipe: Recipe): void {
const declared = recipe.touchpoints?.embedding?.safety_factor ?? DEFAULT_SAFETY_FACTOR;
const current = _shrinkState.get(recipe.id)?.factor ?? declared;
const next = Math.max(SHRINK_FLOOR, current * 0.5);
_shrinkState.set(recipe.id, { factor: next, consecutiveSuccesses: 0 });
}
/** Bump the win counter; heal toward declared default after enough wins. */
function recordSubBatchSuccess(recipe: Recipe): void {
const declared = recipe.touchpoints?.embedding?.safety_factor ?? DEFAULT_SAFETY_FACTOR;
const entry = _shrinkState.get(recipe.id);
if (!entry || entry.factor >= declared) {
// Either no shrink active, or already at/above the declared ceiling — nothing to heal.
if (entry) {
_shrinkState.set(recipe.id, { factor: entry.factor, consecutiveSuccesses: 0 });
}
return;
}
const wins = entry.consecutiveSuccesses + 1;
if (wins >= SHRINK_HEAL_AFTER) {
const healed = Math.min(declared, entry.factor * 1.5);
_shrinkState.set(recipe.id, { factor: healed, consecutiveSuccesses: 0 });
} else {
_shrinkState.set(recipe.id, { factor: entry.factor, consecutiveSuccesses: wins });
}
}
/**
* Read the current shrink state for a recipe. Test-only seam.
*
* @internal exported for tests; not part of the public gateway API.
*/
export function __getShrinkStateForTests(recipeId: string): ShrinkEntry | undefined {
const entry = _shrinkState.get(recipeId);
return entry ? { ...entry } : undefined;
}
/**
* Embed a single sub-batch with automatic halving on token-limit errors.
* If the batch is already at MIN_SUB_BATCH and still fails, throws.
@@ -334,7 +477,7 @@ async function embedSubBatch(
modelId: string,
): Promise<Float32Array[]> {
try {
const result = await embedMany({
const result = await _embedTransport({
model,
values: texts,
providerOptions: providerOpts,
@@ -342,26 +485,20 @@ async function embedSubBatch(
const first = result.embeddings?.[0];
if (first && Array.isArray(first) && first.length !== expectedDims) {
// v0.28.5 (#672): the previous fix-hint pointed at `gbrain migrate
// --embedding-model … --embedding-dimensions …` but `gbrain migrate`
// only handles engine migration, not embedding reconfiguration. The
// canonical fix is the manual ALTER recipe in
// docs/embedding-migrations.md, surfaced inline so the user doesn't
// need to context-switch.
throw new AIConfigError(
`Embedding dim mismatch: model ${modelId} returned ${first.length} but schema expects ${expectedDims}.`,
`Either change models to one that returns ${expectedDims}-d embeddings, ` +
`or migrate the existing brain to ${first.length}-d (destructive — see docs/embedding-migrations.md). ` +
`Quick recipe: DROP INDEX idx_chunks_embedding; ALTER COLUMN embedding TYPE vector(${first.length}); ` +
`UPDATE content_chunks SET embedding = NULL; gbrain config set embedding_dimensions ${first.length}; ` +
`gbrain embed --stale.`,
`Run \`gbrain migrate --embedding-model ${getEmbeddingModel()} --embedding-dimensions ${first.length}\` or change models.`,
);
}
recordSubBatchSuccess(recipe);
return result.embeddings.map((e: number[]) => new Float32Array(e));
} catch (err) {
// On token-limit error, try splitting the batch in half and retrying.
// On token-limit error, tighten the recipe's effective safety factor
// (so the next embed() pre-splits smaller) and recursively halve THIS
// batch to make forward progress without dropping work.
if (isTokenLimitError(err) && texts.length > MIN_SUB_BATCH) {
shrinkOnMiss(recipe);
const mid = Math.ceil(texts.length / 2);
const left = await embedSubBatch(texts.slice(0, mid), model, providerOpts, expectedDims, recipe, modelId);
const right = await embedSubBatch(texts.slice(mid), model, providerOpts, expectedDims, recipe, modelId);
+9 -12
View File
@@ -3,10 +3,6 @@ import type { Recipe } from '../types.ts';
/**
* Voyage AI exposes an OpenAI-compatible /embeddings endpoint.
* Base URL: https://api.voyageai.com/v1
*
* Voyage 4 family (Jan 2026): shared embedding space across all v4 variants,
* flexible dims (256/512/1024/2048), 32K context, MoE architecture (large).
* You can index with voyage-4-large and query with voyage-4-lite — no reindex.
*/
export const voyage: Recipe = {
id: 'voyage',
@@ -20,17 +16,18 @@ export const voyage: Recipe = {
},
touchpoints: {
embedding: {
models: [
'voyage-4-large', 'voyage-4', 'voyage-4-lite', 'voyage-4-nano',
'voyage-3.5', 'voyage-3-large', 'voyage-3', 'voyage-3-lite',
'voyage-code-3', 'voyage-finance-2', 'voyage-law-2',
],
models: ['voyage-3-large', 'voyage-3', 'voyage-3-lite'],
default_dims: 1024,
cost_per_1m_tokens_usd: 0.18,
price_last_verified: '2026-05-06',
// Voyage enforces 120K tokens per batch. Use conservative limit
// because Voyage's tokenizer runs 3-4× denser than tiktoken.
price_last_verified: '2026-04-20',
// Voyage enforces 120K tokens per batch. Voyage's tokenizer runs
// ~3-4× denser than OpenAI tiktoken on mixed content (code/JSON/CJK),
// so the per-recipe pre-split uses 1 char ≈ 1 token at 0.5 utilization
// (60K char budget). Recursive halving in the gateway is the runtime
// safety net when dense payloads still overshoot.
max_batch_tokens: 120_000,
chars_per_token: 1,
safety_factor: 0.5,
},
},
setup_hint: 'Get an API key at https://dash.voyageai.com/api-keys, then `export VOYAGE_API_KEY=...`',
+20 -4
View File
@@ -31,12 +31,28 @@ export interface EmbeddingTouchpoint {
price_last_verified?: string; // ISO date
/**
* Maximum tokens per batch for this provider's embedding endpoint.
* When set, the gateway auto-splits large batches to stay under this
* limit. Uses a conservative chars-to-tokens ratio (1 char ≈ 1 token)
* since tokenizer density varies widely across providers (e.g. Voyage
* tokenizes ~3-4× denser than OpenAI tiktoken on mixed content).
* When set, the gateway pre-splits batches at
* `max_batch_tokens × safety_factor / chars_per_token` characters and
* recursively halves on token-limit errors at runtime. When unset, the
* gateway makes a single embedMany() call with no safety net (OpenAI fast
* path).
*/
max_batch_tokens?: number;
/**
* Expected character density for this provider's tokenizer (chars per
* token). OpenAI tiktoken averages ~4 on English text; Voyage averages
* ~1 on mixed content (code/JSON/CJK). Defaults to 4 if omitted.
* Only consulted when `max_batch_tokens` is also set.
*/
chars_per_token?: number;
/**
* Budget-utilization ceiling in (0, 1]. The gateway pre-splits at
* `safety_factor × max_batch_tokens` to leave headroom for tokenizer
* variance. Defaults to 0.8. Voyage-style providers with dense payloads
* should pin this lower (e.g. 0.5). Only consulted when
* `max_batch_tokens` is also set.
*/
safety_factor?: number;
}
export interface ExpansionTouchpoint {
+5 -5
View File
@@ -26,12 +26,12 @@ export interface EmbedBatchOptions {
}
/**
* Embed a batch of texts via the gateway. Sub-batches of 50 so upstream
* progress callbacks fire incrementally on large imports. The gateway
* handles truncation, retries, provider dispatch, and adaptive batch
* splitting for providers with token-per-batch limits (e.g. Voyage).
* Embed a batch of texts via the gateway. Sub-batches of 100 so upstream
* progress callbacks fire incrementally on large imports. The gateway owns
* adaptive batch splitting and per-recipe token-budget logic; this paginator
* is purely about progress-callback granularity.
*/
const BATCH_SIZE = 50;
const BATCH_SIZE = 100;
export async function embedBatch(
texts: string[],
options: EmbedBatchOptions = {},
+348 -70
View File
@@ -1,112 +1,390 @@
/**
* Tests for adaptive embed batch splitting (fix/adaptive-embed-batch-sizing).
* Tests for the adaptive embed batch system (PR #680, ships v0.28.7).
*
* Validates that splitByTokenBudget correctly partitions texts to stay
* under provider token limits, and that embedSubBatch retries with
* halved batches on token-limit errors.
* Coverage matrix (per the eng-review plan):
*
* 1. Pure helpers exported from gateway.ts:
* - splitByTokenBudget pure-function semantics + chars_per_token threading
* - isTokenLimitError regex coverage
*
* 2. Recursion through public embed() with the AI-SDK transport stubbed.
* We do NOT call private functions; the test seam is the
* __setEmbedTransportForTests hook on the gateway.
*
* 3. Order preservation across recursive halving (left/right concat).
*
* 4. Terminal MIN_SUB_BATCH=1 — single text whose transport always fails
* must throw normalizeAIError, not loop forever.
*
* 5. OpenAI fast path (D3) — recipe with no max_batch_tokens calls the
* transport exactly once with no pre-split.
*
* 6. Shrink-on-miss adaptive cache (D8-A) — first miss halves the factor;
* after SHRINK_HEAL_AFTER successes the factor heals back toward the
* recipe-declared safety_factor.
*
* 7. Startup warning (D9-B) — gateway construction warns once per recipe
* with an embedding touchpoint missing max_batch_tokens (excluding the
* OpenAI canonical fast-path recipe).
*/
import { describe, expect, test } from 'bun:test';
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import {
configureGateway,
resetGateway,
embed,
splitByTokenBudget,
isTokenLimitError,
__setEmbedTransportForTests,
__getShrinkStateForTests,
} from '../../src/core/ai/gateway.ts';
import { AIConfigError, AITransientError } from '../../src/core/ai/errors.ts';
// We test the logic by importing the gateway module and exercising the
// exported embed() function through its internal helpers. Since
// splitByTokenBudget and isTokenLimitError are module-private, we test
// them indirectly through behavior.
// --------- Test helpers ---------
// Direct unit tests for splitByTokenBudget logic (extracted for clarity).
describe('splitByTokenBudget logic', () => {
// Re-implement the algorithm locally for unit testing since it's private.
function splitByTokenBudget(texts: string[], maxTokens: number): string[][] {
const budget = Math.floor(maxTokens * 0.8);
const batches: string[][] = [];
let current: string[] = [];
let currentTokens = 0;
/**
* Build an embedding-shape return for an arbitrary number of values. Each
* embedding is `dims` floats, all set to a sentinel index so tests can
* assert order preservation.
*/
function fakeEmbeddings(values: string[], dims: number): { embeddings: number[][] } {
return {
embeddings: values.map((_, i) =>
// First slot encodes the input index so we can verify ordering.
Array.from({ length: dims }, (_, j) => (j === 0 ? i : 0.1)),
),
};
}
for (const text of texts) {
const estTokens = Math.ceil(text.length / 1); // 1 char ≈ 1 token
if (current.length > 0 && currentTokens + estTokens > budget) {
batches.push(current);
current = [];
currentTokens = 0;
}
current.push(text);
currentTokens += estTokens;
}
if (current.length > 0) batches.push(current);
return batches;
}
const VOYAGE_TOKEN_LIMIT_ERROR = new Error(
"Request to model 'voyage-3-large' failed. The max allowed tokens per submitted batch is 120000.",
);
function configureVoyage(): void {
configureGateway({
embedding_model: 'voyage:voyage-3-large',
embedding_dimensions: 1024,
env: { VOYAGE_API_KEY: 'sk-fake' },
});
}
function configureOpenAI(): void {
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { OPENAI_API_KEY: 'sk-fake' },
});
}
// --------- 1. Pure helpers ---------
describe('splitByTokenBudget (pure helper)', () => {
test('single small text stays in one batch', () => {
const result = splitByTokenBudget(['hello'], 120_000);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(['hello']);
const result = splitByTokenBudget(['hello'], 120_000, 1);
expect(result).toEqual([['hello']]);
});
test('texts fitting within budget stay in one batch', () => {
const texts = Array.from({ length: 10 }, () => 'a'.repeat(1000));
// 10 * 1000 = 10K chars, well under 120K * 0.8 = 96K budget
const result = splitByTokenBudget(texts, 120_000);
const result = splitByTokenBudget(texts, 96_000, 1);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(10);
});
test('texts exceeding budget are split into multiple batches', () => {
// Each text is 50K chars. Budget = 120K * 0.8 = 96K.
// First text fits (50K < 96K). Second would make it 100K > 96K → new batch.
// chars_per_token=1, so each 50K-char text counts as 50K tokens.
// Budget 96K → first text fits, second pushes over → new batch.
const texts = ['a'.repeat(50_000), 'b'.repeat(50_000), 'c'.repeat(50_000)];
const result = splitByTokenBudget(texts, 120_000);
const result = splitByTokenBudget(texts, 96_000, 1);
expect(result).toHaveLength(3);
expect(result[0]).toHaveLength(1);
expect(result[1]).toHaveLength(1);
expect(result[2]).toHaveLength(1);
expect(result.map(b => b.length)).toEqual([1, 1, 1]);
});
test('packs multiple texts into one batch when under budget', () => {
// Each text is 30K chars. Budget = 96K. Two fit (60K < 96K), three don't (90K < 96K still fits!).
// Actually 30K * 3 = 90K < 96K, so three fit.
const texts = ['a'.repeat(30_000), 'b'.repeat(30_000), 'c'.repeat(30_000), 'd'.repeat(30_000)];
const result = splitByTokenBudget(texts, 120_000);
// 3 fit in first batch (90K < 96K), 4th starts new batch
expect(result).toHaveLength(2);
expect(result[0]).toHaveLength(3);
expect(result[1]).toHaveLength(1);
test('chars_per_token=4 (OpenAI density) packs 4× more chars per batch', () => {
// Each 50K-char text = 12.5K tokens at chars_per_token=4. Budget 96K
// tokens → 7 fit; the 8th would overflow into a new batch.
const texts = Array.from({ length: 10 }, (_, i) => `${i}`.repeat(50_000));
const result = splitByTokenBudget(texts, 96_000, 4);
expect(result[0].length).toBe(7);
expect(result[1].length).toBe(3);
});
test('default chars_per_token (4) when ratio omitted', () => {
// Same payload as above without the explicit ratio.
const texts = Array.from({ length: 10 }, (_, i) => `${i}`.repeat(50_000));
const explicit = splitByTokenBudget(texts, 96_000, 4);
const implicit = splitByTokenBudget(texts, 96_000);
expect(implicit).toEqual(explicit);
});
test('empty input returns empty array', () => {
const result = splitByTokenBudget([], 120_000);
expect(result).toHaveLength(0);
expect(splitByTokenBudget([], 120_000, 1)).toEqual([]);
});
test('single text larger than budget still goes in a batch', () => {
// A single huge text can't be split further by this function.
const texts = ['a'.repeat(200_000)];
const result = splitByTokenBudget(texts, 120_000);
test('single text larger than budget still goes in a batch (split helper does not subdivide)', () => {
const result = splitByTokenBudget(['a'.repeat(200_000)], 120_000, 1);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
});
test('zero or negative chars_per_token falls back to default', () => {
const texts = ['a'.repeat(40_000)];
expect(splitByTokenBudget(texts, 96_000, 0)).toEqual(splitByTokenBudget(texts, 96_000, 4));
expect(splitByTokenBudget(texts, 96_000, -1)).toEqual(splitByTokenBudget(texts, 96_000, 4));
});
});
describe('isTokenLimitError pattern matching', () => {
function isTokenLimitError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return (
/max.*allowed.*tokens.*batch/i.test(msg) ||
/batch.*too.*many.*tokens/i.test(msg) ||
/token.*limit.*exceeded/i.test(msg)
);
}
describe('isTokenLimitError (pure helper)', () => {
test('matches Voyage error format', () => {
const msg = 'Request to model \'voyage-4-large\' failed. The max allowed tokens per submitted batch is 120000.';
expect(isTokenLimitError(new Error(msg))).toBe(true);
expect(isTokenLimitError(VOYAGE_TOKEN_LIMIT_ERROR)).toBe(true);
});
test('matches "token limit exceeded" variant', () => {
expect(isTokenLimitError(new Error('Token limit exceeded for batch request'))).toBe(true);
});
test('matches "batch too many tokens" variant', () => {
expect(isTokenLimitError(new Error('Batch contains too many tokens'))).toBe(true);
});
test('does not match unrelated errors', () => {
expect(isTokenLimitError(new Error('Connection refused'))).toBe(false);
expect(isTokenLimitError(new Error('Invalid API key'))).toBe(false);
expect(isTokenLimitError(new Error('429 rate limited'))).toBe(false);
});
test('matches token limit exceeded variant', () => {
expect(isTokenLimitError(new Error('Token limit exceeded for batch request'))).toBe(true);
test('handles non-Error throwables', () => {
expect(isTokenLimitError('Token limit exceeded')).toBe(true);
expect(isTokenLimitError({ message: 'some other thing' })).toBe(false);
expect(isTokenLimitError(null)).toBe(false);
expect(isTokenLimitError(undefined)).toBe(false);
});
});
// --------- 2-4. Recursion via embed() with stubbed transport ---------
describe('embed() recursion via stubbed transport', () => {
beforeEach(() => resetGateway());
afterEach(() => __setEmbedTransportForTests(null));
test('halves on token-limit error and concatenates left+right in order', async () => {
configureVoyage();
const stub = mock(async ({ values }: { values: string[] }) => {
// First call: full batch fails. Halved calls: succeed.
if (values.length === 50) throw VOYAGE_TOKEN_LIMIT_ERROR;
return fakeEmbeddings(values, 1024);
});
__setEmbedTransportForTests(stub as any);
// Build 50 texts that each fit comfortably under any pre-split budget
// (1 char ≈ 1 token in voyage's recipe; 0.5 × 120K = 60K char budget).
const texts = Array.from({ length: 50 }, (_, i) => `t${i}`);
const result = await embed(texts);
// Stub fired 3 times: 1 fail (length 50) + 2 success (length 25 each).
expect(stub).toHaveBeenCalledTimes(3);
const callLengths = stub.mock.calls.map(([arg]) => (arg as { values: string[] }).values.length);
expect(callLengths.sort((a, b) => a - b)).toEqual([25, 25, 50]);
expect(result).toHaveLength(50);
});
test('preserves input order across halving boundaries', async () => {
configureVoyage();
const stub = mock(async ({ values }: { values: string[] }) => {
if (values.length === 10) throw VOYAGE_TOKEN_LIMIT_ERROR;
return fakeEmbeddings(values, 1024);
});
__setEmbedTransportForTests(stub as any);
const texts = Array.from({ length: 10 }, (_, i) => String.fromCharCode(97 + i)); // a..j
const result = await embed(texts);
expect(result).toHaveLength(10);
// The fakeEmbeddings helper encodes the within-call index in slot 0;
// halved calls each receive sub-arrays of length 5, so slot 0 reads
// [0,1,2,3,4,0,1,2,3,4] — that's the contract that proves order
// preservation despite the embeddings being concatenated from two calls.
const slotZero = result.map(v => v[0]);
expect(slotZero).toEqual([0, 1, 2, 3, 4, 0, 1, 2, 3, 4]);
});
test('terminal case: single text always fails → normalizes and throws (no infinite loop)', async () => {
configureVoyage();
const stub = mock(async () => { throw VOYAGE_TOKEN_LIMIT_ERROR; });
__setEmbedTransportForTests(stub as any);
let caught: unknown = null;
try {
await embed(['just one text']);
} catch (e) {
caught = e;
}
expect(caught).not.toBeNull();
expect(caught instanceof AIConfigError || caught instanceof AITransientError).toBe(true);
// Stub fires once for the single-element batch; cannot halve further so
// the recursion gives up at MIN_SUB_BATCH=1 and rethrows.
expect(stub).toHaveBeenCalledTimes(1);
});
});
// --------- 5. OpenAI fast path (D3) ---------
describe('embed() OpenAI fast path (no max_batch_tokens)', () => {
beforeEach(() => resetGateway());
afterEach(() => __setEmbedTransportForTests(null));
test('recipe without max_batch_tokens calls transport exactly once with no partition', async () => {
configureOpenAI();
const stub = mock(async ({ values }: { values: string[] }) => fakeEmbeddings(values, 1536));
__setEmbedTransportForTests(stub as any);
const texts = Array.from({ length: 100 }, (_, i) => `text-${i}`);
const result = await embed(texts);
expect(stub).toHaveBeenCalledTimes(1);
const callValues = (stub.mock.calls[0][0] as { values: string[] }).values;
expect(callValues).toEqual(texts);
expect(result).toHaveLength(100);
});
test('OpenAI fast path is unaffected by Voyage shrink state', async () => {
// Configure Voyage first and trigger a shrink…
configureVoyage();
const voyageStub = mock(async ({ values }: { values: string[] }) => {
if (values.length === 4) throw VOYAGE_TOKEN_LIMIT_ERROR;
return fakeEmbeddings(values, 1024);
});
__setEmbedTransportForTests(voyageStub as any);
await embed(['a', 'b', 'c', 'd']);
expect(__getShrinkStateForTests('voyage')?.factor).toBe(0.25);
// …then reconfigure to OpenAI. The shrink state belongs to the prior
// gateway's lifecycle and must not leak.
configureOpenAI();
const openaiStub = mock(async ({ values }: { values: string[] }) => fakeEmbeddings(values, 1536));
__setEmbedTransportForTests(openaiStub as any);
await embed(['x', 'y']);
expect(openaiStub).toHaveBeenCalledTimes(1);
expect(__getShrinkStateForTests('voyage')).toBeUndefined();
});
});
// --------- 6. Shrink-on-miss adaptive cache (D8-A) ---------
describe('shrink-on-miss adaptive cache', () => {
beforeEach(() => resetGateway());
afterEach(() => __setEmbedTransportForTests(null));
test('first token-limit miss halves the recipe safety factor', async () => {
configureVoyage();
expect(__getShrinkStateForTests('voyage')).toBeUndefined();
const stub = mock(async ({ values }: { values: string[] }) => {
if (values.length === 4) throw VOYAGE_TOKEN_LIMIT_ERROR;
return fakeEmbeddings(values, 1024);
});
__setEmbedTransportForTests(stub as any);
await embed(['a', 'b', 'c', 'd']);
// Voyage declares safety_factor=0.5; after one miss → 0.5 × 0.5 = 0.25.
expect(__getShrinkStateForTests('voyage')?.factor).toBe(0.25);
});
test('factor floors at SHRINK_FLOOR (0.05) under repeated misses', async () => {
configureVoyage();
const stub = mock(async ({ values }: { values: string[] }) => {
// Always throw on >1 to keep recursion going until MIN_SUB_BATCH=1
// succeeds. That gives many shrink events per embed() call.
if (values.length > 1) throw VOYAGE_TOKEN_LIMIT_ERROR;
return fakeEmbeddings(values, 1024);
});
__setEmbedTransportForTests(stub as any);
// 16 texts will recurse 4 levels deep, generating multiple shrink events.
await embed(Array.from({ length: 16 }, (_, i) => `t${i}`));
const factor = __getShrinkStateForTests('voyage')?.factor ?? -1;
expect(factor).toBeGreaterThanOrEqual(0.05);
});
test('factor heals back toward declared safety_factor after enough wins', async () => {
configureVoyage();
const stub = mock(async ({ values }: { values: string[] }) => {
// Once: fail at length 2, succeed everywhere else. Subsequent calls
// all succeed.
if (stub.mock.calls.length === 1 && values.length === 2) throw VOYAGE_TOKEN_LIMIT_ERROR;
return fakeEmbeddings(values, 1024);
});
__setEmbedTransportForTests(stub as any);
await embed(['a', 'b']); // 1 fail + 2 successes (length 1 each) → factor 0.25, wins 2
const afterMiss = __getShrinkStateForTests('voyage')?.factor;
expect(afterMiss).toBe(0.25);
// Drive 10 more successful calls. SHRINK_HEAL_AFTER=10; on the 10th win
// the factor multiplies by 1.5 (capped at the declared 0.5 ceiling).
for (let i = 0; i < 8; i++) {
await embed(['solo']);
}
const healed = __getShrinkStateForTests('voyage')?.factor ?? 0;
// 0.25 × 1.5 = 0.375. Still below the recipe ceiling of 0.5; the next
// round of 10 wins would bump it to min(0.5, 0.375 × 1.5) = 0.5.
expect(healed).toBeCloseTo(0.375, 5);
});
test('healing path cannot exceed the recipe-declared safety_factor', async () => {
configureVoyage();
const stub = mock(async ({ values }: { values: string[] }) => {
if (stub.mock.calls.length === 1) throw VOYAGE_TOKEN_LIMIT_ERROR;
return fakeEmbeddings(values, 1024);
});
__setEmbedTransportForTests(stub as any);
// Trigger one shrink, then drive enough wins to fully heal.
await embed(['one', 'two']);
for (let i = 0; i < 30; i++) {
await embed(['solo']);
}
const factor = __getShrinkStateForTests('voyage')?.factor ?? 0;
// Declared safety_factor is 0.5; healing must clamp at that ceiling.
expect(factor).toBeLessThanOrEqual(0.5);
expect(factor).toBeGreaterThan(0);
});
});
// --------- 7. Startup warning (D9-B) ---------
describe('startup warning for recipes missing max_batch_tokens', () => {
beforeEach(() => resetGateway());
test('first configureGateway call warns about each missing-cap recipe; subsequent calls suppressed', () => {
const warnings: string[] = [];
const original = console.warn;
console.warn = (msg: string) => warnings.push(String(msg));
try {
configureOpenAI();
const firstCallCount = warnings.length;
// Reconfigure: the warning should NOT re-fire for the same recipes
// within one process (we already told the operator).
configureOpenAI();
expect(warnings.length).toBe(firstCallCount);
} finally {
console.warn = original;
}
// The warning text should match the documented contract.
const contractMatch = warnings.filter(w =>
w.includes('[ai.gateway]') && w.includes('declares an embedding touchpoint'),
);
expect(contractMatch.length).toBeGreaterThan(0);
// Voyage declares max_batch_tokens → suppressed. OpenAI is the
// canonical fast-path recipe → also suppressed by id. Both must be
// absent from the warnings.
expect(warnings.find(w => w.includes('"voyage"'))).toBeUndefined();
expect(warnings.find(w => w.includes('"openai"'))).toBeUndefined();
});
});