875f6ba9c5
Вся персистентная история реплеится провайдеру КАЖДЫЙ ход, поэтому длинный чат рано или поздно упирается в контекстное окно и получает провайдерский 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>
36 lines
1.8 KiB
TypeScript
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);
|
|
}
|