axonium 1.0.0rc1__tar.gz
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.
- axonium-1.0.0rc1/.gitignore +22 -0
- axonium-1.0.0rc1/LICENSE +27 -0
- axonium-1.0.0rc1/PKG-INFO +243 -0
- axonium-1.0.0rc1/README.md +217 -0
- axonium-1.0.0rc1/examples/concurrent_and_errors.py +83 -0
- axonium-1.0.0rc1/examples/quickstart.py +80 -0
- axonium-1.0.0rc1/examples/streaming.py +66 -0
- axonium-1.0.0rc1/pyproject.toml +84 -0
- axonium-1.0.0rc1/src/axonium/__init__.py +142 -0
- axonium-1.0.0rc1/src/axonium/_version.py +8 -0
- axonium-1.0.0rc1/src/axonium/auth.py +386 -0
- axonium-1.0.0rc1/src/axonium/client.py +536 -0
- axonium-1.0.0rc1/src/axonium/config.py +146 -0
- axonium-1.0.0rc1/src/axonium/errors.py +555 -0
- axonium-1.0.0rc1/src/axonium/models/__init__.py +0 -0
- axonium-1.0.0rc1/src/axonium/models/catalog.py +53 -0
- axonium-1.0.0rc1/src/axonium/models/chat.py +166 -0
- axonium-1.0.0rc1/src/axonium/models/common.py +151 -0
- axonium-1.0.0rc1/src/axonium/models/inference.py +74 -0
- axonium-1.0.0rc1/src/axonium/models/requests.py +191 -0
- axonium-1.0.0rc1/src/axonium/observability/__init__.py +0 -0
- axonium-1.0.0rc1/src/axonium/observability/logging.py +51 -0
- axonium-1.0.0rc1/src/axonium/observability/otel.py +82 -0
- axonium-1.0.0rc1/src/axonium/observability/scopes.py +50 -0
- axonium-1.0.0rc1/src/axonium/preflight.py +69 -0
- axonium-1.0.0rc1/src/axonium/providers.py +152 -0
- axonium-1.0.0rc1/src/axonium/py.typed +0 -0
- axonium-1.0.0rc1/src/axonium/resources/__init__.py +0 -0
- axonium-1.0.0rc1/src/axonium/resources/chat.py +99 -0
- axonium-1.0.0rc1/src/axonium/resources/embeddings.py +50 -0
- axonium-1.0.0rc1/src/axonium/resources/images.py +51 -0
- axonium-1.0.0rc1/src/axonium/resources/models.py +54 -0
- axonium-1.0.0rc1/src/axonium/streaming.py +184 -0
- axonium-1.0.0rc1/src/axonium/transport/__init__.py +0 -0
- axonium-1.0.0rc1/src/axonium/transport/dispatch.py +136 -0
- axonium-1.0.0rc1/src/axonium/transport/http.py +94 -0
- axonium-1.0.0rc1/src/axonium/transport/retry.py +135 -0
- axonium-1.0.0rc1/src/axonium/transport/sse.py +169 -0
- axonium-1.0.0rc1/tests/conftest.py +48 -0
- axonium-1.0.0rc1/tests/contract/test_manifest.py +247 -0
- axonium-1.0.0rc1/tests/integration/test_platform_security.py +328 -0
- axonium-1.0.0rc1/tests/security/test_credential_safety.py +284 -0
- axonium-1.0.0rc1/tests/security/test_hostile_responses.py +444 -0
- axonium-1.0.0rc1/tests/test_auth.py +622 -0
- axonium-1.0.0rc1/tests/test_config.py +155 -0
- axonium-1.0.0rc1/tests/test_credential_modes.py +378 -0
- axonium-1.0.0rc1/tests/test_dispatch.py +51 -0
- axonium-1.0.0rc1/tests/test_errors.py +217 -0
- axonium-1.0.0rc1/tests/test_inference.py +473 -0
- axonium-1.0.0rc1/tests/test_models_common.py +105 -0
- axonium-1.0.0rc1/tests/test_models_resource.py +334 -0
- axonium-1.0.0rc1/tests/test_observability.py +322 -0
- axonium-1.0.0rc1/tests/test_preflight.py +343 -0
- axonium-1.0.0rc1/tests/test_reasoning.py +265 -0
- axonium-1.0.0rc1/tests/test_requests.py +167 -0
- axonium-1.0.0rc1/tests/test_retry.py +159 -0
- axonium-1.0.0rc1/tests/test_streaming.py +508 -0
- axonium-1.0.0rc1/tests/test_transport_http.py +85 -0
- axonium-1.0.0rc1/uv.lock +965 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
.venv/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.coverage
|
|
13
|
+
htmlcov/
|
|
14
|
+
|
|
15
|
+
# Rust
|
|
16
|
+
target/
|
|
17
|
+
|
|
18
|
+
# Environment variables
|
|
19
|
+
**/.env
|
|
20
|
+
|
|
21
|
+
# macOS
|
|
22
|
+
.DS_Store
|
axonium-1.0.0rc1/LICENSE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Emeric Espiritu Santiago
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
Additional Terms for Enterprise Use:
|
|
24
|
+
|
|
25
|
+
For commercial or enterprise use, please contact the copyright holder at
|
|
26
|
+
emericespiritusantiago@gmail.com to discuss licensing terms and potential
|
|
27
|
+
remuneration.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: axonium
|
|
3
|
+
Version: 1.0.0rc1
|
|
4
|
+
Summary: Python SDK for the Prometheus Gateway inference API
|
|
5
|
+
Project-URL: Homepage, https://github.com/Root1V/axonium-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/Root1V/axonium-sdk
|
|
7
|
+
Project-URL: Changelog, https://github.com/Root1V/axonium-sdk/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Emeric Espiritu Santiago
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: inference,llm,openai-compatible,prometheus-gateway,sdk
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Requires-Dist: pydantic-settings>=2.2
|
|
22
|
+
Requires-Dist: pydantic>=2.7
|
|
23
|
+
Provides-Extra: otel
|
|
24
|
+
Requires-Dist: opentelemetry-api>=1.25; extra == 'otel'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# axonium (Python)
|
|
28
|
+
|
|
29
|
+
Python SDK for the Prometheus Gateway inference API.
|
|
30
|
+
|
|
31
|
+
> **Status: release candidate.** The client is feature-complete against the current gateway
|
|
32
|
+
> contract. Not yet published to PyPI.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install axonium
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
With OpenTelemetry span support:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install "axonium[otel]"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.10+.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from axonium import Axonium
|
|
52
|
+
|
|
53
|
+
with Axonium() as client:
|
|
54
|
+
print(client.models.mine().ids) # what this token can actually call
|
|
55
|
+
|
|
56
|
+
completion = client.chat.completions.create(
|
|
57
|
+
model="llama3-8b-q4",
|
|
58
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
59
|
+
)
|
|
60
|
+
print(completion.content)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Streaming is a separate method, because it needs a different scope and is never retried
|
|
64
|
+
automatically:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
with client.chat.completions.stream(model="llama3-8b-q4", messages=messages) as stream:
|
|
68
|
+
for chunk in stream:
|
|
69
|
+
print(chunk.content or "", end="", flush=True)
|
|
70
|
+
print(stream.usage())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`AsyncAxonium` mirrors the whole surface — same names, same behavior, with `await`, `async with`
|
|
74
|
+
and `async for`.
|
|
75
|
+
|
|
76
|
+
### Reasoning models
|
|
77
|
+
|
|
78
|
+
A reasoning model streams its chain of thought before any answer token, and keeps it in a field
|
|
79
|
+
separate from the answer. `content` is therefore empty until it stops thinking — with a small
|
|
80
|
+
`max_tokens` it can stay empty, and `finish_reason` will be `"length"`. Both are exposed, and
|
|
81
|
+
neither is inferred from the other:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
if completion.content:
|
|
85
|
+
print(completion.content)
|
|
86
|
+
elif completion.reasoning:
|
|
87
|
+
print(f"still thinking: {completion.reasoning}") # raise max_tokens
|
|
88
|
+
|
|
89
|
+
# Streaming: a chunk carries one or the other, so progress can show which phase it is in.
|
|
90
|
+
for chunk in stream:
|
|
91
|
+
print(chunk.reasoning or chunk.content or "", end="")
|
|
92
|
+
print(stream.reasoning, stream.content)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Runnable examples are in [`examples/`](examples/).
|
|
96
|
+
|
|
97
|
+
## What the client does for you
|
|
98
|
+
|
|
99
|
+
**Authentication.** Tokens are obtained with OAuth2 `client_credentials` and refreshed *ahead* of
|
|
100
|
+
expiry, so a request never fails just to discover its token died. The lifetime comes from the
|
|
101
|
+
server — its `Date` header and the token's `exp` claim are both server-side readings, so no clock
|
|
102
|
+
skew between your machine and the platform can shorten or extend it. Concurrent callers share one
|
|
103
|
+
refresh rather than each triggering their own.
|
|
104
|
+
|
|
105
|
+
**Typed errors.** Every gateway error maps to its own exception class, so you branch on the type
|
|
106
|
+
rather than string-matching a human-readable message. The token endpoint's errors are a separate
|
|
107
|
+
branch of the hierarchy, since their shape and meaning differ — `except APIError` will not swallow
|
|
108
|
+
an authentication failure.
|
|
109
|
+
|
|
110
|
+
**Retries that will not double-bill you.** This API has no idempotency mechanism: a retried
|
|
111
|
+
generation is a new billable one, not a replay. So retries happen only where the platform tells us
|
|
112
|
+
no generation occurred — a rate limit, or a circuit breaker that fast-failed without reaching a
|
|
113
|
+
model. A `502` may have reached one, so retrying it is opt-in. Client-side timeouts are never
|
|
114
|
+
retried, because the backend is probably still working. A server-supplied `Retry-After` is honored,
|
|
115
|
+
but capped: a wait longer than `max_backoff` is handed back to you rather than slept through.
|
|
116
|
+
|
|
117
|
+
**Request validation before the wire.** The gateway silently drops fields it does not accept, so
|
|
118
|
+
the SDK warns by name about every one it removes instead of letting you believe a parameter took
|
|
119
|
+
effect. Remote image URLs are rejected client-side, with the SSRF reason spelled out.
|
|
120
|
+
|
|
121
|
+
**Optional modality preflight.** The gateway's modality check is one-directional: calling
|
|
122
|
+
`/v1/embeddings` with a text model is rejected, but calling `/v1/chat/completions` with an
|
|
123
|
+
*embedding* model is not — it returns `200` with degenerate output that you pay for. Enable the
|
|
124
|
+
check to catch that, and typos, before the request is sent:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
client = Axonium(verify_modality=True) # or AXONIUM_VERIFY_MODALITY=true
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Off by default because it costs one catalog request per client, and the SDK otherwise makes no
|
|
131
|
+
request you did not ask for. If you already call `models.list()`, the result is reused and the
|
|
132
|
+
check is free. If the catalog cannot be loaded the check is skipped rather than failing your
|
|
133
|
+
request — a guard rail should not become a new way for inference to break.
|
|
134
|
+
|
|
135
|
+
## Credential modes
|
|
136
|
+
|
|
137
|
+
Two modes, permanent and mutually exclusive. Which one you use follows from how the SDK is
|
|
138
|
+
embedded, not from preference.
|
|
139
|
+
|
|
140
|
+
**Autonomous** — the SDK mints and refreshes its own tokens. For development, notebooks and tests,
|
|
141
|
+
where there is no host to ask.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
client = Axonium(client_id=..., client_secret=...) # or from the environment
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Governed** — a host that already owns the credential supplies tokens, and the SDK never holds a
|
|
148
|
+
secret. The provider is the sole authority: it owns caching, refresh and rotation, and Axonium does
|
|
149
|
+
no refresh-ahead of its own, because two caches for one token is how a client ends up sending a
|
|
150
|
+
token its owner already retired.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
def tokens(
|
|
154
|
+
rejected: str | None,
|
|
155
|
+
) -> str: ... # `rejected` is the token the gateway just refused, or None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
client = Axonium(token_provider=tokens)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**The rejected token comes back, not a flag.** With a boolean a provider cannot tell whether two
|
|
162
|
+
concurrent refreshes concern the same dead token or different ones, so it must mint twice or guess
|
|
163
|
+
with a time window. Given the token, the answer is exact — if what it holds already differs, it
|
|
164
|
+
refreshed already.
|
|
165
|
+
|
|
166
|
+
`AsyncAxonium` requires an async provider: a blocking token fetch would stall the event loop, so
|
|
167
|
+
the mismatch is reported rather than tolerated.
|
|
168
|
+
|
|
169
|
+
Asking for both modes by name is refused. Credentials that merely happen to be in the environment
|
|
170
|
+
are not — the explicit provider wins, and the credentials are **discarded** with a warning, so the
|
|
171
|
+
secret really does leave the process rather than sitting unused.
|
|
172
|
+
|
|
173
|
+
## Configuration
|
|
174
|
+
|
|
175
|
+
The SDK never hardcodes a host, port, or certificate — every deployment supplies its own. Settings
|
|
176
|
+
resolve in this order, first match wins:
|
|
177
|
+
|
|
178
|
+
1. Per-call argument
|
|
179
|
+
2. Constructor argument
|
|
180
|
+
3. Environment variable
|
|
181
|
+
4. `ConfigurationError` naming the missing setting and its environment variable
|
|
182
|
+
|
|
183
|
+
| Environment variable | Purpose |
|
|
184
|
+
|---|---|
|
|
185
|
+
| `AXONIUM_AUTH_BASE_URL` | auth-service base URL (OAuth2 token endpoint) |
|
|
186
|
+
| `AXONIUM_GATEWAY_BASE_URL` | gateway base URL (`/v1/` inference API) |
|
|
187
|
+
| `AXONIUM_CLIENT_ID` | OAuth2 client ID issued by the platform operator |
|
|
188
|
+
| `AXONIUM_CLIENT_SECRET` | OAuth2 client secret |
|
|
189
|
+
| `AXONIUM_SCOPE` | Optional space-separated scope request |
|
|
190
|
+
| `AXONIUM_CA_BUNDLE` | Path to a CA bundle, for deployments using a self-signed certificate |
|
|
191
|
+
|
|
192
|
+
The SDK does not read `.env` files. Loading them is the application's responsibility (for example
|
|
193
|
+
with `python-dotenv`), and because settings resolve when the client is constructed rather than at
|
|
194
|
+
import time, load order does not silently change behavior.
|
|
195
|
+
|
|
196
|
+
## Observability
|
|
197
|
+
|
|
198
|
+
The platform owns tracing. This SDK provides only the complementary piece: enough for your traces
|
|
199
|
+
and logs to line up with the platform's, and nothing that duplicates what it already records.
|
|
200
|
+
|
|
201
|
+
**Correlation IDs on every response.** `X-Request-ID` and `X-Trace-ID` are parsed onto
|
|
202
|
+
`response.meta` for successes as well as failures, and onto every raised error. Correlating a slow
|
|
203
|
+
but successful call matters as much as correlating a failed one.
|
|
204
|
+
|
|
205
|
+
**Rate-limit visibility.** `client.last_rate_limit` and `response.meta.rate_limit` expose the
|
|
206
|
+
`X-RateLimit-*` budget from every response that carries one, so you can slow down before a `429`
|
|
207
|
+
rather than only reacting to one. The token figures are the gateway's post-hoc accounting — a
|
|
208
|
+
strong signal, not a guarantee.
|
|
209
|
+
|
|
210
|
+
**Structured logging.** The SDK logs under the `axonium` logger with a `NullHandler` attached, so
|
|
211
|
+
it stays silent until your application configures logging. Records carry `request_id`, `trace_id`,
|
|
212
|
+
`model`, `status`, `duration_ms` and `attempt`.
|
|
213
|
+
|
|
214
|
+
Prompts, completions and credentials are **never** logged, and there is no option to enable it.
|
|
215
|
+
Anything you need to log about the content, you already have at the call site.
|
|
216
|
+
|
|
217
|
+
**OpenTelemetry spans** are available behind an extra and off by default:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
client = Axonium(otel_enabled=True) # or AXONIUM_OTEL_ENABLED=true
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Spans only — exporters and providers are your application's to configure. Trace context is not
|
|
224
|
+
propagated outbound, because the gateway never reads `traceparent` in any mode; correlation runs
|
|
225
|
+
inbound instead, via the IDs above.
|
|
226
|
+
|
|
227
|
+
**Scope diagnostics.** A `403` is matched against the scopes your token was actually granted, so
|
|
228
|
+
the error says what is missing rather than just that access was refused:
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
Forbidden Scope check: the token holds inference:read but not inference:stream;
|
|
232
|
+
streaming needs its own scope and holding one does not grant the other. request_id=...
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Development
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
cd python
|
|
239
|
+
uv sync
|
|
240
|
+
uv run pytest
|
|
241
|
+
uv run ruff check
|
|
242
|
+
uv run mypy src/
|
|
243
|
+
```
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# axonium (Python)
|
|
2
|
+
|
|
3
|
+
Python SDK for the Prometheus Gateway inference API.
|
|
4
|
+
|
|
5
|
+
> **Status: release candidate.** The client is feature-complete against the current gateway
|
|
6
|
+
> contract. Not yet published to PyPI.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install axonium
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
With OpenTelemetry span support:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install "axonium[otel]"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires Python 3.10+.
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from axonium import Axonium
|
|
26
|
+
|
|
27
|
+
with Axonium() as client:
|
|
28
|
+
print(client.models.mine().ids) # what this token can actually call
|
|
29
|
+
|
|
30
|
+
completion = client.chat.completions.create(
|
|
31
|
+
model="llama3-8b-q4",
|
|
32
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
33
|
+
)
|
|
34
|
+
print(completion.content)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Streaming is a separate method, because it needs a different scope and is never retried
|
|
38
|
+
automatically:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
with client.chat.completions.stream(model="llama3-8b-q4", messages=messages) as stream:
|
|
42
|
+
for chunk in stream:
|
|
43
|
+
print(chunk.content or "", end="", flush=True)
|
|
44
|
+
print(stream.usage())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`AsyncAxonium` mirrors the whole surface — same names, same behavior, with `await`, `async with`
|
|
48
|
+
and `async for`.
|
|
49
|
+
|
|
50
|
+
### Reasoning models
|
|
51
|
+
|
|
52
|
+
A reasoning model streams its chain of thought before any answer token, and keeps it in a field
|
|
53
|
+
separate from the answer. `content` is therefore empty until it stops thinking — with a small
|
|
54
|
+
`max_tokens` it can stay empty, and `finish_reason` will be `"length"`. Both are exposed, and
|
|
55
|
+
neither is inferred from the other:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
if completion.content:
|
|
59
|
+
print(completion.content)
|
|
60
|
+
elif completion.reasoning:
|
|
61
|
+
print(f"still thinking: {completion.reasoning}") # raise max_tokens
|
|
62
|
+
|
|
63
|
+
# Streaming: a chunk carries one or the other, so progress can show which phase it is in.
|
|
64
|
+
for chunk in stream:
|
|
65
|
+
print(chunk.reasoning or chunk.content or "", end="")
|
|
66
|
+
print(stream.reasoning, stream.content)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Runnable examples are in [`examples/`](examples/).
|
|
70
|
+
|
|
71
|
+
## What the client does for you
|
|
72
|
+
|
|
73
|
+
**Authentication.** Tokens are obtained with OAuth2 `client_credentials` and refreshed *ahead* of
|
|
74
|
+
expiry, so a request never fails just to discover its token died. The lifetime comes from the
|
|
75
|
+
server — its `Date` header and the token's `exp` claim are both server-side readings, so no clock
|
|
76
|
+
skew between your machine and the platform can shorten or extend it. Concurrent callers share one
|
|
77
|
+
refresh rather than each triggering their own.
|
|
78
|
+
|
|
79
|
+
**Typed errors.** Every gateway error maps to its own exception class, so you branch on the type
|
|
80
|
+
rather than string-matching a human-readable message. The token endpoint's errors are a separate
|
|
81
|
+
branch of the hierarchy, since their shape and meaning differ — `except APIError` will not swallow
|
|
82
|
+
an authentication failure.
|
|
83
|
+
|
|
84
|
+
**Retries that will not double-bill you.** This API has no idempotency mechanism: a retried
|
|
85
|
+
generation is a new billable one, not a replay. So retries happen only where the platform tells us
|
|
86
|
+
no generation occurred — a rate limit, or a circuit breaker that fast-failed without reaching a
|
|
87
|
+
model. A `502` may have reached one, so retrying it is opt-in. Client-side timeouts are never
|
|
88
|
+
retried, because the backend is probably still working. A server-supplied `Retry-After` is honored,
|
|
89
|
+
but capped: a wait longer than `max_backoff` is handed back to you rather than slept through.
|
|
90
|
+
|
|
91
|
+
**Request validation before the wire.** The gateway silently drops fields it does not accept, so
|
|
92
|
+
the SDK warns by name about every one it removes instead of letting you believe a parameter took
|
|
93
|
+
effect. Remote image URLs are rejected client-side, with the SSRF reason spelled out.
|
|
94
|
+
|
|
95
|
+
**Optional modality preflight.** The gateway's modality check is one-directional: calling
|
|
96
|
+
`/v1/embeddings` with a text model is rejected, but calling `/v1/chat/completions` with an
|
|
97
|
+
*embedding* model is not — it returns `200` with degenerate output that you pay for. Enable the
|
|
98
|
+
check to catch that, and typos, before the request is sent:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
client = Axonium(verify_modality=True) # or AXONIUM_VERIFY_MODALITY=true
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Off by default because it costs one catalog request per client, and the SDK otherwise makes no
|
|
105
|
+
request you did not ask for. If you already call `models.list()`, the result is reused and the
|
|
106
|
+
check is free. If the catalog cannot be loaded the check is skipped rather than failing your
|
|
107
|
+
request — a guard rail should not become a new way for inference to break.
|
|
108
|
+
|
|
109
|
+
## Credential modes
|
|
110
|
+
|
|
111
|
+
Two modes, permanent and mutually exclusive. Which one you use follows from how the SDK is
|
|
112
|
+
embedded, not from preference.
|
|
113
|
+
|
|
114
|
+
**Autonomous** — the SDK mints and refreshes its own tokens. For development, notebooks and tests,
|
|
115
|
+
where there is no host to ask.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
client = Axonium(client_id=..., client_secret=...) # or from the environment
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Governed** — a host that already owns the credential supplies tokens, and the SDK never holds a
|
|
122
|
+
secret. The provider is the sole authority: it owns caching, refresh and rotation, and Axonium does
|
|
123
|
+
no refresh-ahead of its own, because two caches for one token is how a client ends up sending a
|
|
124
|
+
token its owner already retired.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
def tokens(
|
|
128
|
+
rejected: str | None,
|
|
129
|
+
) -> str: ... # `rejected` is the token the gateway just refused, or None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
client = Axonium(token_provider=tokens)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**The rejected token comes back, not a flag.** With a boolean a provider cannot tell whether two
|
|
136
|
+
concurrent refreshes concern the same dead token or different ones, so it must mint twice or guess
|
|
137
|
+
with a time window. Given the token, the answer is exact — if what it holds already differs, it
|
|
138
|
+
refreshed already.
|
|
139
|
+
|
|
140
|
+
`AsyncAxonium` requires an async provider: a blocking token fetch would stall the event loop, so
|
|
141
|
+
the mismatch is reported rather than tolerated.
|
|
142
|
+
|
|
143
|
+
Asking for both modes by name is refused. Credentials that merely happen to be in the environment
|
|
144
|
+
are not — the explicit provider wins, and the credentials are **discarded** with a warning, so the
|
|
145
|
+
secret really does leave the process rather than sitting unused.
|
|
146
|
+
|
|
147
|
+
## Configuration
|
|
148
|
+
|
|
149
|
+
The SDK never hardcodes a host, port, or certificate — every deployment supplies its own. Settings
|
|
150
|
+
resolve in this order, first match wins:
|
|
151
|
+
|
|
152
|
+
1. Per-call argument
|
|
153
|
+
2. Constructor argument
|
|
154
|
+
3. Environment variable
|
|
155
|
+
4. `ConfigurationError` naming the missing setting and its environment variable
|
|
156
|
+
|
|
157
|
+
| Environment variable | Purpose |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `AXONIUM_AUTH_BASE_URL` | auth-service base URL (OAuth2 token endpoint) |
|
|
160
|
+
| `AXONIUM_GATEWAY_BASE_URL` | gateway base URL (`/v1/` inference API) |
|
|
161
|
+
| `AXONIUM_CLIENT_ID` | OAuth2 client ID issued by the platform operator |
|
|
162
|
+
| `AXONIUM_CLIENT_SECRET` | OAuth2 client secret |
|
|
163
|
+
| `AXONIUM_SCOPE` | Optional space-separated scope request |
|
|
164
|
+
| `AXONIUM_CA_BUNDLE` | Path to a CA bundle, for deployments using a self-signed certificate |
|
|
165
|
+
|
|
166
|
+
The SDK does not read `.env` files. Loading them is the application's responsibility (for example
|
|
167
|
+
with `python-dotenv`), and because settings resolve when the client is constructed rather than at
|
|
168
|
+
import time, load order does not silently change behavior.
|
|
169
|
+
|
|
170
|
+
## Observability
|
|
171
|
+
|
|
172
|
+
The platform owns tracing. This SDK provides only the complementary piece: enough for your traces
|
|
173
|
+
and logs to line up with the platform's, and nothing that duplicates what it already records.
|
|
174
|
+
|
|
175
|
+
**Correlation IDs on every response.** `X-Request-ID` and `X-Trace-ID` are parsed onto
|
|
176
|
+
`response.meta` for successes as well as failures, and onto every raised error. Correlating a slow
|
|
177
|
+
but successful call matters as much as correlating a failed one.
|
|
178
|
+
|
|
179
|
+
**Rate-limit visibility.** `client.last_rate_limit` and `response.meta.rate_limit` expose the
|
|
180
|
+
`X-RateLimit-*` budget from every response that carries one, so you can slow down before a `429`
|
|
181
|
+
rather than only reacting to one. The token figures are the gateway's post-hoc accounting — a
|
|
182
|
+
strong signal, not a guarantee.
|
|
183
|
+
|
|
184
|
+
**Structured logging.** The SDK logs under the `axonium` logger with a `NullHandler` attached, so
|
|
185
|
+
it stays silent until your application configures logging. Records carry `request_id`, `trace_id`,
|
|
186
|
+
`model`, `status`, `duration_ms` and `attempt`.
|
|
187
|
+
|
|
188
|
+
Prompts, completions and credentials are **never** logged, and there is no option to enable it.
|
|
189
|
+
Anything you need to log about the content, you already have at the call site.
|
|
190
|
+
|
|
191
|
+
**OpenTelemetry spans** are available behind an extra and off by default:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
client = Axonium(otel_enabled=True) # or AXONIUM_OTEL_ENABLED=true
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Spans only — exporters and providers are your application's to configure. Trace context is not
|
|
198
|
+
propagated outbound, because the gateway never reads `traceparent` in any mode; correlation runs
|
|
199
|
+
inbound instead, via the IDs above.
|
|
200
|
+
|
|
201
|
+
**Scope diagnostics.** A `403` is matched against the scopes your token was actually granted, so
|
|
202
|
+
the error says what is missing rather than just that access was refused:
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
Forbidden Scope check: the token holds inference:read but not inference:stream;
|
|
206
|
+
streaming needs its own scope and holding one does not grant the other. request_id=...
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Development
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
cd python
|
|
213
|
+
uv sync
|
|
214
|
+
uv run pytest
|
|
215
|
+
uv run ruff check
|
|
216
|
+
uv run mypy src/
|
|
217
|
+
```
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Concurrent requests, and what the typed errors let you do about failures.
|
|
2
|
+
|
|
3
|
+
python examples/concurrent_and_errors.py MODEL_ID
|
|
4
|
+
|
|
5
|
+
One client is shared across all the tasks: the token is fetched once and refreshed once no matter
|
|
6
|
+
how many requests are in flight, and connections are pooled.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from axonium import (
|
|
15
|
+
AsyncAxonium,
|
|
16
|
+
BackendUnavailableError,
|
|
17
|
+
ConfigurationError,
|
|
18
|
+
ContextExceededError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
RetryPolicy,
|
|
21
|
+
SpendCapExceededError,
|
|
22
|
+
TimeoutError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
QUESTIONS = [
|
|
26
|
+
"What is the capital of France?",
|
|
27
|
+
"What is the capital of Japan?",
|
|
28
|
+
"What is the capital of Peru?",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def ask(client: AsyncAxonium, model: str, question: str) -> str:
|
|
33
|
+
try:
|
|
34
|
+
completion = await client.chat.completions.create(
|
|
35
|
+
model=model,
|
|
36
|
+
messages=[{"role": "user", "content": question}],
|
|
37
|
+
# Generous on purpose: a reasoning model spends its budget thinking before it emits
|
|
38
|
+
# any content, so a tight limit yields an empty string rather than a short answer.
|
|
39
|
+
max_tokens=512,
|
|
40
|
+
)
|
|
41
|
+
except RateLimitError as exc:
|
|
42
|
+
# Already retried according to the policy; reaching here means the budget is still spent.
|
|
43
|
+
return f"rate limited, retry in {exc.retry_after}s"
|
|
44
|
+
except BackendUnavailableError as exc:
|
|
45
|
+
# With retry_after set the gateway's circuit breaker is open and that value is its real
|
|
46
|
+
# expected recovery time. Without it, the backend was simply unreachable.
|
|
47
|
+
return f"backend unavailable{f', retry in {exc.retry_after}s' if exc.retry_after else ''}"
|
|
48
|
+
except SpendCapExceededError:
|
|
49
|
+
# Distinct from a rate limit on purpose: waiting will not fix this one.
|
|
50
|
+
return "spend cap reached; the cap has to be raised"
|
|
51
|
+
except ContextExceededError:
|
|
52
|
+
return "prompt is too long for this model's context window"
|
|
53
|
+
except TimeoutError:
|
|
54
|
+
# Deliberately not retried: the backend may still be generating, and a retry would start
|
|
55
|
+
# a second billable generation rather than resuming the first.
|
|
56
|
+
return "timed out; the backend may still be working"
|
|
57
|
+
|
|
58
|
+
return completion.content or "(no content)"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def main(model: str) -> None:
|
|
62
|
+
try:
|
|
63
|
+
# Retrying a generation is never free, so the policy is explicit rather than implied.
|
|
64
|
+
client = AsyncAxonium(retry=RetryPolicy(max_attempts=3))
|
|
65
|
+
except ConfigurationError as exc:
|
|
66
|
+
print(f"Not configured: {exc}")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
async with client:
|
|
70
|
+
answers = await asyncio.gather(*(ask(client, model, q) for q in QUESTIONS))
|
|
71
|
+
|
|
72
|
+
for question, answer in zip(QUESTIONS, answers, strict=True):
|
|
73
|
+
print(f"{question}\n -> {answer}\n")
|
|
74
|
+
|
|
75
|
+
if client.last_rate_limit:
|
|
76
|
+
print(f"Requests left this window: {client.last_rate_limit.remaining_requests}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
if len(sys.argv) != 2:
|
|
81
|
+
print(__doc__)
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
asyncio.run(main(sys.argv[1]))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Basic usage: discover what a token can call, then call it.
|
|
2
|
+
|
|
3
|
+
python examples/quickstart.py
|
|
4
|
+
|
|
5
|
+
Reads configuration from the environment. If your credentials live in a .env file, load it before
|
|
6
|
+
constructing the client — the SDK does not read .env itself, and deliberately resolves settings
|
|
7
|
+
when the client is built rather than at import time, so load order cannot silently change what you
|
|
8
|
+
get.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from axonium import Axonium, ConfigurationError, ForbiddenError, UnknownModelError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
try:
|
|
18
|
+
client = Axonium()
|
|
19
|
+
except ConfigurationError as exc:
|
|
20
|
+
# Names the missing setting and the environment variable that supplies it.
|
|
21
|
+
print(f"Not configured: {exc}")
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
with client:
|
|
25
|
+
# Public: no token is fetched for this call.
|
|
26
|
+
catalog = client.models.list()
|
|
27
|
+
print(f"Deployed models: {', '.join(catalog.ids) or '(none)'}")
|
|
28
|
+
|
|
29
|
+
# Filtered to what this token actually holds a model:<id> grant for. Worth doing once at
|
|
30
|
+
# startup: it turns "why is everything 403" into a list you can read.
|
|
31
|
+
mine = client.models.mine()
|
|
32
|
+
print(f"Callable with this token: {', '.join(mine.ids) or '(none)'}")
|
|
33
|
+
|
|
34
|
+
if not mine.ids:
|
|
35
|
+
print("This token has no model grants; ask the operator to add model:<id> scopes.")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
text_models = [m.id for m in mine if m.modality == "text"]
|
|
39
|
+
if not text_models:
|
|
40
|
+
print("This token has no text model to chat with.")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
model = text_models[0]
|
|
44
|
+
try:
|
|
45
|
+
completion = client.chat.completions.create(
|
|
46
|
+
model=model,
|
|
47
|
+
messages=[{"role": "user", "content": "Reply with exactly: pong"}],
|
|
48
|
+
# Generous on purpose. A reasoning model spends its budget thinking before it
|
|
49
|
+
# emits any content, so a small limit returns an empty string and
|
|
50
|
+
# finish_reason="length" rather than a short answer.
|
|
51
|
+
max_tokens=512,
|
|
52
|
+
)
|
|
53
|
+
except UnknownModelError:
|
|
54
|
+
print(f"{model} is not registered on this gateway.")
|
|
55
|
+
return
|
|
56
|
+
except ForbiddenError as exc:
|
|
57
|
+
# The SDK checks the denial against the scopes the token was granted and says what
|
|
58
|
+
# is missing, rather than leaving you to guess.
|
|
59
|
+
print(f"Denied: {exc}")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
if completion.content:
|
|
63
|
+
print(f"\n{model} says: {completion.content}")
|
|
64
|
+
else:
|
|
65
|
+
reason = completion.choices[0].finish_reason if completion.choices else None
|
|
66
|
+
print(f"\n{model} returned no content (finish_reason={reason!r}).")
|
|
67
|
+
if completion.reasoning:
|
|
68
|
+
print(f"It was still reasoning: {completion.reasoning[:120]}...")
|
|
69
|
+
print("Raise max_tokens to give it room to finish.")
|
|
70
|
+
|
|
71
|
+
if completion.usage:
|
|
72
|
+
print(f"Tokens: {completion.usage.total_tokens}")
|
|
73
|
+
if completion.meta:
|
|
74
|
+
print(f"Request ID: {completion.meta.request_id}")
|
|
75
|
+
if client.last_rate_limit:
|
|
76
|
+
print(f"Requests left this window: {client.last_rate_limit.remaining_requests}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
main()
|