vin-guard-sdk 0.3.0__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.
- vin_guard_sdk-0.3.0/MANIFEST.in +3 -0
- vin_guard_sdk-0.3.0/PKG-INFO +268 -0
- vin_guard_sdk-0.3.0/README.md +256 -0
- vin_guard_sdk-0.3.0/RESEARCH.md +95 -0
- vin_guard_sdk-0.3.0/examples/async_chat.py +22 -0
- vin_guard_sdk-0.3.0/examples/basic_chat.py +23 -0
- vin_guard_sdk-0.3.0/examples/dashboard_reads.py +20 -0
- vin_guard_sdk-0.3.0/examples/langchain_async.py +21 -0
- vin_guard_sdk-0.3.0/examples/langchain_chat.py +25 -0
- vin_guard_sdk-0.3.0/examples/openai_compat.py +21 -0
- vin_guard_sdk-0.3.0/pyproject.toml +27 -0
- vin_guard_sdk-0.3.0/setup.cfg +4 -0
- vin_guard_sdk-0.3.0/src/ai_gateway/__init__.py +23 -0
- vin_guard_sdk-0.3.0/src/ai_gateway/_shared.py +116 -0
- vin_guard_sdk-0.3.0/src/ai_gateway/_sse.py +59 -0
- vin_guard_sdk-0.3.0/src/ai_gateway/async_client.py +99 -0
- vin_guard_sdk-0.3.0/src/ai_gateway/client.py +169 -0
- vin_guard_sdk-0.3.0/src/ai_gateway/control_plane.py +161 -0
- vin_guard_sdk-0.3.0/src/ai_gateway/errors.py +264 -0
- vin_guard_sdk-0.3.0/src/ai_gateway/langchain.py +210 -0
- vin_guard_sdk-0.3.0/src/ai_gateway/py.typed +0 -0
- vin_guard_sdk-0.3.0/src/ai_gateway/types.py +19 -0
- vin_guard_sdk-0.3.0/src/vin_guard_sdk.egg-info/PKG-INFO +268 -0
- vin_guard_sdk-0.3.0/src/vin_guard_sdk.egg-info/SOURCES.txt +33 -0
- vin_guard_sdk-0.3.0/src/vin_guard_sdk.egg-info/dependency_links.txt +1 -0
- vin_guard_sdk-0.3.0/src/vin_guard_sdk.egg-info/requires.txt +7 -0
- vin_guard_sdk-0.3.0/src/vin_guard_sdk.egg-info/top_level.txt +1 -0
- vin_guard_sdk-0.3.0/tests/test_chat.py +99 -0
- vin_guard_sdk-0.3.0/tests/test_error_contract.py +376 -0
- vin_guard_sdk-0.3.0/tests/test_errors.py +36 -0
- vin_guard_sdk-0.3.0/tests/test_expansion.py +226 -0
- vin_guard_sdk-0.3.0/tests/test_langchain.py +232 -0
- vin_guard_sdk-0.3.0/tests/test_models.py +51 -0
- vin_guard_sdk-0.3.0/tests/test_optional_dependency.py +27 -0
- vin_guard_sdk-0.3.0/tests/test_sse.py +20 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vin-guard-sdk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Sync and async AI Gateway clients with routing metadata and dashboard reads
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx<1,>=0.27
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
10
|
+
Provides-Extra: langchain
|
|
11
|
+
Requires-Dist: langchain-core<2,>=1; extra == "langchain"
|
|
12
|
+
|
|
13
|
+
# AI Gateway — Python SDK (Level 1)
|
|
14
|
+
|
|
15
|
+
Thin wrapper over the OpenAI-compatible data plane (`docs/API_DESIGN.md §3`,
|
|
16
|
+
`docs/ai-gateway-design.md §4.9`).
|
|
17
|
+
|
|
18
|
+
## Level 0 — use any OpenAI SDK as-is
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from openai import OpenAI
|
|
22
|
+
|
|
23
|
+
client = OpenAI(
|
|
24
|
+
api_key="gw_live_...",
|
|
25
|
+
base_url="https://gateway.example.com/v1",
|
|
26
|
+
)
|
|
27
|
+
resp = client.chat.completions.create(
|
|
28
|
+
model="auto", # or policy/<name> | <provider>/<model> | alias
|
|
29
|
+
messages=[{"role": "user", "content": "Summarize this ticket..."}],
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
See `examples/openai_compat.py`.
|
|
34
|
+
|
|
35
|
+
## Level 1 — this package
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from ai_gateway import GatewayClient
|
|
39
|
+
|
|
40
|
+
client = GatewayClient(
|
|
41
|
+
api_key="gw_live_...",
|
|
42
|
+
base_url="https://gateway.example.com/v1",
|
|
43
|
+
)
|
|
44
|
+
resp = client.chat_completions(
|
|
45
|
+
model="auto",
|
|
46
|
+
messages=[{"role": "user", "content": "hi"}],
|
|
47
|
+
tags={"team": "support", "env": "prod"},
|
|
48
|
+
metadata={"ticket": "t-123"},
|
|
49
|
+
)
|
|
50
|
+
print(resp.content)
|
|
51
|
+
print(resp.gateway.request_id, resp.gateway.tier)
|
|
52
|
+
print(resp.headers.get("x-gateway-provider"))
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Request options map to `API_DESIGN §2.5` headers:
|
|
56
|
+
|
|
57
|
+
| Arg | Header |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `tags: dict \| str` | `X-Gateway-Tags: team=support,env=prod` |
|
|
60
|
+
| `metadata: dict` | `X-Gateway-Metadata: {...}` (≤4KB, opaque JSON) |
|
|
61
|
+
| `route_policy: str` | `X-Gateway-Route-Policy` |
|
|
62
|
+
| `no_store: bool` | `X-Gateway-No-Store: true` |
|
|
63
|
+
| `idempotency_key: str` | `Idempotency-Key` (non-streaming retries) |
|
|
64
|
+
|
|
65
|
+
Errors keep the fields `GatewayError` always had (`.message`, `.code`, `.type`,
|
|
66
|
+
`.param`, `.status`, `.request_id`, `.retry_after`) and understand both the
|
|
67
|
+
OpenAI envelope and RFC 7807 problem details (`detail`/`title`/`type`,
|
|
68
|
+
`errors`, extensions). Header lookups are case-insensitive and the HTTP status
|
|
69
|
+
is authoritative. Request ID priority is `X-Gateway-Request-Id` →
|
|
70
|
+
`X-Request-Id` → `traceId`/`requestId`/`request_id` in the body. The parsed body
|
|
71
|
+
stays on `err.payload` (plus `err.errors`/`err.extensions`) so callers can
|
|
72
|
+
inspect it; it may contain sensitive server data, so the SDK never logs it.
|
|
73
|
+
|
|
74
|
+
Failures before a response arrives raise `GatewayTransportError` subclasses:
|
|
75
|
+
`GatewayConnectionError` (connection) and `GatewayTimeoutError` (timeout with a
|
|
76
|
+
`phase` of `connect`/`read`/`write`/`pool`). Their `status` is `0` and the
|
|
77
|
+
original HTTPX exception is on `__cause__`/`.cause`. Migration note: code that
|
|
78
|
+
caught `httpx.TransportError`/`httpx.TimeoutException` should catch
|
|
79
|
+
`GatewayTransportError` (or the narrower classes) instead.
|
|
80
|
+
|
|
81
|
+
No automatic retries are performed; the current Java hot path does not enforce
|
|
82
|
+
the documented idempotency header. Retrying a chat POST may duplicate billing.
|
|
83
|
+
|
|
84
|
+
## Install and verify
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
python -m pip install -e '.[dev]'
|
|
88
|
+
python -m pytest -q
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The default run skips `tests/integration` (real-stack smoke). CI has separate
|
|
92
|
+
`sdk-core` (no LangChain), `sdk-langchain` (extra required) and `sdk-package`
|
|
93
|
+
(wheel/sdist installed into a clean venv outside the source tree) jobs, across
|
|
94
|
+
Python 3.10/3.12 and HTTPX 0.27.0/0.28.1, with `langchain-core` 1.0.0/1.6.7.
|
|
95
|
+
The adapter tests import LangChain directly, so a missing dependency fails
|
|
96
|
+
collection instead of skipping green. Publishing a GitHub Release runs
|
|
97
|
+
`.github/workflows/publish.yml`, which rebuilds the same artifacts and uploads
|
|
98
|
+
them to PyPI with Trusted Publishing (no stored token).
|
|
99
|
+
|
|
100
|
+
## Async data plane
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import asyncio
|
|
104
|
+
import os
|
|
105
|
+
from ai_gateway import AsyncGatewayClient
|
|
106
|
+
|
|
107
|
+
async def main():
|
|
108
|
+
async with AsyncGatewayClient(
|
|
109
|
+
api_key=os.environ["GATEWAY_API_KEY"],
|
|
110
|
+
base_url=os.getenv("GATEWAY_BASE_URL", "http://localhost:8080/v1"),
|
|
111
|
+
) as client:
|
|
112
|
+
response = await client.chat_completions(
|
|
113
|
+
model="auto", messages=[{"role": "user", "content": "Hello"}],
|
|
114
|
+
)
|
|
115
|
+
print(response.content, response.gateway.request_id)
|
|
116
|
+
|
|
117
|
+
asyncio.run(main())
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`AsyncGatewayClient` mirrors chat, models, health, ready, streaming and text
|
|
121
|
+
collection. Use `aclose()` or `async with`; the sync client uses `close()` or `with`.
|
|
122
|
+
Both accept an HTTPX timeout and transport for contract testing.
|
|
123
|
+
|
|
124
|
+
Health probes call `/healthz` and `/readyz` at the service root, without the API
|
|
125
|
+
key. A proxy mount prefix is retained (`/gateway/v1` → `/gateway/healthz`).
|
|
126
|
+
|
|
127
|
+
`temperature` and `max_tokens` work with the current Java DTO. `top_p`, `stop`,
|
|
128
|
+
`tools`, `tool_choice`, `response_format`, `user`, and `seed` are documented
|
|
129
|
+
pass-through options for future backends: the current backend ignores them.
|
|
130
|
+
Gateway headers are forwarded; setting tags/no-store/metadata does not by itself
|
|
131
|
+
implement server-side enforcement. Metadata is checked against the 4KB limit.
|
|
132
|
+
Tool-only completions with `content: null` expose an empty `.content` string and
|
|
133
|
+
preserve tool calls in `.choices` and `.raw`.
|
|
134
|
+
|
|
135
|
+
Streaming helpers parse SSE including multiline events and surface malformed
|
|
136
|
+
events and stream errors. The current backend returns JSON even with
|
|
137
|
+
`stream: true`; helpers raise `GatewayError(code="streaming_not_supported")`
|
|
138
|
+
instead of silently returning no text. They do not simulate token streaming.
|
|
139
|
+
|
|
140
|
+
## Dashboard reads (serverless control plane)
|
|
141
|
+
|
|
142
|
+
This is a separate service and uses an OIDC access token, not a `gw_live_` key.
|
|
143
|
+
These endpoints require the serverless control-plane deployment. Both SDK
|
|
144
|
+
branches now include its source after synchronization with `origin/main` on
|
|
145
|
+
2026-10-07; source presence does not mean the service is already deployed.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
import os
|
|
149
|
+
from ai_gateway import ControlPlaneClient
|
|
150
|
+
|
|
151
|
+
with ControlPlaneClient(
|
|
152
|
+
base_url=os.environ["CONTROL_PLANE_BASE_URL"], # includes /api/v1
|
|
153
|
+
access_token=os.environ["CONTROL_PLANE_ACCESS_TOKEN"],
|
|
154
|
+
) as dashboard:
|
|
155
|
+
print(dashboard.me())
|
|
156
|
+
for request in dashboard.iter_items(
|
|
157
|
+
dashboard.list_requests, "org_example", limit=100,
|
|
158
|
+
**{"from": "2026-10-01T00:00:00Z", "projectId": "prj_example"},
|
|
159
|
+
):
|
|
160
|
+
print(request["requestId"])
|
|
161
|
+
print(dashboard.get_usage("org_example", group_by="project").data)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
An access-token callable can supply a refreshed token on every request. Local
|
|
165
|
+
development explicitly uses `dev_subject="..."` instead of `access_token`.
|
|
166
|
+
The client does not authenticate users or refresh tokens itself.
|
|
167
|
+
|
|
168
|
+
Read helpers: `me`, `list_orgs`, `list_projects`, `list_api_keys`,
|
|
169
|
+
`list_providers`, `list_models`, `list_routing_policies`, `list_policy_versions`,
|
|
170
|
+
`get_usage`, `list_requests`, `get_request`, `get_routing_insights`,
|
|
171
|
+
`list_audit_logs`. List helpers return `Page(data, next_cursor, has_more)`;
|
|
172
|
+
`iter_items` traverses all pages and fails on missing/repeated cursors. Query
|
|
173
|
+
kwargs use wire names (`projectId`, `minLatencyMs`, `cursor`, `limit`, etc.).
|
|
174
|
+
Usage is daily only; hourly granularity is not implemented by the control plane.
|
|
175
|
+
Wire dictionaries preserve nullable and additional fields. Key reads return
|
|
176
|
+
metadata only; no SDK secret persistence or key mutations are added here.
|
|
177
|
+
|
|
178
|
+
Research and prioritized endpoint/function suggestions:
|
|
179
|
+
[RESEARCH.md](RESEARCH.md).
|
|
180
|
+
|
|
181
|
+
Vietnamese architecture guide with component flows, methods, compatibility,
|
|
182
|
+
examples and troubleshooting: [SDK guide](../docs/SDK_PYTHON_GUIDE.vi.md).
|
|
183
|
+
For a documentation-style reading view, open the [offline HTML guide](../docs/sdk-python-guide.html).
|
|
184
|
+
|
|
185
|
+
## LangChain integration
|
|
186
|
+
|
|
187
|
+
The `sdk-python` branch contains the core SDK plus an optional native
|
|
188
|
+
`ChatGateway` model.
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
python -m pip install -e '.[dev,langchain]'
|
|
192
|
+
python -m pytest -q
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
import os
|
|
197
|
+
from ai_gateway.langchain import ChatGateway
|
|
198
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
199
|
+
|
|
200
|
+
prompt = ChatPromptTemplate.from_messages([
|
|
201
|
+
("system", "Answer briefly."), ("human", "{question}"),
|
|
202
|
+
])
|
|
203
|
+
with ChatGateway(
|
|
204
|
+
api_key=os.environ["GATEWAY_API_KEY"],
|
|
205
|
+
base_url="http://localhost:8080/v1",
|
|
206
|
+
model="auto", max_tokens=200,
|
|
207
|
+
gateway_tags={"team": "support"},
|
|
208
|
+
gateway_metadata={"ticket": "t-123"},
|
|
209
|
+
) as model:
|
|
210
|
+
message = (prompt | model).invoke({"question": "How does routing work?"})
|
|
211
|
+
print(message.content)
|
|
212
|
+
print(message.response_metadata["_gateway"])
|
|
213
|
+
print(message.response_metadata["gateway_headers"])
|
|
214
|
+
print(message.usage_metadata)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`ChatGateway` supports `invoke`, native async `ainvoke`, `batch`/`abatch` and
|
|
218
|
+
prompt pipelines. `max_tokens` is sent unchanged to the Java DTO. Responses
|
|
219
|
+
preserve additional `_gateway` fields, headers, selected model, finish reason,
|
|
220
|
+
and LangChain token usage. `stream`/`astream` emit one **buffered full result**
|
|
221
|
+
via LangChain's fallback; they do not request server SSE or token streaming.
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
async with ChatGateway(api_key=os.environ["GATEWAY_API_KEY"],
|
|
225
|
+
base_url="http://localhost:8080/v1") as model:
|
|
226
|
+
messages = await model.abatch(["Explain routing", "Explain budgets"],
|
|
227
|
+
config={"max_concurrency": 2})
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Use `gateway_tags` and `gateway_metadata` for gateway headers; LangChain's
|
|
231
|
+
`config={"tags": [...], "metadata": {...}}` controls tracing only. Per-call
|
|
232
|
+
overrides work through `.bind(gateway_tags=..., route_policy=..., no_store=...)`
|
|
233
|
+
or invoke kwargs. Do not bind a fixed idempotency key to a reusable model.
|
|
234
|
+
|
|
235
|
+
Tools, stop sequences, structured output and other advanced chat options fail
|
|
236
|
+
clearly by default because the current backend ignores them. For a future
|
|
237
|
+
backend that implements them, `allow_advanced_options=True` enables forwarding,
|
|
238
|
+
`bind_tools`, tool-history conversion, and inherited `with_structured_output`.
|
|
239
|
+
This switch does not add server capabilities or execute tools.
|
|
240
|
+
|
|
241
|
+
Sync `with`/`close()` closes the owned sync client; async `with`/`aclose()` closes
|
|
242
|
+
both owned clients. If you use both sync and async methods on one instance,
|
|
243
|
+
finish with `await model.aclose()`. Injected `client`/`async_client` SDK clients
|
|
244
|
+
are caller-owned and remain open. API keys and clients are excluded from model
|
|
245
|
+
repr/dumps. LangChain caching is disabled by default; enable caching only with a
|
|
246
|
+
cache scoped to the same tenant/key and routing policy.
|
|
247
|
+
|
|
248
|
+
Only `from ai_gateway.langchain import ChatGateway` imports LangChain. Core
|
|
249
|
+
SDK users can install without the `langchain` extra. See
|
|
250
|
+
`examples/langchain_chat.py` and `examples/langchain_async.py`.
|
|
251
|
+
|
|
252
|
+
## End-to-end smoke (real stack)
|
|
253
|
+
|
|
254
|
+
`tests/integration/test_e2e_smoke.py` exercises the real
|
|
255
|
+
control plane → publisher → Valkey → Java gateway path with the backend's
|
|
256
|
+
`StubAdapter` as the only substituted LLM upstream. It creates an isolated
|
|
257
|
+
org/project/key over the control plane, publishes a snapshot, checks
|
|
258
|
+
`healthz`/`readyz`/models, chats sync and async (`openai/stub-sdk-smoke`),
|
|
259
|
+
reads project/key metadata, verifies tenant isolation, runs the LangChain
|
|
260
|
+
adapter, rejects a bad key, then revokes the key, republishes and expects
|
|
261
|
+
`401 key_revoked`.
|
|
262
|
+
|
|
263
|
+
The harness is opt-in: without `AIGW_SMOKE=1` the tests skip. With it set, a
|
|
264
|
+
missing config or unreachable service fails loudly. See
|
|
265
|
+
[tests/integration/README.md](tests/integration/README.md) for prerequisites
|
|
266
|
+
and the exact command. Authentication uses the local dev principal
|
|
267
|
+
(`x-dev-sub`/`x-dev-email`), which is not proof of Cognito JWT, and the smoke
|
|
268
|
+
does not assert trace/usage while the usage producer is not wired.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# AI Gateway — Python SDK (Level 1)
|
|
2
|
+
|
|
3
|
+
Thin wrapper over the OpenAI-compatible data plane (`docs/API_DESIGN.md §3`,
|
|
4
|
+
`docs/ai-gateway-design.md §4.9`).
|
|
5
|
+
|
|
6
|
+
## Level 0 — use any OpenAI SDK as-is
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from openai import OpenAI
|
|
10
|
+
|
|
11
|
+
client = OpenAI(
|
|
12
|
+
api_key="gw_live_...",
|
|
13
|
+
base_url="https://gateway.example.com/v1",
|
|
14
|
+
)
|
|
15
|
+
resp = client.chat.completions.create(
|
|
16
|
+
model="auto", # or policy/<name> | <provider>/<model> | alias
|
|
17
|
+
messages=[{"role": "user", "content": "Summarize this ticket..."}],
|
|
18
|
+
)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
See `examples/openai_compat.py`.
|
|
22
|
+
|
|
23
|
+
## Level 1 — this package
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from ai_gateway import GatewayClient
|
|
27
|
+
|
|
28
|
+
client = GatewayClient(
|
|
29
|
+
api_key="gw_live_...",
|
|
30
|
+
base_url="https://gateway.example.com/v1",
|
|
31
|
+
)
|
|
32
|
+
resp = client.chat_completions(
|
|
33
|
+
model="auto",
|
|
34
|
+
messages=[{"role": "user", "content": "hi"}],
|
|
35
|
+
tags={"team": "support", "env": "prod"},
|
|
36
|
+
metadata={"ticket": "t-123"},
|
|
37
|
+
)
|
|
38
|
+
print(resp.content)
|
|
39
|
+
print(resp.gateway.request_id, resp.gateway.tier)
|
|
40
|
+
print(resp.headers.get("x-gateway-provider"))
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Request options map to `API_DESIGN §2.5` headers:
|
|
44
|
+
|
|
45
|
+
| Arg | Header |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `tags: dict \| str` | `X-Gateway-Tags: team=support,env=prod` |
|
|
48
|
+
| `metadata: dict` | `X-Gateway-Metadata: {...}` (≤4KB, opaque JSON) |
|
|
49
|
+
| `route_policy: str` | `X-Gateway-Route-Policy` |
|
|
50
|
+
| `no_store: bool` | `X-Gateway-No-Store: true` |
|
|
51
|
+
| `idempotency_key: str` | `Idempotency-Key` (non-streaming retries) |
|
|
52
|
+
|
|
53
|
+
Errors keep the fields `GatewayError` always had (`.message`, `.code`, `.type`,
|
|
54
|
+
`.param`, `.status`, `.request_id`, `.retry_after`) and understand both the
|
|
55
|
+
OpenAI envelope and RFC 7807 problem details (`detail`/`title`/`type`,
|
|
56
|
+
`errors`, extensions). Header lookups are case-insensitive and the HTTP status
|
|
57
|
+
is authoritative. Request ID priority is `X-Gateway-Request-Id` →
|
|
58
|
+
`X-Request-Id` → `traceId`/`requestId`/`request_id` in the body. The parsed body
|
|
59
|
+
stays on `err.payload` (plus `err.errors`/`err.extensions`) so callers can
|
|
60
|
+
inspect it; it may contain sensitive server data, so the SDK never logs it.
|
|
61
|
+
|
|
62
|
+
Failures before a response arrives raise `GatewayTransportError` subclasses:
|
|
63
|
+
`GatewayConnectionError` (connection) and `GatewayTimeoutError` (timeout with a
|
|
64
|
+
`phase` of `connect`/`read`/`write`/`pool`). Their `status` is `0` and the
|
|
65
|
+
original HTTPX exception is on `__cause__`/`.cause`. Migration note: code that
|
|
66
|
+
caught `httpx.TransportError`/`httpx.TimeoutException` should catch
|
|
67
|
+
`GatewayTransportError` (or the narrower classes) instead.
|
|
68
|
+
|
|
69
|
+
No automatic retries are performed; the current Java hot path does not enforce
|
|
70
|
+
the documented idempotency header. Retrying a chat POST may duplicate billing.
|
|
71
|
+
|
|
72
|
+
## Install and verify
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
python -m pip install -e '.[dev]'
|
|
76
|
+
python -m pytest -q
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The default run skips `tests/integration` (real-stack smoke). CI has separate
|
|
80
|
+
`sdk-core` (no LangChain), `sdk-langchain` (extra required) and `sdk-package`
|
|
81
|
+
(wheel/sdist installed into a clean venv outside the source tree) jobs, across
|
|
82
|
+
Python 3.10/3.12 and HTTPX 0.27.0/0.28.1, with `langchain-core` 1.0.0/1.6.7.
|
|
83
|
+
The adapter tests import LangChain directly, so a missing dependency fails
|
|
84
|
+
collection instead of skipping green. Publishing a GitHub Release runs
|
|
85
|
+
`.github/workflows/publish.yml`, which rebuilds the same artifacts and uploads
|
|
86
|
+
them to PyPI with Trusted Publishing (no stored token).
|
|
87
|
+
|
|
88
|
+
## Async data plane
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import asyncio
|
|
92
|
+
import os
|
|
93
|
+
from ai_gateway import AsyncGatewayClient
|
|
94
|
+
|
|
95
|
+
async def main():
|
|
96
|
+
async with AsyncGatewayClient(
|
|
97
|
+
api_key=os.environ["GATEWAY_API_KEY"],
|
|
98
|
+
base_url=os.getenv("GATEWAY_BASE_URL", "http://localhost:8080/v1"),
|
|
99
|
+
) as client:
|
|
100
|
+
response = await client.chat_completions(
|
|
101
|
+
model="auto", messages=[{"role": "user", "content": "Hello"}],
|
|
102
|
+
)
|
|
103
|
+
print(response.content, response.gateway.request_id)
|
|
104
|
+
|
|
105
|
+
asyncio.run(main())
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`AsyncGatewayClient` mirrors chat, models, health, ready, streaming and text
|
|
109
|
+
collection. Use `aclose()` or `async with`; the sync client uses `close()` or `with`.
|
|
110
|
+
Both accept an HTTPX timeout and transport for contract testing.
|
|
111
|
+
|
|
112
|
+
Health probes call `/healthz` and `/readyz` at the service root, without the API
|
|
113
|
+
key. A proxy mount prefix is retained (`/gateway/v1` → `/gateway/healthz`).
|
|
114
|
+
|
|
115
|
+
`temperature` and `max_tokens` work with the current Java DTO. `top_p`, `stop`,
|
|
116
|
+
`tools`, `tool_choice`, `response_format`, `user`, and `seed` are documented
|
|
117
|
+
pass-through options for future backends: the current backend ignores them.
|
|
118
|
+
Gateway headers are forwarded; setting tags/no-store/metadata does not by itself
|
|
119
|
+
implement server-side enforcement. Metadata is checked against the 4KB limit.
|
|
120
|
+
Tool-only completions with `content: null` expose an empty `.content` string and
|
|
121
|
+
preserve tool calls in `.choices` and `.raw`.
|
|
122
|
+
|
|
123
|
+
Streaming helpers parse SSE including multiline events and surface malformed
|
|
124
|
+
events and stream errors. The current backend returns JSON even with
|
|
125
|
+
`stream: true`; helpers raise `GatewayError(code="streaming_not_supported")`
|
|
126
|
+
instead of silently returning no text. They do not simulate token streaming.
|
|
127
|
+
|
|
128
|
+
## Dashboard reads (serverless control plane)
|
|
129
|
+
|
|
130
|
+
This is a separate service and uses an OIDC access token, not a `gw_live_` key.
|
|
131
|
+
These endpoints require the serverless control-plane deployment. Both SDK
|
|
132
|
+
branches now include its source after synchronization with `origin/main` on
|
|
133
|
+
2026-10-07; source presence does not mean the service is already deployed.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
import os
|
|
137
|
+
from ai_gateway import ControlPlaneClient
|
|
138
|
+
|
|
139
|
+
with ControlPlaneClient(
|
|
140
|
+
base_url=os.environ["CONTROL_PLANE_BASE_URL"], # includes /api/v1
|
|
141
|
+
access_token=os.environ["CONTROL_PLANE_ACCESS_TOKEN"],
|
|
142
|
+
) as dashboard:
|
|
143
|
+
print(dashboard.me())
|
|
144
|
+
for request in dashboard.iter_items(
|
|
145
|
+
dashboard.list_requests, "org_example", limit=100,
|
|
146
|
+
**{"from": "2026-10-01T00:00:00Z", "projectId": "prj_example"},
|
|
147
|
+
):
|
|
148
|
+
print(request["requestId"])
|
|
149
|
+
print(dashboard.get_usage("org_example", group_by="project").data)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
An access-token callable can supply a refreshed token on every request. Local
|
|
153
|
+
development explicitly uses `dev_subject="..."` instead of `access_token`.
|
|
154
|
+
The client does not authenticate users or refresh tokens itself.
|
|
155
|
+
|
|
156
|
+
Read helpers: `me`, `list_orgs`, `list_projects`, `list_api_keys`,
|
|
157
|
+
`list_providers`, `list_models`, `list_routing_policies`, `list_policy_versions`,
|
|
158
|
+
`get_usage`, `list_requests`, `get_request`, `get_routing_insights`,
|
|
159
|
+
`list_audit_logs`. List helpers return `Page(data, next_cursor, has_more)`;
|
|
160
|
+
`iter_items` traverses all pages and fails on missing/repeated cursors. Query
|
|
161
|
+
kwargs use wire names (`projectId`, `minLatencyMs`, `cursor`, `limit`, etc.).
|
|
162
|
+
Usage is daily only; hourly granularity is not implemented by the control plane.
|
|
163
|
+
Wire dictionaries preserve nullable and additional fields. Key reads return
|
|
164
|
+
metadata only; no SDK secret persistence or key mutations are added here.
|
|
165
|
+
|
|
166
|
+
Research and prioritized endpoint/function suggestions:
|
|
167
|
+
[RESEARCH.md](RESEARCH.md).
|
|
168
|
+
|
|
169
|
+
Vietnamese architecture guide with component flows, methods, compatibility,
|
|
170
|
+
examples and troubleshooting: [SDK guide](../docs/SDK_PYTHON_GUIDE.vi.md).
|
|
171
|
+
For a documentation-style reading view, open the [offline HTML guide](../docs/sdk-python-guide.html).
|
|
172
|
+
|
|
173
|
+
## LangChain integration
|
|
174
|
+
|
|
175
|
+
The `sdk-python` branch contains the core SDK plus an optional native
|
|
176
|
+
`ChatGateway` model.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
python -m pip install -e '.[dev,langchain]'
|
|
180
|
+
python -m pytest -q
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
import os
|
|
185
|
+
from ai_gateway.langchain import ChatGateway
|
|
186
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
187
|
+
|
|
188
|
+
prompt = ChatPromptTemplate.from_messages([
|
|
189
|
+
("system", "Answer briefly."), ("human", "{question}"),
|
|
190
|
+
])
|
|
191
|
+
with ChatGateway(
|
|
192
|
+
api_key=os.environ["GATEWAY_API_KEY"],
|
|
193
|
+
base_url="http://localhost:8080/v1",
|
|
194
|
+
model="auto", max_tokens=200,
|
|
195
|
+
gateway_tags={"team": "support"},
|
|
196
|
+
gateway_metadata={"ticket": "t-123"},
|
|
197
|
+
) as model:
|
|
198
|
+
message = (prompt | model).invoke({"question": "How does routing work?"})
|
|
199
|
+
print(message.content)
|
|
200
|
+
print(message.response_metadata["_gateway"])
|
|
201
|
+
print(message.response_metadata["gateway_headers"])
|
|
202
|
+
print(message.usage_metadata)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`ChatGateway` supports `invoke`, native async `ainvoke`, `batch`/`abatch` and
|
|
206
|
+
prompt pipelines. `max_tokens` is sent unchanged to the Java DTO. Responses
|
|
207
|
+
preserve additional `_gateway` fields, headers, selected model, finish reason,
|
|
208
|
+
and LangChain token usage. `stream`/`astream` emit one **buffered full result**
|
|
209
|
+
via LangChain's fallback; they do not request server SSE or token streaming.
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
async with ChatGateway(api_key=os.environ["GATEWAY_API_KEY"],
|
|
213
|
+
base_url="http://localhost:8080/v1") as model:
|
|
214
|
+
messages = await model.abatch(["Explain routing", "Explain budgets"],
|
|
215
|
+
config={"max_concurrency": 2})
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Use `gateway_tags` and `gateway_metadata` for gateway headers; LangChain's
|
|
219
|
+
`config={"tags": [...], "metadata": {...}}` controls tracing only. Per-call
|
|
220
|
+
overrides work through `.bind(gateway_tags=..., route_policy=..., no_store=...)`
|
|
221
|
+
or invoke kwargs. Do not bind a fixed idempotency key to a reusable model.
|
|
222
|
+
|
|
223
|
+
Tools, stop sequences, structured output and other advanced chat options fail
|
|
224
|
+
clearly by default because the current backend ignores them. For a future
|
|
225
|
+
backend that implements them, `allow_advanced_options=True` enables forwarding,
|
|
226
|
+
`bind_tools`, tool-history conversion, and inherited `with_structured_output`.
|
|
227
|
+
This switch does not add server capabilities or execute tools.
|
|
228
|
+
|
|
229
|
+
Sync `with`/`close()` closes the owned sync client; async `with`/`aclose()` closes
|
|
230
|
+
both owned clients. If you use both sync and async methods on one instance,
|
|
231
|
+
finish with `await model.aclose()`. Injected `client`/`async_client` SDK clients
|
|
232
|
+
are caller-owned and remain open. API keys and clients are excluded from model
|
|
233
|
+
repr/dumps. LangChain caching is disabled by default; enable caching only with a
|
|
234
|
+
cache scoped to the same tenant/key and routing policy.
|
|
235
|
+
|
|
236
|
+
Only `from ai_gateway.langchain import ChatGateway` imports LangChain. Core
|
|
237
|
+
SDK users can install without the `langchain` extra. See
|
|
238
|
+
`examples/langchain_chat.py` and `examples/langchain_async.py`.
|
|
239
|
+
|
|
240
|
+
## End-to-end smoke (real stack)
|
|
241
|
+
|
|
242
|
+
`tests/integration/test_e2e_smoke.py` exercises the real
|
|
243
|
+
control plane → publisher → Valkey → Java gateway path with the backend's
|
|
244
|
+
`StubAdapter` as the only substituted LLM upstream. It creates an isolated
|
|
245
|
+
org/project/key over the control plane, publishes a snapshot, checks
|
|
246
|
+
`healthz`/`readyz`/models, chats sync and async (`openai/stub-sdk-smoke`),
|
|
247
|
+
reads project/key metadata, verifies tenant isolation, runs the LangChain
|
|
248
|
+
adapter, rejects a bad key, then revokes the key, republishes and expects
|
|
249
|
+
`401 key_revoked`.
|
|
250
|
+
|
|
251
|
+
The harness is opt-in: without `AIGW_SMOKE=1` the tests skip. With it set, a
|
|
252
|
+
missing config or unreachable service fails loudly. See
|
|
253
|
+
[tests/integration/README.md](tests/integration/README.md) for prerequisites
|
|
254
|
+
and the exact command. Authentication uses the local dev principal
|
|
255
|
+
(`x-dev-sub`/`x-dev-email`), which is not proof of Cognito JWT, and the smoke
|
|
256
|
+
does not assert trace/usage while the usage producer is not wired.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Python SDK expansion and LangChain integration
|
|
2
|
+
|
|
3
|
+
Research date: 2026-10-05. Base: `feat/sdk-python` at `6f7414b`.
|
|
4
|
+
Two deliverables: `sdk-python-expand` and `sdk-python-langchain`.
|
|
5
|
+
LangChain builds on the expansion commit so transport/error handling is shared.
|
|
6
|
+
|
|
7
|
+
Architecture update: 2026-10-07. Both branches now contain `origin/main` at
|
|
8
|
+
`dd0129f`, including the serverless control plane and direct Valkey snapshot
|
|
9
|
+
reads. The historical base constraints below describe the initial research.
|
|
10
|
+
Current flows, scaffold limitations, methods and compatibility are explained in
|
|
11
|
+
[the Vietnamese SDK guide](../docs/SDK_PYTHON_GUIDE.vi.md).
|
|
12
|
+
|
|
13
|
+
## Evidence and constraints
|
|
14
|
+
|
|
15
|
+
- `docs/API_DESIGN.md` §§2–4 defines chat, model catalog, health, headers,
|
|
16
|
+
control-plane auth, pagination and errors. Backend code takes precedence
|
|
17
|
+
over features described only in the design.
|
|
18
|
+
- `backend/.../gateway/web/ChatDtos.java` only reads model/messages/temperature/
|
|
19
|
+
max_tokens/stream. Provider input currently has no tools, stop or structured
|
|
20
|
+
output. The controller returns JSON, not SSE.
|
|
21
|
+
- `HealthController` serves `/healthz` and `/readyz` at the root. The previous
|
|
22
|
+
SDK incorrectly requested `/v1/healthz` and `/v1/readyz`.
|
|
23
|
+
- The serverless route tables and read handlers were inspected on
|
|
24
|
+
`codex/clarify-firehose-raw` at `8cbbf4f`. That checkout includes
|
|
25
|
+
`docs/API_CONTRACT_AUDIT.md`; this SDK base does not include the control plane.
|
|
26
|
+
M1/M2 make message-based routing simulation unreliable; M4 is daily-only
|
|
27
|
+
usage; M5 requires preserving nulls; M7 requires visible pagination.
|
|
28
|
+
- [HTTPX async guide](https://www.python-httpx.org/async/) supports pooled
|
|
29
|
+
`AsyncClient` requests and streaming with explicit closure.
|
|
30
|
+
- [HTTPX transports](https://www.python-httpx.org/advanced/transports/) supplies
|
|
31
|
+
`MockTransport` for deterministic wire-contract tests without live credentials.
|
|
32
|
+
- [LangChain ChatOpenAI documentation](https://docs.langchain.com/oss/python/integrations/chat/openai)
|
|
33
|
+
supports a custom `base_url`, but notes that nonstandard response fields are
|
|
34
|
+
not preserved. It also converts `max_tokens` to `max_completion_tokens`.
|
|
35
|
+
These are concrete reasons for a native gateway adapter: keep `_gateway` and
|
|
36
|
+
send the `max_tokens` that the current Java DTO actually reads.
|
|
37
|
+
- [LangChain BaseChatModel reference](https://reference.langchain.com/python/langchain-core/language_models/chat_models)
|
|
38
|
+
defines the custom model contract. Its installed source is checked alongside
|
|
39
|
+
tests for invoke/ainvoke, prompt pipelines, batching and usage metadata.
|
|
40
|
+
|
|
41
|
+
## Recommended SDK functions and endpoints
|
|
42
|
+
|
|
43
|
+
| Priority | Function / endpoint | Why | Decision |
|
|
44
|
+
| --- | --- | --- | --- |
|
|
45
|
+
| P0 | Async chat, models, probes | FastAPI/async apps need nonblocking I/O | Implemented |
|
|
46
|
+
| P0 | Fix health URLs | Existing SDK probes target nonexistent paths | Implemented |
|
|
47
|
+
| P0 | Error details + Retry-After | Preserve failure context for callers | Implemented |
|
|
48
|
+
| P0 | Null content + raw tool calls | Prevent `None` becoming literal text | Implemented |
|
|
49
|
+
| P0 | Streaming content-type/error validation | Avoid silently losing JSON responses | Implemented; server SSE still pending |
|
|
50
|
+
| P1 | `GET /me`, orgs/projects | Discover identity and tenant scope | Read helpers implemented |
|
|
51
|
+
| P1 | `GET /orgs/{id}/usage` | Cost reports and budget automation | Implemented daily reads |
|
|
52
|
+
| P1 | Requests + `GET /requests/{id}` | Explain routing/fallbacks after a call | Implemented with cursor iteration |
|
|
53
|
+
| P1 | Providers, catalog, policies/versions | Discover available models and routing config | Read helpers implemented |
|
|
54
|
+
| P1 | Routing insights + audit logs | Compare route behavior and admin activity | Read helpers implemented |
|
|
55
|
+
| P1 | API-key metadata lists | Inspect keys without returning secrets | Read helper implemented |
|
|
56
|
+
| P1 | Native LangChain chat model | Preserve route/cost/usage in Runnable flows | Separate integration branch |
|
|
57
|
+
| P2 | Create/rotate/revoke keys | CI provisioning and service automation | Next iteration; idempotency + one-time secret handling required |
|
|
58
|
+
| P2 | Policy create/update/publish/simulate | Programmatic routing rollout | Defer until simulate M1/M2 resolved; honor If-Match/412 |
|
|
59
|
+
| P2 | Async control-plane reads | Large asynchronous monitoring clients | Follow-up after sync contract is exercised |
|
|
60
|
+
| P2 | Tools + response_format end to end | LangChain agents and structured output | SDK pass-through only; extend backend DTO/adapters first |
|
|
61
|
+
| P2 | `POST /v1/embeddings` | RAG/vector indexing | Design P2; no server implementation, no callable SDK stub |
|
|
62
|
+
| P3 | `/v1/responses`, `/v1/messages` | Native advanced provider protocols | Explicitly deferred by API_DESIGN §3.5 |
|
|
63
|
+
|
|
64
|
+
## LangChain choice (implemented on sdk-python-langchain)
|
|
65
|
+
|
|
66
|
+
Use a small `BaseChatModel` adapter over `GatewayClient` and
|
|
67
|
+
`AsyncGatewayClient`, with `langchain-core` as an optional dependency. Avoid a
|
|
68
|
+
mandatory full `langchain`/`langchain-openai` dependency for ordinary SDK users.
|
|
69
|
+
Support `invoke`, `ainvoke`, prompt pipes and batching; return gateway fields,
|
|
70
|
+
headers, selected model, finish reason and token usage on `AIMessage`.
|
|
71
|
+
Keep API keys excluded from repr/serialization and expose explicit closure.
|
|
72
|
+
The implementation uses `langchain-core>=1,<2` as an optional extra; the default
|
|
73
|
+
SDK import does not load it. HTTP requests remain on `/v1/chat/completions`.
|
|
74
|
+
|
|
75
|
+
For this Java backend, `stream`/`astream` must use LangChain's buffered fallback
|
|
76
|
+
and tools/stop/structured-output requests must fail clearly unless the caller
|
|
77
|
+
explicitly enables future-backend pass-through. Do not advertise token streaming,
|
|
78
|
+
agent tool execution, or embeddings as current server capabilities.
|
|
79
|
+
|
|
80
|
+
## Validation boundary
|
|
81
|
+
|
|
82
|
+
Contract tests validate paths, payloads, auth headers, token refresh hooks,
|
|
83
|
+
pagination, SSE parsing, error envelopes and LangChain conversion using mocked
|
|
84
|
+
HTTP responses. Wheel/sdist builds validate packaging. These do not prove an
|
|
85
|
+
OIDC deployment, a live provider, actual SSE, or gateway header enforcement.
|
|
86
|
+
No live requests with real credentials or changes to backend/schema are needed.
|
|
87
|
+
|
|
88
|
+
Verified results on Python 3.12 / HTTPX 0.28.1:
|
|
89
|
+
|
|
90
|
+
- Expansion commit: 34 tests passed; wheel and sdist built.
|
|
91
|
+
- LangChain branch: 50 tests passed with both `langchain-core` 1.6.6 and 1.0.0
|
|
92
|
+
(the minimum package was isolated on PYTHONPATH with the same dependencies).
|
|
93
|
+
- Integration wheel/sdist built; wheel metadata keeps LangChain optional.
|
|
94
|
+
- All SDK modules parse with Python 3.10 syntax rules. Python 3.10 runtime and
|
|
95
|
+
dependency combinations beyond the versions above were not exercised.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Native async chat; configure GATEWAY_API_KEY and GATEWAY_BASE_URL."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from ai_gateway import AsyncGatewayClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def main():
|
|
10
|
+
async with AsyncGatewayClient(
|
|
11
|
+
api_key=os.environ["GATEWAY_API_KEY"],
|
|
12
|
+
base_url=os.getenv("GATEWAY_BASE_URL", "http://localhost:8080/v1"),
|
|
13
|
+
) as client:
|
|
14
|
+
response = await client.chat_completions(
|
|
15
|
+
model="auto", messages=[{"role": "user", "content": "Explain gateway routing."}],
|
|
16
|
+
)
|
|
17
|
+
print(response.content)
|
|
18
|
+
print(response.gateway)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Basic Level 1 usage (needs a live gateway + GW_API_KEY env)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from ai_gateway import GatewayClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
api_key = os.environ.get("GW_API_KEY", "gw_live_demo")
|
|
10
|
+
base_url = os.environ.get("GW_BASE_URL", "http://localhost:8080/v1")
|
|
11
|
+
with GatewayClient(api_key=api_key, base_url=base_url) as client:
|
|
12
|
+
resp = client.chat_completions(
|
|
13
|
+
model="auto",
|
|
14
|
+
messages=[{"role": "user", "content": "Summarize this ticket..."}],
|
|
15
|
+
tags={"team": "support", "env": "dev"},
|
|
16
|
+
)
|
|
17
|
+
print(resp.content)
|
|
18
|
+
print("request:", resp.gateway.request_id, "tier:", resp.gateway.tier)
|
|
19
|
+
print("provider:", resp.headers.get("x-gateway-provider"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|