Files
OmniRoute/src/shared/utils/tiktokenCounter.ts
Andrew B. c702a27eda perf(compression): OOM mitigations for large payload hashing, memoization, and token estimation (#7847) (#11844)
* perf(compression): memory and OOM mitigations for large payload hashing and token estimation

* fix(compression): implement getMemoStats observability for result memo (#7847)

Adds the missing memo observability layer referenced by
tests/unit/compression/oom-memo-memory.test.ts and the monitoring API:

- resultMemo.ts: lifetime hit/miss counters + bounded time-ordered ring
  buffer (10k entries, ~90KB) powering 1m/5m/15m/1h hit-rate windows;
  getMemoStats() reports size/capacity/hits/misses/hitRate + windows.
- memoLookup() tags served results with stats.memoHit = true.
- clearMemoStore() also resets counters and the ring.
- compression/index.ts re-exports getMemoStats for the monitoring route.
- types.ts: optional memoHit field on CompressionStats.
- New GET /api/monitoring/compression route exposing the stats snapshot
  (lightweight, no DB) for operators to track cache-hit efficiency.

* fix(compression): align memo contract with upstream #11727 — return caller object, reset lookup counter in clearMemoStore

* fix(compression): restore unwrapEventEnvelope in stream payload collector summaries

The OOM-mitigation commit accidentally replaced unwrapEventEnvelope(evt.data)
with asRecord(evt.data) in the summary builders and live push, breaking
translate-mode {event, data} envelope unwrapping (clientPayload type detection)
and failing 2 stream-payload-collector tests. Restored upstream semantics;
kept the jsonLength OOM optimization as the only delta in this file.

* refactor(compression): break down writeValue and writeEncodedString to pass complexity ratchets

Refactors jsonSha256 internal helpers (writeValue, writeEncodedString)
into small, single-responsibility sub-functions under the complexity
threshold (max cyclomatic 15, max cognitive 15). Preserves exact
JSON.stringify parity, circular reference guards on both arrays and
plain objects, and escape behavior (all 530 relevant tests pass).

* test(compression): make oom-memo heap assertion robust without expose-gc

The CI unit-test shard runner does not pass --expose-gc, so global.gc is
undefined and heapUsed can still momentarily hold GC-pending transients
(observed 53.4 MiB after a 3MiB body). Gate the retained-heap assertion
on forced collection being available (3 forced cycles for array buffers)
instead of skipping it silently, and keep it fully active when
--expose-gc is present.

* fix(compression): restore worker-pool offload path in runCompressionAsync

The OOM-mitigation refactor dropped the isCompressionWorkerEligible /
runCompressionInWorker dispatch at the top of runCompressionAsync, silently
removing the base's worker-thread offload for eligible large payloads.
Restore the block exactly as on release/v3.8.51, ahead of the result-memo
path, keeping the memoization and hashing improvements intact.

* docs(api): document GET /api/monitoring/compression and log route errors via pino

Add the new monitoring endpoint to docs/openapi.yaml following the
neighboring System entries, and replace the route's console.error with
the repo-standard pino logger.

* fix(skills): regenerate omni-resilience and add changelog fragment

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Andrian Balanescu <AndrianBalanescu@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-09-02 00:01:59 -03:00

102 lines
3.7 KiB
TypeScript

import type { Tiktoken } from "js-tiktoken";
import { createRequire } from "module";
const _require = createRequire(import.meta.url);
export type TokenizerEncoding = "cl100k_base" | "o200k_base";
export interface TokenizerContext {
provider?: string | null;
model?: string | null;
}
export function tokenizerContextFromBody(body: unknown): TokenizerContext {
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
const record = body as Record<string, unknown>;
return {
provider: typeof record.provider === "string" ? record.provider : undefined,
model: typeof record.model === "string" ? record.model : undefined,
};
}
const encoders = new Map<TokenizerEncoding, Tiktoken>();
/**
* Above this many characters the exact tokenizer is skipped in favor of the
* char-heuristic (chars/4). js-tiktoken's pure-JS encoder is near-quadratic on
* large inputs — a 10 MB base64 image payload can block the event loop for
* tens of seconds (OmniRoute worker wedge incident). Token counting is used for
* compression stats/estimates only, so a heuristic on oversized inputs is
* acceptable and keeps the loop responsive.
*/
export const MAX_EXACT_TOKEN_COUNT_CHARS = 50_000;
/**
* Base64 data URIs (e.g. OpenAI-style `image_url.url`) must not be tokenized:
* they are image payloads, not text. Matching a data URI of any `image/*`
* media type and stripping it keeps the count accurate (the raw bytes of an
* image are not meaningful "text" tokens) while avoiding the quadratic encode
* cost on large attachments.
*/
const BASE64_DATA_URI_RE = /data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi;
function stripBase64DataUris(text: string): string {
return text.replace(BASE64_DATA_URI_RE, "");
}
function normalize(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export function isCodexTokenizerContext(context?: TokenizerContext): boolean {
const provider = normalize(context?.provider);
const model = normalize(context?.model);
return (
provider === "codex" ||
provider === "cx" ||
model.startsWith("codex/") ||
model.startsWith("cx/") ||
model.includes("codex")
);
}
export function resolveTokenizerEncoding(context?: TokenizerContext): TokenizerEncoding {
return isCodexTokenizerContext(context) ? "o200k_base" : "cl100k_base";
}
function getEncoder(encoding: TokenizerEncoding): Tiktoken {
const cached = encoders.get(encoding);
if (cached) return cached;
let tiktoken: { getEncoding: (name: string) => Tiktoken } | null = null;
try {
tiktoken ??= _require("js-tiktoken") as { getEncoding: (name: string) => Tiktoken };
const created = tiktoken.getEncoding(encoding);
encoders.set(encoding, created);
return created;
} catch {
throw new Error(`js-tiktoken not available: cannot create encoder for ${encoding}`);
}
}
/**
* Exact token count for a string using the selected offline tokenizer.
* Existing callers retain cl100k_base; Codex callers may pass provider/model context
* to use o200k_base.
* Defensive: never throws in a counting path — falls back to a char heuristic.
* Oversized inputs (over 50k chars) and base64 image data URIs are never
* tokenized: the encoder is near-quadratic on large strings and would block the
* event loop (worker wedge regression).
*/
export function countTextTokens(text: string, context?: TokenizerContext): number {
if (!text || typeof text !== "string") return 0;
const stripped = stripBase64DataUris(text);
if (stripped.length > MAX_EXACT_TOKEN_COUNT_CHARS) {
return Math.ceil(stripped.length / 4);
}
try {
return getEncoder(resolveTokenizerEncoding(context)).encode(stripped).length;
} catch {
return Math.ceil(stripped.length / 4);
}
}