pydecide 0.1.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.
decide/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ from decide.client import AsyncClient, Client
2
+ from decide.errors import (
3
+ AllBackendsFailed,
4
+ AuthError,
5
+ BackendConnectionError,
6
+ BackendError,
7
+ BadResponseError,
8
+ ConfigError,
9
+ DecideError,
10
+ RateLimitError,
11
+ )
12
+ from decide.gate import Gate
13
+ from decide.types import (
14
+ Choice,
15
+ ChoiceAnswer,
16
+ Content,
17
+ Meta,
18
+ Noul,
19
+ NoulAnswer,
20
+ Question,
21
+ Request,
22
+ Response,
23
+ Score,
24
+ ScoreAnswer,
25
+ )
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "AllBackendsFailed",
31
+ "AsyncClient",
32
+ "AuthError",
33
+ "BackendConnectionError",
34
+ "BackendError",
35
+ "BadResponseError",
36
+ "Choice",
37
+ "ChoiceAnswer",
38
+ "Client",
39
+ "ConfigError",
40
+ "Content",
41
+ "DecideError",
42
+ "Gate",
43
+ "Meta",
44
+ "Noul",
45
+ "NoulAnswer",
46
+ "Question",
47
+ "RateLimitError",
48
+ "Request",
49
+ "Response",
50
+ "Score",
51
+ "ScoreAnswer",
52
+ "__version__",
53
+ ]
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import importlib.util
5
+
6
+ from decide.backends.base import Backend, BaseBackend, Capabilities
7
+ from decide.errors import ConfigError
8
+
9
+ REGISTRY: dict[str, str] = {
10
+ "typesafe": "decide.backends.typesafe:TypeSafeBackend",
11
+ "openrouter": "decide.backends.openrouter:OpenRouterBackend",
12
+ "laya": "decide.backends.laya:LayaBackend",
13
+ "laya_mlx": "decide.backends.laya_mlx:LayaMLXBackend",
14
+ "crossencoder": "decide.backends.crossencoder:CrossEncoderBackend",
15
+ "llm": "decide.backends.llm:LLMBackend",
16
+ }
17
+
18
+ # The extra (if any) that installs the third-party dependency a backend needs.
19
+ _EXTRAS: dict[str, str | None] = {
20
+ "typesafe": None,
21
+ "openrouter": None,
22
+ "laya": "pydecide[laya]",
23
+ "laya_mlx": "pydecide[mlx]",
24
+ "crossencoder": "pydecide[st]",
25
+ "llm": None,
26
+ }
27
+
28
+ # The third-party package whose presence determines whether a backend is importable.
29
+ _REQUIRES: dict[str, str] = {
30
+ "typesafe": "httpx",
31
+ "openrouter": "httpx",
32
+ "laya": "laya",
33
+ "laya_mlx": "laya_mlx",
34
+ "crossencoder": "sentence_transformers",
35
+ "llm": "httpx",
36
+ }
37
+
38
+
39
+ def load(name: str, **kwargs: object) -> Backend:
40
+ """Load and instantiate a backend by its registry name, forwarding kwargs untouched."""
41
+ target = REGISTRY.get(name)
42
+ if target is None:
43
+ raise ConfigError(f"unknown backend {name!r}; available: {sorted(REGISTRY)}")
44
+
45
+ module_path, _, class_name = target.partition(":")
46
+ try:
47
+ module = importlib.import_module(module_path)
48
+ except ImportError as exc:
49
+ extra = _EXTRAS.get(name)
50
+ if extra:
51
+ message = (
52
+ f"backend {name!r} requires the {extra!r} extra; install it to use this backend"
53
+ )
54
+ else:
55
+ message = f"backend {name!r} is not available: {module_path} could not be imported"
56
+ raise ConfigError(message) from exc
57
+
58
+ cls = getattr(module, class_name)
59
+ return cls(**kwargs)
60
+
61
+
62
+ def available() -> dict[str, bool]:
63
+ """Report, for each registered backend, whether its dependency is importable."""
64
+ return {name: importlib.util.find_spec(pkg) is not None for name, pkg in _REQUIRES.items()}
65
+
66
+
67
+ __all__ = ["REGISTRY", "Backend", "BaseBackend", "Capabilities", "available", "load"]
@@ -0,0 +1,99 @@
1
+ """Shared plumbing for httpx-based backends.
2
+
3
+ `HttpClientMixin` gives a backend a lazily-created, reused `httpx.Client` /
4
+ `httpx.AsyncClient` pair (plus `close()`/`aclose()`), and `raise_for_status`
5
+ maps a non-2xx `httpx.Response` to the right `decide.errors.BackendError`
6
+ subclass. Both `typesafe.py` and `llm.py` build on these instead of
7
+ duplicating the client lifecycle and status-code mapping.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from decide.errors import AuthError, BackendConnectionError, BackendError, RateLimitError
17
+
18
+
19
+ class HttpClientMixin:
20
+ """Lazy `httpx.Client`/`httpx.AsyncClient` lifecycle shared by HTTP backends.
21
+
22
+ Subclasses must set `self.name: str`, `self.base_url: str`,
23
+ `self.timeout: float`,
24
+ `self._transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None`,
25
+ `self._client: httpx.Client | None = None` and
26
+ `self._aclient: httpx.AsyncClient | None = None` in `__init__`.
27
+ """
28
+
29
+ name: str
30
+ base_url: str
31
+ timeout: float
32
+ _transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None
33
+ _client: httpx.Client | None
34
+ _aclient: httpx.AsyncClient | None
35
+
36
+ def _client_(self) -> httpx.Client:
37
+ """Lazily create and reuse one `httpx.Client` for this backend instance."""
38
+ if self._client is None:
39
+ self._client = httpx.Client(
40
+ base_url=self.base_url, timeout=self.timeout, transport=self._transport
41
+ )
42
+ return self._client
43
+
44
+ def _aclient_(self) -> httpx.AsyncClient:
45
+ """Lazily create and reuse one `httpx.AsyncClient` for this backend instance."""
46
+ if self._aclient is None:
47
+ self._aclient = httpx.AsyncClient(
48
+ base_url=self.base_url, timeout=self.timeout, transport=self._transport
49
+ )
50
+ return self._aclient
51
+
52
+ def close(self) -> None:
53
+ if self._client is not None:
54
+ self._client.close()
55
+ self._client = None
56
+
57
+ async def aclose(self) -> None:
58
+ if self._aclient is not None:
59
+ await self._aclient.aclose()
60
+ self._aclient = None
61
+
62
+ def _post_json(
63
+ self, path: str, *, json: dict[str, Any], headers: dict[str, str]
64
+ ) -> httpx.Response:
65
+ """POST `json` to `path` on the sync client, mapping a transport failure to
66
+ `BackendConnectionError`."""
67
+ try:
68
+ return self._client_().post(path, json=json, headers=headers)
69
+ except httpx.TransportError as exc:
70
+ raise BackendConnectionError(
71
+ self.name, f"could not reach {self.name}: {exc}", exc
72
+ ) from exc
73
+
74
+ async def _apost_json(
75
+ self, path: str, *, json: dict[str, Any], headers: dict[str, str]
76
+ ) -> httpx.Response:
77
+ """POST `json` to `path` on the async client, mapping a transport failure to
78
+ `BackendConnectionError`."""
79
+ try:
80
+ return await self._aclient_().post(path, json=json, headers=headers)
81
+ except httpx.TransportError as exc:
82
+ raise BackendConnectionError(
83
+ self.name, f"could not reach {self.name}: {exc}", exc
84
+ ) from exc
85
+
86
+
87
+ def raise_for_status(backend: str, response: httpx.Response) -> None:
88
+ """Raise the appropriate `BackendError` subclass for a non-2xx response.
89
+
90
+ No-op for a successful response. 401/403 -> `AuthError`, 429 ->
91
+ `RateLimitError`, any other 4xx/5xx -> `BackendError` carrying a short
92
+ excerpt of the response body.
93
+ """
94
+ if response.status_code in (401, 403):
95
+ raise AuthError(backend, f"authentication failed (HTTP {response.status_code})")
96
+ if response.status_code == 429:
97
+ raise RateLimitError(backend, "rate limited (HTTP 429)")
98
+ if response.status_code >= 400:
99
+ raise BackendError(backend, f"HTTP {response.status_code}: {response.text[:200]}")
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any, Protocol, runtime_checkable
8
+
9
+ from decide.types import Answer, Meta, Request, Response
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Capabilities:
14
+ batch: bool = False
15
+ local: bool = False
16
+ max_choice_options: int | None = None
17
+
18
+
19
+ @runtime_checkable
20
+ class Backend(Protocol):
21
+ name: str
22
+
23
+ def capabilities(self) -> Capabilities: ...
24
+
25
+ def decide(self, request: Request) -> Response: ...
26
+
27
+ async def adecide(self, request: Request) -> Response: ...
28
+
29
+ def decide_batch(self, requests: Sequence[Request]) -> list[Response]: ...
30
+
31
+
32
+ class BaseBackend:
33
+ """Common scaffolding for backend implementations.
34
+
35
+ Subclasses implement `_decide` (and, for a real async transport rather than
36
+ a thread-pooled sync call, `_adecide`) and may override `capabilities` and
37
+ `decide_batch` for backend-specific behavior (e.g. real batching).
38
+
39
+ `_decide`/`_adecide` return `(answers, raw, model)`: `model` is the model
40
+ name actually used to answer, or `None` to mean "report `self.model`" (the
41
+ common case for backends with a single fixed or instance-configured
42
+ model). This lets a backend that resolves its model per-request (e.g. from
43
+ `request.model` or a value the response itself reports) surface that in
44
+ `Meta.model` instead of `decide()`/`adecide()` always reporting the
45
+ backend's static default.
46
+ """
47
+
48
+ name = "base"
49
+ model: str | None = None
50
+
51
+ def capabilities(self) -> Capabilities:
52
+ return Capabilities()
53
+
54
+ def decide(self, request: Request) -> Response:
55
+ start = time.perf_counter()
56
+ answers, raw, model = self._decide(request)
57
+ latency_ms = (time.perf_counter() - start) * 1000
58
+ return Response(
59
+ answers=answers,
60
+ meta=Meta(
61
+ backend=self.name,
62
+ model=model if model is not None else self.model,
63
+ latency_ms=latency_ms,
64
+ route=[],
65
+ raw=raw,
66
+ ),
67
+ )
68
+
69
+ def _decide(self, request: Request) -> tuple[dict[str, Answer], Any, str | None]:
70
+ raise NotImplementedError
71
+
72
+ async def adecide(self, request: Request) -> Response:
73
+ start = time.perf_counter()
74
+ answers, raw, model = await self._adecide(request)
75
+ latency_ms = (time.perf_counter() - start) * 1000
76
+ return Response(
77
+ answers=answers,
78
+ meta=Meta(
79
+ backend=self.name,
80
+ model=model if model is not None else self.model,
81
+ latency_ms=latency_ms,
82
+ route=[],
83
+ raw=raw,
84
+ ),
85
+ )
86
+
87
+ async def _adecide(self, request: Request) -> tuple[dict[str, Answer], Any, str | None]:
88
+ return await asyncio.to_thread(self._decide, request)
89
+
90
+ def decide_batch(self, requests: Sequence[Request]) -> list[Response]:
91
+ return [self.decide(r) for r in requests]