openvidia 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openvidia/__init__.py +30 -0
- openvidia/__main__.py +827 -0
- openvidia/_upstream_utils.py +57 -0
- openvidia/anthropic_shim.py +714 -0
- openvidia/compaction.py +851 -0
- openvidia/config.py +513 -0
- openvidia/embedding_cache.py +69 -0
- openvidia/graph_engine.py +466 -0
- openvidia/provider_fallback.py +98 -0
- openvidia/proxy_app.py +863 -0
- openvidia/proxy_state.py +1010 -0
- openvidia/redis_sync.py +93 -0
- openvidia/responses_shim.py +1374 -0
- openvidia/safe_file.py +100 -0
- openvidia/server_manager.py +106 -0
- openvidia/upstream_router.py +50 -0
- openvidia/web/assets/32x32.ico +0 -0
- openvidia/web/assets/64x64.ico +0 -0
- openvidia/web/assets/dashboard.gif +0 -0
- openvidia/web/assets/favicon.ico +0 -0
- openvidia/web/assets/logo.png +0 -0
- openvidia/web/favicon.ico +0 -0
- openvidia/web/index.html +166 -0
- openvidia/web/logo.png +0 -0
- openvidia/web/main.js +732 -0
- openvidia/web/styles.css +425 -0
- openvidia/webui.py +532 -0
- openvidia-2.2.0.dist-info/METADATA +814 -0
- openvidia-2.2.0.dist-info/RECORD +33 -0
- openvidia-2.2.0.dist-info/WHEEL +5 -0
- openvidia-2.2.0.dist-info/entry_points.txt +2 -0
- openvidia-2.2.0.dist-info/licenses/LICENSE +21 -0
- openvidia-2.2.0.dist-info/top_level.txt +1 -0
openvidia/compaction.py
ADDED
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-compaction: history never makes a request fail from context overflow.
|
|
3
|
+
|
|
4
|
+
The conversation history is summarized in-place when it exceeds the model's
|
|
5
|
+
budget. A rolling cache makes every turn after the first *incremental*: only
|
|
6
|
+
the messages added since the last summary are sent upstream, so the steady
|
|
7
|
+
state costs a ~2s call instead of re-compressing the whole history.
|
|
8
|
+
|
|
9
|
+
Latency is bounded by an inline deadline, not by the upstream. If the
|
|
10
|
+
summarize call is still running when the deadline expires the request is
|
|
11
|
+
served immediately (cached summary + verbatim remainder, or deterministic
|
|
12
|
+
trim) while the summarize keeps running in the background and lands in the
|
|
13
|
+
cache for the next turn. A client (Codex/opencode) therefore never waits more
|
|
14
|
+
than ``inline_deadline`` seconds, and never loses early context for more than
|
|
15
|
+
one turn.
|
|
16
|
+
|
|
17
|
+
Fallback ladder, best first:
|
|
18
|
+
1. fresh summary (just computed, or cache hit with nothing new)
|
|
19
|
+
2. cached summary + as many verbatim newer messages as fit
|
|
20
|
+
3. deterministic trim
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
|
|
34
|
+
from . import config
|
|
35
|
+
from ._upstream_utils import get_upstream_sem, is_resource_exhausted
|
|
36
|
+
from .config import UPSTREAM_CHAT as UPSTREAM
|
|
37
|
+
|
|
38
|
+
_DEFAULTS = {
|
|
39
|
+
"enabled": True,
|
|
40
|
+
# Activation threshold (~4 chars/token). Below this token count we
|
|
41
|
+
# forward untouched. Keep a comfortable margin BELOW the model context
|
|
42
|
+
# window. 80k is conservative for 128k-window NIM models.
|
|
43
|
+
"budget_tokens": 80_000,
|
|
44
|
+
# Floor for the deterministic trim fallback. On the summary path the
|
|
45
|
+
# verbatim tail is sized to FILL the target instead (see _split_point),
|
|
46
|
+
# so a large budget is not collapsed into "summary + 8 messages".
|
|
47
|
+
"keep_recent": 8,
|
|
48
|
+
"summary_max_tokens": 1024, # cap on summarized output
|
|
49
|
+
# Generation headroom reserved from budget. Combined with budget_tokens
|
|
50
|
+
# this yields the effective activation point: budget - reserved.
|
|
51
|
+
"reserved_tokens": 8000,
|
|
52
|
+
# Compaction target as a fraction of the budget. Compacting exactly TO
|
|
53
|
+
# the budget means the next turn overflows again immediately — the
|
|
54
|
+
# "recompact every single turn" loop. 0.6 leaves room for several turns.
|
|
55
|
+
"compact_ratio": 0.6,
|
|
56
|
+
# Set this to a server-local "quiet" model so summarization does not use
|
|
57
|
+
# the hot streamed model the client is hammering (Codex/whatever): a
|
|
58
|
+
# compaction request then competes for keys on a DIFFERENT traffic stream.
|
|
59
|
+
# Empty = the user's selected model. Strongly recommended: point this at
|
|
60
|
+
# a FAST model — a reasoning-heavy model needs 25s+ to compress 70k
|
|
61
|
+
# tokens. The default is empty on purpose: it used to pin
|
|
62
|
+
# meta/llama-3.1-8b-instruct, the provider retired it, and every
|
|
63
|
+
# compaction then spent its attempts on 404s and fell back to trimming.
|
|
64
|
+
# A model that 404s is also retried once on the user's model (see
|
|
65
|
+
# _start_summarize), so a stale value in compaction.json degrades the
|
|
66
|
+
# same way.
|
|
67
|
+
"summary_model": "",
|
|
68
|
+
# Hard cap on summarize attempts: if the pool is saturated we must NOT
|
|
69
|
+
# serially try all 25 keys, so we bail to deterministic trim after this
|
|
70
|
+
# many rotate-attempts.
|
|
71
|
+
"max_summarize_attempts": 3,
|
|
72
|
+
# Per-attempt upstream read cap. Must be sized for the REAL cost of
|
|
73
|
+
# compressing a large history (tens of seconds on a big model), not for
|
|
74
|
+
# the incremental case — the client is protected by inline_deadline, not
|
|
75
|
+
# by this. Too low here means the summary never completes at all.
|
|
76
|
+
"summarize_timeout": 45.0,
|
|
77
|
+
# How long the *client request* is willing to wait for a summary. Past
|
|
78
|
+
# this the request is served from the fallback ladder while the
|
|
79
|
+
# summarize keeps running in the background for the next turn.
|
|
80
|
+
"inline_deadline": 6.0,
|
|
81
|
+
# Bounds on the text handed to the summarizer. Tool outputs dominate a
|
|
82
|
+
# coding-agent history and compress poorly; truncating them keeps the
|
|
83
|
+
# summarize call fast without losing decisions/paths/state.
|
|
84
|
+
"summary_msg_chars": 4_000,
|
|
85
|
+
"summary_input_chars": 120_000,
|
|
86
|
+
# When the pool health is below this fraction of live keys, skip the
|
|
87
|
+
# summarize attempt entirely and go straight to the fallback ladder.
|
|
88
|
+
"min_healthy_fraction": 0.25,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── Dynamic per-model context budgets ──────────────────────────────────
|
|
93
|
+
# DYNAMIC MODEL BUDGETS — no hardcoded per-model context windows.
|
|
94
|
+
# NVIDIA NIM does NOT advertise a context_window/max_tokens field on
|
|
95
|
+
# /v1/models, so we cannot read it live. Instead, every model gets the
|
|
96
|
+
# generic ``budget_tokens`` default unless the user explicitly overrides a
|
|
97
|
+
# specific model in compaction.json via the ``model_budgets`` map, e.g.:
|
|
98
|
+
# {"model_budgets": {"z-ai/glm-5.2": 120000, "qwen/...": 32000}}
|
|
99
|
+
# This is the only place where per-model knowledge lives — runtime code
|
|
100
|
+
# never hardcodes a provider/model name. The proxy is provider-agnostic.
|
|
101
|
+
def _model_budgets(cfg: dict) -> dict[str, int]:
|
|
102
|
+
"""User-supplied per-model budget overrides from compaction.json."""
|
|
103
|
+
return cfg.get("model_budgets") or {}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── Learned context windows ────────────────────────────────────────────
|
|
107
|
+
# A model the proxy has never seen must work at full context with ZERO
|
|
108
|
+
# configuration: providers add models continuously, and hand-maintaining a
|
|
109
|
+
# budget table means every new model silently runs truncated until someone
|
|
110
|
+
# notices. NVIDIA does not advertise the window on /v1/models, but it states
|
|
111
|
+
# it exactly when a request exceeds it:
|
|
112
|
+
#
|
|
113
|
+
# This model's maximum context length is 202752 tokens. However, your
|
|
114
|
+
# messages resulted in 320011 tokens.
|
|
115
|
+
#
|
|
116
|
+
# So the number is read out of that rejection, once, and cached on disk. Until
|
|
117
|
+
# a model has overflowed at least once the conservative default applies, so an
|
|
118
|
+
# unknown model is never allowed to overflow in the first place.
|
|
119
|
+
_CTX_LIMIT_RE = re.compile(r"maximum context length is (\d+)")
|
|
120
|
+
# The ~4 chars/token estimator underestimates on code and JSON, so only spend
|
|
121
|
+
# this fraction of a learned window.
|
|
122
|
+
_LEARNED_SAFETY = 0.8
|
|
123
|
+
|
|
124
|
+
# Value the removed probe wrote whenever it could not parse a window out of
|
|
125
|
+
# the response — which included 401, 403, 429 and any differently-worded 400.
|
|
126
|
+
# It means "this model accepted 325k tokens", which no NIM model does, and it
|
|
127
|
+
# disabled compaction for that model permanently. Entries carrying it are
|
|
128
|
+
# discarded on load rather than trusted.
|
|
129
|
+
_PROBE_ARTIFACT_WINDOW = 325_000
|
|
130
|
+
|
|
131
|
+
_learned_limits: dict[str, int] = {}
|
|
132
|
+
_learned_loaded = False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _limits_path():
|
|
136
|
+
return config.config_dir() / "model_limits.json"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _load_learned() -> dict[str, int]:
|
|
140
|
+
global _learned_loaded
|
|
141
|
+
if not _learned_loaded:
|
|
142
|
+
_learned_loaded = True
|
|
143
|
+
try:
|
|
144
|
+
p = _limits_path()
|
|
145
|
+
if p.exists():
|
|
146
|
+
data = json.loads(p.read_text())
|
|
147
|
+
_learned_limits.update(
|
|
148
|
+
{
|
|
149
|
+
str(k): int(v)
|
|
150
|
+
for k, v in data.items()
|
|
151
|
+
if int(v) > 0 and int(v) != _PROBE_ARTIFACT_WINDOW
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
|
155
|
+
pass
|
|
156
|
+
return _learned_limits
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def note_context_limit(model: str, body: str) -> int | None:
|
|
160
|
+
"""Record the window stated in an upstream error body, if any.
|
|
161
|
+
|
|
162
|
+
This is the only way a window is learned. It costs nothing: the number
|
|
163
|
+
comes out of a rejection the user's own request already received.
|
|
164
|
+
"""
|
|
165
|
+
if not model or not body:
|
|
166
|
+
return None
|
|
167
|
+
m = _CTX_LIMIT_RE.search(body)
|
|
168
|
+
if not m:
|
|
169
|
+
return None
|
|
170
|
+
limit = int(m.group(1))
|
|
171
|
+
_load_learned()
|
|
172
|
+
if _learned_limits.get(model) == limit:
|
|
173
|
+
return limit
|
|
174
|
+
_learned_limits[model] = limit
|
|
175
|
+
try:
|
|
176
|
+
config.atomic_write(_limits_path(), json.dumps(_learned_limits, indent=2))
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
return limit
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# The active probe that used to live here sent a 1.3 MB request to make the
|
|
183
|
+
# provider state its context window in the rejection. It was removed because
|
|
184
|
+
# every failure mode it had was worse than the problem it solved:
|
|
185
|
+
#
|
|
186
|
+
# * Any non-matching response — 401, 403, 429, an unknown model, a 400 worded
|
|
187
|
+
# differently — fell through to "it accepted 325k tokens", which was then
|
|
188
|
+
# written to disk. Compaction was disabled for that model permanently, and
|
|
189
|
+
# nothing ever corrected it.
|
|
190
|
+
# * A probe that raised was never recorded, so it ran again on the next
|
|
191
|
+
# request. Forever. At 1.3 MB of upload each time.
|
|
192
|
+
# * It bypassed the upstream semaphore and the RPM accounting entirely.
|
|
193
|
+
#
|
|
194
|
+
# note_context_limit() above learns the same number from the error body of a
|
|
195
|
+
# request the user actually made — which is now forwarded instead of discarded.
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _resolve_budget(cfg: dict, model: str) -> int:
|
|
199
|
+
"""User override → learned window → conservative default."""
|
|
200
|
+
override = _model_budgets(cfg).get(model or "")
|
|
201
|
+
if override:
|
|
202
|
+
return int(override)
|
|
203
|
+
learned = _load_learned().get(model or "")
|
|
204
|
+
if learned:
|
|
205
|
+
return int(learned * _LEARNED_SAFETY)
|
|
206
|
+
return int(cfg["budget_tokens"])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_settings_cache: tuple[float, float, dict] | None = None
|
|
210
|
+
_SETTINGS_TTL = 5.0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _settings() -> dict:
|
|
214
|
+
"""compaction.json, re-read at most every _SETTINGS_TTL seconds.
|
|
215
|
+
|
|
216
|
+
maybe_compact runs on every request; stat+read+parse per request is pure
|
|
217
|
+
syscall overhead on the hot path.
|
|
218
|
+
"""
|
|
219
|
+
global _settings_cache
|
|
220
|
+
now = time.monotonic()
|
|
221
|
+
if _settings_cache is not None and now - _settings_cache[0] < _SETTINGS_TTL:
|
|
222
|
+
return _settings_cache[2]
|
|
223
|
+
loaded = dict(_DEFAULTS)
|
|
224
|
+
mtime = 0.0
|
|
225
|
+
try:
|
|
226
|
+
p = config.config_dir() / "compaction.json"
|
|
227
|
+
if p.exists():
|
|
228
|
+
mtime = p.stat().st_mtime
|
|
229
|
+
loaded = {**_DEFAULTS, **json.loads(p.read_text())}
|
|
230
|
+
except (json.JSONDecodeError, OSError):
|
|
231
|
+
pass
|
|
232
|
+
_settings_cache = (now, mtime, loaded)
|
|
233
|
+
return loaded
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def estimate_tokens(messages: list[dict]) -> int:
|
|
237
|
+
"""Rough token estimate: ~4 chars/token on the serialized JSON."""
|
|
238
|
+
try:
|
|
239
|
+
return len(json.dumps(messages, ensure_ascii=False)) // 4
|
|
240
|
+
except (TypeError, ValueError):
|
|
241
|
+
return sum(len(str(m)) for m in messages) // 4
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _msg_tokens(m: dict) -> int:
|
|
245
|
+
"""Per-message estimate. Summing these over a list is always >= the
|
|
246
|
+
estimate of the serialized list, so budget arithmetic built on them is
|
|
247
|
+
conservative by construction."""
|
|
248
|
+
return estimate_tokens([m])
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _split(messages: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
252
|
+
"""Split into (leading system messages, the rest of the conversation)."""
|
|
253
|
+
i = 0
|
|
254
|
+
while i < len(messages) and messages[i].get("role") == "system":
|
|
255
|
+
i += 1
|
|
256
|
+
return messages[:i], messages[i:]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _render(messages: list[dict]) -> str:
|
|
260
|
+
lines: list[str] = []
|
|
261
|
+
for m in messages:
|
|
262
|
+
role = m.get("role", "?")
|
|
263
|
+
c = m.get("content")
|
|
264
|
+
if not isinstance(c, str):
|
|
265
|
+
c = json.dumps(c, ensure_ascii=False)
|
|
266
|
+
tcs = m.get("tool_calls")
|
|
267
|
+
if tcs:
|
|
268
|
+
names = ", ".join(t.get("function", {}).get("name", "") for t in tcs)
|
|
269
|
+
c = (c or "") + f" [tool_calls: {names}]"
|
|
270
|
+
lines.append(f"{role}: {c}")
|
|
271
|
+
return "\n".join(lines)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _clip(text: str, cap: int) -> str:
|
|
275
|
+
"""Keep the head and the tail of an oversized blob, drop the middle."""
|
|
276
|
+
if cap <= 0 or len(text) <= cap:
|
|
277
|
+
return text
|
|
278
|
+
head = (cap * 2) // 3
|
|
279
|
+
tail = cap - head
|
|
280
|
+
return f"{text[:head]}\n…[{len(text) - cap} chars omitted]…\n{text[-tail:]}"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _render_for_summary(messages: list[dict], msg_cap: int, total_cap: int) -> str:
|
|
284
|
+
"""Bounded render used as summarizer input.
|
|
285
|
+
|
|
286
|
+
Never send an unbounded history to the summarizer: a coding-agent
|
|
287
|
+
transcript is mostly tool output, which is what makes the call slow
|
|
288
|
+
enough to blow the deadline. Per-message clipping keeps decisions, paths
|
|
289
|
+
and state; the total cap keeps the most recent material when even the
|
|
290
|
+
clipped render is too large.
|
|
291
|
+
"""
|
|
292
|
+
rendered = [_clip(_render([m]), msg_cap) for m in messages]
|
|
293
|
+
out = "\n".join(rendered)
|
|
294
|
+
if total_cap > 0 and len(out) > total_cap:
|
|
295
|
+
kept: list[str] = []
|
|
296
|
+
size = 0
|
|
297
|
+
for line in reversed(rendered):
|
|
298
|
+
if size + len(line) > total_cap:
|
|
299
|
+
break
|
|
300
|
+
kept.insert(0, line)
|
|
301
|
+
size += len(line) + 1
|
|
302
|
+
out = "[older messages elided]\n" + "\n".join(kept)
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
_CONV_KEY_PREFIX_MSGS = 4 # initial messages of `rest` used for the conversation identity
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _conv_key(system_block: list[dict], rest: list[dict]) -> str:
|
|
310
|
+
"""
|
|
311
|
+
Stable identity for a conversation, INVARIANT as the conversation grows.
|
|
312
|
+
|
|
313
|
+
Identity = hash of system_block + the first N messages. Two conversations
|
|
314
|
+
that share a system prompt but diverge at message 2 (common with agentic
|
|
315
|
+
CLIs) hash differently, so the rolling cache never swaps summaries across
|
|
316
|
+
sessions. The length is deliberately NOT part of the key: including it
|
|
317
|
+
minted a new key on every turn, which made the rolling cache unreachable
|
|
318
|
+
and forced a full re-summarize of the whole history each time. Staleness
|
|
319
|
+
is handled by the fingerprint check on the covered prefix, not by the key.
|
|
320
|
+
"""
|
|
321
|
+
h = hashlib.sha256()
|
|
322
|
+
for m in system_block + rest[:_CONV_KEY_PREFIX_MSGS]:
|
|
323
|
+
h.update(_render([m]).encode("utf-8", "replace"))
|
|
324
|
+
return h.hexdigest()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _fingerprint(msgs: list[dict]) -> str:
|
|
328
|
+
"""Full SHA-256 of a message sequence to guard against cache collisions.
|
|
329
|
+
|
|
330
|
+
A 16-char truncation (~64-bit) collides too often when many concurrent
|
|
331
|
+
openvidia sessions share the same system prompt: rolling cache would reuse
|
|
332
|
+
the wrong summary and contaminate the context. Use the full digest.
|
|
333
|
+
"""
|
|
334
|
+
h = hashlib.sha256()
|
|
335
|
+
for m in msgs:
|
|
336
|
+
h.update(_render([m]).encode("utf-8", "replace"))
|
|
337
|
+
return h.hexdigest()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ck -> (covered_count, summary, fingerprint_of_covered_prefix)
|
|
341
|
+
_rolling: dict[str, tuple[int, str, str]] = {}
|
|
342
|
+
_ROLLING_CAP = 256
|
|
343
|
+
# ck -> (task, covered_count_the_task_will_cover)
|
|
344
|
+
_inflight: dict[str, tuple[asyncio.Task, int]] = {}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _cache_put(ck: str, covered: int, summary: str, fp: str) -> None:
|
|
348
|
+
if ck not in _rolling and len(_rolling) >= _ROLLING_CAP:
|
|
349
|
+
# FIFO eviction — dropping the whole cache would re-trigger a full
|
|
350
|
+
# re-summarize for every live session at once.
|
|
351
|
+
_rolling.pop(next(iter(_rolling)), None)
|
|
352
|
+
_rolling[ck] = (covered, summary, fp)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _cache_get(ck: str, old: list[dict]) -> tuple[int, str] | None:
|
|
356
|
+
"""Return (covered, summary) if the cached summary is a valid prefix of
|
|
357
|
+
the current history, else None."""
|
|
358
|
+
prev = _rolling.get(ck)
|
|
359
|
+
if not prev:
|
|
360
|
+
return None
|
|
361
|
+
covered, summary, fp = prev
|
|
362
|
+
if covered > len(old):
|
|
363
|
+
return None
|
|
364
|
+
if _fingerprint(old[:covered]) != fp:
|
|
365
|
+
return None
|
|
366
|
+
return covered, summary
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class ModelNotFoundError(RuntimeError):
|
|
370
|
+
"""The upstream answered 404 for the summary model: retired or misspelled."""
|
|
371
|
+
|
|
372
|
+
def __init__(self, model: str) -> None:
|
|
373
|
+
super().__init__(f"summarize HTTP 404: model {model!r} not found upstream")
|
|
374
|
+
self.model = model
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def _summarize(
|
|
378
|
+
client,
|
|
379
|
+
keys: list[str],
|
|
380
|
+
model: str,
|
|
381
|
+
prev_summary: str | None,
|
|
382
|
+
new_msgs: list[dict],
|
|
383
|
+
max_tokens: int,
|
|
384
|
+
*,
|
|
385
|
+
max_attempts: int = 3,
|
|
386
|
+
attempt_timeout: float = 45.0,
|
|
387
|
+
msg_cap: int = 4_000,
|
|
388
|
+
total_cap: int = 120_000,
|
|
389
|
+
) -> str:
|
|
390
|
+
"""Summarize with rotation across the given keys + bounded attempts.
|
|
391
|
+
|
|
392
|
+
We rotate across at most ``max_attempts`` keys (NOT the whole pool — with
|
|
393
|
+
25 keys that would mean minutes of upstream work for a single summary).
|
|
394
|
+
``attempt_timeout`` must be generous enough for the call to actually
|
|
395
|
+
succeed; the caller protects the client with its own inline deadline and
|
|
396
|
+
lets this finish in the background.
|
|
397
|
+
"""
|
|
398
|
+
sys = (
|
|
399
|
+
"You are a conversation history compressor. Produce a concise but dense "
|
|
400
|
+
"summary that preserves decisions, facts, file modifications and their "
|
|
401
|
+
"paths, the current state of the task, and important values/parameters. "
|
|
402
|
+
"No preambles — return only the summary."
|
|
403
|
+
)
|
|
404
|
+
user = ""
|
|
405
|
+
if prev_summary:
|
|
406
|
+
user += "Previous summary:\n" + prev_summary + "\n\n"
|
|
407
|
+
user += "New messages to integrate into the summary:\n" + _render_for_summary(
|
|
408
|
+
new_msgs, msg_cap, total_cap
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
payload = {
|
|
412
|
+
"model": model,
|
|
413
|
+
"messages": [
|
|
414
|
+
{"role": "system", "content": sys},
|
|
415
|
+
{"role": "user", "content": user},
|
|
416
|
+
],
|
|
417
|
+
"max_completion_tokens": max_tokens,
|
|
418
|
+
"temperature": 0.2,
|
|
419
|
+
"stream": False,
|
|
420
|
+
}
|
|
421
|
+
config.apply_model_options(payload)
|
|
422
|
+
last_err = ""
|
|
423
|
+
attempts = 0
|
|
424
|
+
seen_429 = False
|
|
425
|
+
for key in keys:
|
|
426
|
+
if attempts >= max_attempts:
|
|
427
|
+
break
|
|
428
|
+
attempts += 1
|
|
429
|
+
hdrs = {
|
|
430
|
+
"Authorization": f"Bearer {key}",
|
|
431
|
+
"Content-Type": "application/json",
|
|
432
|
+
"User-Agent": "openvidia/2.0",
|
|
433
|
+
}
|
|
434
|
+
try:
|
|
435
|
+
req = client.build_request(
|
|
436
|
+
"POST",
|
|
437
|
+
UPSTREAM,
|
|
438
|
+
json=payload,
|
|
439
|
+
headers=hdrs,
|
|
440
|
+
extensions={
|
|
441
|
+
"timeout": {
|
|
442
|
+
"connect": 5.0,
|
|
443
|
+
"read": attempt_timeout,
|
|
444
|
+
"write": 10.0,
|
|
445
|
+
"pool": attempt_timeout,
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
)
|
|
449
|
+
async with get_upstream_sem():
|
|
450
|
+
resp = await client.send(req)
|
|
451
|
+
except httpx.HTTPError as e:
|
|
452
|
+
last_err = str(e) or type(e).__name__
|
|
453
|
+
continue
|
|
454
|
+
try:
|
|
455
|
+
if resp.status_code != 200:
|
|
456
|
+
resp_bytes = None
|
|
457
|
+
try:
|
|
458
|
+
resp_bytes = await resp.aread()
|
|
459
|
+
except Exception:
|
|
460
|
+
pass
|
|
461
|
+
if resp.status_code == 429 and is_resource_exhausted(resp_bytes):
|
|
462
|
+
attempts -= 1 # Don't burn attempt on transient worker limit
|
|
463
|
+
await asyncio.sleep(0.5)
|
|
464
|
+
continue
|
|
465
|
+
if resp.status_code == 404:
|
|
466
|
+
# The model does not exist for any key: rotating would
|
|
467
|
+
# only spend the remaining attempts on the same answer.
|
|
468
|
+
raise ModelNotFoundError(model)
|
|
469
|
+
last_err = f"summarize HTTP {resp.status_code}"
|
|
470
|
+
if resp.status_code == 429:
|
|
471
|
+
seen_429 = True
|
|
472
|
+
last_err = "summarize HTTP 429 (pool saturated)"
|
|
473
|
+
continue
|
|
474
|
+
data = resp.json()
|
|
475
|
+
finally:
|
|
476
|
+
await resp.aclose()
|
|
477
|
+
|
|
478
|
+
txt = ((data.get("choices") or [{}])[0].get("message", {}) or {}).get("content") or ""
|
|
479
|
+
if not txt.strip():
|
|
480
|
+
last_err = "summarize empty"
|
|
481
|
+
continue
|
|
482
|
+
return txt.strip()
|
|
483
|
+
|
|
484
|
+
suffix = " (pool rate-limited)" if seen_429 else ""
|
|
485
|
+
raise RuntimeError((last_err or "summarize: all keys failed") + suffix)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _orphan_free(msgs: list[dict]) -> list[dict]:
|
|
489
|
+
"""Drop leading tool results whose assistant message is not in ``msgs``.
|
|
490
|
+
|
|
491
|
+
Every cut this module makes is at a numeric index, and a message with
|
|
492
|
+
role "tool" only means anything next to the assistant message whose
|
|
493
|
+
tool_calls it answers. Cutting between the two leaves an orphan, and NIM
|
|
494
|
+
enforces the pairing strictly:
|
|
495
|
+
|
|
496
|
+
400 — messages with role 'tool' must be a response to a preceding
|
|
497
|
+
message with 'tool_calls'
|
|
498
|
+
|
|
499
|
+
which the rotation loop then charged to the keys, cooling down five of
|
|
500
|
+
them for a request no key could have served. The failure appears exactly
|
|
501
|
+
when compaction is doing its job: a long agentic history, dense with tool
|
|
502
|
+
calls. Dropping forward is always safe — it only ever removes messages,
|
|
503
|
+
so it cannot push the result back over budget.
|
|
504
|
+
"""
|
|
505
|
+
i = 0
|
|
506
|
+
n = len(msgs)
|
|
507
|
+
while i < n and msgs[i].get("role") == "tool":
|
|
508
|
+
i += 1
|
|
509
|
+
return msgs[i:] if i else msgs
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _safe_cut(rest: list[dict], idx: int) -> int:
|
|
513
|
+
"""Move a boundary forward until the suffix ``rest[idx:]`` has no orphans."""
|
|
514
|
+
n = len(rest)
|
|
515
|
+
i = max(0, min(idx, n))
|
|
516
|
+
while i < n and rest[i].get("role") == "tool":
|
|
517
|
+
i += 1
|
|
518
|
+
return i
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _trim(system_block: list[dict], rest: list[dict], budget: int, keep_recent: int) -> list[dict]:
|
|
522
|
+
"""Deterministic fallback: keep system + first msg + as many recent as fit.
|
|
523
|
+
|
|
524
|
+
GUARANTEE: the returned message list never exceeds ``budget`` tokens.
|
|
525
|
+
Sizing uses per-message estimates (whose sum is always >= the estimate of
|
|
526
|
+
the serialized list), so the arithmetic is conservative and the whole
|
|
527
|
+
function stays O(n) — the previous version re-serialized the entire list
|
|
528
|
+
inside its safety loops, which is O(n²) on a 400-message history.
|
|
529
|
+
"""
|
|
530
|
+
notice = {"role": "system", "content": "[previous messages omitted to fit context]"}
|
|
531
|
+
notice_t = _msg_tokens(notice)
|
|
532
|
+
sys_t = sum(_msg_tokens(m) for m in system_block)
|
|
533
|
+
|
|
534
|
+
head = rest[:1]
|
|
535
|
+
head_t = sum(_msg_tokens(m) for m in head)
|
|
536
|
+
|
|
537
|
+
base = sys_t + notice_t + head_t
|
|
538
|
+
if base > budget: # head does not fit: drop it
|
|
539
|
+
head, head_t = [], 0
|
|
540
|
+
base = sys_t + notice_t
|
|
541
|
+
if base > budget: # system block itself overflows: notice only
|
|
542
|
+
return [notice]
|
|
543
|
+
|
|
544
|
+
# Contiguous suffix. The previous version did `continue` here, which
|
|
545
|
+
# skipped a message that did not fit and carried on with older ones — so
|
|
546
|
+
# the kept tail had a hole in the middle, and the message most likely to be
|
|
547
|
+
# skipped is the big one: the assistant turn carrying tool_calls, or its
|
|
548
|
+
# result. Stopping at the first message that does not fit keeps the tail a
|
|
549
|
+
# real suffix of the conversation.
|
|
550
|
+
tail: list[dict] = []
|
|
551
|
+
total = base
|
|
552
|
+
for m in reversed(rest[1:]):
|
|
553
|
+
t = _msg_tokens(m)
|
|
554
|
+
if total + t > budget and len(tail) >= keep_recent:
|
|
555
|
+
break
|
|
556
|
+
tail.insert(0, m)
|
|
557
|
+
total += t
|
|
558
|
+
|
|
559
|
+
tail = _orphan_free(tail)
|
|
560
|
+
result = system_block + head + [notice] + tail
|
|
561
|
+
# Cheap post-condition check against the real serialization; the summed
|
|
562
|
+
# estimate is an upper bound, so this practically never fires.
|
|
563
|
+
while tail and estimate_tokens(result) > budget:
|
|
564
|
+
tail = _orphan_free(tail[1:])
|
|
565
|
+
result = system_block + head + [notice] + tail
|
|
566
|
+
return result
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _summary_block(summary: str) -> dict:
|
|
570
|
+
return {"role": "system", "content": "Previous conversation summary:\n" + summary}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _split_point(system_block: list[dict], rest: list[dict], target: int, reserve: int) -> int:
|
|
574
|
+
"""How many trailing messages to keep verbatim.
|
|
575
|
+
|
|
576
|
+
Fills the target with the largest recent suffix that fits, leaving room
|
|
577
|
+
for the system block and the summary. ``keep_recent`` is only a floor for
|
|
578
|
+
the trim fallback: when a 150k budget is available, collapsing everything
|
|
579
|
+
but the last 8 messages into one paragraph throws away context the model
|
|
580
|
+
could have used.
|
|
581
|
+
"""
|
|
582
|
+
avail = target - sum(_msg_tokens(m) for m in system_block) - reserve
|
|
583
|
+
n = 0
|
|
584
|
+
total = 0
|
|
585
|
+
for m in reversed(rest):
|
|
586
|
+
t = _msg_tokens(m)
|
|
587
|
+
if n >= 1 and total + t > avail:
|
|
588
|
+
break
|
|
589
|
+
total += t
|
|
590
|
+
n += 1
|
|
591
|
+
# Always leave at least one message on the summarize side.
|
|
592
|
+
return max(1, min(n, len(rest) - 1))
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _assemble(
|
|
596
|
+
system_block: list[dict],
|
|
597
|
+
summary: str,
|
|
598
|
+
remainder: list[dict],
|
|
599
|
+
tail: list[dict],
|
|
600
|
+
budget: int,
|
|
601
|
+
) -> list[dict]:
|
|
602
|
+
"""system + summary block + verbatim remainder + recent tail, under budget.
|
|
603
|
+
|
|
604
|
+
``remainder`` are messages newer than the summary but older than the
|
|
605
|
+
kept tail (they exist when we serve a summary computed one turn ago).
|
|
606
|
+
They are dropped oldest-first until the result fits.
|
|
607
|
+
"""
|
|
608
|
+
block = _summary_block(summary)
|
|
609
|
+
tail = _orphan_free(list(tail))
|
|
610
|
+
fixed = sum(_msg_tokens(m) for m in system_block + [block] + tail)
|
|
611
|
+
rem = list(remainder)
|
|
612
|
+
total = fixed + sum(_msg_tokens(m) for m in rem)
|
|
613
|
+
while rem and total > budget:
|
|
614
|
+
total -= _msg_tokens(rem.pop(0))
|
|
615
|
+
# Dropping the assistant turn leaves its tool results answering
|
|
616
|
+
# nothing, so they go with it.
|
|
617
|
+
while rem and rem[0].get("role") == "tool":
|
|
618
|
+
total -= _msg_tokens(rem.pop(0))
|
|
619
|
+
rem = _orphan_free(rem)
|
|
620
|
+
result = system_block + [block] + rem + tail
|
|
621
|
+
while len(tail) > 1 and estimate_tokens(result) > budget:
|
|
622
|
+
tail = _orphan_free(tail[1:])
|
|
623
|
+
result = system_block + [block] + rem + tail
|
|
624
|
+
return result
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _healthy_keys(state) -> tuple[list[str], int]:
|
|
628
|
+
"""Healthy + RPM-eligible keys, least-busy first."""
|
|
629
|
+
keys = [k for k in state.keys if state.is_key_healthy(k) and state.key_can_send_rpm(k)]
|
|
630
|
+
try: # least-RPM-first: land the summary on the quietest key, not key[0]
|
|
631
|
+
keys.sort(key=state.key_rpm)
|
|
632
|
+
except (AttributeError, TypeError):
|
|
633
|
+
pass
|
|
634
|
+
return keys, len(state.keys) or 1
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _start_summarize(
|
|
638
|
+
ck: str,
|
|
639
|
+
covered_target: int,
|
|
640
|
+
fp: str,
|
|
641
|
+
*,
|
|
642
|
+
client,
|
|
643
|
+
keys: list[str],
|
|
644
|
+
model: str,
|
|
645
|
+
base: str | None,
|
|
646
|
+
new_msgs: list[dict],
|
|
647
|
+
cfg: dict,
|
|
648
|
+
log: Callable[[str], None],
|
|
649
|
+
fallback_model: str = "",
|
|
650
|
+
) -> asyncio.Task:
|
|
651
|
+
"""Launch (or reuse) the single summarize task for this conversation.
|
|
652
|
+
|
|
653
|
+
``fallback_model`` is tried once when ``model`` 404s: a summary model the
|
|
654
|
+
provider retired must not turn every compaction into a trim.
|
|
655
|
+
"""
|
|
656
|
+
existing = _inflight.get(ck)
|
|
657
|
+
if existing and not existing[0].done():
|
|
658
|
+
return existing[0]
|
|
659
|
+
|
|
660
|
+
started = time.monotonic()
|
|
661
|
+
n_new = len(new_msgs)
|
|
662
|
+
used = model
|
|
663
|
+
|
|
664
|
+
async def _attempt(m: str) -> str:
|
|
665
|
+
return await _summarize(
|
|
666
|
+
client,
|
|
667
|
+
keys,
|
|
668
|
+
m,
|
|
669
|
+
base,
|
|
670
|
+
new_msgs,
|
|
671
|
+
cfg["summary_max_tokens"],
|
|
672
|
+
max_attempts=cfg.get("max_summarize_attempts", 3),
|
|
673
|
+
attempt_timeout=cfg.get("summarize_timeout", 45.0),
|
|
674
|
+
msg_cap=cfg.get("summary_msg_chars", 4_000),
|
|
675
|
+
total_cap=cfg.get("summary_input_chars", 120_000),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
async def _run() -> str:
|
|
679
|
+
nonlocal used
|
|
680
|
+
try:
|
|
681
|
+
return await _attempt(model)
|
|
682
|
+
except ModelNotFoundError:
|
|
683
|
+
if not fallback_model or fallback_model == model:
|
|
684
|
+
raise
|
|
685
|
+
log(f"⧉ compaction: summary_model {model!r} not found upstream → {fallback_model}")
|
|
686
|
+
used = fallback_model
|
|
687
|
+
return await _attempt(fallback_model)
|
|
688
|
+
|
|
689
|
+
task = asyncio.ensure_future(_run())
|
|
690
|
+
|
|
691
|
+
def _done(t: asyncio.Task) -> None:
|
|
692
|
+
cur = _inflight.get(ck)
|
|
693
|
+
if cur and cur[0] is t:
|
|
694
|
+
_inflight.pop(ck, None)
|
|
695
|
+
if t.cancelled():
|
|
696
|
+
return
|
|
697
|
+
err = t.exception()
|
|
698
|
+
elapsed = time.monotonic() - started
|
|
699
|
+
if err is not None:
|
|
700
|
+
log(f"⧉ compaction: summarize failed ({err}) in {elapsed:.1f}s → fallback")
|
|
701
|
+
return
|
|
702
|
+
summary = t.result()
|
|
703
|
+
_cache_put(ck, covered_target, summary, fp)
|
|
704
|
+
log(
|
|
705
|
+
f"⧉ compaction: summarized {n_new} new msg (covers {covered_target}) "
|
|
706
|
+
f"→ {len(summary)} char in {elapsed:.1f}s (model={used})"
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
task.add_done_callback(_done)
|
|
710
|
+
_inflight[ck] = (task, covered_target)
|
|
711
|
+
return task
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
async def maybe_compact(
|
|
715
|
+
messages: list[dict],
|
|
716
|
+
*,
|
|
717
|
+
state,
|
|
718
|
+
client,
|
|
719
|
+
log: Callable[[str], None],
|
|
720
|
+
model: str = "",
|
|
721
|
+
) -> list[dict]:
|
|
722
|
+
"""
|
|
723
|
+
Return compacted messages if needed, otherwise the original list
|
|
724
|
+
(same reference — caller can skip re-serializing).
|
|
725
|
+
|
|
726
|
+
``model`` is the model this request will run on; it sizes the budget.
|
|
727
|
+
Callers that omit it fall back to the dashboard selection.
|
|
728
|
+
"""
|
|
729
|
+
cfg = _settings()
|
|
730
|
+
if not cfg["enabled"] or not isinstance(messages, list) or len(messages) < 4:
|
|
731
|
+
return messages
|
|
732
|
+
# Per-model context budget − reserved generation headroom. Per-model
|
|
733
|
+
# override comes from the user compaction.json ``model_budgets`` map
|
|
734
|
+
# (dynamic, no hardcoded provider names). Falls back to budget_tokens.
|
|
735
|
+
reserved = cfg.get("reserved_tokens", _DEFAULTS["reserved_tokens"])
|
|
736
|
+
# The model this request will actually run on. Using state.active_model
|
|
737
|
+
# sized the budget for whatever the dashboard had selected, so a client
|
|
738
|
+
# asking for a different model was compacted against the wrong window —
|
|
739
|
+
# too little, and it overflowed; too much, and it compacted for nothing.
|
|
740
|
+
active = model or state.active_model or ""
|
|
741
|
+
budget = max(_resolve_budget(cfg, active) - reserved, reserved)
|
|
742
|
+
if estimate_tokens(messages) <= budget:
|
|
743
|
+
return messages
|
|
744
|
+
|
|
745
|
+
# Compact BELOW the trigger, not exactly to it: landing on the threshold
|
|
746
|
+
# means the very next turn overflows again (the recompact-every-turn loop).
|
|
747
|
+
ratio = cfg.get("compact_ratio", _DEFAULTS["compact_ratio"])
|
|
748
|
+
target = max(int(budget * ratio), reserved)
|
|
749
|
+
|
|
750
|
+
keep_recent = cfg["keep_recent"]
|
|
751
|
+
system_block, rest = _split(messages)
|
|
752
|
+
if len(rest) < 2:
|
|
753
|
+
return messages
|
|
754
|
+
|
|
755
|
+
ck = _conv_key(system_block, rest)
|
|
756
|
+
# Room the summary block itself will occupy once generated.
|
|
757
|
+
reserve = cfg["summary_max_tokens"] + 64
|
|
758
|
+
|
|
759
|
+
cached = _cache_get(ck, rest)
|
|
760
|
+
covered, base = cached if cached else (0, None)
|
|
761
|
+
|
|
762
|
+
if base is not None:
|
|
763
|
+
# Fast path: cached summary + EVERYTHING after it, verbatim. The
|
|
764
|
+
# summary boundary only advances when this no longer fits, so an
|
|
765
|
+
# upstream summarize happens once every few turns instead of once
|
|
766
|
+
# per turn — and the model keeps the maximum verbatim context.
|
|
767
|
+
verbatim = rest[_safe_cut(rest, covered) :]
|
|
768
|
+
candidate = system_block + [_summary_block(base)] + verbatim
|
|
769
|
+
if estimate_tokens(candidate) <= budget:
|
|
770
|
+
return candidate
|
|
771
|
+
|
|
772
|
+
# Boundary must advance: keep the largest recent suffix that fits the
|
|
773
|
+
# target and summarize everything before it.
|
|
774
|
+
keep_n = _split_point(system_block, rest, target, reserve)
|
|
775
|
+
split = _safe_cut(rest, len(rest) - keep_n)
|
|
776
|
+
new_msgs = rest[covered:split]
|
|
777
|
+
if not new_msgs:
|
|
778
|
+
# Already summarized up to the split yet still over budget (huge
|
|
779
|
+
# recent messages): shed verbatim messages oldest-first.
|
|
780
|
+
if base is not None:
|
|
781
|
+
return _assemble(system_block, base, rest[_safe_cut(rest, covered) :], [], target)
|
|
782
|
+
return _trim(system_block, rest, target, keep_recent)
|
|
783
|
+
|
|
784
|
+
# Summarize ALWAYS runs on the server-local "quiet" model, NOT the hot
|
|
785
|
+
# streamed model the client is saturating. Using the same model would
|
|
786
|
+
# compete with the very requests that triggered compaction and burn the
|
|
787
|
+
# only available RPM. No hardcoded model here — the user's selection
|
|
788
|
+
# is the source of truth when summary_model is unset.
|
|
789
|
+
from .proxy_app import default_model
|
|
790
|
+
|
|
791
|
+
user_model = default_model(state)
|
|
792
|
+
summary_model = cfg.get("summary_model") or user_model
|
|
793
|
+
candidate_keys, n_keys = _healthy_keys(state)
|
|
794
|
+
min_healthy = max(1, int(n_keys * cfg.get("min_healthy_fraction", 0.25)))
|
|
795
|
+
|
|
796
|
+
task: asyncio.Task | None = None
|
|
797
|
+
inflight = _inflight.get(ck)
|
|
798
|
+
if inflight and not inflight[0].done():
|
|
799
|
+
# Another request (or an earlier turn) is already compressing this
|
|
800
|
+
# conversation — never duplicate the work, just wait on it.
|
|
801
|
+
task = inflight[0]
|
|
802
|
+
elif len(candidate_keys) < min_healthy:
|
|
803
|
+
log(
|
|
804
|
+
f"⧉ compaction: pool saturated ({len(candidate_keys)}/{n_keys} healthy) "
|
|
805
|
+
f"→ no summarize this turn"
|
|
806
|
+
)
|
|
807
|
+
else:
|
|
808
|
+
task = _start_summarize(
|
|
809
|
+
ck,
|
|
810
|
+
split,
|
|
811
|
+
_fingerprint(rest[:split]),
|
|
812
|
+
client=client,
|
|
813
|
+
keys=candidate_keys,
|
|
814
|
+
model=summary_model,
|
|
815
|
+
base=base,
|
|
816
|
+
new_msgs=new_msgs,
|
|
817
|
+
cfg=cfg,
|
|
818
|
+
log=log,
|
|
819
|
+
fallback_model=user_model,
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
if task is not None:
|
|
823
|
+
deadline = cfg.get("inline_deadline", _DEFAULTS["inline_deadline"])
|
|
824
|
+
# Shield: if the client disconnects mid-wait the summarize must
|
|
825
|
+
# survive to populate the cache for the next turn.
|
|
826
|
+
try:
|
|
827
|
+
await asyncio.wait_for(asyncio.shield(task), timeout=deadline)
|
|
828
|
+
except TimeoutError:
|
|
829
|
+
log(
|
|
830
|
+
f"⧉ compaction: summarize still running (>{deadline:.0f}s) "
|
|
831
|
+
f"→ serving now, summary lands next turn"
|
|
832
|
+
)
|
|
833
|
+
except Exception: # noqa: BLE001 — logged by the task callback
|
|
834
|
+
pass
|
|
835
|
+
fresh = _cache_get(ck, rest)
|
|
836
|
+
if fresh and fresh[0] >= covered:
|
|
837
|
+
covered, base = fresh
|
|
838
|
+
|
|
839
|
+
# Stale-but-valid summary beats a raw trim: keep the compressed early
|
|
840
|
+
# context and append as many verbatim newer messages as fit.
|
|
841
|
+
if base is not None:
|
|
842
|
+
result = _assemble(system_block, base, rest[_safe_cut(rest, covered) :], [], target)
|
|
843
|
+
log(
|
|
844
|
+
f"⧉ compaction: summary covers {covered}/{len(rest)} msg "
|
|
845
|
+
f"+ {len(result) - len(system_block) - 1} verbatim (~{estimate_tokens(result)} tok)"
|
|
846
|
+
)
|
|
847
|
+
return result
|
|
848
|
+
|
|
849
|
+
trimmed = _trim(system_block, rest, target, keep_recent)
|
|
850
|
+
log(f"⧉ compaction: trim → {len(trimmed)} msg (~{estimate_tokens(trimmed)} tok)")
|
|
851
|
+
return trimmed
|