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 +53 -0
- decide/backends/__init__.py +67 -0
- decide/backends/_http.py +99 -0
- decide/backends/base.py +91 -0
- decide/backends/crossencoder.py +416 -0
- decide/backends/laya.py +176 -0
- decide/backends/laya_mlx.py +64 -0
- decide/backends/llm.py +358 -0
- decide/backends/openrouter.py +12 -0
- decide/backends/typesafe.py +82 -0
- decide/cli.py +294 -0
- decide/client.py +477 -0
- decide/errors.py +71 -0
- decide/gate.py +56 -0
- decide/server.py +124 -0
- decide/types.py +146 -0
- decide/wire.py +327 -0
- pydecide-0.1.0.dist-info/METADATA +379 -0
- pydecide-0.1.0.dist-info/RECORD +22 -0
- pydecide-0.1.0.dist-info/WHEEL +4 -0
- pydecide-0.1.0.dist-info/entry_points.txt +2 -0
- pydecide-0.1.0.dist-info/licenses/LICENSE +21 -0
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"]
|
decide/backends/_http.py
ADDED
|
@@ -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]}")
|
decide/backends/base.py
ADDED
|
@@ -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]
|