mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(recipes/openai): add max_batch_tokens to embedding touchpoint OpenAI is the only recipe in the codebase without a max_batch_tokens cap. Every other provider declares one (voyage=120K, azure-openai=8K, dashscope=8K, zhipu=8K, minimax=4K). Without it, gbrain's recursive-halving safety net never engages — batches dispatched purely on the char/4 estimator window will trip OpenAI's 1M-token TPM ceiling on token-dense pages (Discord exports, JSON dumps, code-heavy markdown), then retry storm and block the queue head. Setting cap to 100_000: - gbrain's batcher estimates tokens as chars/4 - Token-dense markdown+JSON tokenizes at ~chars/2.7 - 100K estimated = ~150K real worst-case, safely under OpenAI's 300K per-request hard cap and the 1M/min TPM ceiling - Leaves headroom for recursive-halving on outlier chunks (cherry picked from commit40536aace5) * fix(ai/embed): recognize OpenAI 'maximum request size' error in isTokenLimitError OpenAI's /v1/embeddings endpoint hard-caps a single request at 300k tokens total across all input items. When the cap is exceeded it returns: Invalid 'input': maximum request size is 300000 tokens per request. None of the three existing regexes in isTokenLimitError matched this phrasing, so the recursive-halving safety net in embedSubBatch never engaged for OpenAI. The same fat page (a token-dense markdown export, e.g. a Discord transcript) would re-fail every pass, blocking forward progress on the whole batch indefinitely. Locally reproduced on a 31,129-chunk Postgres brain: 2,125 chunks stuck at 'remaining' across 30+ embed --stale passes with retry loops + sleep delays. Adding the two new patterns lets halving fire; the same backlog cleared in one pass after the regex change (the companion max_batch_tokens recipe fix from PR #924 caps fresh batches, but existing oversize pages still need halving to recover). Adds: - /maximum request size.*tokens/i — OpenAI verbatim - /max.*tokens.*per.*request/i — defensive against minor rewording Tests: - Regression test for the exact OpenAI error string - Coverage for the generic 'max tokens per request' variant - All 25 tests in adaptive-embed-batch.test.ts pass No behavior change for providers whose errors already matched. (cherry picked from commitb834e84c56) * fix(connection-manager): strip .<project-ref> suffix from username when deriving direct URL `deriveDirectUrl()` correctly rewrites the host (`aws-0-us-east-1.pooler.supabase.com` → `db.abcxyz.supabase.co`) but preserves the full pooler-form username (`postgres.abcxyz`). Supabase direct connections expect a bare `postgres` username — Supavisor uses the `.<ref>` suffix for tenant routing, but it's not a real database user. The auto-derived URL therefore fails to authenticate even with the correct password: password authentication failed for user "postgres.abcxyz" Strip the suffix to `postgres` whenever the project-ref was successfully extracted (same condition that triggers the host rewrite). The non-pooler username branch is unaffected — preserved as-is to keep the port-only fallback case working. Hit while exercising v0.30.1's dual-pool routing on a real Supabase brain; the kill switch (`GBRAIN_DISABLE_DIRECT_POOL=1`) papered over it locally but every Supabase user with a stock pooler URL would silently fall through to single-pool until the user-supplied a `GBRAIN_DIRECT_DATABASE_URL` override. With this fix, dual-pool works out of the box for the canonical Supabase shape. Test additions: - 1 case asserting bare `postgres:secret@` in the derived URL when project-ref is parseable from the pooler URL (the new behavior) - extends the existing "falls back to port-only" case with an assertion that non-pooler usernames are preserved (unchanged behavior) `bun run typecheck` clean. `deriveDirectUrl` test block passes 5/5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commitddf2c6a9a0) * fix(init): --help should not mutate config or scan filesystem `gbrain init --help` (and `-h`) currently fall through to the smart-detection branch in runInit(), which scans cwd for .md files and on a directory with 1000+ files prints "Found ~1500 .md files. For a brain this size, Supabase gives faster search..." then defaults to PGLite — calling saveConfig() and overwriting any existing Postgres config with `engine: 'pglite' + database_path: ~/.gbrain/brain.pglite`. Confirmed in the wild: ran `gbrain init --help` from $HOME on a machine where ~/.gbrain/config.json pointed at a Supabase Postgres brain with 10K+ pages. The config was silently flipped to PGLite. The Supabase data was intact, but gbrain stopped pointing at it until the config was manually restored. Root cause: cli.ts:62-69 only routes --help → printOpHelp() for shared-op commands; CLI_ONLY commands (init, embed, etc.) fall through to their handler with --help still in argv. None of them check for it. Fix: add a --help/-h guard at the top of runInit() that prints help text and returns. Help should never mutate state — Postel's robustness principle for CLI tools. Help text covers all flags (engine selection, AI provider options, thin-client mode) so users running `--help` get the canonical list rather than having to read the source. A wider architectural fix — adding --help routing for all CLI_ONLY commands in cli.ts — is plausible follow-up, but each CLI_ONLY command would still need its own help text. This per-command pattern matches how shared ops handle it via printOpHelp(). Init is the highest-stakes case because it's the only CLI_ONLY command that calls saveConfig(). Smoke test: from a directory with 1500 .md files, with GBRAIN_HOME pointed at a fresh tempdir: - Before fix: ~/.gbrain/config.json materialized with engine: 'pglite' - After fix: help text printed, no config dir created `bun run typecheck` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commited11fdd58c) * test(frontmatter-install-hook): isolate hooksPath assertion from developer global config The "installHook writes ... and sets core.hooksPath" test asserted `git config --get core.hooksPath` returns `.githooks`, which falls back to the global scope when local is unset. Developers who set `core.hooksPath` globally (common with dotfiles managers pointing at ~/.config/git/hooks) saw a deterministic FAIL because installHook intentionally respects an existing global value and skips writing the local one — exactly the documented contract. Fix: read via `git config --local --get core.hooksPath` (scope-locked) and branch the assertion on whether a global is already set. Both clean-CI (local should be '.githooks') and developer-with-global (local should be empty; installHook correctly didn't clobber) now pass deterministically. No API change. installHook behavior is unchanged. Verified locally with the affected test passing under `GIT_CONFIG_GLOBAL=~/.gitconfig` carrying `core.hooksPath=...`. (cherry picked from commit0e4da2cb38) * fix: guard against missing 'intent' field in routing-eval fixtures Two defensive fixes: 1. normalizeText(): return empty string on null/undefined input instead of crashing with 'undefined is not an object (evaluating s.toLowerCase)' 2. loadRoutingFixtures(): validate that parsed fixture has 'intent' as a string before adding to fixtures array. Fixtures with wrong field names (e.g. 'input' instead of 'intent') are now reported as malformed with a helpful error message listing the actual keys found. Root cause: a skill's routing-eval.jsonl used {"input": ...} instead of {"intent": ...}. The JSON parsed fine but the cast to RoutingFixture was unchecked, so fixture.intent was undefined. normalizeText(undefined) then crashed. This made 'gbrain doctor' completely unusable. (cherry picked from commitb142bbdb0d) * fix(test): isolate HOME in run-e2e.sh to stop config corruption Replaces #517 (re-ported fresh against current scripts/run-e2e.sh after v0.23.1 rewrote the script — original cherry-pick would not apply). E2E tests call setupDB which writes $HOME/.gbrain/config.json pointing at the docker test container. When the container tears down, the user's real autopilot daemon wedges trying to connect to a vanished postgres. Three operators hit this within 16 days before the original PR filed. Fix: wrapper exports HOME + GBRAIN_HOME to a mktemp tmpdir BEFORE bun starts so config writes land in the tmpdir, with a post-run breach detector that compares md5 of the user's real config against pre-run. Both env vars required: loadConfig/saveConfig resolve via HOME while configPath honors GBRAIN_HOME. HOME set before bun starts because os.homedir() caches at first call. Test seam: test/gbrain-home-isolation.test.ts updated to assert against homedir() === configDir() when GBRAIN_HOME unset (correct under the safety wrapper itself) instead of the prior "not /tmp/" sentinel. Revert path: git revert <this-sha> if test:e2e regresses on master. Co-Authored-By: orendi84 <orendi84@users.noreply.github.com> * test(dream-cycle): add schema-suggest to EXPECTED_PHASES v0.40.7.0 Schema Cathedral v3 added the 'schema-suggest' phase between 'orphans' and 'purge' in ALL_PHASES, but the E2E phase-order test was not updated to match. ALL_PHASES vs EXPECTED_PHASES diverged and the shape-pin test failed every run on master. Surfaced during fix-wave: warm-narwhal E2E gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(autopilot-fanout): use relative timestamp inside freshness window The 'end-to-end: updateSourceConfig persists timestamp visible to next listAllSources' test pinned last_full_cycle_at to a hardcoded '2026-05-22T15:00:00.000Z'. The 60-minute freshness window passed within ~1 hour of write — every run after the deadline classified the source as stale and dispatched it, breaking the test's .skippedFresh expectation. Switch to Date.now() - 30min relative timestamp (mirrors the prior 'source with last_full_cycle_at < 60min ago is skipped by gate' test). Surfaced during fix-wave: warm-narwhal E2E gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(fresh-install-pglite): unset other provider keys in beforeEach init.ts:455 fails loud when multiple embedding providers are env-ready in non-TTY mode. The test sets ZEROENTROPY_API_KEY then runs init, but developer machines commonly have OPENAI_API_KEY + VOYAGE_API_KEY + ZEROENTROPY_API_KEY all set, so init sees 3 providers and exits 1. Save+unset OPENAI_API_KEY + VOYAGE_API_KEY in beforeEach, restore in afterEach. Now only ZE is env-ready, init picks it, schema sized to zembed-1's 1280d as the test expects. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(voyage-multimodal): switch fixture from AVIF to PNG Voyage's /multimodalembeddings endpoint rejects AVIF as of 2026-05 with 'Please provide a valid base64-encoded image'. The prior comment ('AVIF is fine for an embed call') held at v0.27.x and regressed silently on the provider side. Add test/fixtures/images/tiny.png (16x16 RGB PNG, 1307 bytes generated via sips from the macOS default wallpaper). PNG is universally accepted by Voyage and other multimodal providers. Surfaced during fix-wave: warm-narwhal E2E gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cycle/synthesize): prefix bare anthropic model ids before queue.add queue.add's subagent capability validator (classifyCapabilities → resolveRecipe) requires provider:model format and rejects bare ids with 'unknown provider'. resolveModel returns the bare id from TIER_DEFAULTS / DEFAULT_ALIASES (e.g. 'claude-sonnet-4-6'), which the validator then rejects, dropping the synthesize phase to status:fail with SYNTH_PHASE_FAIL. Narrow fix at the call site: if config.model has no colon AND starts with 'claude-', prefix 'anthropic:'. Other providers must already declare a colon. Avoids changing TIER_DEFAULTS / DEFAULT_ALIASES constant shapes, which would ripple across every resolveModel caller. Surfaced by dream-synthesize-chunking E2E during fix-wave: warm-narwhal. Affected tests: 'single-chunk transcript uses legacy idempotency key' and 'multi-chunk transcript spawns N children with chunk-suffixed idempotency keys' — both relied on result.details.children_submitted which only the ok() path sets; the failed() path returns details: {}. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(mechanical): pin doctor init embedding model + clean non-default sources Two fixes in the E2E Doctor Command describe block, both surfaced by cross-file state pollution under the full sequential E2E run: 1. Pass --embedding-model openai:text-embedding-3-large to the init subprocess. Without the explicit flag, doctor inherits whatever the resolver picks from env keys (ZE if ZEROENTROPY_API_KEY is set, defaulting to zembed-1 at 1280d). The test's setupDB initialized schema at 1536d, so the dim mismatch fires embedding_width_consistency WARN, exiting doctor 1. 2. DELETE FROM sources WHERE id != 'default' in beforeAll. Prior E2E files leave non-default source rows (e.g. 'delta' from autopilot / sources tests). sync_freshness + cycle_freshness then FAIL on those orphans because they were never synced/cycled, exiting doctor 1. setupDB TRUNCATEs sources but schema.sql re-seeds 'default' via initSchema; this leaves only the canonical single-source brain the test expects. Surfaced during fix-wave: warm-narwhal E2E gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(run-e2e): per-file connection flush + 180s outer timeout Two cross-file isolation hardenings for the sequential E2E runner: 1. Terminate stale Postgres connections before each file. Without this, idle connections from the prior bun process's pool race with the next file's setupDB() TRUNCATE CASCADE, producing 'fixture pages disappear mid-test' failures. The terminate call is idempotent + ~50ms; first iteration is a no-op. 2. Hard outer timeout (180s per file) via gtimeout / timeout. bun's --timeout=60000 is per-test; if a PGLite WASM call hangs in beforeAll/afterAll (e.g. ingestion-roundtrip.test.ts wedging 30+ minutes on macOS), --timeout never fires and the entire suite wedges. Outer SIGKILL lets the suite advance and the file is recorded as failed for triage. Falls through to bare bun if neither gtimeout nor timeout is on PATH. Surfaced during fix-wave: warm-narwhal — 3 of 5 cross-file flakes caught by the connection flush; ingestion-roundtrip 30-min wedge caught by the outer timeout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.3.0) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: annotate synthesize.ts narrow prefix fix (v0.41.3.0) CLAUDE.md gains the v0.41.3.0 note on src/core/cycle/synthesize.ts (narrow anthropic: prefix at the queue.add boundary so resolveModel's bare ids satisfy the subagent validator). llms-full.txt regenerated to match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: rebump v0.41.3.0 → v0.41.5.0 (queue drift; PR #1377 claimed .4.0) Sibling fix-wave PR #1377 (garrytan/community-pr-wave) claimed v0.41.4.0 between my queue check (.3.0 was available) and PR creation. Re-bump to the next available slot per workspace-aware allocator. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(cycle/synthesize): refuse empty brainDir + resolve relative paths Pre-fix, runPhaseSynthesize accepted any brainDir string and passed it to writeReversePages which does join(brainDir, '<slug>.md'). When brainDir is '' or relative ('.' / './brain' / etc), join() produces a relative path that writeFileSync resolves against cwd. Result: every synthesize reverse-write spills into <cwd>/companies/<slug>.md, <cwd>/people/<slug>.md, etc. instead of the intended brainDir tempdir. Surfaced by the warm-narwhal wave when E2E test cleanup found orphan synthesize pages (companies/novamind.md, people/sarah-chen.md, meetings/2025-04-01-novamind-board-update.md) at the gbrain repo root from a runCycle({brainDir: '.'}) chain that ran during morning E2E execution. Fix at the function entry, single location, all callers protected: 1. Empty/whitespace brainDir → return failed(BRAINDIR_EMPTY) loud instead of silently resolving against cwd 2. Relative brainDir → resolve(opts.brainDir) before any read/write can use it. opts.brainDir mutated so writeReversePages, writeSummaryPage, and every join() downstream see the absolute path Regression test pins all 4 contracts: - empty string → fail(BRAINDIR_EMPTY) - whitespace-only → fail(BRAINDIR_EMPTY) - '.' → mutated to absolute on entry - already-absolute → unchanged Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(dream): resolve brainDir to absolute at CLI surface Defense-in-depth for the synthesize-braindir spillage bug class. The core fix lives in runPhaseSynthesize (commit98222a08); this resolves brainDir one layer earlier so the entire 9-phase runCycle gets the absolute path, not just synthesize. Two paths in resolveBrainDir get path.resolve(): - explicit --dir argument (e.g., `gbrain dream --dir .`) - sync.repo_path config (in case it was ever stored relative) resolveBrainDir already checked existsSync; resolve() just canonicalizes before return. No behavior change for paths already absolute. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Matt Gunnin <mgunnin@esports.one> Co-authored-by: Brandon Lipman <brandon@offdeck.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Jeremy Knows <jeremy@veefriends.com> Co-authored-by: root <root@localhost> Co-authored-by: orendi84 <orendigergo@gmail.com> Co-authored-by: orendi84 <orendi84@users.noreply.github.com> Co-authored-by: Garry Tan <garry@ycombinator.com>
417 lines
16 KiB
TypeScript
417 lines
16 KiB
TypeScript
/**
|
||
* Tests for the adaptive embed batch system (PR #680, ships v0.28.7).
|
||
*
|
||
* 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 for the
|
||
* configured embedding recipe when it is missing max_batch_tokens
|
||
* (excluding the OpenAI canonical fast-path recipe).
|
||
*/
|
||
|
||
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';
|
||
|
||
// --------- Test helpers ---------
|
||
|
||
/**
|
||
* 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)),
|
||
),
|
||
};
|
||
}
|
||
|
||
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' },
|
||
});
|
||
}
|
||
|
||
function configureGoogle(): void {
|
||
configureGateway({
|
||
embedding_model: 'google:gemini-embedding-001',
|
||
embedding_dimensions: 768,
|
||
env: { GOOGLE_GENERATIVE_AI_API_KEY: 'fake' },
|
||
});
|
||
}
|
||
|
||
// --------- 1. Pure helpers ---------
|
||
|
||
describe('splitByTokenBudget (pure helper)', () => {
|
||
test('single small text stays in one batch', () => {
|
||
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));
|
||
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', () => {
|
||
// 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, 96_000, 1);
|
||
expect(result).toHaveLength(3);
|
||
expect(result.map(b => b.length)).toEqual([1, 1, 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', () => {
|
||
expect(splitByTokenBudget([], 120_000, 1)).toEqual([]);
|
||
});
|
||
|
||
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 (pure helper)', () => {
|
||
test('matches Voyage error format', () => {
|
||
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('matches OpenAI embeddings "maximum request size" error (regression: PR ###)', () => {
|
||
// Real error string returned by OpenAI's /v1/embeddings endpoint when the
|
||
// sum of all input items exceeds 300k tokens. Without this match, gbrain's
|
||
// recursive-halving safety net never engages on OpenAI and the queue stalls
|
||
// forever on token-dense pages.
|
||
const openaiErr = new Error(
|
||
"Invalid 'input': maximum request size is 300000 tokens per request.",
|
||
);
|
||
expect(isTokenLimitError(openaiErr)).toBe(true);
|
||
});
|
||
|
||
test('matches generic "max tokens per request" phrasing', () => {
|
||
expect(isTokenLimitError(new Error('Exceeded 300000 max tokens per request'))).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('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('configured missing-cap recipe warns once; unrelated recipes stay quiet', () => {
|
||
const warnings: string[] = [];
|
||
const original = console.warn;
|
||
console.warn = (msg: string) => warnings.push(String(msg));
|
||
try {
|
||
configureOpenAI();
|
||
expect(warnings.length).toBe(0);
|
||
configureGoogle();
|
||
const firstCallCount = warnings.length;
|
||
// Reconfigure: the warning should NOT re-fire for the same recipes
|
||
// within one process (we already told the operator).
|
||
configureGoogle();
|
||
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).toBe(1);
|
||
|
||
// 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();
|
||
expect(warnings.find(w => w.includes('"google"'))).toBeDefined();
|
||
});
|
||
});
|