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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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."""