Files
gitmost/packages/token-estimate/src/index.ts
T
agent_coder 875f6ba9c5 feat(ai-chat): токен-бюджет реплея истории + реактивная ветка (#490)
Вся персистентная история реплеится провайдеру КАЖДЫЙ ход, поэтому длинный чат
рано или поздно упирается в контекстное окно и получает провайдерский 400 на
каждом ходу — навсегда (чат «кирпичится»). Бюджетер ограничивает РЕПЛЕЙ (никогда
не мутирует персист — в БД остаётся полная запись), детерминированно и byte-stable
(обрезанный префикс идентичен от хода к ходу → дружелюбно к prompt-cache).

Единый оценщик chars/2.5 (кириллица; chars/4 занижает вдвое) вынесен в shared-пакет
packages/token-estimate; клиентский count-stream-tokens.ts переведён на него ТЕМ ЖЕ
коммитом (два расходящихся оценщика = «бейдж 60%, а бюджет уже режет»).

history-budget.ts (чистый, покрыт тестами):
- resolveReplayBudget(raw): min(100k, 0.7×window) при заданном окне; флэт 100k при
  незаданном (именно эти инсталляции ловят терминальный overflow — warn-лог); 0 =
  явный off-switch. Читается СЫРОЙ chatContextWindow, т.к. parsePositiveInt схлопывает
  0 и unset в undefined (новое поле ResolvedAiConfig.chatContextWindowRaw).
- trimHistoryForReplay: первичный сигнал — провайдерский факт metadata.contextTokens
  прошлого хода; chars-оценка — дельта/раскройка/фолбэк. Порядок: обрезка tool-outputs
  старых ходов (head+tail+маркер) → механическое схлопывание старейших ходов
  (конкатенация, НЕ LLM) → текущий + последние N ходов всегда полные. Пейринг
  tool-call/result сохраняется (схлопывание убирает ОБЕ части).
- isContextOverflowError: классификация провайдерского 400 (статус + паттерны).

Реактивная ветка: превентивная оценка не даёт инварианта (первый переполняющий ход
не имеет usage). onError классифицирует context-overflow → пишет различимую причину
и штампует metadata.replayOverflow; следующий ход бюджетер режет агрессивно
(0.5×), что и раскирпичивает чат. Наблюдаемость: metadata.replayTrimmedToTokens.

ПРИМЕЧАНИЕ по реактивной ветке (форк, требует решения ревьюера): истинный in-turn
re-pipe (перезапуск streamText в тот же ответ) архитектурно несовместим с текущим
пайпом — pipeUIMessageStreamToResponse пишет writeHead СИНХРОННО (подтверждено в
ai@6.0.207), а suite ожидает await stream() c моком, не дёргающим колбэки, — так что
отложенный пайп/ожидание сигнала повесит тесты. Поэтому реализована реактивная
рекавери «классификация → штамп → агрессивный ре-трим на следующем ходу», что даёт
тот же инвариант (чат не кирпичится) без рискованного рефактора стрима.

Тесты (наблюдаемые свойства): объём записи через дельту pg_current_wal_lsn() на живой
gitmost-test-pg вокруг 50-шагового прогона (несжимаемые payload'ы) — trace-колонка
v1=140МБ → v2=0.04МБ (в 3206× меньше), полная строка 289МБ → 140МБ (−51%); dual-shape
не нужен здесь; «окно не задано → бюджет применяется»; реактивная классификация на
реальном 400-шейпе; parity клиент/сервер оценщика.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00

36 lines
1.8 KiB
TypeScript

/**
* Shared, provider-agnostic token estimator (#490).
*
* No provider exposes an exact tokenizer we can afford to run on the hot path (a
* real BPE pass is O(n²)-ish, bloats the client bundle, and is wrong for
* Gemini/Ollama anyway), so both the client's in-body counter AND the server's
* history-replay budgeter use this ONE cheap chars-based heuristic. Keeping it in
* a single shared module is deliberate: two independent estimators drift, and then
* "the badge shows 60%" while "the budgeter already trimmed" — the exact confusion
* this package prevents.
*
* Ratio: **chars / 2.5**. Most content here is Cyrillic, where a token is ~2.5
* characters; the common English `chars/4` rule of thumb UNDER-counts Cyrillic by
* ~2×, which for a budget check is the dangerous direction (it lets the context
* overflow). 2.5 slightly over-estimates pure English/code, which is the SAFE
* direction for a budget. This is an estimate, never an exact count — the
* authoritative figure is always the provider's reported usage; the estimate is
* for UI affordances, the delta of not-yet-sent messages, and deciding what to
* trim.
*/
/** Characters per token for the shared estimate. See the module comment. */
export const CHARS_PER_TOKEN = 2.5;
/**
* Rough token estimate for a piece of text (chars / {@link CHARS_PER_TOKEN}).
* Returns 0 for empty/nullish input, and ceils so any non-empty text counts as at
* least one token. Pure and deterministic (byte-stable), so the same text always
* yields the same estimate — which the server budgeter relies on to keep replay
* trimming stable turn to turn (provider prompt-cache friendliness).
*/
export function estimateTokens(text: string | null | undefined): number {
if (!text) return 0;
return Math.ceil(text.length / CHARS_PER_TOKEN);
}