multichat-sdk 0.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.
multichat/_recorder.py ADDED
@@ -0,0 +1,729 @@
1
+ """Conversations and recording: which session a call belongs to, the delta it adds, and the
2
+ background worker that sends deltas, usage and queue tasks to the services in order.
3
+
4
+ Recording never stands in the way of the provider call: lines wait in the conversation until
5
+ the store takes them (a line replaces the line at the same path, so sending again is safe), and
6
+ a failure is logged on the ``multichat`` logger, never raised into your code.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import atexit
12
+ import hashlib
13
+ import logging
14
+ import queue
15
+ import threading
16
+ import time
17
+ import uuid
18
+ import weakref
19
+ from collections import OrderedDict
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Callable, Dict, List, Optional, Tuple
22
+
23
+ from . import convert as cv
24
+ from . import sync as sy
25
+ from ._errors import ServiceError
26
+ from .guardrails import GuardrailError
27
+ from .files import parse_data_url
28
+ from .files import ref as file_ref
29
+ from .services import Services, due_at
30
+
31
+ log = logging.getLogger("multichat")
32
+
33
+ #: Block kinds that can be files (docs/v2/files.md).
34
+ MEDIA = ("image", "audio", "video", "document")
35
+
36
+ ENDPOINT = {cv.OPENAI: ["openai", "v1", "chat", "completions"], cv.ANTHROPIC: ["anthropic", "v1", "messages"]}
37
+
38
+ #: Request fields that are not part of the provider's JSON body.
39
+ TRANSPORT_KEYS = ("extra_headers", "extra_query", "extra_body", "timeout", "betas")
40
+
41
+
42
+ # ------------------------------------------------------------------ public records
43
+
44
+
45
+ @dataclass
46
+ class KeepAlive:
47
+ """Keep Anthropic's prompt cache warm between turns (decision 0006, keep-alive).
48
+
49
+ The queue sends the turn's exact request again (``max_tokens`` 1) at send time + TTL - margin,
50
+ and again a TTL later while the session's enhanced cache extensions last (the queue keeps the
51
+ count, so it never sends more than the session allows); your next turn cancels the chain.
52
+ ``count=0`` turns it off. The queue needs your key to
53
+ send it: ``key_mode="held"`` gives it the key, encrypted and deleted once the task fires;
54
+ ``key_mode="webhook"`` asks your ``webhook`` to send it instead.
55
+ """
56
+
57
+ key_mode: str = "held"
58
+ webhook: Optional[str] = None
59
+ count: int = 1
60
+ margin_s: float = 30.0
61
+
62
+
63
+ @dataclass
64
+ class TurnInfo:
65
+ """What happened in one turn: where it was recorded, who answered, what was sent, and what
66
+ the planner would change (``changes``; applied only with ``optimise=True``)."""
67
+
68
+ session_id: Optional[str]
69
+ turn: int
70
+ model: str = ""
71
+ answered_by: str = "primary"
72
+ ms: int = 0
73
+ usage: Dict[str, int] = field(default_factory=dict)
74
+ optimised: bool = False
75
+ changes: List[Dict[str, Any]] = field(default_factory=list)
76
+ x: Optional[Dict[str, Any]] = None
77
+ y: Optional[Dict[str, Any]] = None
78
+ sent: Optional[Dict[str, Any]] = None
79
+
80
+
81
+ class Conversation:
82
+ """One conversation, one store session. Pass it as ``conversation=`` to keep calls together
83
+ explicitly; without it the SDK matches each call's message list to the conversations it has
84
+ seen (the longest recorded prefix, or the last recorded reply for trimmed histories)."""
85
+
86
+ def __init__(self, session_id: Optional[str] = None, fmt: Optional[str] = None):
87
+ self.session_id = session_id
88
+ self.fmt = fmt
89
+ self.hashes: List[str] = []
90
+ self.turns = sy.Turns()
91
+ self.model: Optional[str] = None
92
+ self.tools_key: Optional[str] = None
93
+ self.params_key: Optional[str] = None
94
+ # Whether the store may hold a response.tool_choice: a new conversation has none; an existing
95
+ # session might. A call without tool_choice then sends null@<sid>.response.tool_choice, so
96
+ # the choice of an earlier call is not kept for this one.
97
+ self.tool_choice: Optional[bool] = False if session_id is None else None
98
+ self.loaded = session_id is None # an existing session is read from the store on first use
99
+ self.version: Optional[int] = None # the store version this conversation's deltas are pinned to
100
+ self.last: Optional[TurnInfo] = None
101
+ self.preview: Optional[Dict[str, Any]] = None
102
+ self.spend: Optional[Dict[str, Any]] = None # the planner's POST /v2/spend answer (guardrails)
103
+ self.spend_local = 0.0 # replies priced here since that answer
104
+ self._pending: List[str] = []
105
+ self._usage: List[Dict[str, Any]] = []
106
+ self._keepalives: List[str] = []
107
+ self._lock = threading.Lock()
108
+
109
+ def __repr__(self) -> str:
110
+ return "Conversation(session_id=%r, turn=%d)" % (self.session_id, self.turns.turn)
111
+
112
+ @property
113
+ def turn(self) -> int:
114
+ return self.turns.turn
115
+
116
+ @property
117
+ def pending(self) -> int:
118
+ """Lines not yet in the store."""
119
+ return len(self._pending)
120
+
121
+
122
+ @dataclass
123
+ class Prepared:
124
+ conv: Conversation
125
+ turn: int
126
+ fmt: str
127
+ model: str
128
+ body: Dict[str, Any]
129
+ started: float
130
+ info: TurnInfo
131
+
132
+
133
+ # ------------------------------------------------------------------ hashing and deltas
134
+
135
+
136
+ def _strip(m: Any) -> Any:
137
+ if isinstance(m, dict):
138
+ return {k: _strip(v) for k, v in m.items() if k not in ("cache", "model", "finish")}
139
+ if isinstance(m, list):
140
+ return [_strip(x) for x in m]
141
+ return m
142
+
143
+
144
+ def message_hash(neutral: List[Dict[str, Any]]) -> str:
145
+ return hashlib.sha256(sy.canonical_json(_strip(neutral)).encode("utf-8")).hexdigest()[:32]
146
+
147
+
148
+ def source_messages(fmt: str, body: Dict[str, Any]) -> List[List[Dict[str, Any]]]:
149
+ """The request's messages, each as its neutral messages (the system prompt first for Anthropic)."""
150
+ out: List[List[Dict[str, Any]]] = []
151
+ if fmt == cv.ANTHROPIC:
152
+ sys_msgs = cv.anthropic_system_to_neutral(body.get("system"))
153
+ if sys_msgs:
154
+ out.append(sys_msgs)
155
+ for m in body.get("messages") or []:
156
+ out.append(cv.anthropic_message_to_neutral(m))
157
+ else:
158
+ for m in body.get("messages") or []:
159
+ out.append(cv.openai_message_to_neutral(m))
160
+ return out
161
+
162
+
163
+ def find_new(recorded: List[str], hashes: List[str]) -> Optional[int]:
164
+ """Where the new messages start in ``hashes``, given what ``recorded`` holds; None when the
165
+ request does not continue it. A full prefix counts first; otherwise the last recorded message
166
+ found in the request (with the one before it, when present) anchors a trimmed history."""
167
+ n = len(recorded)
168
+ if n == 0:
169
+ return None
170
+ if n <= len(hashes) and hashes[:n] == recorded:
171
+ return n
172
+ last = recorded[-1]
173
+ for j in range(len(hashes) - 1, -1, -1):
174
+ if hashes[j] == last and (j == 0 or n < 2 or hashes[j - 1] == recorded[-2]):
175
+ return j + 1
176
+ return None
177
+
178
+
179
+ def params_lines(fmt: str, body: Dict[str, Any], sid: str) -> List[str]:
180
+ L = lambda v, *p: sy.line(sid, p, v) # noqa: E731
181
+ out: List[str] = []
182
+ t = body.get("temperature")
183
+ if isinstance(t, (int, float)):
184
+ out.append(L(int(t / (2 if fmt == cv.OPENAI else 1) * 100 + 0.5), "params", "temperature"))
185
+ if isinstance(body.get("top_p"), (int, float)):
186
+ out.append(L(int(body["top_p"] * 100 + 0.5), "params", "top_p"))
187
+ if isinstance(body.get("top_k"), int):
188
+ out.append(L(body["top_k"], "params", "top_k"))
189
+ mt = body.get("max_completion_tokens") or body.get("max_tokens")
190
+ if isinstance(mt, int):
191
+ out.append(L(mt, "params", "max_tokens"))
192
+ if isinstance(body.get("seed"), int):
193
+ out.append(L(body["seed"], "params", "seed"))
194
+ stop = body.get("stop") if fmt == cv.OPENAI else body.get("stop_sequences")
195
+ for i, s in enumerate([stop] if isinstance(stop, str) else (stop or [])):
196
+ out.append(L(s, "params", "stop", i))
197
+ if body.get("reasoning_effort"):
198
+ out.append(L(body["reasoning_effort"], "params", "reasoning_effort"))
199
+ th = body.get("thinking") or {}
200
+ if isinstance(th, dict) and th.get("budget_tokens"):
201
+ out.append(L(th["budget_tokens"], "params", "reasoning_budget"))
202
+ if body.get("stream") is not None:
203
+ out.append(L(bool(body.get("stream")), "response", "stream"))
204
+ tc = body.get("tool_choice")
205
+ choice = None
206
+ if isinstance(tc, str):
207
+ choice = tc
208
+ elif isinstance(tc, dict):
209
+ if fmt == cv.ANTHROPIC:
210
+ choice = {"any": "required", "tool": "tool:" + str(tc.get("name"))}.get(tc.get("type"), tc.get("type"))
211
+ else:
212
+ name = (tc.get("function") or {}).get("name")
213
+ choice = "tool:" + name if name else None
214
+ if choice:
215
+ out.append(L(choice, "response", "tool_choice"))
216
+ return out
217
+
218
+
219
+ def has_cache_markers(body: Any) -> Tuple[bool, int]:
220
+ """Whether a Messages request asks for caching, and the TTL in seconds (5 minutes, or 1 hour)."""
221
+ found, ttl = False, 300
222
+
223
+ def walk(v: Any) -> None:
224
+ nonlocal found, ttl
225
+ if isinstance(v, dict):
226
+ cc = v.get("cache_control")
227
+ if isinstance(cc, dict):
228
+ found = True
229
+ if cc.get("ttl") == "1h":
230
+ ttl = 3600
231
+ for x in v.values():
232
+ walk(x)
233
+ elif isinstance(v, list):
234
+ for x in v:
235
+ walk(x)
236
+
237
+ walk(body)
238
+ return found, ttl
239
+
240
+
241
+ def request_body(kwargs: Dict[str, Any]) -> Dict[str, Any]:
242
+ """The provider JSON body a create() call sends: transport options left out, extra_body merged."""
243
+ body = {k: v for k, v in kwargs.items() if k not in TRANSPORT_KEYS}
244
+ body = cv.plain(body)
245
+ extra = kwargs.get("extra_body")
246
+ if isinstance(extra, dict):
247
+ body.update(cv.plain(extra))
248
+ return body
249
+
250
+
251
+ # ------------------------------------------------------------------ the background worker
252
+
253
+
254
+ class _Worker:
255
+ """One thread running jobs in order, so deltas reach the store in the order they happened."""
256
+
257
+ def __init__(self, on_error: Optional[Callable[[Exception], None]] = None) -> None:
258
+ self._q: "queue.Queue[Optional[Tuple[Callable[[], Any], Optional[threading.Event]]]]" = queue.Queue()
259
+ self._thread: Optional[threading.Thread] = None
260
+ self._lock = threading.Lock()
261
+ self.on_error = on_error
262
+ self.off = False # the account cannot record: queued jobs are dropped
263
+
264
+ def submit(self, fn: Callable[[], Any]) -> threading.Event:
265
+ done = threading.Event()
266
+ with self._lock:
267
+ if self._thread is None or not self._thread.is_alive():
268
+ self._thread = threading.Thread(target=self._run, name="multichat-recorder", daemon=True)
269
+ self._thread.start()
270
+ self._q.put((fn, done))
271
+ return done
272
+
273
+ def _run(self) -> None:
274
+ while True:
275
+ item = self._q.get()
276
+ if item is None:
277
+ return
278
+ fn, done = item
279
+ try:
280
+ if not self.off:
281
+ fn()
282
+ except Exception as e: # never let one job stop the worker
283
+ if self.on_error is not None:
284
+ self.on_error(e)
285
+ else:
286
+ log.warning("multichat: %s", e)
287
+ finally:
288
+ if done is not None:
289
+ done.set()
290
+
291
+ def drain(self, timeout: float) -> bool:
292
+ """Waits until every job submitted so far has run."""
293
+ return self.submit(lambda: None).wait(timeout)
294
+
295
+
296
+ #: Statuses with which a multiChat service refuses the account itself (no or a revoked key, no plan,
297
+ #: out of credit): the drop-in clients then stop recording and pass every call to the provider.
298
+ ACCOUNT_STATUSES = (401, 402, 403)
299
+
300
+
301
+ def account_refused(e: BaseException) -> bool:
302
+ return isinstance(e, ServiceError) and e.status_code in ACCOUNT_STATUSES
303
+
304
+
305
+ _recorders: "weakref.WeakSet[Recorder]" = weakref.WeakSet()
306
+
307
+
308
+ @atexit.register
309
+ def _flush_all() -> None: # pragma: no cover - runs at interpreter exit
310
+ for r in list(_recorders):
311
+ try:
312
+ r.flush(timeout=5)
313
+ except Exception:
314
+ pass
315
+
316
+
317
+ class Recorder:
318
+ """Records the conversations of one client (and its copies) in the store."""
319
+
320
+ def __init__(self, services: Services, *, keepalive: Optional[KeepAlive] = None,
321
+ provider_key: Optional[Callable[[], Optional[str]]] = None,
322
+ provider_base_url: Optional[Callable[[], Optional[str]]] = None, max_conversations: int = 512,
323
+ record_files: bool = True):
324
+ self.services = services
325
+ # Inline media (data: URLs) go to the provider as written; the store gets a file reference.
326
+ self.record_files = record_files
327
+ self._files_off = False # the store has no files here (503) or keeps nothing (ZDR)
328
+ self._file_refs: "OrderedDict[Tuple[str, str], str]" = OrderedDict() # (session, sha256) -> mcfile:<id>
329
+ self.keepalive = keepalive
330
+ self.provider_key = provider_key or (lambda: None)
331
+ self.provider_base_url = provider_base_url or (lambda: None)
332
+ self.conversations: "OrderedDict[int, Conversation]" = OrderedDict()
333
+ self.max_conversations = max_conversations
334
+ self.last: Optional[TurnInfo] = None
335
+ self.guard: Any = None # guardrails.Guard: checked before each call (docs/v2/advice.md)
336
+ self.off = False # the multiChat account is missing, invalid or out of credit: record nothing
337
+ self._worker = _Worker(self._failed)
338
+ self._lock = threading.Lock()
339
+ _recorders.add(self)
340
+
341
+ def _failed(self, e: Exception) -> None:
342
+ """A recording job failed. When the store refused the account (401, 402, 403: missing,
343
+ invalid or out of credit; decision 0008), recording stops for this client and every call
344
+ goes straight to the provider, with one log line; anything else is logged and retried later."""
345
+ if account_refused(e):
346
+ self.stop(e)
347
+ return
348
+ log.warning("multichat: %s", e)
349
+
350
+ def stop(self, why: Any) -> None:
351
+ """Stops recording for good (the account cannot record); logs once."""
352
+ if self.off:
353
+ return
354
+ self.off = True
355
+ self._worker.off = True
356
+ log.warning("multichat: the multiChat account cannot record (%s): calls go straight to the provider, "
357
+ "without recording or multiChat features", why)
358
+
359
+ # ---------------------------------------------------------------- conversations
360
+
361
+ def conversation(self, session_id: Optional[str] = None) -> Conversation:
362
+ """A new conversation, or an existing session to continue (read from the store on first use)."""
363
+ conv = Conversation(session_id)
364
+ self._keep(conv)
365
+ return conv
366
+
367
+ def _keep(self, conv: Conversation) -> None:
368
+ with self._lock:
369
+ self.conversations[id(conv)] = conv
370
+ self.conversations.move_to_end(id(conv))
371
+ while len(self.conversations) > self.max_conversations:
372
+ self.conversations.popitem(last=False)
373
+
374
+ def _match(self, hashes: List[str], fmt: str) -> Tuple[Optional[Conversation], int]:
375
+ best, start, best_full = None, 0, False
376
+ with self._lock:
377
+ convs = list(self.conversations.values())
378
+ for conv in convs:
379
+ if conv.fmt not in (None, fmt) or not conv.hashes:
380
+ continue
381
+ at = find_new(conv.hashes, hashes)
382
+ if at is None:
383
+ continue
384
+ full = hashes[:len(conv.hashes)] == conv.hashes
385
+ if best is None or (full, at) > (best_full, start):
386
+ best, start, best_full = conv, at, full
387
+ return best, start
388
+
389
+ def _load(self, conv: Conversation) -> None:
390
+ """Reads an existing session's turn numbers and messages from the store."""
391
+ conv.loaded = True
392
+ try:
393
+ snap = self.services.snapshot(conv.session_id) # type: ignore[arg-type]
394
+ except Exception as e:
395
+ log.warning("multichat: could not read session %s: %s", conv.session_id, e)
396
+ return
397
+ conv.version = snap.get("current_version", snap.get("version"))
398
+ msgs = sy.lines_to_messages(snap.get("lines") or [])
399
+ last_turn, count = 0, 0
400
+ for raw in snap.get("lines") or []:
401
+ p = sy.parse_line(raw)
402
+ if p and len(p[1]) > 2 and p[1][0] == "history" and p[1][1].isdigit() and p[1][2].isdigit():
403
+ t, m = int(p[1][1]), int(p[1][2])
404
+ if t > last_turn:
405
+ last_turn, count = t, m + 1
406
+ elif t == last_turn:
407
+ count = max(count, m + 1)
408
+ conv.turns = sy.Turns(last_turn, count)
409
+ fmt = conv.fmt or cv.OPENAI
410
+ conv.hashes = [message_hash([m]) for m in msgs] if fmt == cv.OPENAI else \
411
+ [message_hash(cv.anthropic_message_to_neutral(a)) for a in cv.neutral_to_anthropic(msgs)[1]]
412
+
413
+ # ---------------------------------------------------------------- a turn
414
+
415
+ def begin(self, fmt: str, body: Dict[str, Any], handle: Optional[Any] = None) -> Optional[Prepared]:
416
+ """Finds the call's conversation, queues its new lines, and cancels the last keep-alive."""
417
+ if self.off:
418
+ return None
419
+ try:
420
+ return self._begin(fmt, body, handle)
421
+ except GuardrailError:
422
+ raise # a limit stops the call before anything is recorded or sent
423
+ except Exception as e: # recording must never break the call
424
+ log.warning("multichat: not recorded: %s", e)
425
+ return None
426
+
427
+ def _begin(self, fmt: str, body: Dict[str, Any], handle: Optional[Any]) -> Prepared:
428
+ src = source_messages(fmt, body)
429
+ hashes = [message_hash(n) for n in src]
430
+ conv: Optional[Conversation]
431
+ if isinstance(handle, str):
432
+ handle = self._by_session(handle) or self.conversation(handle)
433
+ if isinstance(handle, Conversation):
434
+ conv = handle
435
+ self._keep(conv)
436
+ if not conv.loaded:
437
+ self._load(conv)
438
+ start = find_new(conv.hashes, hashes)
439
+ if start is None:
440
+ start = 0 if not conv.hashes else _after_last_assistant(src)
441
+ else:
442
+ conv, start = self._match(hashes, fmt)
443
+ if conv is None:
444
+ conv, start = self.conversation(), 0
445
+ conv.fmt = conv.fmt or fmt
446
+ if self.guard is not None:
447
+ self.guard.check(conv, body)
448
+ model = str(body.get("model") or "")
449
+ sid = sy.NIL_SID
450
+ lines: List[str] = []
451
+ if model and model != conv.model:
452
+ lines.append(sy.line(sid, ["endpoint"] + ENDPOINT[fmt], model))
453
+ conv.model = model
454
+ tools = cv.openai_tools_to_neutral(body.get("tools")) if fmt == cv.OPENAI else cv.anthropic_tools_to_neutral(body.get("tools"))
455
+ tkey = message_hash(tools) if tools else ""
456
+ if tkey != (conv.tools_key or ""):
457
+ if conv.tools_key:
458
+ lines.append(sy.line(sid, ["tools"], "null"))
459
+ lines += sy.tool_lines(sid, tools)
460
+ conv.tools_key = tkey
461
+ plines = params_lines(fmt, body, sid)
462
+ has_choice = any(sy.parse_line(x)[1] == ["response", "tool_choice"] for x in plines) # type: ignore[index]
463
+ if not has_choice and conv.tool_choice is not False:
464
+ plines.append(sy.line(sid, ["response", "tool_choice"], "null"))
465
+ pkey = message_hash(plines) # type: ignore[arg-type]
466
+ if pkey != conv.params_key:
467
+ lines += plines
468
+ conv.params_key = pkey
469
+ conv.tool_choice = has_choice
470
+ for neutral in src[start:]:
471
+ for m in neutral:
472
+ t, i = conv.turns.next(m["role"])
473
+ lines += sy.message_lines(sid, t, i, m)
474
+ conv.hashes = hashes
475
+ info = TurnInfo(session_id=conv.session_id, turn=conv.turns.turn, model=model)
476
+ prep = Prepared(conv=conv, turn=conv.turns.turn, fmt=fmt, model=model, body=body, started=time.monotonic(), info=info)
477
+ with conv._lock:
478
+ conv._pending += lines
479
+ turn = prep.turn
480
+ self._worker.submit(lambda: self._cancel_keepalives(conv, turn))
481
+ self._worker.submit(lambda: self._flush(conv))
482
+ return prep
483
+
484
+ def _by_session(self, sid: str) -> Optional[Conversation]:
485
+ with self._lock:
486
+ return next((c for c in self.conversations.values() if c.session_id == sid), None)
487
+
488
+ def finish(self, prep: Optional[Prepared], response: Dict[str, Any], *, answered_by: str = "primary",
489
+ fmt: Optional[str] = None, sent: Optional[Dict[str, Any]] = None, preview: bool = False) -> Optional[TurnInfo]:
490
+ """Records the reply (``response`` in ``fmt``, the primary's format by default) and its usage."""
491
+ if prep is None:
492
+ return None
493
+ try:
494
+ return self._finish(prep, response, answered_by, fmt or prep.fmt, sent, preview)
495
+ except Exception as e:
496
+ log.warning("multichat: reply not recorded: %s", e)
497
+ return None
498
+
499
+ def _finish(self, prep: Prepared, response: Dict[str, Any], answered_by: str, fmt: str,
500
+ sent: Optional[Dict[str, Any]], preview: bool) -> TurnInfo:
501
+ conv = prep.conv
502
+ msg, usage = cv.response_to_neutral(response, fmt)
503
+ ms = int((time.monotonic() - prep.started) * 1000)
504
+ t, i = conv.turns.next("assistant")
505
+ lines = sy.message_lines(sy.NIL_SID, t, i, msg, usage)
506
+ # The reply as the caller will append it to their history, in the primary's format.
507
+ as_primary = cv.assistant_message(msg, prep.fmt)
508
+ nm = cv.openai_message_to_neutral(as_primary) if prep.fmt == cv.OPENAI else cv.anthropic_message_to_neutral(as_primary)
509
+ conv.hashes = conv.hashes + [message_hash(nm)]
510
+ info = prep.info
511
+ info.model, info.answered_by, info.ms, info.usage = msg.get("model") or prep.model, answered_by, ms, usage
512
+ info.sent = sent or prep.body
513
+ record = dict(usage, turn=prep.turn, model=info.model, ms=ms, answered_by=answered_by)
514
+ with conv._lock:
515
+ conv._pending += lines
516
+ conv._usage.append(record)
517
+ # The session may have been created while the provider answered (begin's flush).
518
+ info.session_id = info.session_id or conv.session_id
519
+ conv.last = info
520
+ self.last = info
521
+ self._worker.submit(lambda: self._flush(conv))
522
+ if self.guard is not None:
523
+ self.guard.after(conv, info)
524
+ if answered_by == "primary" and prep.fmt == cv.ANTHROPIC and self.keepalive and self.keepalive.count > 0:
525
+ found, ttl = has_cache_markers(info.sent)
526
+ if found:
527
+ body = dict(info.sent or {}, max_tokens=1)
528
+ body.pop("stream", None)
529
+ self._worker.submit(lambda: self._schedule_keepalive(conv, prep.turn, body, ttl))
530
+ if preview:
531
+ self._worker.submit(lambda: self._preview(conv, info, prep.model))
532
+ return info
533
+
534
+ # ---------------------------------------------------------------- jobs (worker thread)
535
+
536
+ def _flush(self, conv: Conversation) -> None:
537
+ for attempt in range(3):
538
+ try:
539
+ self._send(conv)
540
+ return
541
+ except Exception as e:
542
+ if account_refused(e):
543
+ self.stop(e) # the account cannot record: no retries, nothing kept
544
+ return
545
+ if attempt == 2:
546
+ log.warning("multichat: store unavailable, %d lines kept for the next turn: %s", conv.pending, e)
547
+ return
548
+ time.sleep(0.2 * (attempt + 1))
549
+
550
+ def _send(self, conv: Conversation) -> None:
551
+ with conv._lock:
552
+ lines = list(conv._pending)
553
+ usage = list(conv._usage)
554
+ if not lines and not usage:
555
+ return
556
+ if conv.session_id is None:
557
+ created = self.services.create_session()
558
+ with conv._lock:
559
+ conv.session_id = created["session_id"]
560
+ conv.version = created.get("version")
561
+ if conv.last is not None:
562
+ conv.last.session_id = conv.session_id
563
+ if lines:
564
+ orig = lines
565
+ if self.record_files and not self._files_off:
566
+ lines = self._record_files(conv, lines)
567
+ if lines is not orig:
568
+ with conv._lock: # a retry sends the same references, not new uploads
569
+ conv._pending[:len(orig)] = lines
570
+ try:
571
+ self._delta(conv, sy.readdress(lines, conv.session_id))
572
+ except ServiceError as e:
573
+ # A referenced file was deleted meanwhile: upload again from the original bytes.
574
+ if e.status_code != 400 or "mcfile:" not in str(e) or lines is orig:
575
+ raise
576
+ self._forget_files(conv)
577
+ lines = self._record_files(conv, orig)
578
+ self._delta(conv, sy.readdress(lines, conv.session_id))
579
+ with conv._lock:
580
+ del conv._pending[:len(lines)]
581
+ for rec in usage:
582
+ self.services.usage(conv.session_id, rec)
583
+ with conv._lock:
584
+ conv._usage.remove(rec)
585
+
586
+ def _record_files(self, conv: Conversation, lines: List[str]) -> List[str]:
587
+ """Replaces each inline media value (a ``data:`` URL) with the reference of an uploaded file,
588
+ tied to the conversation's session; a failed upload keeps the bytes in the line. Answers
589
+ ``lines`` itself when nothing changed."""
590
+ out, changed = [], False
591
+ for raw in lines:
592
+ p = sy.parse_line(raw)
593
+ if not self._files_off and p and len(p[1]) == 6 and p[1][0] == "history" and p[1][5] in MEDIA:
594
+ got = parse_data_url(p[2])
595
+ if got:
596
+ r = self._upload(conv, got[0], got[1])
597
+ if r:
598
+ raw, changed = sy.line(p[0], p[1], r), True
599
+ out.append(raw)
600
+ return out if changed else lines
601
+
602
+ def _upload(self, conv: Conversation, mime: str, data: bytes) -> Optional[str]:
603
+ key = (conv.session_id or "", hashlib.sha256(data).hexdigest())
604
+ with self._lock:
605
+ if key in self._file_refs:
606
+ return self._file_refs[key]
607
+ try:
608
+ f = self.services.upload_file(data, mime, session=conv.session_id)
609
+ except ServiceError as e:
610
+ if e.status_code in (403, 404, 405, 501, 503):
611
+ self._files_off = True # no files here: keep recording the bytes inline
612
+ log.info("multichat: file not uploaded, its bytes stay in the conversation: %s", e)
613
+ return None
614
+ except Exception as e:
615
+ log.info("multichat: file not uploaded, its bytes stay in the conversation: %s", e)
616
+ return None
617
+ r = f.get("ref") or file_ref(f["id"])
618
+ with self._lock:
619
+ self._file_refs[key] = r
620
+ while len(self._file_refs) > 1024:
621
+ self._file_refs.popitem(last=False)
622
+ return r
623
+
624
+ def _forget_files(self, conv: Conversation) -> None:
625
+ with self._lock:
626
+ for k in [k for k in self._file_refs if k[0] == (conv.session_id or "")]:
627
+ del self._file_refs[k]
628
+
629
+ def _delta(self, conv: Conversation, lines: List[str]) -> None:
630
+ """Sends a delta pinned to the version this conversation last saw. When a service wrote a
631
+ newer version meanwhile (the planner's compacted items, docs/v2/planner.md), the store
632
+ answers 409 version_conflict: the session is read again and the delta retried once."""
633
+ try:
634
+ r = self.services.delta(conv.session_id, lines, version=conv.version) # type: ignore[arg-type]
635
+ except ServiceError as e:
636
+ if e.status_code != 409 or e.code != "version_conflict":
637
+ raise
638
+ snap = self.services.snapshot(conv.session_id) # type: ignore[arg-type]
639
+ conv.version = snap.get("current_version", snap.get("version"))
640
+ r = self.services.delta(conv.session_id, lines, version=conv.version) # type: ignore[arg-type]
641
+ if isinstance(r, dict) and isinstance(r.get("version"), int):
642
+ conv.version = r["version"]
643
+
644
+ def _cancel_keepalives(self, conv: Conversation, turn: Optional[int] = None) -> None:
645
+ with conv._lock:
646
+ names, conv._keepalives = conv._keepalives, []
647
+ for name in names:
648
+ try:
649
+ self.services.cancel_task(name)
650
+ except Exception as e:
651
+ log.warning("multichat: could not cancel keep-alive %s: %s", name, e)
652
+ if names and turn is not None and conv.session_id:
653
+ # The scheduler's own flip (docs/v2/bitguard.md, path 3), besides the cancel above.
654
+ try:
655
+ self.services.signal(conv.session_id, turn, "turn")
656
+ except Exception as e:
657
+ log.debug("multichat: signal not sent: %s", e)
658
+
659
+ def _schedule_keepalive(self, conv: Conversation, turn: int, body: Dict[str, Any], ttl: int) -> None:
660
+ ka = self.keepalive
661
+ if ka is None or conv.session_id is None:
662
+ return
663
+ # One task, the first of the chain (count 0): the queue schedules each next one while the
664
+ # session's extensions remain (docs/v2/queue.md "Keep-alive rules"). Posting more here would
665
+ # start chains of their own and go past the session's extension count.
666
+ name = "keepalive-%s-%d" % (conv.session_id, turn)
667
+ task: Dict[str, Any] = {"name": name, "kind": "keepalive", "session": conv.session_id, "turn": turn,
668
+ "due_at": due_at(max(ttl - ka.margin_s, 1)), "request": body, "provider": "anthropic",
669
+ "key_mode": ka.key_mode, "count": 0}
670
+ base = self.provider_base_url()
671
+ if base:
672
+ task["base_url"] = base
673
+ if ka.key_mode == "held":
674
+ task["key"] = self.provider_key()
675
+ elif ka.key_mode == "webhook":
676
+ task["webhook"] = ka.webhook
677
+ self.services.post_task(task)
678
+ with conv._lock:
679
+ conv._keepalives.append(name)
680
+
681
+ def _preview(self, conv: Conversation, info: TurnInfo, model: str) -> None:
682
+ if conv.session_id is None or not conv.fmt:
683
+ return
684
+ plan = self.services.assemble(conv.session_id, conv.fmt, model or None)
685
+ conv.preview = plan
686
+ info.changes, info.x, info.y = plan.get("changes") or [], plan.get("x"), plan.get("y")
687
+ if info.changes:
688
+ saved = sum(int(c.get("tokens_saved") or 0) for c in info.changes)
689
+ log.info("multichat: optimise=True would make %d change(s) to the next request (about %d tokens saved): %s",
690
+ len(info.changes), saved, "; ".join(str(c.get("what")) for c in info.changes))
691
+
692
+ # ---------------------------------------------------------------- waiting
693
+
694
+ def flush(self, timeout: float = 10.0) -> bool:
695
+ """Waits until everything queued so far has been sent (or failed and been kept)."""
696
+ return self._worker.drain(timeout)
697
+
698
+ def ensure_session(self, prep: Prepared, timeout: float = 10.0) -> Optional[str]:
699
+ """Sends the turn's lines now and returns the session ID (None if the store is unreachable)."""
700
+ self.flush(timeout)
701
+ return prep.conv.session_id
702
+
703
+ def optimised(self, prep: Prepared, timeout: float = 10.0) -> Optional[Dict[str, Any]]:
704
+ """``optimise=True``: the assembled request for this turn, or None (the call goes as written)."""
705
+ sid = self.ensure_session(prep, timeout)
706
+ if sid is None:
707
+ log.warning("multichat: optimise skipped: the store did not take the turn")
708
+ return None
709
+ try:
710
+ plan = self.services.assemble(sid, prep.fmt, prep.model or None)
711
+ except Exception as e:
712
+ log.warning("multichat: optimise skipped: %s", e)
713
+ return None
714
+ prep.info.optimised = True
715
+ prep.info.changes, prep.info.x, prep.info.y = plan.get("changes") or [], plan.get("x"), plan.get("y")
716
+ prep.conv.preview = plan
717
+ if prep.info.changes:
718
+ log.info("multichat: optimised: %s", "; ".join(str(c.get("what")) for c in prep.info.changes))
719
+ return plan.get("request")
720
+
721
+ def new_name(self, kind: str, prep: Prepared) -> str:
722
+ return "%s-%s-%d-%s" % (kind, prep.conv.session_id, prep.turn, uuid.uuid4().hex[:6])
723
+
724
+
725
+ def _after_last_assistant(src: List[List[Dict[str, Any]]]) -> int:
726
+ for j in range(len(src) - 1, -1, -1):
727
+ if any(m["role"] == "assistant" for m in src[j]):
728
+ return j + 1
729
+ return 0