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.
Files changed (59) hide show
  1. axonium-1.0.0rc1/.gitignore +22 -0
  2. axonium-1.0.0rc1/LICENSE +27 -0
  3. axonium-1.0.0rc1/PKG-INFO +243 -0
  4. axonium-1.0.0rc1/README.md +217 -0
  5. axonium-1.0.0rc1/examples/concurrent_and_errors.py +83 -0
  6. axonium-1.0.0rc1/examples/quickstart.py +80 -0
  7. axonium-1.0.0rc1/examples/streaming.py +66 -0
  8. axonium-1.0.0rc1/pyproject.toml +84 -0
  9. axonium-1.0.0rc1/src/axonium/__init__.py +142 -0
  10. axonium-1.0.0rc1/src/axonium/_version.py +8 -0
  11. axonium-1.0.0rc1/src/axonium/auth.py +386 -0
  12. axonium-1.0.0rc1/src/axonium/client.py +536 -0
  13. axonium-1.0.0rc1/src/axonium/config.py +146 -0
  14. axonium-1.0.0rc1/src/axonium/errors.py +555 -0
  15. axonium-1.0.0rc1/src/axonium/models/__init__.py +0 -0
  16. axonium-1.0.0rc1/src/axonium/models/catalog.py +53 -0
  17. axonium-1.0.0rc1/src/axonium/models/chat.py +166 -0
  18. axonium-1.0.0rc1/src/axonium/models/common.py +151 -0
  19. axonium-1.0.0rc1/src/axonium/models/inference.py +74 -0
  20. axonium-1.0.0rc1/src/axonium/models/requests.py +191 -0
  21. axonium-1.0.0rc1/src/axonium/observability/__init__.py +0 -0
  22. axonium-1.0.0rc1/src/axonium/observability/logging.py +51 -0
  23. axonium-1.0.0rc1/src/axonium/observability/otel.py +82 -0
  24. axonium-1.0.0rc1/src/axonium/observability/scopes.py +50 -0
  25. axonium-1.0.0rc1/src/axonium/preflight.py +69 -0
  26. axonium-1.0.0rc1/src/axonium/providers.py +152 -0
  27. axonium-1.0.0rc1/src/axonium/py.typed +0 -0
  28. axonium-1.0.0rc1/src/axonium/resources/__init__.py +0 -0
  29. axonium-1.0.0rc1/src/axonium/resources/chat.py +99 -0
  30. axonium-1.0.0rc1/src/axonium/resources/embeddings.py +50 -0
  31. axonium-1.0.0rc1/src/axonium/resources/images.py +51 -0
  32. axonium-1.0.0rc1/src/axonium/resources/models.py +54 -0
  33. axonium-1.0.0rc1/src/axonium/streaming.py +184 -0
  34. axonium-1.0.0rc1/src/axonium/transport/__init__.py +0 -0
  35. axonium-1.0.0rc1/src/axonium/transport/dispatch.py +136 -0
  36. axonium-1.0.0rc1/src/axonium/transport/http.py +94 -0
  37. axonium-1.0.0rc1/src/axonium/transport/retry.py +135 -0
  38. axonium-1.0.0rc1/src/axonium/transport/sse.py +169 -0
  39. axonium-1.0.0rc1/tests/conftest.py +48 -0
  40. axonium-1.0.0rc1/tests/contract/test_manifest.py +247 -0
  41. axonium-1.0.0rc1/tests/integration/test_platform_security.py +328 -0
  42. axonium-1.0.0rc1/tests/security/test_credential_safety.py +284 -0
  43. axonium-1.0.0rc1/tests/security/test_hostile_responses.py +444 -0
  44. axonium-1.0.0rc1/tests/test_auth.py +622 -0
  45. axonium-1.0.0rc1/tests/test_config.py +155 -0
  46. axonium-1.0.0rc1/tests/test_credential_modes.py +378 -0
  47. axonium-1.0.0rc1/tests/test_dispatch.py +51 -0
  48. axonium-1.0.0rc1/tests/test_errors.py +217 -0
  49. axonium-1.0.0rc1/tests/test_inference.py +473 -0
  50. axonium-1.0.0rc1/tests/test_models_common.py +105 -0
  51. axonium-1.0.0rc1/tests/test_models_resource.py +334 -0
  52. axonium-1.0.0rc1/tests/test_observability.py +322 -0
  53. axonium-1.0.0rc1/tests/test_preflight.py +343 -0
  54. axonium-1.0.0rc1/tests/test_reasoning.py +265 -0
  55. axonium-1.0.0rc1/tests/test_requests.py +167 -0
  56. axonium-1.0.0rc1/tests/test_retry.py +159 -0
  57. axonium-1.0.0rc1/tests/test_streaming.py +508 -0
  58. axonium-1.0.0rc1/tests/test_transport_http.py +85 -0
  59. 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
@@ -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()