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.
@@ -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,3 @@
1
+ from p034_gateway.resources.requests import AsyncRequests, Requests
2
+
3
+ __all__ = ["AsyncRequests", "Requests"]
@@ -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.")