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/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""multiChat for Python: change one import and your OpenAI or Anthropic code keeps calling the
|
|
2
|
+
provider directly with your own key, while every turn is recorded in a durable conversation
|
|
3
|
+
store, with failover to a backup model and the planner's savings shown before they are applied.
|
|
4
|
+
|
|
5
|
+
from multichat import OpenAI # instead of: from openai import OpenAI
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._clients import Anthropic, AsyncAnthropic, AsyncOpenAI, Engine, OpenAI
|
|
9
|
+
from ._errors import BackupUnavailableError, MultiChatError, NotLoggedInError, ServiceError
|
|
10
|
+
from ._recorder import Conversation, KeepAlive, TurnInfo
|
|
11
|
+
from ._version import VERSION
|
|
12
|
+
from .convert import ANTHROPIC, OPENAI, convert_request, convert_response
|
|
13
|
+
from .failover import Backup, is_failover_error
|
|
14
|
+
from .files import Files
|
|
15
|
+
from .guardrails import GuardrailError, Guardrails
|
|
16
|
+
from .helpers import Messages, Model, Session, Turn, messages, session, turn
|
|
17
|
+
from .services import Services
|
|
18
|
+
from . import workspace
|
|
19
|
+
from .workspace import Workspace
|
|
20
|
+
|
|
21
|
+
__version__ = VERSION
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"OpenAI", "AsyncOpenAI", "Anthropic", "AsyncAnthropic", "Engine",
|
|
25
|
+
"Backup", "KeepAlive", "Conversation", "TurnInfo",
|
|
26
|
+
"Session", "Messages", "Model", "Turn", "session", "messages", "turn",
|
|
27
|
+
"convert_request", "convert_response", "OPENAI", "ANTHROPIC", "is_failover_error",
|
|
28
|
+
"Services", "Files", "Guardrails", "GuardrailError", "Workspace", "workspace", "MultiChatError", "NotLoggedInError", "ServiceError", "BackupUnavailableError",
|
|
29
|
+
"VERSION", "__version__",
|
|
30
|
+
]
|
multichat/__main__.py
ADDED
multichat/_clients.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Drop-in OpenAI and Anthropic clients: the official clients, calling the provider directly with
|
|
2
|
+
your key, with each turn recorded in the multiChat store, optional failover, and the planner's
|
|
3
|
+
proposals shown (or applied with ``optimise=True``)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from functools import cached_property
|
|
11
|
+
from typing import Any, Callable, Dict, Optional, Union
|
|
12
|
+
|
|
13
|
+
from . import _config
|
|
14
|
+
from . import convert as cv
|
|
15
|
+
from ._errors import NotLoggedInError
|
|
16
|
+
from ._recorder import TRANSPORT_KEYS, Conversation, KeepAlive, Recorder, TurnInfo, request_body
|
|
17
|
+
from ._streams import (AsyncRecordingStream, RecordingStream, achain, alist, chain, event_object, response_object,
|
|
18
|
+
stream_events)
|
|
19
|
+
from .failover import (Backup, _Primed, backup_request, queue_base_url, queue_task, queue_waiter_async, queue_waiter_sync,
|
|
20
|
+
run_async, run_sync)
|
|
21
|
+
from .files import Files
|
|
22
|
+
from .guardrails import Guard, Guardrails, stable_tools
|
|
23
|
+
from .services import Services
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("multichat")
|
|
26
|
+
|
|
27
|
+
_no_account_logged = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _no_account_once() -> None:
|
|
31
|
+
global _no_account_logged
|
|
32
|
+
if not _no_account_logged:
|
|
33
|
+
_no_account_logged = True
|
|
34
|
+
log.warning("multichat: no multiChat credential (MULTICHAT_API_KEY or `multichat login`): calls go straight "
|
|
35
|
+
"to the provider, without recording or multiChat features")
|
|
36
|
+
|
|
37
|
+
Optimise = Union[bool, str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Engine:
|
|
41
|
+
"""What a drop-in client adds, reachable as ``client.multichat``.
|
|
42
|
+
|
|
43
|
+
- ``conversation(session_id=None)``: a conversation handle to pass as ``conversation=``
|
|
44
|
+
(a new one, or an existing session to continue).
|
|
45
|
+
- ``last``: the last turn's :class:`TurnInfo` (session, answered_by, usage, what was sent,
|
|
46
|
+
the planner's ``changes``).
|
|
47
|
+
- ``flush(timeout)``: waits until the store has everything recorded so far.
|
|
48
|
+
- ``files``: upload files and get signed links (:class:`multichat.files.Files`).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, fmt: str, *, failover: Optional[Backup], optimise: Optimise, keepalive: Optional[KeepAlive],
|
|
52
|
+
record: bool, multichat_api_key: Optional[str], services: Optional[Services],
|
|
53
|
+
provider_key: Callable[[], Optional[str]], provider_base_url: Optional[Callable[[], Optional[str]]] = None,
|
|
54
|
+
record_files: bool = True, guardrails: Optional[Guardrails] = None, stable_tools: bool = False):
|
|
55
|
+
if optimise not in (True, False, "preview"):
|
|
56
|
+
raise ValueError('optimise must be True, False or "preview"')
|
|
57
|
+
if keepalive is True:
|
|
58
|
+
keepalive = KeepAlive()
|
|
59
|
+
self.fmt = fmt
|
|
60
|
+
self.stable_tools = stable_tools
|
|
61
|
+
self.guardrails = guardrails
|
|
62
|
+
self.keepalive = keepalive or None
|
|
63
|
+
self.failover = failover
|
|
64
|
+
self.optimise = optimise
|
|
65
|
+
self.services: Optional[Services] = None
|
|
66
|
+
self.recorder: Optional[Recorder] = None
|
|
67
|
+
if record and services is None:
|
|
68
|
+
cred = _config.find_credential(multichat_api_key)
|
|
69
|
+
if cred:
|
|
70
|
+
services = Services(cred)
|
|
71
|
+
else:
|
|
72
|
+
# No multiChat account: never break the developer's app (decision 0008). The client
|
|
73
|
+
# is the provider's own, unrecorded; said once per process.
|
|
74
|
+
_no_account_once()
|
|
75
|
+
record = False
|
|
76
|
+
if record:
|
|
77
|
+
self.services = services
|
|
78
|
+
self.recorder = Recorder(services, keepalive=keepalive or None, provider_key=provider_key,
|
|
79
|
+
provider_base_url=provider_base_url, record_files=record_files)
|
|
80
|
+
if guardrails is not None:
|
|
81
|
+
self.recorder.guard = Guard(guardrails, services, self.recorder._worker.submit)
|
|
82
|
+
elif services is not None:
|
|
83
|
+
self.services = services
|
|
84
|
+
if guardrails is not None and self.recorder is None:
|
|
85
|
+
log.warning("multichat: guardrails need recording (record=True); they are not checked")
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------- advice (docs/v2/advice.md)
|
|
88
|
+
|
|
89
|
+
def _sid(self, conversation: Union[Conversation, str, None]) -> str:
|
|
90
|
+
if isinstance(conversation, str):
|
|
91
|
+
return conversation
|
|
92
|
+
self.flush()
|
|
93
|
+
sid = getattr(conversation, "session_id", None) if conversation is not None else \
|
|
94
|
+
(self.recorder.last.session_id if self.recorder is not None and self.recorder.last else None)
|
|
95
|
+
if not sid:
|
|
96
|
+
raise ValueError("no recorded conversation yet: pass conversation= or a session ID")
|
|
97
|
+
return sid
|
|
98
|
+
|
|
99
|
+
def advice(self, conversation: Union[Conversation, str, None] = None) -> Dict[str, Any]:
|
|
100
|
+
"""What the conversation (default: the last one) has cost and what is costing more than it
|
|
101
|
+
needs to: ``{spend, findings: [{id, title, cost_usd, saving_usd, fixes}], saving_usd}``."""
|
|
102
|
+
if self.services is None:
|
|
103
|
+
raise NotLoggedInError()
|
|
104
|
+
return self.services.advice(self._sid(conversation))
|
|
105
|
+
|
|
106
|
+
def spend(self, conversation: Union[Conversation, str, None] = None, scope: Optional[str] = None,
|
|
107
|
+
since: Optional[int] = None) -> Dict[str, Any]:
|
|
108
|
+
"""``POST /v2/spend`` for the conversation (default: the last one)."""
|
|
109
|
+
if self.services is None:
|
|
110
|
+
raise NotLoggedInError()
|
|
111
|
+
limits = None
|
|
112
|
+
g = self.guardrails
|
|
113
|
+
if g is not None:
|
|
114
|
+
limits = {k: v for k, v in (("session_usd", g.max_session_usd), ("turn_usd", g.max_turn_usd),
|
|
115
|
+
("account_usd", g.max_account_usd),
|
|
116
|
+
("context_tokens", g.max_context_tokens if isinstance(g.max_context_tokens, int) else None))
|
|
117
|
+
if v is not None}
|
|
118
|
+
if scope == "account" and since is None:
|
|
119
|
+
since = g.since_ms()
|
|
120
|
+
return self.services.spend(self._sid(conversation), scope=scope, since=since, limits=limits or None)
|
|
121
|
+
|
|
122
|
+
def apply_fix(self, fix: Dict[str, Any], conversation: Union[Conversation, str, None] = None) -> Optional[Conversation]:
|
|
123
|
+
"""Applies one of an advice finding's ``fixes``: an ``sdk_option`` is set on this client
|
|
124
|
+
(``optimise``, ``stable_tools``, ``keepalive``); a ``fork`` starts a fork of the
|
|
125
|
+
conversation with the fix's settings and returns it to pass as ``conversation=``. A
|
|
126
|
+
``code`` fix is yours to make (ValueError)."""
|
|
127
|
+
kind = fix.get("kind")
|
|
128
|
+
if kind == "sdk_option":
|
|
129
|
+
opt, value = fix.get("option"), fix.get("value")
|
|
130
|
+
if opt == "optimise":
|
|
131
|
+
self.optimise = value
|
|
132
|
+
elif opt == "stable_tools":
|
|
133
|
+
self.stable_tools = bool(value)
|
|
134
|
+
elif opt == "keepalive":
|
|
135
|
+
ka = KeepAlive() if value is True else (value or None)
|
|
136
|
+
self.keepalive = ka
|
|
137
|
+
if self.recorder is not None:
|
|
138
|
+
self.recorder.keepalive = ka
|
|
139
|
+
else:
|
|
140
|
+
raise ValueError("unknown SDK option %r" % opt)
|
|
141
|
+
return None
|
|
142
|
+
if kind == "fork":
|
|
143
|
+
if self.services is None:
|
|
144
|
+
raise NotLoggedInError()
|
|
145
|
+
out = self.services.fork(self._sid(conversation), fix.get("settings")) # type: ignore[arg-type]
|
|
146
|
+
return self.conversation(out["session_id"])
|
|
147
|
+
raise ValueError("this fix is a change in your code: %s" % fix.get("label"))
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------- public
|
|
150
|
+
|
|
151
|
+
def conversation(self, session_id: Optional[str] = None) -> Conversation:
|
|
152
|
+
if self.recorder is None:
|
|
153
|
+
return Conversation(session_id, self.fmt)
|
|
154
|
+
c = self.recorder.conversation(session_id)
|
|
155
|
+
c.fmt = self.fmt
|
|
156
|
+
return c
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def files(self) -> Files:
|
|
160
|
+
if self.services is None:
|
|
161
|
+
raise NotLoggedInError()
|
|
162
|
+
return Files(self.services)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def last(self) -> Optional[TurnInfo]:
|
|
166
|
+
return self.recorder.last if self.recorder else None
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def conversations(self):
|
|
170
|
+
return list(self.recorder.conversations.values()) if self.recorder else []
|
|
171
|
+
|
|
172
|
+
def flush(self, timeout: float = 10.0) -> bool:
|
|
173
|
+
return self.recorder.flush(timeout) if self.recorder else True
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------- one call
|
|
176
|
+
|
|
177
|
+
def _split(self, kwargs: Dict[str, Any]):
|
|
178
|
+
handle = kwargs.pop("conversation", None)
|
|
179
|
+
if self.stable_tools and isinstance(kwargs.get("tools"), list):
|
|
180
|
+
kwargs["tools"] = stable_tools(kwargs)["tools"]
|
|
181
|
+
fo = kwargs.pop("failover", self.failover)
|
|
182
|
+
opt = kwargs.pop("optimise", self.optimise)
|
|
183
|
+
return kwargs, fo, opt, handle
|
|
184
|
+
|
|
185
|
+
def _begin(self, kwargs: Dict[str, Any]):
|
|
186
|
+
kwargs, fo, opt, handle = self._split(kwargs)
|
|
187
|
+
prep = self.recorder.begin(self.fmt, request_body(kwargs), handle) if self.recorder else None
|
|
188
|
+
return kwargs, fo, opt, prep
|
|
189
|
+
|
|
190
|
+
async def _abegin(self, kwargs: Dict[str, Any]):
|
|
191
|
+
kwargs, fo, opt, handle = self._split(kwargs)
|
|
192
|
+
if self.recorder is None:
|
|
193
|
+
return kwargs, fo, opt, None
|
|
194
|
+
body = request_body(kwargs)
|
|
195
|
+
if isinstance(handle, str) or (isinstance(handle, Conversation) and not handle.loaded):
|
|
196
|
+
# Continuing a stored session reads it first: off the event loop.
|
|
197
|
+
prep = await asyncio.get_running_loop().run_in_executor(None, self.recorder.begin, self.fmt, body, handle)
|
|
198
|
+
else:
|
|
199
|
+
prep = self.recorder.begin(self.fmt, body, handle)
|
|
200
|
+
return kwargs, fo, opt, prep
|
|
201
|
+
|
|
202
|
+
def _merge(self, kwargs: Dict[str, Any], assembled: Dict[str, Any]) -> Dict[str, Any]:
|
|
203
|
+
send = {k: v for k, v in kwargs.items() if k in TRANSPORT_KEYS}
|
|
204
|
+
send.update(assembled)
|
|
205
|
+
for k in ("stream", "stream_options"):
|
|
206
|
+
if k in kwargs:
|
|
207
|
+
send[k] = kwargs[k]
|
|
208
|
+
else:
|
|
209
|
+
send.pop(k, None)
|
|
210
|
+
return send
|
|
211
|
+
|
|
212
|
+
def _finish(self, prep, resp: Dict[str, Any], sent: Dict[str, Any], opt: Optimise, answered_by: str = "primary",
|
|
213
|
+
fmt: Optional[str] = None) -> None:
|
|
214
|
+
if self.recorder is not None:
|
|
215
|
+
self.recorder.finish(prep, resp, answered_by=answered_by, fmt=fmt, sent=sent, preview=opt == "preview")
|
|
216
|
+
|
|
217
|
+
def _queue_setup(self, fo: Backup, prep, breq: Dict[str, Any]):
|
|
218
|
+
"""Queue mode: posts the backup task; returns (sid, name) or None to use the timer instead."""
|
|
219
|
+
if self.recorder is None or prep is None or self.services is None:
|
|
220
|
+
log.warning("multichat: queue failover needs recording; using the in-process timer")
|
|
221
|
+
return None
|
|
222
|
+
sid = self.recorder.ensure_session(prep)
|
|
223
|
+
if sid is None:
|
|
224
|
+
log.warning("multichat: queue failover: no session; using the in-process timer")
|
|
225
|
+
return None
|
|
226
|
+
name = self.recorder.new_name("backup", prep)
|
|
227
|
+
try:
|
|
228
|
+
self.services.post_task(queue_task(fo, name, sid, prep.turn, breq))
|
|
229
|
+
except Exception as e:
|
|
230
|
+
log.warning("multichat: queue failover unavailable, using the in-process timer: %s", e)
|
|
231
|
+
return None
|
|
232
|
+
return sid, name
|
|
233
|
+
|
|
234
|
+
def _first_token(self, sid: str, turn: int, name: str) -> None:
|
|
235
|
+
"""The primary answered: cancel the queued backup, and tell the scheduler directly."""
|
|
236
|
+
try:
|
|
237
|
+
self.services.cancel_task(name)
|
|
238
|
+
except Exception as e:
|
|
239
|
+
log.warning("multichat: could not cancel the queued backup %s: %s", name, e)
|
|
240
|
+
try:
|
|
241
|
+
self.services.signal(sid, turn, "first_token")
|
|
242
|
+
except Exception as e:
|
|
243
|
+
log.debug("multichat: signal not sent: %s", e)
|
|
244
|
+
|
|
245
|
+
def _backup_result(self, out, fo: Backup, stream: bool, send: Dict[str, Any], prep, opt: Optimise, sent: Dict[str, Any]):
|
|
246
|
+
prim = cv.convert_response(out.backup_response, fo.format, self.fmt)
|
|
247
|
+
self._finish(prep, out.backup_response, sent, opt, "backup", fo.format)
|
|
248
|
+
if stream:
|
|
249
|
+
include_usage = bool((send.get("stream_options") or {}).get("include_usage"))
|
|
250
|
+
return [event_object(e, self.fmt) for e in stream_events(prim, self.fmt, include_usage)]
|
|
251
|
+
return response_object(prim, self.fmt)
|
|
252
|
+
|
|
253
|
+
def call_sync(self, raw: Callable[..., Any], raw_noretry: Callable[[], Callable[..., Any]], kwargs: Dict[str, Any]) -> Any:
|
|
254
|
+
kwargs, fo, opt, prep = self._begin(dict(kwargs))
|
|
255
|
+
send = kwargs
|
|
256
|
+
if opt is True and prep is not None:
|
|
257
|
+
assembled = self.recorder.optimised(prep) # type: ignore[union-attr]
|
|
258
|
+
if assembled:
|
|
259
|
+
send = self._merge(kwargs, assembled)
|
|
260
|
+
sent = request_body(send)
|
|
261
|
+
stream = bool(send.get("stream"))
|
|
262
|
+
done = lambda resp: self._finish(prep, resp, sent, opt) # noqa: E731
|
|
263
|
+
if not fo:
|
|
264
|
+
r = raw(**send)
|
|
265
|
+
if stream:
|
|
266
|
+
return RecordingStream(r, self.fmt, done, underlying=r)
|
|
267
|
+
done(cv.plain(r))
|
|
268
|
+
return r
|
|
269
|
+
breq = backup_request(fo, sent, self.fmt, self.services)
|
|
270
|
+
queue_wait, on_first = None, None
|
|
271
|
+
if fo.mode == "queue":
|
|
272
|
+
q = self._queue_setup(fo, prep, breq)
|
|
273
|
+
if q:
|
|
274
|
+
sid, name = q
|
|
275
|
+
queue_wait = queue_waiter_sync(self.services, sid, prep.turn, fo)
|
|
276
|
+
on_first = lambda: self.recorder._worker.submit(lambda: self._first_token(sid, prep.turn, name)) # noqa: E731
|
|
277
|
+
create = raw_noretry()
|
|
278
|
+
out = run_sync(lambda: create(**send), fo, breq, stream=stream, primary_fmt=self.fmt, queue_wait=queue_wait,
|
|
279
|
+
on_first_token=on_first)
|
|
280
|
+
if out.answered_by == "primary":
|
|
281
|
+
if stream:
|
|
282
|
+
p: _Primed = out.value
|
|
283
|
+
return RecordingStream(chain(p.first, p.it), self.fmt, done, underlying=p.stream)
|
|
284
|
+
done(cv.plain(out.value))
|
|
285
|
+
return out.value
|
|
286
|
+
res = self._backup_result(out, fo, stream, send, prep, opt, sent)
|
|
287
|
+
return RecordingStream(iter(res), self.fmt, lambda _: None) if stream else res
|
|
288
|
+
|
|
289
|
+
async def call_async(self, raw: Callable[..., Any], raw_noretry: Callable[[], Callable[..., Any]], kwargs: Dict[str, Any]) -> Any:
|
|
290
|
+
kwargs, fo, opt, prep = await self._abegin(dict(kwargs))
|
|
291
|
+
send = kwargs
|
|
292
|
+
loop = asyncio.get_running_loop()
|
|
293
|
+
if opt is True and prep is not None:
|
|
294
|
+
assembled = await loop.run_in_executor(None, self.recorder.optimised, prep) # type: ignore[union-attr]
|
|
295
|
+
if assembled:
|
|
296
|
+
send = self._merge(kwargs, assembled)
|
|
297
|
+
sent = request_body(send)
|
|
298
|
+
stream = bool(send.get("stream"))
|
|
299
|
+
done = lambda resp: self._finish(prep, resp, sent, opt) # noqa: E731
|
|
300
|
+
if not fo:
|
|
301
|
+
r = await raw(**send)
|
|
302
|
+
if stream:
|
|
303
|
+
return AsyncRecordingStream(r.__aiter__(), self.fmt, done, underlying=r)
|
|
304
|
+
done(cv.plain(r))
|
|
305
|
+
return r
|
|
306
|
+
breq = await loop.run_in_executor(None, backup_request, fo, sent, self.fmt, self.services)
|
|
307
|
+
queue_wait, on_first = None, None
|
|
308
|
+
if fo.mode == "queue":
|
|
309
|
+
q = await loop.run_in_executor(None, self._queue_setup, fo, prep, breq)
|
|
310
|
+
if q:
|
|
311
|
+
sid, name = q
|
|
312
|
+
queue_wait = queue_waiter_async(self.services, sid, prep.turn, fo)
|
|
313
|
+
on_first = lambda: self.recorder._worker.submit(lambda: self._first_token(sid, prep.turn, name)) # noqa: E731
|
|
314
|
+
create = raw_noretry()
|
|
315
|
+
out = await run_async(lambda: create(**send), fo, breq, stream=stream, primary_fmt=self.fmt,
|
|
316
|
+
queue_wait=queue_wait, on_first_token=on_first)
|
|
317
|
+
if out.answered_by == "primary":
|
|
318
|
+
if stream:
|
|
319
|
+
p: _Primed = out.value
|
|
320
|
+
return AsyncRecordingStream(achain(p.first, p.it), self.fmt, done, underlying=p.stream)
|
|
321
|
+
done(cv.plain(out.value))
|
|
322
|
+
return out.value
|
|
323
|
+
res = self._backup_result(out, fo, stream, send, prep, opt, sent)
|
|
324
|
+
return AsyncRecordingStream(alist(res), self.fmt, lambda _: None) if stream else res
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------- Anthropic's stream() helper
|
|
327
|
+
|
|
328
|
+
def stream_manager(self, raw_stream: Callable[..., Any], kwargs: Dict[str, Any], is_async: bool):
|
|
329
|
+
kwargs, _fo, opt, prep = self._begin(dict(kwargs))
|
|
330
|
+
sent = request_body(kwargs)
|
|
331
|
+
mgr = raw_stream(**kwargs)
|
|
332
|
+
done = lambda resp: self._finish(prep, resp, sent, opt) # noqa: E731
|
|
333
|
+
return (_AsyncStreamManager if is_async else _StreamManager)(mgr, done)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class _StreamManager:
|
|
337
|
+
"""``messages.stream()``: the official helper; the final message is recorded on a clean exit."""
|
|
338
|
+
|
|
339
|
+
def __init__(self, mgr: Any, done: Callable[[Dict[str, Any]], None]):
|
|
340
|
+
self._mgr, self._done, self._stream = mgr, done, None
|
|
341
|
+
|
|
342
|
+
def __enter__(self):
|
|
343
|
+
self._stream = self._mgr.__enter__()
|
|
344
|
+
return self._stream
|
|
345
|
+
|
|
346
|
+
def __exit__(self, *exc):
|
|
347
|
+
try:
|
|
348
|
+
if exc[0] is None and self._stream is not None:
|
|
349
|
+
self._done(cv.plain(self._stream.get_final_message()))
|
|
350
|
+
finally:
|
|
351
|
+
return self._mgr.__exit__(*exc)
|
|
352
|
+
|
|
353
|
+
def __getattr__(self, name):
|
|
354
|
+
return getattr(self._mgr, name)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class _AsyncStreamManager(_StreamManager):
|
|
358
|
+
async def __aenter__(self):
|
|
359
|
+
self._stream = await self._mgr.__aenter__()
|
|
360
|
+
return self._stream
|
|
361
|
+
|
|
362
|
+
async def __aexit__(self, *exc):
|
|
363
|
+
try:
|
|
364
|
+
if exc[0] is None and self._stream is not None:
|
|
365
|
+
self._done(cv.plain(await self._stream.get_final_message()))
|
|
366
|
+
finally:
|
|
367
|
+
return await self._mgr.__aexit__(*exc)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class _Resource:
|
|
371
|
+
"""A wrapped resource: ``create`` (and ``stream``) recorded; everything else is the official one."""
|
|
372
|
+
|
|
373
|
+
def __init__(self, client: Any, raw: Any, is_async: bool):
|
|
374
|
+
self._client, self._raw, self._async = client, raw, is_async
|
|
375
|
+
|
|
376
|
+
def create(self, **kwargs):
|
|
377
|
+
noretry = lambda: self._client._mc_raw(0).create # noqa: E731
|
|
378
|
+
if self._async:
|
|
379
|
+
return self._client._mc.call_async(self._raw.create, noretry, kwargs)
|
|
380
|
+
return self._client._mc.call_sync(self._raw.create, noretry, kwargs)
|
|
381
|
+
|
|
382
|
+
def __getattr__(self, name):
|
|
383
|
+
return getattr(self._raw, name)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class _AnthropicMessages(_Resource):
|
|
387
|
+
def stream(self, **kwargs):
|
|
388
|
+
return self._client._mc.stream_manager(self._raw.stream, kwargs, self._async)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class _Chat:
|
|
392
|
+
def __init__(self, client: Any, raw: Any, is_async: bool):
|
|
393
|
+
self._raw = raw
|
|
394
|
+
self.completions = _Resource(client, raw.completions, is_async)
|
|
395
|
+
|
|
396
|
+
def __getattr__(self, name):
|
|
397
|
+
return getattr(self._raw, name)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _mc_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
401
|
+
return {k: kwargs.pop(k) for k in ("failover", "optimise", "keepalive", "record", "multichat_api_key", "services", "record_files",
|
|
402
|
+
"guardrails", "stable_tools")
|
|
403
|
+
if k in kwargs}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _make(base: Any, fmt: str, resource: str, is_async: bool):
|
|
407
|
+
"""A subclass of the official client ``base`` with ``resource`` (chat or messages) recorded."""
|
|
408
|
+
|
|
409
|
+
def __init__(self, *, failover: Optional[Backup] = None, optimise: Optimise = "preview",
|
|
410
|
+
keepalive: Union[KeepAlive, bool, None] = None, record: bool = True,
|
|
411
|
+
multichat_api_key: Optional[str] = None, services: Optional[Services] = None,
|
|
412
|
+
record_files: bool = True, guardrails: Optional[Guardrails] = None, stable_tools: bool = False,
|
|
413
|
+
_mc_engine: Optional[Engine] = None, **kwargs: Any) -> None:
|
|
414
|
+
base.__init__(self, **kwargs)
|
|
415
|
+
self._mc_noretry = None
|
|
416
|
+
if _mc_engine is not None:
|
|
417
|
+
self._mc = _mc_engine
|
|
418
|
+
return
|
|
419
|
+
def key():
|
|
420
|
+
k = getattr(self, "api_key", None)
|
|
421
|
+
return k() if callable(k) else k
|
|
422
|
+
self._mc = Engine(fmt, failover=failover, optimise=optimise, keepalive=keepalive, record=record,
|
|
423
|
+
multichat_api_key=multichat_api_key, services=services, provider_key=key,
|
|
424
|
+
provider_base_url=lambda: queue_base_url(self, fmt), record_files=record_files,
|
|
425
|
+
guardrails=guardrails, stable_tools=stable_tools)
|
|
426
|
+
|
|
427
|
+
def copy(self, **kwargs: Any):
|
|
428
|
+
extra = dict(kwargs.pop("_extra_kwargs", {}) or {})
|
|
429
|
+
extra["_mc_engine"] = self._mc
|
|
430
|
+
return base.copy(self, _extra_kwargs=extra, **kwargs)
|
|
431
|
+
|
|
432
|
+
def _raw_resource(self):
|
|
433
|
+
r = base.__dict__[resource].func(self)
|
|
434
|
+
return r.completions if resource == "chat" else r
|
|
435
|
+
|
|
436
|
+
def _mc_raw(self, max_retries: Optional[int] = None):
|
|
437
|
+
if max_retries is None or max_retries == self.max_retries:
|
|
438
|
+
return _raw_resource(self)
|
|
439
|
+
if self._mc_noretry is None:
|
|
440
|
+
self._mc_noretry = base.copy(self, max_retries=max_retries, _extra_kwargs={"_mc_engine": self._mc})
|
|
441
|
+
return _raw_resource(self._mc_noretry)
|
|
442
|
+
|
|
443
|
+
def multichat(self) -> Engine:
|
|
444
|
+
return self._mc
|
|
445
|
+
|
|
446
|
+
if resource == "chat":
|
|
447
|
+
def wrapped(self):
|
|
448
|
+
return _Chat(self, base.__dict__["chat"].func(self), is_async)
|
|
449
|
+
else:
|
|
450
|
+
def wrapped(self):
|
|
451
|
+
return _AnthropicMessages(self, base.__dict__["messages"].func(self), is_async)
|
|
452
|
+
wrapped.__name__ = resource
|
|
453
|
+
|
|
454
|
+
ns = {"__init__": __init__, "copy": copy, "with_options": copy, "_mc_raw": _mc_raw,
|
|
455
|
+
"multichat": property(multichat, doc=Engine.__doc__), resource: cached_property(wrapped),
|
|
456
|
+
"__doc__": "``%s.%s`` calling the provider directly with your key; each turn is recorded in the "
|
|
457
|
+
"multiChat store. Extra arguments: failover, optimise, keepalive, record, record_files, guardrails, stable_tools, multichat_api_key."
|
|
458
|
+
% (base.__module__.split(".")[0], base.__name__),
|
|
459
|
+
"__module__": "multichat"}
|
|
460
|
+
if is_async:
|
|
461
|
+
async def close(self) -> None:
|
|
462
|
+
if self._mc.recorder is not None:
|
|
463
|
+
await asyncio.get_running_loop().run_in_executor(None, self._mc.flush)
|
|
464
|
+
await base.close(self)
|
|
465
|
+
else:
|
|
466
|
+
def close(self) -> None:
|
|
467
|
+
self._mc.flush()
|
|
468
|
+
base.close(self)
|
|
469
|
+
ns["close"] = close
|
|
470
|
+
cls = type(base.__name__, (base,), ns)
|
|
471
|
+
cls.__qualname__ = base.__name__
|
|
472
|
+
cls.__init__.__wrapped__ = base.__init__ # type: ignore[attr-defined]
|
|
473
|
+
return cls
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _missing(name: str, extra: str):
|
|
477
|
+
class Missing:
|
|
478
|
+
def __init__(self, *a: Any, **k: Any) -> None:
|
|
479
|
+
raise ImportError("multichat.%s needs the %s package: pip install 'multichat-sdk[%s]'" % (name, extra, extra))
|
|
480
|
+
Missing.__name__ = name
|
|
481
|
+
return Missing
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
import openai as _openai
|
|
486
|
+
except ImportError: # pragma: no cover
|
|
487
|
+
_openai = None # type: ignore[assignment]
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
import anthropic as _anthropic
|
|
491
|
+
except ImportError: # pragma: no cover
|
|
492
|
+
_anthropic = None # type: ignore[assignment]
|
|
493
|
+
|
|
494
|
+
if _openai is not None:
|
|
495
|
+
OpenAI = _make(_openai.OpenAI, cv.OPENAI, "chat", False)
|
|
496
|
+
AsyncOpenAI = _make(_openai.AsyncOpenAI, cv.OPENAI, "chat", True)
|
|
497
|
+
else: # pragma: no cover
|
|
498
|
+
OpenAI = _missing("OpenAI", "openai")
|
|
499
|
+
AsyncOpenAI = _missing("AsyncOpenAI", "openai")
|
|
500
|
+
|
|
501
|
+
if _anthropic is not None:
|
|
502
|
+
Anthropic = _make(_anthropic.Anthropic, cv.ANTHROPIC, "messages", False)
|
|
503
|
+
AsyncAnthropic = _make(_anthropic.AsyncAnthropic, cv.ANTHROPIC, "messages", True)
|
|
504
|
+
else: # pragma: no cover
|
|
505
|
+
Anthropic = _missing("Anthropic", "anthropic")
|
|
506
|
+
AsyncAnthropic = _missing("AsyncAnthropic", "anthropic")
|
multichat/_config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Credentials and service addresses.
|
|
2
|
+
|
|
3
|
+
Credential, first found: the ``multichat_api_key`` argument, ``MULTICHAT_API_KEY`` (an API key
|
|
4
|
+
from the portal), ``MULTICHAT_TOKEN``, or the sign-in token ``multichat login`` saved in
|
|
5
|
+
``~/.multichat/credentials.json``. Addresses: ``MULTICHAT_STORE_URL``, ``MULTICHAT_QUEUE_URL``,
|
|
6
|
+
``MULTICHAT_PLANNER_URL``, ``MULTICHAT_PORTAL_URL``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
STORE_URL = "https://store.multichat.io"
|
|
17
|
+
QUEUE_URL = "https://queue.multichat.io"
|
|
18
|
+
PLANNER_URL = "https://planner.multichat.io"
|
|
19
|
+
PORTAL_URL = "https://portal.multichat.io"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def store_url() -> str:
|
|
23
|
+
return os.environ.get("MULTICHAT_STORE_URL") or STORE_URL
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def queue_url() -> str:
|
|
27
|
+
return os.environ.get("MULTICHAT_QUEUE_URL") or QUEUE_URL
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def planner_url() -> str:
|
|
31
|
+
return os.environ.get("MULTICHAT_PLANNER_URL") or PLANNER_URL
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def portal_url() -> str:
|
|
35
|
+
return os.environ.get("MULTICHAT_PORTAL_URL") or PORTAL_URL
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_dir() -> Path:
|
|
39
|
+
return Path(os.path.expanduser("~")) / ".multichat"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def credentials_path() -> Path:
|
|
43
|
+
return config_dir() / "credentials.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_credential(explicit: Optional[str] = None) -> Optional[str]:
|
|
47
|
+
if explicit:
|
|
48
|
+
return explicit
|
|
49
|
+
for var in ("MULTICHAT_API_KEY", "MULTICHAT_TOKEN"):
|
|
50
|
+
if os.environ.get(var):
|
|
51
|
+
return os.environ[var]
|
|
52
|
+
try:
|
|
53
|
+
with open(credentials_path(), encoding="utf-8") as f:
|
|
54
|
+
data = json.load(f)
|
|
55
|
+
tok = data.get("token") if isinstance(data, dict) else None
|
|
56
|
+
return tok or None
|
|
57
|
+
except (OSError, ValueError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_token(token: str) -> Path:
|
|
62
|
+
path = credentials_path()
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
65
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
66
|
+
json.dump({"token": token}, f)
|
|
67
|
+
os.chmod(path, 0o600)
|
|
68
|
+
return path
|
multichat/_errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Errors raised by the multiChat SDK itself. Provider errors are the provider SDK's own."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MultiChatError(Exception):
|
|
5
|
+
"""An error from the SDK or one of the multiChat services."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message, status_code=None, code=None):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.message = message
|
|
10
|
+
self.status_code = status_code
|
|
11
|
+
self.code = code
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotLoggedInError(MultiChatError):
|
|
15
|
+
def __init__(self, message=None, status_code=None, code=None):
|
|
16
|
+
super().__init__(message or (
|
|
17
|
+
"No multiChat credential. Set MULTICHAT_API_KEY (an API key from the portal), run "
|
|
18
|
+
"`multichat login`, or pass record=False to call the provider without recording."), status_code, code)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ServiceError(MultiChatError):
|
|
22
|
+
"""A multiChat service (store, queue, planner) answered with an error."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BackupUnavailableError(MultiChatError):
|
|
26
|
+
"""Queue-mode failover: the backup's answer did not arrive in time."""
|