system-one 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.
system_one/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """A vendor-neutral SDK for System One models: `ask(state, questions) -> answers`."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from system_one.agent import AsyncSystemOne, SystemOne
6
+ from system_one.errors import (
7
+ RETRY_STATUSES,
8
+ APIConnectionError,
9
+ APIError,
10
+ APITimeoutError,
11
+ AuthenticationError,
12
+ SystemOneError,
13
+ )
14
+ from system_one.schemas import (
15
+ Answer,
16
+ Choice,
17
+ ChoiceAnswer,
18
+ Noul,
19
+ NoulAnswer,
20
+ NoulCriteria,
21
+ Question,
22
+ Score,
23
+ ScoreAnswer,
24
+ SystemOneInput,
25
+ SystemOneOutput,
26
+ Usage,
27
+ )
28
+ from system_one.settings import (
29
+ HTTPConfig,
30
+ ONNXConfig,
31
+ OpenRouterConfig,
32
+ Settings,
33
+ TypesafeConfig,
34
+ )
35
+
36
+ __version__ = version("system-one")
37
+
38
+ __all__ = [
39
+ "RETRY_STATUSES",
40
+ "APIConnectionError",
41
+ "APIError",
42
+ "APITimeoutError",
43
+ "Answer",
44
+ "AsyncSystemOne",
45
+ "AuthenticationError",
46
+ "Choice",
47
+ "ChoiceAnswer",
48
+ "HTTPConfig",
49
+ "Noul",
50
+ "NoulAnswer",
51
+ "NoulCriteria",
52
+ "ONNXConfig",
53
+ "OpenRouterConfig",
54
+ "Question",
55
+ "Score",
56
+ "ScoreAnswer",
57
+ "Settings",
58
+ "SystemOne",
59
+ "SystemOneError",
60
+ "SystemOneInput",
61
+ "SystemOneOutput",
62
+ "TypesafeConfig",
63
+ "Usage",
64
+ "__version__",
65
+ ]
system_one/agent.py ADDED
@@ -0,0 +1,106 @@
1
+ """The one public entry point: `SystemOne.ask(state, questions)`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from system_one.backends import create_async_backend, create_backend
8
+ from system_one.schemas import SystemOneInput
9
+ from system_one.settings import ONNXConfig, Settings
10
+
11
+ if TYPE_CHECKING:
12
+ from typing_extensions import Self
13
+
14
+ from system_one.backends import AsyncBackend, Backend
15
+ from system_one.schemas import QuestionInput, State, SystemOneOutput
16
+ from system_one.settings import BackendConfig
17
+
18
+
19
+ class BaseSystemOne:
20
+ """Settings resolution and input building, shared by the sync and async agents.
21
+
22
+ `config` is the backend's own configuration — `TypesafeConfig()`,
23
+ `OpenRouterConfig()`, `HTTPConfig(base_url=..., model=...)` or `ONNXConfig()` —
24
+ and picks the backend to build; omitted, `SYSTEM_ONE_BACKEND` decides and the
25
+ config is read from the environment. `using` hands over an already-built backend
26
+ instead, which is how tests and custom providers plug in.
27
+ """
28
+
29
+ backend: Any # narrowed to `Backend` / `AsyncBackend` by the two subclasses
30
+
31
+ def __init__(self, config: BackendConfig | None = None, **overrides: Any) -> None:
32
+ if config is not None and "backend" not in overrides:
33
+ overrides["backend"] = "onnx" if isinstance(config, ONNXConfig) else "http"
34
+ self.settings = Settings(**overrides)
35
+ self.config = config
36
+
37
+ def _input(
38
+ self, state: State, questions: QuestionInput, model: str | None
39
+ ) -> SystemOneInput:
40
+ return SystemOneInput.model_validate(
41
+ {
42
+ "state": state,
43
+ "model": model or self.settings.model or self.backend.model,
44
+ "questions": questions,
45
+ }
46
+ )
47
+
48
+
49
+ class SystemOne(BaseSystemOne):
50
+ """Ask typed questions about a state and get calibrated answers back."""
51
+
52
+ def __init__(
53
+ self,
54
+ config: BackendConfig | None = None,
55
+ *,
56
+ using: Backend | None = None,
57
+ **overrides: Any,
58
+ ) -> None:
59
+ super().__init__(config, **overrides)
60
+ self.backend: Backend = (
61
+ using if using is not None else create_backend(self.settings, config)
62
+ )
63
+
64
+ def ask(
65
+ self, state: State, questions: QuestionInput, *, model: str | None = None
66
+ ) -> SystemOneOutput:
67
+ return self.backend.ask(self._input(state, questions, model))
68
+
69
+ def close(self) -> None:
70
+ self.backend.close()
71
+
72
+ def __enter__(self) -> Self:
73
+ return self
74
+
75
+ def __exit__(self, *exc: object) -> None:
76
+ self.close()
77
+
78
+
79
+ class AsyncSystemOne(BaseSystemOne):
80
+ """The async counterpart of `SystemOne`: the same `ask`, awaited."""
81
+
82
+ def __init__(
83
+ self,
84
+ config: BackendConfig | None = None,
85
+ *,
86
+ using: AsyncBackend | None = None,
87
+ **overrides: Any,
88
+ ) -> None:
89
+ super().__init__(config, **overrides)
90
+ self.backend: AsyncBackend = (
91
+ using if using is not None else create_async_backend(self.settings, config)
92
+ )
93
+
94
+ async def ask(
95
+ self, state: State, questions: QuestionInput, *, model: str | None = None
96
+ ) -> SystemOneOutput:
97
+ return await self.backend.ask(self._input(state, questions, model))
98
+
99
+ async def close(self) -> None:
100
+ await self.backend.close()
101
+
102
+ async def __aenter__(self) -> Self:
103
+ return self
104
+
105
+ async def __aexit__(self, *exc: object) -> None:
106
+ await self.close()
@@ -0,0 +1,94 @@
1
+ """Backend selection. Vendor imports stay inside the factories so the extras stay optional."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from typing import TYPE_CHECKING, Protocol, TypeVar
7
+
8
+ from system_one.errors import SystemOneError
9
+ from system_one.settings import BackendConfig, HTTPConfig, ONNXConfig
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterator
13
+
14
+ from system_one.schemas import SystemOneInput, SystemOneOutput
15
+ from system_one.settings import Settings
16
+
17
+ Config = TypeVar("Config", bound=BackendConfig)
18
+
19
+ EXTRA_HINTS = {
20
+ "http": "The http backend needs httpx2: pip install 'system-one[http]'",
21
+ "onnx": "The onnx backend needs onnxruntime, tokenizers and numpy: "
22
+ "pip install 'system-one[onnx]'",
23
+ }
24
+
25
+
26
+ class Backend(Protocol):
27
+ """What `SystemOne` needs from a backend."""
28
+
29
+ model: str
30
+ """The model to ask when neither the call nor `SYSTEM_ONE_MODEL` names one."""
31
+
32
+ def ask(self, request: SystemOneInput) -> SystemOneOutput: ...
33
+
34
+ def close(self) -> None: ...
35
+
36
+
37
+ class AsyncBackend(Protocol):
38
+ """The same two methods as `Backend`, awaited."""
39
+
40
+ model: str
41
+
42
+ async def ask(self, request: SystemOneInput) -> SystemOneOutput: ...
43
+
44
+ async def close(self) -> None: ...
45
+
46
+
47
+ @contextmanager
48
+ def _hint(name: str) -> Iterator[None]:
49
+ """Turn a missing vendor dependency — and only that — into its install hint."""
50
+ try:
51
+ yield
52
+ except ImportError as exc:
53
+ raise ImportError(EXTRA_HINTS[name]) from exc
54
+
55
+
56
+ def _resolve(
57
+ settings: Settings, config: BackendConfig | None, default: type[Config]
58
+ ) -> Config:
59
+ """The config to build with: the given one, or one read from the environment."""
60
+ # mypy cannot see that pydantic-settings fills the required fields from env.
61
+ config = default() if config is None else config # type: ignore[call-arg]
62
+ if not isinstance(config, default):
63
+ message = (
64
+ f"{type(config).__name__} does not configure the "
65
+ f"{settings.backend!r} backend."
66
+ )
67
+ raise SystemOneError(message)
68
+ if settings.model is not None:
69
+ config = config.model_copy(update={"model": settings.model})
70
+ return config
71
+
72
+
73
+ def create_backend(settings: Settings, config: BackendConfig | None = None) -> Backend:
74
+ """The sync backend named by `settings.backend`."""
75
+ if settings.backend == "onnx":
76
+ with _hint("onnx"):
77
+ from system_one.backends.onnx import ONNXBackend
78
+ return ONNXBackend(_resolve(settings, config, ONNXConfig))
79
+ with _hint("http"):
80
+ from system_one.backends.http import HTTPBackend
81
+ return HTTPBackend(_resolve(settings, config, HTTPConfig))
82
+
83
+
84
+ def create_async_backend(
85
+ settings: Settings, config: BackendConfig | None = None
86
+ ) -> AsyncBackend:
87
+ """The async backend named by `settings.backend`."""
88
+ if settings.backend == "onnx":
89
+ with _hint("onnx"):
90
+ from system_one.backends.onnx import AsyncONNXBackend
91
+ return AsyncONNXBackend(_resolve(settings, config, ONNXConfig))
92
+ with _hint("http"):
93
+ from system_one.backends.http import AsyncHTTPBackend
94
+ return AsyncHTTPBackend(_resolve(settings, config, HTTPConfig))
@@ -0,0 +1,182 @@
1
+ """One HTTP backend for every vendor: same JSON body, the config carries the endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import secrets
8
+ import time
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import httpx2
12
+
13
+ from system_one import __version__
14
+ from system_one.errors import (
15
+ RETRY_STATUSES,
16
+ APIConnectionError,
17
+ APIError,
18
+ APITimeoutError,
19
+ AuthenticationError,
20
+ SystemOneError,
21
+ )
22
+ from system_one.schemas import SystemOneOutput
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Mapping
26
+
27
+ from system_one.schemas import SystemOneInput
28
+ from system_one.settings import HTTPConfig
29
+
30
+ logger = logging.getLogger("system_one")
31
+
32
+ MAX_BACKOFF = 5.0
33
+ INITIAL_BACKOFF = 0.5
34
+ MAX_ERROR_BODY = 200
35
+ AUTH_STATUSES = frozenset({401, 403})
36
+
37
+
38
+ def retry_after(headers: Mapping[str, str]) -> float | None:
39
+ """Read `retry-after-ms` then `retry-after`. HTTP-date forms fall back to backoff."""
40
+ for header, divisor in (("retry-after-ms", 1000.0), ("retry-after", 1.0)):
41
+ raw = headers.get(header)
42
+ if raw is None:
43
+ continue
44
+ try:
45
+ return float(raw) / divisor
46
+ except ValueError:
47
+ continue
48
+ return None
49
+
50
+
51
+ def backoff(attempt: int, after: float | None) -> float:
52
+ if after is not None:
53
+ return min(after, MAX_BACKOFF)
54
+ delay = min(MAX_BACKOFF, INITIAL_BACKOFF * (1 << attempt))
55
+ return delay * (0.5 + secrets.randbelow(500) / 1000)
56
+
57
+
58
+ def api_error(response: httpx2.Response) -> APIError:
59
+ status = response.status_code
60
+ error = AuthenticationError if status in AUTH_STATUSES else APIError
61
+ return error(
62
+ f"System One API returned {status}",
63
+ status=status,
64
+ body=response.text[:MAX_ERROR_BODY],
65
+ retry_after=retry_after(response.headers),
66
+ )
67
+
68
+
69
+ def is_retryable(error: SystemOneError) -> bool:
70
+ if isinstance(error, AuthenticationError):
71
+ return False
72
+ if isinstance(error, APIError):
73
+ return error.status in RETRY_STATUSES
74
+ return True
75
+
76
+
77
+ def transport_error(request: httpx2.Request, exc: httpx2.HTTPError) -> SystemOneError:
78
+ if isinstance(exc, httpx2.TimeoutException):
79
+ return APITimeoutError(f"Request to {request.url} timed out")
80
+ return APIConnectionError(f"Could not reach {request.url}")
81
+
82
+
83
+ class BaseHTTPBackend:
84
+ """Request building, error mapping and retry pacing, shared by both flavours."""
85
+
86
+ def __init__(self, config: HTTPConfig, *, transport: Any = None) -> None:
87
+ self.config = config
88
+ self.model = config.model
89
+ self.base_url = config.base_url.rstrip("/")
90
+ self.path = config.path
91
+ self._transport = transport
92
+ self._headers = {
93
+ "Content-Type": "application/json",
94
+ "Accept": "application/json",
95
+ "User-Agent": f"system-one/{__version__}",
96
+ }
97
+ if config.api_key is not None:
98
+ self._headers["Authorization"] = (
99
+ f"Bearer {config.api_key.get_secret_value()}"
100
+ )
101
+
102
+ def _build(self, method: str, path: str, body: str | None = None) -> httpx2.Request:
103
+ url = self.base_url + path
104
+ logger.debug("%s %s", method, url)
105
+ return httpx2.Request(method, url, headers=self._headers, content=body)
106
+
107
+ def _ask_request(self, request: SystemOneInput) -> httpx2.Request:
108
+ return self._build("POST", self.path, request.model_dump_json())
109
+
110
+ def _delay_or_reraise(self, error: SystemOneError, attempt: int) -> float:
111
+ if attempt >= self.config.max_retries or not is_retryable(error):
112
+ raise error
113
+ after = error.retry_after if isinstance(error, APIError) else None
114
+ return backoff(attempt, after)
115
+
116
+
117
+ class HTTPBackend(BaseHTTPBackend):
118
+ """Talks to any vendor implementing the System One contract over HTTP."""
119
+
120
+ def __init__(self, config: HTTPConfig, *, transport: Any = None) -> None:
121
+ super().__init__(config, transport=transport)
122
+ self._client = httpx2.Client(timeout=config.timeout, transport=transport)
123
+
124
+ def ask(self, request: SystemOneInput) -> SystemOneOutput:
125
+ response = self._send(self._ask_request(request))
126
+ return SystemOneOutput.model_validate_json(response.content)
127
+
128
+ def close(self) -> None:
129
+ self._client.close()
130
+
131
+ def _send(self, request: httpx2.Request) -> httpx2.Response:
132
+ attempt = 0
133
+ while True:
134
+ try:
135
+ return self._attempt(request)
136
+ except SystemOneError as exc:
137
+ delay = self._delay_or_reraise(exc, attempt)
138
+ attempt += 1
139
+ time.sleep(delay)
140
+
141
+ def _attempt(self, request: httpx2.Request) -> httpx2.Response:
142
+ try:
143
+ response = self._client.send(request)
144
+ except httpx2.HTTPError as exc:
145
+ raise transport_error(request, exc) from exc
146
+ if response.is_success:
147
+ return response
148
+ raise api_error(response)
149
+
150
+
151
+ class AsyncHTTPBackend(BaseHTTPBackend):
152
+ """The async counterpart of `HTTPBackend`, sharing its config and retry policy."""
153
+
154
+ def __init__(self, config: HTTPConfig, *, transport: Any = None) -> None:
155
+ super().__init__(config, transport=transport)
156
+ self._client = httpx2.AsyncClient(timeout=config.timeout, transport=transport)
157
+
158
+ async def ask(self, request: SystemOneInput) -> SystemOneOutput:
159
+ response = await self._send(self._ask_request(request))
160
+ return SystemOneOutput.model_validate_json(response.content)
161
+
162
+ async def close(self) -> None:
163
+ await self._client.aclose()
164
+
165
+ async def _send(self, request: httpx2.Request) -> httpx2.Response:
166
+ attempt = 0
167
+ while True:
168
+ try:
169
+ return await self._attempt(request)
170
+ except SystemOneError as exc:
171
+ delay = self._delay_or_reraise(exc, attempt)
172
+ attempt += 1
173
+ await asyncio.sleep(delay)
174
+
175
+ async def _attempt(self, request: httpx2.Request) -> httpx2.Response:
176
+ try:
177
+ response = await self._client.send(request)
178
+ except httpx2.HTTPError as exc:
179
+ raise transport_error(request, exc) from exc
180
+ if response.is_success:
181
+ return response
182
+ raise api_error(response)