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.
@@ -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