p034-gateway-sdk 0.1.1__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.
- p034_gateway_sdk-0.1.1/.gitignore +69 -0
- p034_gateway_sdk-0.1.1/LICENSE +21 -0
- p034_gateway_sdk-0.1.1/PKG-INFO +156 -0
- p034_gateway_sdk-0.1.1/README.md +127 -0
- p034_gateway_sdk-0.1.1/pyproject.toml +46 -0
- p034_gateway_sdk-0.1.1/src/p034_gateway/__init__.py +66 -0
- p034_gateway_sdk-0.1.1/src/p034_gateway/_transport.py +383 -0
- p034_gateway_sdk-0.1.1/src/p034_gateway/client.py +102 -0
- p034_gateway_sdk-0.1.1/src/p034_gateway/exceptions.py +96 -0
- p034_gateway_sdk-0.1.1/src/p034_gateway/models.py +96 -0
- p034_gateway_sdk-0.1.1/src/p034_gateway/py.typed +1 -0
- p034_gateway_sdk-0.1.1/src/p034_gateway/resources/__init__.py +3 -0
- p034_gateway_sdk-0.1.1/src/p034_gateway/resources/requests.py +126 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.so
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.eggs/
|
|
9
|
+
|
|
10
|
+
# Virtual environments
|
|
11
|
+
venv/
|
|
12
|
+
.venv/
|
|
13
|
+
env/
|
|
14
|
+
|
|
15
|
+
# Environment variables
|
|
16
|
+
.env
|
|
17
|
+
.env.local
|
|
18
|
+
.env.production
|
|
19
|
+
|
|
20
|
+
# IDE
|
|
21
|
+
.vscode/
|
|
22
|
+
.idea/
|
|
23
|
+
*.swp
|
|
24
|
+
*.swo
|
|
25
|
+
*~
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
|
|
31
|
+
# Data (never commit large data)
|
|
32
|
+
data/
|
|
33
|
+
data/*.sqlite3-*
|
|
34
|
+
*.db
|
|
35
|
+
*.sqlite3
|
|
36
|
+
|
|
37
|
+
# Logs
|
|
38
|
+
logs/
|
|
39
|
+
*.log
|
|
40
|
+
|
|
41
|
+
# Test coverage
|
|
42
|
+
htmlcov/
|
|
43
|
+
.coverage
|
|
44
|
+
coverage.xml
|
|
45
|
+
|
|
46
|
+
# Docker
|
|
47
|
+
docker-compose.override.yml
|
|
48
|
+
|
|
49
|
+
# Node (frontend)
|
|
50
|
+
node_modules/
|
|
51
|
+
.next/
|
|
52
|
+
out/
|
|
53
|
+
.nuxt/
|
|
54
|
+
|
|
55
|
+
# Build
|
|
56
|
+
*.pyc
|
|
57
|
+
*.pyo
|
|
58
|
+
|
|
59
|
+
# AI logs — tracked structure, ignored content
|
|
60
|
+
.ai-log/*.jsonl
|
|
61
|
+
.ai-log/archive/
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
P-034-streamlit demo full
|
|
65
|
+
.env copy.example
|
|
66
|
+
|
|
67
|
+
tmp
|
|
68
|
+
|
|
69
|
+
P-034-dev
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AI20K Build Phase
|
|
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.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: p034-gateway-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python SDK for the P-034 AI Gateway runtime API
|
|
5
|
+
Project-URL: Repository, https://github.com/AI20K-Build-Phase-Cohort-4/P-034
|
|
6
|
+
Project-URL: Documentation, https://github.com/AI20K-Build-Phase-Cohort-4/P-034/tree/main/sdk/python
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
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.11
|
|
20
|
+
Requires-Dist: httpx<1,>=0.28
|
|
21
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
27
|
+
Requires-Dist: twine>=6; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# P-034 Gateway Python SDK
|
|
31
|
+
|
|
32
|
+
`p034-gateway-sdk` is a synchronous and asynchronous Python client for the
|
|
33
|
+
native P-034 AI Gateway runtime. A gateway API key identifies the application;
|
|
34
|
+
the Gateway backend handles model-pool routing, provider credentials,
|
|
35
|
+
execution, persistence, and billing. Provider API keys are configured on the
|
|
36
|
+
backend and are not passed to this SDK.
|
|
37
|
+
|
|
38
|
+
Tài liệu tiếng Việt: [README.vi.md](README.vi.md).
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Requires Python 3.11 or newer. Install the latest release from PyPI:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m pip install p034-gateway-sdk
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
To pin this release explicitly:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python -m pip install "p034-gateway-sdk==0.1.1"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Set the API root and application key issued once by the dashboard's application
|
|
55
|
+
API-key page:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
export GATEWAY_API_KEY='ak_...'
|
|
59
|
+
export GATEWAY_BASE_URL='https://gateway.example.com/api/v1'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`base_url` is the API root. Keep `/api/v1` in the value when the deployment
|
|
63
|
+
uses that prefix. Explicit constructor arguments override those two environment
|
|
64
|
+
variables. The SDK does not load `.env` files and has no implicit production URL.
|
|
65
|
+
|
|
66
|
+
## Synchronous request
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from p034_gateway import GatewayClient, RequestConstraints
|
|
70
|
+
|
|
71
|
+
with GatewayClient() as client:
|
|
72
|
+
result = client.requests.create(
|
|
73
|
+
prompt="Summarize this document and suggest three next steps.",
|
|
74
|
+
context={"document": "Text to summarize"},
|
|
75
|
+
constraints=RequestConstraints(
|
|
76
|
+
quality_priority="cost_first",
|
|
77
|
+
max_output_tokens=400,
|
|
78
|
+
),
|
|
79
|
+
idempotency_key="ticket-123-summary-v1",
|
|
80
|
+
)
|
|
81
|
+
print(result.output)
|
|
82
|
+
print(result.request_id, result.cost.estimated_cost if result.cost else None)
|
|
83
|
+
|
|
84
|
+
receipt = client.requests.get(result.request_id)
|
|
85
|
+
print(receipt.status)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
See [`examples/sync_request.py`](examples/sync_request.py) for a complete
|
|
89
|
+
example.
|
|
90
|
+
|
|
91
|
+
## Asynchronous request
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from p034_gateway import AsyncGatewayClient
|
|
95
|
+
|
|
96
|
+
async with AsyncGatewayClient() as client:
|
|
97
|
+
result = await client.requests.create(prompt="Summarize this document.")
|
|
98
|
+
receipt = await client.requests.get(result.request_id)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See [`examples/async_request.py`](examples/async_request.py). The async client
|
|
102
|
+
uses HTTPX's async transport and does not start or manage an event loop for you.
|
|
103
|
+
|
|
104
|
+
## Retries and recovery
|
|
105
|
+
|
|
106
|
+
Retries are off by default. Set `max_retries` to an integer from 0 to 3 to retry
|
|
107
|
+
network errors, retryable admission responses, and requests that are still
|
|
108
|
+
running. A create call generates one idempotency key when omitted and reuses the
|
|
109
|
+
same key and request body for every attempt. Keep a caller-supplied key if you
|
|
110
|
+
need to recover across separate method calls.
|
|
111
|
+
|
|
112
|
+
`RequestInProgressError` includes the request ID and idempotency key. Poll with
|
|
113
|
+
`client.requests.get(request_id)` or retry create with the same key and body.
|
|
114
|
+
`RequestOutcomeUnknownError` means the gateway cannot confirm the provider
|
|
115
|
+
outcome; the SDK will not retry it. Do not submit a new key unless you intend to
|
|
116
|
+
start another execution that could incur additional cost.
|
|
117
|
+
|
|
118
|
+
API errors are typed (`AuthenticationError`, `ValidationError`,
|
|
119
|
+
`RateLimitError`, `ProviderExecutionError`, `ServiceUnavailableError`, and
|
|
120
|
+
others). A GET of a failed request returns a `RuntimeRequest` whose status is
|
|
121
|
+
`failed`; it does not raise a provider exception. A failed create call raises
|
|
122
|
+
the mapped API exception.
|
|
123
|
+
|
|
124
|
+
## Runtime limits
|
|
125
|
+
|
|
126
|
+
- The public runtime accepts prompt text, a JSON `context`, optional constraints,
|
|
127
|
+
and optional metadata. It does not accept chat messages, streaming, tools,
|
|
128
|
+
images, audio, or files.
|
|
129
|
+
- `max_output_tokens` caps each routed model generation, including retries and
|
|
130
|
+
fallback attempts. It does not cap the combined output or structured pipeline
|
|
131
|
+
stages.
|
|
132
|
+
- `cost.estimated_cost` is an estimate. Application budget ceilings are
|
|
133
|
+
advisory in this API version and do not reserve or hard-block spend.
|
|
134
|
+
- Partial output is returned only when `allow_partial_response=True` and useful
|
|
135
|
+
task output remains.
|
|
136
|
+
- The server deadline defaults to 120 seconds. The SDK's default HTTPX timeout
|
|
137
|
+
uses 5 seconds connect/pool, 10 seconds write, and 130 seconds read. This is
|
|
138
|
+
HTTPX phase timeout behavior, not a total wall-clock deadline.
|
|
139
|
+
|
|
140
|
+
Injected `httpx.Client` and `httpx.AsyncClient` instances remain owned by the
|
|
141
|
+
caller. The SDK closes only clients it creates. Redirect following is disabled
|
|
142
|
+
per request so the gateway key is not forwarded to a redirect target.
|
|
143
|
+
|
|
144
|
+
## Development
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
cd sdk/python
|
|
148
|
+
python -m pip install -e '.[dev]'
|
|
149
|
+
pytest
|
|
150
|
+
ruff check src/ tests/
|
|
151
|
+
python -m build
|
|
152
|
+
python -m twine check dist/*
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The SDK runtime depends only on HTTPX and Pydantic. Package publication and
|
|
156
|
+
PyPI name ownership checks are separate release steps.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# P-034 Gateway Python SDK
|
|
2
|
+
|
|
3
|
+
`p034-gateway-sdk` is a synchronous and asynchronous Python client for the
|
|
4
|
+
native P-034 AI Gateway runtime. A gateway API key identifies the application;
|
|
5
|
+
the Gateway backend handles model-pool routing, provider credentials,
|
|
6
|
+
execution, persistence, and billing. Provider API keys are configured on the
|
|
7
|
+
backend and are not passed to this SDK.
|
|
8
|
+
|
|
9
|
+
Tài liệu tiếng Việt: [README.vi.md](README.vi.md).
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Requires Python 3.11 or newer. Install the latest release from PyPI:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python -m pip install p034-gateway-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
To pin this release explicitly:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python -m pip install "p034-gateway-sdk==0.1.1"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Set the API root and application key issued once by the dashboard's application
|
|
26
|
+
API-key page:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export GATEWAY_API_KEY='ak_...'
|
|
30
|
+
export GATEWAY_BASE_URL='https://gateway.example.com/api/v1'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`base_url` is the API root. Keep `/api/v1` in the value when the deployment
|
|
34
|
+
uses that prefix. Explicit constructor arguments override those two environment
|
|
35
|
+
variables. The SDK does not load `.env` files and has no implicit production URL.
|
|
36
|
+
|
|
37
|
+
## Synchronous request
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from p034_gateway import GatewayClient, RequestConstraints
|
|
41
|
+
|
|
42
|
+
with GatewayClient() as client:
|
|
43
|
+
result = client.requests.create(
|
|
44
|
+
prompt="Summarize this document and suggest three next steps.",
|
|
45
|
+
context={"document": "Text to summarize"},
|
|
46
|
+
constraints=RequestConstraints(
|
|
47
|
+
quality_priority="cost_first",
|
|
48
|
+
max_output_tokens=400,
|
|
49
|
+
),
|
|
50
|
+
idempotency_key="ticket-123-summary-v1",
|
|
51
|
+
)
|
|
52
|
+
print(result.output)
|
|
53
|
+
print(result.request_id, result.cost.estimated_cost if result.cost else None)
|
|
54
|
+
|
|
55
|
+
receipt = client.requests.get(result.request_id)
|
|
56
|
+
print(receipt.status)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
See [`examples/sync_request.py`](examples/sync_request.py) for a complete
|
|
60
|
+
example.
|
|
61
|
+
|
|
62
|
+
## Asynchronous request
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from p034_gateway import AsyncGatewayClient
|
|
66
|
+
|
|
67
|
+
async with AsyncGatewayClient() as client:
|
|
68
|
+
result = await client.requests.create(prompt="Summarize this document.")
|
|
69
|
+
receipt = await client.requests.get(result.request_id)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
See [`examples/async_request.py`](examples/async_request.py). The async client
|
|
73
|
+
uses HTTPX's async transport and does not start or manage an event loop for you.
|
|
74
|
+
|
|
75
|
+
## Retries and recovery
|
|
76
|
+
|
|
77
|
+
Retries are off by default. Set `max_retries` to an integer from 0 to 3 to retry
|
|
78
|
+
network errors, retryable admission responses, and requests that are still
|
|
79
|
+
running. A create call generates one idempotency key when omitted and reuses the
|
|
80
|
+
same key and request body for every attempt. Keep a caller-supplied key if you
|
|
81
|
+
need to recover across separate method calls.
|
|
82
|
+
|
|
83
|
+
`RequestInProgressError` includes the request ID and idempotency key. Poll with
|
|
84
|
+
`client.requests.get(request_id)` or retry create with the same key and body.
|
|
85
|
+
`RequestOutcomeUnknownError` means the gateway cannot confirm the provider
|
|
86
|
+
outcome; the SDK will not retry it. Do not submit a new key unless you intend to
|
|
87
|
+
start another execution that could incur additional cost.
|
|
88
|
+
|
|
89
|
+
API errors are typed (`AuthenticationError`, `ValidationError`,
|
|
90
|
+
`RateLimitError`, `ProviderExecutionError`, `ServiceUnavailableError`, and
|
|
91
|
+
others). A GET of a failed request returns a `RuntimeRequest` whose status is
|
|
92
|
+
`failed`; it does not raise a provider exception. A failed create call raises
|
|
93
|
+
the mapped API exception.
|
|
94
|
+
|
|
95
|
+
## Runtime limits
|
|
96
|
+
|
|
97
|
+
- The public runtime accepts prompt text, a JSON `context`, optional constraints,
|
|
98
|
+
and optional metadata. It does not accept chat messages, streaming, tools,
|
|
99
|
+
images, audio, or files.
|
|
100
|
+
- `max_output_tokens` caps each routed model generation, including retries and
|
|
101
|
+
fallback attempts. It does not cap the combined output or structured pipeline
|
|
102
|
+
stages.
|
|
103
|
+
- `cost.estimated_cost` is an estimate. Application budget ceilings are
|
|
104
|
+
advisory in this API version and do not reserve or hard-block spend.
|
|
105
|
+
- Partial output is returned only when `allow_partial_response=True` and useful
|
|
106
|
+
task output remains.
|
|
107
|
+
- The server deadline defaults to 120 seconds. The SDK's default HTTPX timeout
|
|
108
|
+
uses 5 seconds connect/pool, 10 seconds write, and 130 seconds read. This is
|
|
109
|
+
HTTPX phase timeout behavior, not a total wall-clock deadline.
|
|
110
|
+
|
|
111
|
+
Injected `httpx.Client` and `httpx.AsyncClient` instances remain owned by the
|
|
112
|
+
caller. The SDK closes only clients it creates. Redirect following is disabled
|
|
113
|
+
per request so the gateway key is not forwarded to a redirect target.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
cd sdk/python
|
|
119
|
+
python -m pip install -e '.[dev]'
|
|
120
|
+
pytest
|
|
121
|
+
ruff check src/ tests/
|
|
122
|
+
python -m build
|
|
123
|
+
python -m twine check dist/*
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The SDK runtime depends only on HTTPX and Pydantic. Package publication and
|
|
127
|
+
PyPI name ownership checks are separate release steps.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "p034-gateway-sdk"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Python SDK for the P-034 AI Gateway runtime API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = ["httpx>=0.28,<1", "pydantic>=2.10,<3"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Repository = "https://github.com/AI20K-Build-Phase-Cohort-4/P-034"
|
|
29
|
+
Documentation = "https://github.com/AI20K-Build-Phase-Cohort-4/P-034/tree/main/sdk/python"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/p034_gateway"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
include = ["/src/p034_gateway", "/README.md", "/LICENSE"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
asyncio_mode = "auto"
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 120
|
|
43
|
+
target-version = "py311"
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
dev = ["build>=1.2", "pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.9", "twine>=6"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from p034_gateway.client import AsyncGatewayClient, GatewayClient
|
|
4
|
+
from p034_gateway.exceptions import (
|
|
5
|
+
APIConnectionError,
|
|
6
|
+
APIError,
|
|
7
|
+
APIResponseValidationError,
|
|
8
|
+
APITimeoutError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
ConfigurationError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
GatewayError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
ProviderExecutionError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
RequestInProgressError,
|
|
18
|
+
RequestOutcomeUnknownError,
|
|
19
|
+
RequestTooLargeError,
|
|
20
|
+
ServiceUnavailableError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
from p034_gateway.models import (
|
|
24
|
+
RequestConstraints,
|
|
25
|
+
RuntimeCost,
|
|
26
|
+
RuntimeOperationError,
|
|
27
|
+
RuntimeRequest,
|
|
28
|
+
RuntimeUsage,
|
|
29
|
+
RuntimeUsageScope,
|
|
30
|
+
RuntimeWarning,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
__version__ = version("p034-gateway-sdk")
|
|
35
|
+
except PackageNotFoundError:
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"APIConnectionError",
|
|
40
|
+
"APIError",
|
|
41
|
+
"APIResponseValidationError",
|
|
42
|
+
"APITimeoutError",
|
|
43
|
+
"AsyncGatewayClient",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"ConfigurationError",
|
|
46
|
+
"ConflictError",
|
|
47
|
+
"GatewayClient",
|
|
48
|
+
"GatewayError",
|
|
49
|
+
"NotFoundError",
|
|
50
|
+
"PermissionDeniedError",
|
|
51
|
+
"ProviderExecutionError",
|
|
52
|
+
"RateLimitError",
|
|
53
|
+
"RequestConstraints",
|
|
54
|
+
"RequestInProgressError",
|
|
55
|
+
"RequestOutcomeUnknownError",
|
|
56
|
+
"RequestTooLargeError",
|
|
57
|
+
"RuntimeCost",
|
|
58
|
+
"RuntimeOperationError",
|
|
59
|
+
"RuntimeRequest",
|
|
60
|
+
"RuntimeUsage",
|
|
61
|
+
"RuntimeUsageScope",
|
|
62
|
+
"RuntimeWarning",
|
|
63
|
+
"ServiceUnavailableError",
|
|
64
|
+
"ValidationError",
|
|
65
|
+
"__version__",
|
|
66
|
+
]
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
14
|
+
|
|
15
|
+
from p034_gateway.exceptions import (
|
|
16
|
+
APIConnectionError,
|
|
17
|
+
APIError,
|
|
18
|
+
APIResponseValidationError,
|
|
19
|
+
APITimeoutError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
ConfigurationError,
|
|
22
|
+
ConflictError,
|
|
23
|
+
GatewayError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
PermissionDeniedError,
|
|
26
|
+
ProviderExecutionError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
RequestInProgressError,
|
|
29
|
+
RequestOutcomeUnknownError,
|
|
30
|
+
RequestTooLargeError,
|
|
31
|
+
ServiceUnavailableError,
|
|
32
|
+
ValidationError,
|
|
33
|
+
)
|
|
34
|
+
from p034_gateway.models import RuntimeRequest
|
|
35
|
+
|
|
36
|
+
_RETRYABLE_STATUSES = {429, 503}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BaseTransport:
|
|
40
|
+
def __init__(self, *, api_key: str, base_url: str, timeout: float | httpx.Timeout, max_retries: int):
|
|
41
|
+
self.api_key = api_key
|
|
42
|
+
self.base_url = _normalize_base_url(base_url)
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
self.max_retries = max_retries
|
|
45
|
+
self.headers = {
|
|
46
|
+
"Authorization": f"Bearer {api_key}",
|
|
47
|
+
"Accept": "application/json",
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"User-Agent": _user_agent(),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def _url(self, request_id: str | None = None) -> str:
|
|
53
|
+
path = "runtime/requests" if request_id is None else f"runtime/requests/{quote(request_id, safe='')}"
|
|
54
|
+
return f"{self.base_url}/{path}"
|
|
55
|
+
|
|
56
|
+
def _decode_response(
|
|
57
|
+
self,
|
|
58
|
+
response: httpx.Response,
|
|
59
|
+
*,
|
|
60
|
+
idempotency_key: str | None = None,
|
|
61
|
+
) -> RuntimeRequest:
|
|
62
|
+
_require_api_version(response)
|
|
63
|
+
try:
|
|
64
|
+
payload = response.json()
|
|
65
|
+
except (json.JSONDecodeError, ValueError):
|
|
66
|
+
raise APIResponseValidationError(
|
|
67
|
+
"Gateway returned an unreadable success response.",
|
|
68
|
+
status_code=response.status_code,
|
|
69
|
+
request_id=response.headers.get("X-Request-ID"),
|
|
70
|
+
idempotency_key=idempotency_key,
|
|
71
|
+
) from None
|
|
72
|
+
try:
|
|
73
|
+
parsed = RuntimeRequest.model_validate(payload)
|
|
74
|
+
except (PydanticValidationError, TypeError, ValueError):
|
|
75
|
+
raise APIResponseValidationError(
|
|
76
|
+
"Gateway success response did not match API v1.",
|
|
77
|
+
status_code=response.status_code,
|
|
78
|
+
request_id=response.headers.get("X-Request-ID"),
|
|
79
|
+
idempotency_key=idempotency_key,
|
|
80
|
+
) from None
|
|
81
|
+
replayed = response.headers.get("Idempotency-Replayed", "false").lower() == "true"
|
|
82
|
+
return parsed.model_copy(update={"idempotency_key": idempotency_key, "replayed": replayed})
|
|
83
|
+
|
|
84
|
+
def _api_error(self, response: httpx.Response, *, idempotency_key: str | None = None) -> GatewayError:
|
|
85
|
+
envelope: dict[str, Any] = {}
|
|
86
|
+
try:
|
|
87
|
+
data = response.json()
|
|
88
|
+
if isinstance(data, dict):
|
|
89
|
+
envelope = data
|
|
90
|
+
except (json.JSONDecodeError, ValueError):
|
|
91
|
+
pass
|
|
92
|
+
error = envelope.get("error") if isinstance(envelope.get("error"), dict) else {}
|
|
93
|
+
code = error.get("code") if isinstance(error.get("code"), str) else None
|
|
94
|
+
message = error.get("message") if isinstance(error.get("message"), str) else "Gateway request failed."
|
|
95
|
+
raw_details = error.get("details") if isinstance(error.get("details"), dict) else {}
|
|
96
|
+
details = _redact(raw_details, self.api_key)
|
|
97
|
+
request_id = envelope.get("request_id") or response.headers.get("X-Request-ID")
|
|
98
|
+
retryable = error.get("retryable") is True
|
|
99
|
+
exception_type: type[GatewayError]
|
|
100
|
+
if response.status_code == 401:
|
|
101
|
+
exception_type = AuthenticationError
|
|
102
|
+
elif response.status_code == 403:
|
|
103
|
+
exception_type = PermissionDeniedError
|
|
104
|
+
elif response.status_code == 404:
|
|
105
|
+
exception_type = NotFoundError
|
|
106
|
+
elif response.status_code == 409 and code == "REQUEST_IN_PROGRESS":
|
|
107
|
+
exception_type = RequestInProgressError
|
|
108
|
+
elif response.status_code == 409 and code == "REQUEST_OUTCOME_UNKNOWN":
|
|
109
|
+
exception_type = RequestOutcomeUnknownError
|
|
110
|
+
elif response.status_code == 409:
|
|
111
|
+
exception_type = ConflictError
|
|
112
|
+
elif response.status_code == 413:
|
|
113
|
+
exception_type = RequestTooLargeError
|
|
114
|
+
elif response.status_code in {400, 422}:
|
|
115
|
+
exception_type = ValidationError
|
|
116
|
+
elif response.status_code == 429:
|
|
117
|
+
exception_type = RateLimitError
|
|
118
|
+
elif response.status_code == 502:
|
|
119
|
+
exception_type = ProviderExecutionError
|
|
120
|
+
elif response.status_code == 503:
|
|
121
|
+
exception_type = ServiceUnavailableError
|
|
122
|
+
else:
|
|
123
|
+
exception_type = APIError
|
|
124
|
+
return exception_type(
|
|
125
|
+
_redact(message[:500], self.api_key),
|
|
126
|
+
code=code,
|
|
127
|
+
status_code=response.status_code,
|
|
128
|
+
request_id=request_id,
|
|
129
|
+
idempotency_key=idempotency_key,
|
|
130
|
+
retryable=retryable,
|
|
131
|
+
details=details,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _post_retryable(self, response: httpx.Response) -> bool:
|
|
135
|
+
if response.status_code == 429:
|
|
136
|
+
return True
|
|
137
|
+
if response.status_code == 409:
|
|
138
|
+
error = _read_error(response)
|
|
139
|
+
return error.get("code") == "REQUEST_IN_PROGRESS"
|
|
140
|
+
if response.status_code == 503:
|
|
141
|
+
try:
|
|
142
|
+
data = response.json()
|
|
143
|
+
except (json.JSONDecodeError, ValueError):
|
|
144
|
+
return True
|
|
145
|
+
error = data.get("error") if isinstance(data, dict) else None
|
|
146
|
+
return isinstance(error, dict) and error.get("retryable") is True
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _delay(response: httpx.Response | None, retry_index: int) -> float:
|
|
151
|
+
retry_after = response.headers.get("Retry-After") if response is not None else None
|
|
152
|
+
if retry_after:
|
|
153
|
+
delay = _parse_retry_after(retry_after)
|
|
154
|
+
if delay is not None:
|
|
155
|
+
return min(10.0, max(0.0, delay))
|
|
156
|
+
return min(10.0, 0.5 * (2**retry_index))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SyncTransport(BaseTransport):
|
|
160
|
+
def __init__(self, *, http_client: httpx.Client | None = None, **kwargs):
|
|
161
|
+
super().__init__(**kwargs)
|
|
162
|
+
self._owns_client = http_client is None
|
|
163
|
+
self.client = http_client or httpx.Client(timeout=self.timeout, follow_redirects=False, verify=True)
|
|
164
|
+
|
|
165
|
+
def create(self, body: bytes, idempotency_key: str, timeout: float | httpx.Timeout | None) -> RuntimeRequest:
|
|
166
|
+
last_error: GatewayError | None = None
|
|
167
|
+
for retry_index in range(self.max_retries + 1):
|
|
168
|
+
try:
|
|
169
|
+
response = self.client.post(
|
|
170
|
+
self._url(),
|
|
171
|
+
content=body,
|
|
172
|
+
headers={**self.headers, "Idempotency-Key": idempotency_key},
|
|
173
|
+
timeout=timeout if timeout is not None else self.timeout,
|
|
174
|
+
follow_redirects=False,
|
|
175
|
+
)
|
|
176
|
+
except httpx.TimeoutException as exc:
|
|
177
|
+
last_error = APITimeoutError(
|
|
178
|
+
"Gateway request timed out.", idempotency_key=idempotency_key, retryable=True
|
|
179
|
+
)
|
|
180
|
+
if retry_index >= self.max_retries:
|
|
181
|
+
raise last_error from exc
|
|
182
|
+
time.sleep(self._delay(None, retry_index))
|
|
183
|
+
continue
|
|
184
|
+
except httpx.RequestError as exc:
|
|
185
|
+
last_error = APIConnectionError(
|
|
186
|
+
"Could not connect to the gateway.", idempotency_key=idempotency_key, retryable=True
|
|
187
|
+
)
|
|
188
|
+
if retry_index >= self.max_retries:
|
|
189
|
+
raise last_error from exc
|
|
190
|
+
time.sleep(self._delay(None, retry_index))
|
|
191
|
+
continue
|
|
192
|
+
if 200 <= response.status_code < 300:
|
|
193
|
+
return self._decode_response(response, idempotency_key=idempotency_key)
|
|
194
|
+
last_error = self._api_error(response, idempotency_key=idempotency_key)
|
|
195
|
+
if retry_index >= self.max_retries or not self._post_retryable(response):
|
|
196
|
+
raise last_error
|
|
197
|
+
time.sleep(self._delay(response, retry_index))
|
|
198
|
+
assert last_error is not None
|
|
199
|
+
raise last_error
|
|
200
|
+
|
|
201
|
+
def get(self, request_id: str, timeout: float | httpx.Timeout | None) -> RuntimeRequest:
|
|
202
|
+
last_error: GatewayError | None = None
|
|
203
|
+
for retry_index in range(self.max_retries + 1):
|
|
204
|
+
try:
|
|
205
|
+
response = self.client.get(
|
|
206
|
+
self._url(request_id),
|
|
207
|
+
headers=self.headers,
|
|
208
|
+
timeout=timeout if timeout is not None else self.timeout,
|
|
209
|
+
follow_redirects=False,
|
|
210
|
+
)
|
|
211
|
+
except httpx.TimeoutException as exc:
|
|
212
|
+
last_error = APITimeoutError("Gateway request timed out.", retryable=True)
|
|
213
|
+
if retry_index >= self.max_retries:
|
|
214
|
+
raise last_error from exc
|
|
215
|
+
time.sleep(self._delay(None, retry_index))
|
|
216
|
+
continue
|
|
217
|
+
except httpx.RequestError as exc:
|
|
218
|
+
last_error = APIConnectionError("Could not connect to the gateway.", retryable=True)
|
|
219
|
+
if retry_index >= self.max_retries:
|
|
220
|
+
raise last_error from exc
|
|
221
|
+
time.sleep(self._delay(None, retry_index))
|
|
222
|
+
continue
|
|
223
|
+
if 200 <= response.status_code < 300:
|
|
224
|
+
return self._decode_response(response)
|
|
225
|
+
last_error = self._api_error(response)
|
|
226
|
+
if retry_index >= self.max_retries or response.status_code not in _RETRYABLE_STATUSES:
|
|
227
|
+
raise last_error
|
|
228
|
+
time.sleep(self._delay(response, retry_index))
|
|
229
|
+
assert last_error is not None
|
|
230
|
+
raise last_error
|
|
231
|
+
|
|
232
|
+
def close(self) -> None:
|
|
233
|
+
if self._owns_client:
|
|
234
|
+
self.client.close()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class AsyncTransport(BaseTransport):
|
|
238
|
+
def __init__(self, *, http_client: httpx.AsyncClient | None = None, **kwargs):
|
|
239
|
+
super().__init__(**kwargs)
|
|
240
|
+
self._owns_client = http_client is None
|
|
241
|
+
self.client = http_client or httpx.AsyncClient(timeout=self.timeout, follow_redirects=False, verify=True)
|
|
242
|
+
|
|
243
|
+
async def create(self, body: bytes, idempotency_key: str, timeout: float | httpx.Timeout | None) -> RuntimeRequest:
|
|
244
|
+
last_error: GatewayError | None = None
|
|
245
|
+
for retry_index in range(self.max_retries + 1):
|
|
246
|
+
try:
|
|
247
|
+
response = await self.client.post(
|
|
248
|
+
self._url(),
|
|
249
|
+
content=body,
|
|
250
|
+
headers={**self.headers, "Idempotency-Key": idempotency_key},
|
|
251
|
+
timeout=timeout if timeout is not None else self.timeout,
|
|
252
|
+
follow_redirects=False,
|
|
253
|
+
)
|
|
254
|
+
except httpx.TimeoutException as exc:
|
|
255
|
+
last_error = APITimeoutError(
|
|
256
|
+
"Gateway request timed out.", idempotency_key=idempotency_key, retryable=True
|
|
257
|
+
)
|
|
258
|
+
if retry_index >= self.max_retries:
|
|
259
|
+
raise last_error from exc
|
|
260
|
+
await asyncio.sleep(self._delay(None, retry_index))
|
|
261
|
+
continue
|
|
262
|
+
except httpx.RequestError as exc:
|
|
263
|
+
last_error = APIConnectionError(
|
|
264
|
+
"Could not connect to the gateway.", idempotency_key=idempotency_key, retryable=True
|
|
265
|
+
)
|
|
266
|
+
if retry_index >= self.max_retries:
|
|
267
|
+
raise last_error from exc
|
|
268
|
+
await asyncio.sleep(self._delay(None, retry_index))
|
|
269
|
+
continue
|
|
270
|
+
if 200 <= response.status_code < 300:
|
|
271
|
+
return self._decode_response(response, idempotency_key=idempotency_key)
|
|
272
|
+
last_error = self._api_error(response, idempotency_key=idempotency_key)
|
|
273
|
+
if retry_index >= self.max_retries or not self._post_retryable(response):
|
|
274
|
+
raise last_error
|
|
275
|
+
await asyncio.sleep(self._delay(response, retry_index))
|
|
276
|
+
assert last_error is not None
|
|
277
|
+
raise last_error
|
|
278
|
+
|
|
279
|
+
async def get(self, request_id: str, timeout: float | httpx.Timeout | None) -> RuntimeRequest:
|
|
280
|
+
last_error: GatewayError | None = None
|
|
281
|
+
for retry_index in range(self.max_retries + 1):
|
|
282
|
+
try:
|
|
283
|
+
response = await self.client.get(
|
|
284
|
+
self._url(request_id),
|
|
285
|
+
headers=self.headers,
|
|
286
|
+
timeout=timeout if timeout is not None else self.timeout,
|
|
287
|
+
follow_redirects=False,
|
|
288
|
+
)
|
|
289
|
+
except httpx.TimeoutException as exc:
|
|
290
|
+
last_error = APITimeoutError("Gateway request timed out.", retryable=True)
|
|
291
|
+
if retry_index >= self.max_retries:
|
|
292
|
+
raise last_error from exc
|
|
293
|
+
await asyncio.sleep(self._delay(None, retry_index))
|
|
294
|
+
continue
|
|
295
|
+
except httpx.RequestError as exc:
|
|
296
|
+
last_error = APIConnectionError("Could not connect to the gateway.", retryable=True)
|
|
297
|
+
if retry_index >= self.max_retries:
|
|
298
|
+
raise last_error from exc
|
|
299
|
+
await asyncio.sleep(self._delay(None, retry_index))
|
|
300
|
+
continue
|
|
301
|
+
if 200 <= response.status_code < 300:
|
|
302
|
+
return self._decode_response(response)
|
|
303
|
+
last_error = self._api_error(response)
|
|
304
|
+
if retry_index >= self.max_retries or response.status_code not in _RETRYABLE_STATUSES:
|
|
305
|
+
raise last_error
|
|
306
|
+
await asyncio.sleep(self._delay(response, retry_index))
|
|
307
|
+
assert last_error is not None
|
|
308
|
+
raise last_error
|
|
309
|
+
|
|
310
|
+
async def aclose(self) -> None:
|
|
311
|
+
if self._owns_client:
|
|
312
|
+
await self.client.aclose()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _normalize_base_url(base_url: str) -> str:
|
|
316
|
+
try:
|
|
317
|
+
parsed = httpx.URL(base_url.strip())
|
|
318
|
+
except (httpx.InvalidURL, TypeError, ValueError):
|
|
319
|
+
raise ConfigurationError("base_url must be a valid HTTP(S) API root") from None
|
|
320
|
+
if parsed.scheme not in {"http", "https"} or not parsed.host or parsed.username or parsed.password:
|
|
321
|
+
raise ConfigurationError("base_url must be a valid HTTP(S) API root")
|
|
322
|
+
if parsed.query or parsed.fragment:
|
|
323
|
+
raise ConfigurationError("base_url must not include a query string or fragment")
|
|
324
|
+
return str(parsed.copy_with(path=parsed.path.rstrip("/"), query=None, fragment=None)).rstrip("/")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _require_api_version(response: httpx.Response) -> None:
|
|
328
|
+
value = response.headers.get("X-Gateway-API-Version", "")
|
|
329
|
+
try:
|
|
330
|
+
major = int(value.split(".", 1)[0])
|
|
331
|
+
except ValueError as exc:
|
|
332
|
+
raise APIResponseValidationError(
|
|
333
|
+
"Gateway success response omitted a valid API version.",
|
|
334
|
+
status_code=response.status_code,
|
|
335
|
+
request_id=response.headers.get("X-Request-ID"),
|
|
336
|
+
) from exc
|
|
337
|
+
if major != 1:
|
|
338
|
+
raise APIResponseValidationError(
|
|
339
|
+
"Gateway API major version is not supported.",
|
|
340
|
+
code=value or None,
|
|
341
|
+
status_code=response.status_code,
|
|
342
|
+
request_id=response.headers.get("X-Request-ID"),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _read_error(response: httpx.Response) -> dict[str, Any]:
|
|
347
|
+
try:
|
|
348
|
+
data = response.json()
|
|
349
|
+
except (json.JSONDecodeError, ValueError):
|
|
350
|
+
return {}
|
|
351
|
+
error = data.get("error") if isinstance(data, dict) else None
|
|
352
|
+
return error if isinstance(error, dict) else {}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _parse_retry_after(value: str) -> float | None:
|
|
356
|
+
try:
|
|
357
|
+
return max(0.0, float(int(value.strip())))
|
|
358
|
+
except ValueError:
|
|
359
|
+
try:
|
|
360
|
+
retry_time = parsedate_to_datetime(value)
|
|
361
|
+
except (TypeError, ValueError, OverflowError):
|
|
362
|
+
return None
|
|
363
|
+
if retry_time.tzinfo is None:
|
|
364
|
+
retry_time = retry_time.replace(tzinfo=UTC)
|
|
365
|
+
return max(0.0, (retry_time.astimezone(UTC) - datetime.now(UTC)).total_seconds())
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _user_agent() -> str:
|
|
369
|
+
try:
|
|
370
|
+
package_version = version("p034-gateway-sdk")
|
|
371
|
+
except PackageNotFoundError:
|
|
372
|
+
package_version = "0.1.0"
|
|
373
|
+
return f"p034-gateway-sdk/{package_version}"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _redact(value: Any, secret: str) -> Any:
|
|
377
|
+
if isinstance(value, str):
|
|
378
|
+
return value.replace(secret, "[redacted]") if secret else value
|
|
379
|
+
if isinstance(value, list):
|
|
380
|
+
return [_redact(item, secret) for item in value]
|
|
381
|
+
if isinstance(value, dict):
|
|
382
|
+
return {_redact(key, secret): _redact(item, secret) for key, item in value.items()}
|
|
383
|
+
return value
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from p034_gateway._transport import AsyncTransport, SyncTransport
|
|
9
|
+
from p034_gateway.exceptions import ConfigurationError
|
|
10
|
+
from p034_gateway.resources.requests import AsyncRequests, Requests
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resolve_config(api_key: str | None, base_url: str | None) -> tuple[str, str]:
|
|
14
|
+
resolved_key = api_key if api_key is not None else os.getenv("GATEWAY_API_KEY")
|
|
15
|
+
resolved_url = base_url if base_url is not None else os.getenv("GATEWAY_BASE_URL")
|
|
16
|
+
if not isinstance(resolved_key, str) or not resolved_key.strip() or any(char.isspace() for char in resolved_key):
|
|
17
|
+
raise ConfigurationError("api_key is required; set api_key or GATEWAY_API_KEY.")
|
|
18
|
+
if not isinstance(resolved_url, str) or not resolved_url.strip():
|
|
19
|
+
raise ConfigurationError("base_url is required; set base_url or GATEWAY_BASE_URL.")
|
|
20
|
+
try:
|
|
21
|
+
parsed = httpx.URL(resolved_url.strip())
|
|
22
|
+
except (httpx.InvalidURL, TypeError, ValueError):
|
|
23
|
+
raise ConfigurationError("base_url must be a valid HTTP(S) API root.") from None
|
|
24
|
+
if (
|
|
25
|
+
parsed.scheme not in {"http", "https"}
|
|
26
|
+
or not parsed.host
|
|
27
|
+
or parsed.username
|
|
28
|
+
or parsed.password
|
|
29
|
+
or parsed.query
|
|
30
|
+
or parsed.fragment
|
|
31
|
+
):
|
|
32
|
+
raise ConfigurationError("base_url must be a valid HTTP(S) API root.")
|
|
33
|
+
return resolved_key.strip(), resolved_url.strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validate_retries(max_retries: int) -> None:
|
|
37
|
+
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or not 0 <= max_retries <= 3:
|
|
38
|
+
raise ConfigurationError("max_retries must be an integer from 0 to 3.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GatewayClient:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
api_key: str | None = None,
|
|
46
|
+
base_url: str | None = None,
|
|
47
|
+
timeout: float | httpx.Timeout | None = None,
|
|
48
|
+
max_retries: int = 0,
|
|
49
|
+
http_client: httpx.Client | None = None,
|
|
50
|
+
):
|
|
51
|
+
self.api_key, self.base_url = _resolve_config(api_key, base_url)
|
|
52
|
+
_validate_retries(max_retries)
|
|
53
|
+
self.timeout = timeout if timeout is not None else httpx.Timeout(connect=5, pool=5, write=10, read=130)
|
|
54
|
+
self._transport = SyncTransport(
|
|
55
|
+
api_key=self.api_key,
|
|
56
|
+
base_url=self.base_url,
|
|
57
|
+
timeout=self.timeout,
|
|
58
|
+
max_retries=max_retries,
|
|
59
|
+
http_client=http_client,
|
|
60
|
+
)
|
|
61
|
+
self.requests = Requests(self._transport)
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
self._transport.close()
|
|
65
|
+
|
|
66
|
+
def __enter__(self) -> Self:
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, exc_type, exc, traceback) -> None:
|
|
70
|
+
self.close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AsyncGatewayClient:
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
api_key: str | None = None,
|
|
78
|
+
base_url: str | None = None,
|
|
79
|
+
timeout: float | httpx.Timeout | None = None,
|
|
80
|
+
max_retries: int = 0,
|
|
81
|
+
http_client: httpx.AsyncClient | None = None,
|
|
82
|
+
):
|
|
83
|
+
self.api_key, self.base_url = _resolve_config(api_key, base_url)
|
|
84
|
+
_validate_retries(max_retries)
|
|
85
|
+
self.timeout = timeout if timeout is not None else httpx.Timeout(connect=5, pool=5, write=10, read=130)
|
|
86
|
+
self._transport = AsyncTransport(
|
|
87
|
+
api_key=self.api_key,
|
|
88
|
+
base_url=self.base_url,
|
|
89
|
+
timeout=self.timeout,
|
|
90
|
+
max_retries=max_retries,
|
|
91
|
+
http_client=http_client,
|
|
92
|
+
)
|
|
93
|
+
self.requests = AsyncRequests(self._transport)
|
|
94
|
+
|
|
95
|
+
async def aclose(self) -> None:
|
|
96
|
+
await self._transport.aclose()
|
|
97
|
+
|
|
98
|
+
async def __aenter__(self) -> Self:
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
async def __aexit__(self, exc_type, exc, traceback) -> None:
|
|
102
|
+
await self.aclose()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GatewayError(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
code: str | None = None,
|
|
12
|
+
status_code: int | None = None,
|
|
13
|
+
request_id: str | None = None,
|
|
14
|
+
idempotency_key: str | None = None,
|
|
15
|
+
retryable: bool = False,
|
|
16
|
+
details: dict[str, Any] | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
self.message = message
|
|
19
|
+
self.code = code
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.request_id = request_id
|
|
22
|
+
self.idempotency_key = idempotency_key
|
|
23
|
+
self.retryable = retryable
|
|
24
|
+
self.details = details or {}
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return (
|
|
29
|
+
f"{type(self).__name__}(message={self.message!r}, code={self.code!r}, "
|
|
30
|
+
f"status_code={self.status_code!r}, request_id={self.request_id!r}, "
|
|
31
|
+
f"idempotency_key={self.idempotency_key!r})"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConfigurationError(GatewayError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuthenticationError(GatewayError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PermissionDeniedError(GatewayError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotFoundError(GatewayError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ConflictError(GatewayError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RequestInProgressError(ConflictError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RequestOutcomeUnknownError(ConflictError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RequestTooLargeError(GatewayError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ValidationError(GatewayError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RateLimitError(GatewayError):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ProviderExecutionError(GatewayError):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ServiceUnavailableError(GatewayError):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class APIError(GatewayError):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class APIConnectionError(GatewayError):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class APITimeoutError(APIConnectionError):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class APIResponseValidationError(GatewayError):
|
|
96
|
+
pass
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RequestConstraints(BaseModel):
|
|
10
|
+
"""Request-level routing and output constraints supported by API v1."""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(extra="forbid")
|
|
13
|
+
|
|
14
|
+
quality_priority: Literal["cost_first", "balanced", "latency_first", "quality_first"] | None = None
|
|
15
|
+
max_output_tokens: StrictInt | None = Field(default=None, ge=1, le=32_768)
|
|
16
|
+
max_latency_ms: StrictInt | None = Field(default=None, gt=0)
|
|
17
|
+
allowed_models: list[StrictStr] | None = None
|
|
18
|
+
blocked_models: list[StrictStr] | None = None
|
|
19
|
+
allowed_providers: list[StrictStr] | None = None
|
|
20
|
+
blocked_providers: list[StrictStr] | None = None
|
|
21
|
+
external_provider_allowed: StrictBool | None = None
|
|
22
|
+
output_format: Literal["text", "markdown", "json"] | None = None
|
|
23
|
+
allow_partial_response: StrictBool | None = None
|
|
24
|
+
|
|
25
|
+
def to_api_dict(self) -> dict[str, Any]:
|
|
26
|
+
values = self.model_dump(mode="json", exclude_none=True, exclude_unset=True)
|
|
27
|
+
for name in ("allowed_models", "blocked_models", "allowed_providers", "blocked_providers"):
|
|
28
|
+
if name in values:
|
|
29
|
+
values[name] = sorted(set(values[name]))
|
|
30
|
+
return values
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RuntimeUsageScope(BaseModel):
|
|
34
|
+
model_config = ConfigDict(extra="ignore")
|
|
35
|
+
|
|
36
|
+
input_tokens: int
|
|
37
|
+
output_tokens: int
|
|
38
|
+
total_tokens: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RuntimeUsage(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="ignore")
|
|
43
|
+
|
|
44
|
+
execution: RuntimeUsageScope | None = None
|
|
45
|
+
pipeline: RuntimeUsageScope | None = None
|
|
46
|
+
complete: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RuntimeCost(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="ignore")
|
|
51
|
+
|
|
52
|
+
currency: Literal["USD"] = "USD"
|
|
53
|
+
estimated_cost: float
|
|
54
|
+
estimated: bool = True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RuntimeWarning(BaseModel):
|
|
58
|
+
model_config = ConfigDict(extra="ignore")
|
|
59
|
+
|
|
60
|
+
code: str
|
|
61
|
+
message: str
|
|
62
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RuntimeOperationError(BaseModel):
|
|
66
|
+
model_config = ConfigDict(extra="ignore")
|
|
67
|
+
|
|
68
|
+
code: str
|
|
69
|
+
message: str
|
|
70
|
+
retryable: bool = False
|
|
71
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RuntimeRequest(BaseModel):
|
|
75
|
+
"""Typed API receipt with SDK-only idempotency metadata."""
|
|
76
|
+
|
|
77
|
+
model_config = ConfigDict(extra="ignore")
|
|
78
|
+
|
|
79
|
+
request_id: str
|
|
80
|
+
application_id: str
|
|
81
|
+
status: Literal["running", "completed", "partial", "failed", "indeterminate"]
|
|
82
|
+
execution_mode: Literal["offline", "live"]
|
|
83
|
+
output: str | None = None
|
|
84
|
+
models_used: list[str] = Field(default_factory=list)
|
|
85
|
+
providers_used: list[str] = Field(default_factory=list)
|
|
86
|
+
usage: RuntimeUsage | None = None
|
|
87
|
+
cost: RuntimeCost | None = None
|
|
88
|
+
latency_ms: float | None = None
|
|
89
|
+
fallback_used: bool = False
|
|
90
|
+
config_version: int
|
|
91
|
+
started_at: datetime
|
|
92
|
+
finished_at: datetime | None = None
|
|
93
|
+
warnings: list[RuntimeWarning] = Field(default_factory=list)
|
|
94
|
+
error: RuntimeOperationError | None = None
|
|
95
|
+
idempotency_key: str | None = None
|
|
96
|
+
replayed: bool = False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, StrictStr, field_validator
|
|
9
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
10
|
+
|
|
11
|
+
from p034_gateway._transport import AsyncTransport, SyncTransport
|
|
12
|
+
from p034_gateway.exceptions import ConfigurationError, ValidationError
|
|
13
|
+
from p034_gateway.models import RequestConstraints, RuntimeRequest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _CreateRequest(BaseModel):
|
|
17
|
+
model_config = ConfigDict(extra="forbid")
|
|
18
|
+
|
|
19
|
+
prompt: StrictStr
|
|
20
|
+
context: dict[str, Any] | None = None
|
|
21
|
+
constraints: RequestConstraints | None = None
|
|
22
|
+
metadata: dict[str, Any] | None = None
|
|
23
|
+
|
|
24
|
+
@field_validator("prompt")
|
|
25
|
+
@classmethod
|
|
26
|
+
def require_prompt_text(cls, value: str) -> str:
|
|
27
|
+
if not value.strip():
|
|
28
|
+
raise ValueError("prompt must contain non-whitespace characters")
|
|
29
|
+
if len(value) > 100_000:
|
|
30
|
+
raise ValueError("prompt must not exceed 100000 characters")
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Requests:
|
|
35
|
+
def __init__(self, transport: SyncTransport):
|
|
36
|
+
self._transport = transport
|
|
37
|
+
|
|
38
|
+
def create(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
prompt: str,
|
|
42
|
+
context: dict[str, Any] | None = None,
|
|
43
|
+
constraints: RequestConstraints | dict[str, Any] | None = None,
|
|
44
|
+
metadata: dict[str, Any] | None = None,
|
|
45
|
+
idempotency_key: str | None = None,
|
|
46
|
+
timeout: float | httpx.Timeout | None = None,
|
|
47
|
+
) -> RuntimeRequest:
|
|
48
|
+
body, key = _serialize_create(prompt, context, constraints, metadata, idempotency_key)
|
|
49
|
+
return self._transport.create(body, key, timeout)
|
|
50
|
+
|
|
51
|
+
def get(self, request_id: str, *, timeout: float | httpx.Timeout | None = None) -> RuntimeRequest:
|
|
52
|
+
_validate_request_id(request_id)
|
|
53
|
+
return self._transport.get(request_id, timeout)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AsyncRequests:
|
|
57
|
+
def __init__(self, transport: AsyncTransport):
|
|
58
|
+
self._transport = transport
|
|
59
|
+
|
|
60
|
+
async def create(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
prompt: str,
|
|
64
|
+
context: dict[str, Any] | None = None,
|
|
65
|
+
constraints: RequestConstraints | dict[str, Any] | None = None,
|
|
66
|
+
metadata: dict[str, Any] | None = None,
|
|
67
|
+
idempotency_key: str | None = None,
|
|
68
|
+
timeout: float | httpx.Timeout | None = None,
|
|
69
|
+
) -> RuntimeRequest:
|
|
70
|
+
body, key = _serialize_create(prompt, context, constraints, metadata, idempotency_key)
|
|
71
|
+
return await self._transport.create(body, key, timeout)
|
|
72
|
+
|
|
73
|
+
async def get(self, request_id: str, *, timeout: float | httpx.Timeout | None = None) -> RuntimeRequest:
|
|
74
|
+
_validate_request_id(request_id)
|
|
75
|
+
return await self._transport.get(request_id, timeout)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _serialize_create(
|
|
79
|
+
prompt: str,
|
|
80
|
+
context: dict[str, Any] | None,
|
|
81
|
+
constraints: RequestConstraints | dict[str, Any] | None,
|
|
82
|
+
metadata: dict[str, Any] | None,
|
|
83
|
+
idempotency_key: str | None,
|
|
84
|
+
) -> tuple[bytes, str]:
|
|
85
|
+
if idempotency_key is None:
|
|
86
|
+
key = str(uuid4())
|
|
87
|
+
elif (
|
|
88
|
+
not isinstance(idempotency_key, str)
|
|
89
|
+
or not 1 <= len(idempotency_key) <= 128
|
|
90
|
+
or any(ord(char) < 0x21 or ord(char) > 0x7E for char in idempotency_key)
|
|
91
|
+
):
|
|
92
|
+
raise ValidationError("idempotency_key must contain 1 to 128 visible ASCII characters.")
|
|
93
|
+
else:
|
|
94
|
+
key = idempotency_key
|
|
95
|
+
try:
|
|
96
|
+
constraint_model = (
|
|
97
|
+
None
|
|
98
|
+
if constraints is None
|
|
99
|
+
else constraints
|
|
100
|
+
if isinstance(constraints, RequestConstraints)
|
|
101
|
+
else RequestConstraints.model_validate(constraints)
|
|
102
|
+
)
|
|
103
|
+
request = _CreateRequest.model_validate(
|
|
104
|
+
{"prompt": prompt, "context": context, "constraints": constraint_model, "metadata": metadata}
|
|
105
|
+
)
|
|
106
|
+
body_data: dict[str, Any] = {"prompt": request.prompt}
|
|
107
|
+
if context is not None:
|
|
108
|
+
body_data["context"] = request.context
|
|
109
|
+
if constraint_model is not None:
|
|
110
|
+
body_data["constraints"] = constraint_model.to_api_dict()
|
|
111
|
+
if metadata is not None:
|
|
112
|
+
body_data["metadata"] = request.metadata
|
|
113
|
+
body = json.dumps(body_data, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode(
|
|
114
|
+
"utf-8"
|
|
115
|
+
)
|
|
116
|
+
except PydanticValidationError as exc:
|
|
117
|
+
fields = [".".join(str(piece) for piece in error.get("loc", [])) for error in exc.errors(include_input=False)]
|
|
118
|
+
raise ValidationError("Request arguments failed local validation.", details={"fields": fields}) from None
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
raise ValidationError("context and metadata must contain finite JSON values.") from None
|
|
121
|
+
return body, key
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _validate_request_id(request_id: str) -> None:
|
|
125
|
+
if not isinstance(request_id, str) or not request_id or len(request_id) > 128:
|
|
126
|
+
raise ConfigurationError("request_id must be a non-empty string of at most 128 characters.")
|