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/__init__.py +30 -0
- multichat/__main__.py +3 -0
- multichat/_clients.py +506 -0
- multichat/_config.py +68 -0
- multichat/_errors.py +26 -0
- multichat/_recorder.py +729 -0
- multichat/_streams.py +350 -0
- multichat/_version.py +1 -0
- multichat/cli.py +88 -0
- multichat/convert.py +599 -0
- multichat/failover.py +320 -0
- multichat/files.py +96 -0
- multichat/guardrails.py +196 -0
- multichat/helpers.py +362 -0
- multichat/services.py +276 -0
- multichat/sync.py +374 -0
- multichat/workspace.py +144 -0
- multichat_sdk-0.2.0.dist-info/METADATA +231 -0
- multichat_sdk-0.2.0.dist-info/RECORD +23 -0
- multichat_sdk-0.2.0.dist-info/WHEEL +4 -0
- multichat_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- multichat_sdk-0.2.0.dist-info/licenses/LICENSE +201 -0
- multichat_sdk-0.2.0.dist-info/licenses/NOTICE +11 -0
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
|