vin-guard-sdk 0.3.0__py3-none-any.whl
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.
- ai_gateway/__init__.py +23 -0
- ai_gateway/_shared.py +116 -0
- ai_gateway/_sse.py +59 -0
- ai_gateway/async_client.py +99 -0
- ai_gateway/client.py +169 -0
- ai_gateway/control_plane.py +161 -0
- ai_gateway/errors.py +264 -0
- ai_gateway/langchain.py +210 -0
- ai_gateway/py.typed +0 -0
- ai_gateway/types.py +19 -0
- vin_guard_sdk-0.3.0.dist-info/METADATA +268 -0
- vin_guard_sdk-0.3.0.dist-info/RECORD +14 -0
- vin_guard_sdk-0.3.0.dist-info/WHEEL +5 -0
- vin_guard_sdk-0.3.0.dist-info/top_level.txt +1 -0
ai_gateway/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""AI Gateway Python SDK — Level 1 thin wrapper (API_DESIGN §3)."""
|
|
2
|
+
|
|
3
|
+
from .client import ChatCompletion, GatewayClient, ModelEntry
|
|
4
|
+
from .async_client import AsyncGatewayClient
|
|
5
|
+
from .control_plane import ControlPlaneClient, Page
|
|
6
|
+
from .errors import (GatewayConnectionError, GatewayError, GatewayTimeoutError,
|
|
7
|
+
GatewayTransportError)
|
|
8
|
+
from .types import GatewayInfo, Usage
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AsyncGatewayClient",
|
|
12
|
+
"ControlPlaneClient",
|
|
13
|
+
"Page",
|
|
14
|
+
"ChatCompletion",
|
|
15
|
+
"GatewayClient",
|
|
16
|
+
"GatewayConnectionError",
|
|
17
|
+
"GatewayError",
|
|
18
|
+
"GatewayInfo",
|
|
19
|
+
"GatewayTimeoutError",
|
|
20
|
+
"GatewayTransportError",
|
|
21
|
+
"ModelEntry",
|
|
22
|
+
"Usage",
|
|
23
|
+
]
|
ai_gateway/_shared.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Wire encoding shared by the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .errors import GatewayError, snippet, transport_error_from
|
|
10
|
+
|
|
11
|
+
GATEWAY_HEADERS = (
|
|
12
|
+
"x-gateway-request-id", "x-gateway-provider", "x-gateway-model",
|
|
13
|
+
"x-gateway-route-tier", "x-gateway-route-reason", "x-gateway-cost-usd",
|
|
14
|
+
"x-gateway-latency-ms", "x-ratelimit-remaining-requests",
|
|
15
|
+
"x-ratelimit-remaining-tokens", "retry-after",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def gateway_headers(response: httpx.Response) -> dict[str, str]:
|
|
20
|
+
return {key: response.headers[key] for key in GATEWAY_HEADERS if key in response.headers}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _error_payload(response: httpx.Response) -> Any:
|
|
24
|
+
"""Parse an error body without ever masking the HTTP status.
|
|
25
|
+
|
|
26
|
+
Non-JSON, empty or HTML bodies become a bounded plain-text message; the
|
|
27
|
+
original HTTP status is always carried by :meth:`GatewayError.from_response`.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
return response.json()
|
|
31
|
+
except ValueError:
|
|
32
|
+
text = response.text
|
|
33
|
+
if text and text.strip():
|
|
34
|
+
return {"error": {"message": snippet(text)}}
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def raise_for_status(response: httpx.Response) -> None:
|
|
39
|
+
if response.is_success:
|
|
40
|
+
return
|
|
41
|
+
payload = _error_payload(response)
|
|
42
|
+
raise GatewayError.from_response(response.status_code, payload, response.headers)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@contextmanager
|
|
46
|
+
def translate_transport_errors():
|
|
47
|
+
"""Map HTTPX transport failures to SDK errors, preserving cancellation.
|
|
48
|
+
|
|
49
|
+
Only ``httpx.TransportError`` is caught, so ``KeyboardInterrupt``,
|
|
50
|
+
``asyncio.CancelledError`` and input ``ValueError``/``TypeError`` pass
|
|
51
|
+
through untouched.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
yield
|
|
55
|
+
except httpx.TransportError as exc:
|
|
56
|
+
raise transport_error_from(exc) from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@asynccontextmanager
|
|
60
|
+
async def translate_transport_errors_async():
|
|
61
|
+
try:
|
|
62
|
+
yield
|
|
63
|
+
except httpx.TransportError as exc:
|
|
64
|
+
raise transport_error_from(exc) from exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def gateway_extras(*, tags=None, metadata=None, route_policy=None,
|
|
68
|
+
no_store=False, idempotency_key=None) -> dict[str, str]:
|
|
69
|
+
extra = {}
|
|
70
|
+
encoded = tags if isinstance(tags, str) else ",".join(f"{k}={v}" for k, v in (tags or {}).items())
|
|
71
|
+
if encoded:
|
|
72
|
+
extra["X-Gateway-Tags"] = encoded
|
|
73
|
+
if metadata is not None:
|
|
74
|
+
value = json.dumps(metadata, separators=(",", ":"), allow_nan=False)
|
|
75
|
+
if len(value.encode("utf-8")) > 4096:
|
|
76
|
+
raise ValueError("metadata must fit in the 4KB gateway header")
|
|
77
|
+
extra["X-Gateway-Metadata"] = value
|
|
78
|
+
if route_policy:
|
|
79
|
+
extra["X-Gateway-Route-Policy"] = route_policy
|
|
80
|
+
if no_store:
|
|
81
|
+
extra["X-Gateway-No-Store"] = "true"
|
|
82
|
+
if idempotency_key:
|
|
83
|
+
extra["Idempotency-Key"] = idempotency_key
|
|
84
|
+
return extra
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def chat_request(*, model: str, messages: list[dict[str, Any]], stream=False,
|
|
88
|
+
temperature=None, max_tokens=None, top_p=None, stop=None,
|
|
89
|
+
tools=None, tool_choice=None, response_format=None, user=None,
|
|
90
|
+
seed=None, tags=None, metadata=None, route_policy=None,
|
|
91
|
+
no_store=False, idempotency_key=None):
|
|
92
|
+
"""Accept the documented chat subset; reject misspelled options."""
|
|
93
|
+
body = {"model": model, "messages": messages, "stream": stream}
|
|
94
|
+
options = dict(temperature=temperature, max_tokens=max_tokens, top_p=top_p,
|
|
95
|
+
stop=stop, tools=tools, tool_choice=tool_choice,
|
|
96
|
+
response_format=response_format, user=user, seed=seed)
|
|
97
|
+
body.update({key: value for key, value in options.items() if value is not None})
|
|
98
|
+
return body, gateway_extras(tags=tags, metadata=metadata, route_policy=route_policy,
|
|
99
|
+
no_store=no_store, idempotency_key=idempotency_key)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def health_url(base_url: str, endpoint: str) -> str:
|
|
103
|
+
url = httpx.URL(base_url)
|
|
104
|
+
if url.scheme not in ("http", "https") or not url.host:
|
|
105
|
+
raise ValueError("base_url must be an absolute HTTP(S) URL")
|
|
106
|
+
path = url.path.rstrip("/")
|
|
107
|
+
if path.endswith("/v1"):
|
|
108
|
+
path = path[:-3]
|
|
109
|
+
return str(url.copy_with(path=f"{path}/{endpoint}", query=None, fragment=None))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def ensure_sse(response: httpx.Response) -> None:
|
|
113
|
+
if response.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "text/event-stream":
|
|
114
|
+
raise GatewayError("Gateway returned a non-SSE response; this backend does not stream yet",
|
|
115
|
+
code="streaming_not_supported", status=response.status_code,
|
|
116
|
+
request_id=response.headers.get("x-gateway-request-id"))
|
ai_gateway/_sse.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""OpenAI SSE decoding with multiline events and visible protocol errors."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable, Iterator
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .errors import GatewayError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SSEDecoder:
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._data: list[str] = []
|
|
13
|
+
self.done = False
|
|
14
|
+
|
|
15
|
+
def feed(self, line: str) -> dict[str, Any] | None:
|
|
16
|
+
if self.done:
|
|
17
|
+
return None
|
|
18
|
+
if line.strip():
|
|
19
|
+
if line.startswith("data:"):
|
|
20
|
+
self._data.append(line[5:].removeprefix(" "))
|
|
21
|
+
return None
|
|
22
|
+
if not self._data:
|
|
23
|
+
return None
|
|
24
|
+
data = "\n".join(self._data)
|
|
25
|
+
self._data.clear()
|
|
26
|
+
if data.strip() == "[DONE]":
|
|
27
|
+
self.done = True
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
event = json.loads(data)
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
raise GatewayError("Invalid JSON in gateway SSE event", code="invalid_stream") from exc
|
|
33
|
+
if not isinstance(event, dict):
|
|
34
|
+
raise GatewayError("Expected an object in gateway SSE event", code="invalid_stream")
|
|
35
|
+
if "error" in event:
|
|
36
|
+
raise GatewayError.from_response(200, event, {})
|
|
37
|
+
return event
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_sse_lines(lines: Iterable[str]) -> Iterator[dict[str, Any]]:
|
|
41
|
+
decoder = SSEDecoder()
|
|
42
|
+
for line in lines:
|
|
43
|
+
event = decoder.feed(line)
|
|
44
|
+
if decoder.done:
|
|
45
|
+
return
|
|
46
|
+
if event is not None:
|
|
47
|
+
yield event
|
|
48
|
+
event = decoder.feed("")
|
|
49
|
+
if event is not None:
|
|
50
|
+
yield event
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def iter_text_chunks(events: Iterable[dict[str, Any]]) -> Iterator[str]:
|
|
54
|
+
for event in events:
|
|
55
|
+
choices = event.get("choices") or []
|
|
56
|
+
if choices and isinstance(choices[0], dict):
|
|
57
|
+
content = (choices[0].get("delta") or {}).get("content")
|
|
58
|
+
if content:
|
|
59
|
+
yield content
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Native HTTPX async counterpart of GatewayClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._shared import (chat_request, ensure_sse, health_url, raise_for_status,
|
|
11
|
+
translate_transport_errors_async)
|
|
12
|
+
from ._sse import SSEDecoder
|
|
13
|
+
from .client import ChatCompletion, ModelEntry, parse_chat_response, parse_models
|
|
14
|
+
from .errors import GatewayError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncGatewayClient:
|
|
18
|
+
def __init__(self, *, api_key: str,
|
|
19
|
+
base_url: str = "https://gateway.example.com/v1",
|
|
20
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
21
|
+
transport: httpx.AsyncBaseTransport | None = None) -> None:
|
|
22
|
+
if not api_key:
|
|
23
|
+
raise ValueError("api_key is required (gw_live_...)")
|
|
24
|
+
self._health_url = health_url(base_url, "healthz")
|
|
25
|
+
self._ready_url = health_url(base_url, "readyz")
|
|
26
|
+
self._http = httpx.AsyncClient(base_url=base_url.rstrip("/"), timeout=timeout,
|
|
27
|
+
transport=transport,
|
|
28
|
+
headers={"Authorization": f"Bearer {api_key}"})
|
|
29
|
+
|
|
30
|
+
async def chat_completions(self, *, model: str, messages: list[dict[str, Any]],
|
|
31
|
+
**options: Any) -> ChatCompletion:
|
|
32
|
+
body, headers = chat_request(model=model, messages=messages, **options)
|
|
33
|
+
if body["stream"]:
|
|
34
|
+
raise ValueError("Use stream_chat_completions for stream=True")
|
|
35
|
+
async with translate_transport_errors_async():
|
|
36
|
+
response = await self._http.post("/chat/completions", json=body, headers=headers)
|
|
37
|
+
raise_for_status(response)
|
|
38
|
+
return parse_chat_response(response)
|
|
39
|
+
|
|
40
|
+
async def stream_chat_completions(self, *, model: str, messages: list[dict[str, Any]],
|
|
41
|
+
**options: Any) -> AsyncIterator[dict[str, Any]]:
|
|
42
|
+
body, headers = chat_request(model=model, messages=messages, stream=True, **options)
|
|
43
|
+
async with translate_transport_errors_async():
|
|
44
|
+
async with self._http.stream("POST", "/chat/completions", json=body, headers=headers) as response:
|
|
45
|
+
if not response.is_success:
|
|
46
|
+
await response.aread()
|
|
47
|
+
raise_for_status(response)
|
|
48
|
+
ensure_sse(response)
|
|
49
|
+
decoder = SSEDecoder()
|
|
50
|
+
async for line in response.aiter_lines():
|
|
51
|
+
event = decoder.feed(line)
|
|
52
|
+
if decoder.done:
|
|
53
|
+
return
|
|
54
|
+
if event is not None:
|
|
55
|
+
yield event
|
|
56
|
+
event = decoder.feed("")
|
|
57
|
+
if event is not None:
|
|
58
|
+
yield event
|
|
59
|
+
|
|
60
|
+
async def stream_text(self, **kwargs: Any) -> str:
|
|
61
|
+
parts = []
|
|
62
|
+
async for event in self.stream_chat_completions(**kwargs):
|
|
63
|
+
choices = event.get("choices") or []
|
|
64
|
+
if choices and isinstance(choices[0], dict):
|
|
65
|
+
parts.append((choices[0].get("delta") or {}).get("content") or "")
|
|
66
|
+
return "".join(parts)
|
|
67
|
+
|
|
68
|
+
async def list_models(self) -> list[ModelEntry]:
|
|
69
|
+
async with translate_transport_errors_async():
|
|
70
|
+
response = await self._http.get("/models")
|
|
71
|
+
raise_for_status(response)
|
|
72
|
+
return parse_models(response)
|
|
73
|
+
|
|
74
|
+
async def _probe(self, url: str) -> dict[str, Any]:
|
|
75
|
+
async with translate_transport_errors_async():
|
|
76
|
+
response = await self._http.send(httpx.Request("GET", url))
|
|
77
|
+
raise_for_status(response)
|
|
78
|
+
try:
|
|
79
|
+
return response.json()
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise GatewayError("Gateway returned an unexpected response body",
|
|
82
|
+
code="invalid_response", status=response.status_code,
|
|
83
|
+
request_id=response.headers.get("x-gateway-request-id"),
|
|
84
|
+
payload=None, headers=response.headers) from exc
|
|
85
|
+
|
|
86
|
+
async def health(self) -> dict[str, Any]:
|
|
87
|
+
return await self._probe(self._health_url)
|
|
88
|
+
|
|
89
|
+
async def ready(self) -> dict[str, Any]:
|
|
90
|
+
return await self._probe(self._ready_url)
|
|
91
|
+
|
|
92
|
+
async def aclose(self) -> None:
|
|
93
|
+
await self._http.aclose()
|
|
94
|
+
|
|
95
|
+
async def __aenter__(self) -> AsyncGatewayClient:
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
async def __aexit__(self, *exc) -> None:
|
|
99
|
+
await self.aclose()
|
ai_gateway/client.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Synchronous data-plane client; health probes use the service root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._shared import (chat_request, ensure_sse, gateway_headers, health_url,
|
|
12
|
+
raise_for_status, translate_transport_errors)
|
|
13
|
+
from ._sse import iter_text_chunks, parse_sse_lines
|
|
14
|
+
from .errors import GatewayError
|
|
15
|
+
from .types import GatewayInfo, Usage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ModelEntry:
|
|
20
|
+
id: str
|
|
21
|
+
owned_by: str = "gateway"
|
|
22
|
+
type: str = "policy"
|
|
23
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ChatCompletion:
|
|
28
|
+
id: str
|
|
29
|
+
model: str
|
|
30
|
+
content: str
|
|
31
|
+
choices: list[dict[str, Any]]
|
|
32
|
+
usage: Usage
|
|
33
|
+
gateway: GatewayInfo
|
|
34
|
+
headers: dict[str, str]
|
|
35
|
+
raw: dict[str, Any]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _decode_json(response: httpx.Response) -> Any:
|
|
39
|
+
try:
|
|
40
|
+
return response.json()
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
raise GatewayError("Gateway returned an unexpected response body",
|
|
43
|
+
code="invalid_response", status=response.status_code,
|
|
44
|
+
request_id=response.headers.get("x-gateway-request-id"),
|
|
45
|
+
payload=None, headers=response.headers) from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_chat_response(response: httpx.Response) -> ChatCompletion:
|
|
49
|
+
payload = _decode_json(response)
|
|
50
|
+
if not isinstance(payload, dict):
|
|
51
|
+
raise GatewayError("Gateway returned an unexpected response body",
|
|
52
|
+
code="invalid_response", status=response.status_code,
|
|
53
|
+
request_id=response.headers.get("x-gateway-request-id"),
|
|
54
|
+
payload=payload, headers=response.headers)
|
|
55
|
+
choices = payload.get("choices") or []
|
|
56
|
+
if not isinstance(choices, list):
|
|
57
|
+
choices = []
|
|
58
|
+
first = choices[0] if choices and isinstance(choices[0], dict) else {}
|
|
59
|
+
message = first.get("message") if isinstance(first.get("message"), dict) else {}
|
|
60
|
+
content = message.get("content")
|
|
61
|
+
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
|
|
62
|
+
gateway = payload.get("_gateway") if isinstance(payload.get("_gateway"), dict) else {}
|
|
63
|
+
headers = gateway_headers(response)
|
|
64
|
+
try:
|
|
65
|
+
usage_typed = Usage(**{key: int(usage.get(key) or 0) for key in
|
|
66
|
+
("prompt_tokens", "completion_tokens", "total_tokens")})
|
|
67
|
+
cost_usd = float(gateway["costUsd"] if gateway.get("costUsd") is not None
|
|
68
|
+
else headers.get("x-gateway-cost-usd") or 0)
|
|
69
|
+
except (ValueError, TypeError) as exc:
|
|
70
|
+
raise GatewayError("Gateway returned an unexpected response body",
|
|
71
|
+
code="invalid_response", status=response.status_code,
|
|
72
|
+
request_id=headers.get("x-gateway-request-id"),
|
|
73
|
+
payload=payload, headers=response.headers) from exc
|
|
74
|
+
return ChatCompletion(
|
|
75
|
+
id=str(payload.get("id", "")), model=str(payload.get("model", "")),
|
|
76
|
+
content=content if isinstance(content, str) else "", choices=choices,
|
|
77
|
+
usage=usage_typed,
|
|
78
|
+
gateway=GatewayInfo(
|
|
79
|
+
request_id=gateway.get("requestId") or headers.get("x-gateway-request-id", ""),
|
|
80
|
+
tier=gateway.get("tier") or headers.get("x-gateway-route-tier", ""),
|
|
81
|
+
policy=gateway.get("policy"), confidence=gateway.get("confidence"),
|
|
82
|
+
cost_usd=cost_usd,
|
|
83
|
+
), headers=headers, raw=payload,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_models(response: httpx.Response) -> list[ModelEntry]:
|
|
88
|
+
known = {"id", "owned_by", "type"}
|
|
89
|
+
payload = _decode_json(response)
|
|
90
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
91
|
+
if not isinstance(data, list):
|
|
92
|
+
return []
|
|
93
|
+
return [ModelEntry(id=str(item.get("id", "")),
|
|
94
|
+
owned_by=str(item.get("owned_by", "gateway")),
|
|
95
|
+
type=str(item.get("type", "policy")),
|
|
96
|
+
extra={k: v for k, v in item.items() if k not in known})
|
|
97
|
+
for item in data if isinstance(item, dict)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class GatewayClient:
|
|
101
|
+
"""Auth, gateway headers, typed results and explicit resource ownership.
|
|
102
|
+
|
|
103
|
+
Chat keyword options follow API_DESIGN §3.1. Tools, structured output,
|
|
104
|
+
top_p, stop, user and seed are forwarded for future compatible backends;
|
|
105
|
+
the current Java DTO only consumes temperature and max_tokens.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, *, api_key: str,
|
|
109
|
+
base_url: str = "https://gateway.example.com/v1",
|
|
110
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
111
|
+
transport: httpx.BaseTransport | None = None) -> None:
|
|
112
|
+
if not api_key:
|
|
113
|
+
raise ValueError("api_key is required (gw_live_...)")
|
|
114
|
+
self._health_url = health_url(base_url, "healthz")
|
|
115
|
+
self._ready_url = health_url(base_url, "readyz")
|
|
116
|
+
self._http = httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout,
|
|
117
|
+
transport=transport,
|
|
118
|
+
headers={"Authorization": f"Bearer {api_key}"})
|
|
119
|
+
|
|
120
|
+
def chat_completions(self, *, model: str, messages: list[dict[str, Any]],
|
|
121
|
+
**options: Any) -> ChatCompletion:
|
|
122
|
+
body, headers = chat_request(model=model, messages=messages, **options)
|
|
123
|
+
if body["stream"]:
|
|
124
|
+
raise ValueError("Use stream_chat_completions for stream=True")
|
|
125
|
+
with translate_transport_errors():
|
|
126
|
+
response = self._http.post("/chat/completions", json=body, headers=headers)
|
|
127
|
+
raise_for_status(response)
|
|
128
|
+
return parse_chat_response(response)
|
|
129
|
+
|
|
130
|
+
def stream_chat_completions(self, *, model: str, messages: list[dict[str, Any]],
|
|
131
|
+
**options: Any) -> Iterator[dict[str, Any]]:
|
|
132
|
+
body, headers = chat_request(model=model, messages=messages, stream=True, **options)
|
|
133
|
+
with translate_transport_errors():
|
|
134
|
+
with self._http.stream("POST", "/chat/completions", json=body, headers=headers) as response:
|
|
135
|
+
if not response.is_success:
|
|
136
|
+
response.read()
|
|
137
|
+
raise_for_status(response)
|
|
138
|
+
ensure_sse(response)
|
|
139
|
+
yield from parse_sse_lines(response.iter_lines())
|
|
140
|
+
|
|
141
|
+
def stream_text(self, **kwargs: Any) -> str:
|
|
142
|
+
return "".join(iter_text_chunks(self.stream_chat_completions(**kwargs)))
|
|
143
|
+
|
|
144
|
+
def list_models(self) -> list[ModelEntry]:
|
|
145
|
+
with translate_transport_errors():
|
|
146
|
+
response = self._http.get("/models")
|
|
147
|
+
raise_for_status(response)
|
|
148
|
+
return parse_models(response)
|
|
149
|
+
|
|
150
|
+
def _probe(self, url: str) -> dict[str, Any]:
|
|
151
|
+
with translate_transport_errors():
|
|
152
|
+
response = self._http.send(httpx.Request("GET", url))
|
|
153
|
+
raise_for_status(response)
|
|
154
|
+
return _decode_json(response)
|
|
155
|
+
|
|
156
|
+
def health(self) -> dict[str, Any]:
|
|
157
|
+
return self._probe(self._health_url)
|
|
158
|
+
|
|
159
|
+
def ready(self) -> dict[str, Any]:
|
|
160
|
+
return self._probe(self._ready_url)
|
|
161
|
+
|
|
162
|
+
def close(self) -> None:
|
|
163
|
+
self._http.close()
|
|
164
|
+
|
|
165
|
+
def __enter__(self) -> GatewayClient:
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
def __exit__(self, *exc) -> None:
|
|
169
|
+
self.close()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Read-only dashboard API client with JWT auth and cursor pagination.
|
|
2
|
+
|
|
3
|
+
These routes belong to the serverless control plane, not the Java data plane.
|
|
4
|
+
Dict payloads deliberately preserve unknown fields and nullable wire values.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Iterator
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._shared import raise_for_status, translate_transport_errors
|
|
17
|
+
from .errors import GatewayError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Page:
|
|
22
|
+
data: list[dict[str, Any]]
|
|
23
|
+
next_cursor: str | None
|
|
24
|
+
has_more: bool
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_payload(cls, payload: dict[str, Any]) -> Page:
|
|
28
|
+
if not isinstance(payload, dict):
|
|
29
|
+
raise ValueError("Expected {data, pagination} control-plane envelope")
|
|
30
|
+
pagination = payload.get("pagination")
|
|
31
|
+
data = payload.get("data")
|
|
32
|
+
if not isinstance(data, list) or not isinstance(pagination, dict):
|
|
33
|
+
raise ValueError("Expected {data, pagination} control-plane envelope")
|
|
34
|
+
more = pagination.get("hasMore")
|
|
35
|
+
cursor = pagination.get("nextCursor")
|
|
36
|
+
if not isinstance(more, bool) or (cursor is not None and not isinstance(cursor, str)):
|
|
37
|
+
raise ValueError("Invalid pagination fields")
|
|
38
|
+
if more and not cursor:
|
|
39
|
+
raise ValueError("hasMore=true requires a non-empty nextCursor")
|
|
40
|
+
return cls(data=data, next_cursor=cursor, has_more=more)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _id(value: str) -> str:
|
|
44
|
+
if not value or value in (".", ".."):
|
|
45
|
+
raise ValueError("A resource ID is required")
|
|
46
|
+
return quote(value, safe="")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ControlPlaneClient:
|
|
50
|
+
"""Use an OIDC access token, or explicitly opt into a dev principal.
|
|
51
|
+
|
|
52
|
+
Token callables are resolved on every request so applications can refresh
|
|
53
|
+
tokens themselves. Gateway API keys are not dashboard credentials.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, *, base_url: str,
|
|
57
|
+
access_token: str | Callable[[], str] | None = None,
|
|
58
|
+
dev_subject: str | None = None,
|
|
59
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
60
|
+
transport: httpx.BaseTransport | None = None) -> None:
|
|
61
|
+
if bool(access_token) == bool(dev_subject):
|
|
62
|
+
raise ValueError("Provide exactly one of access_token or dev_subject")
|
|
63
|
+
self._access_token = access_token
|
|
64
|
+
self._dev_subject = dev_subject
|
|
65
|
+
self._http = httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout,
|
|
66
|
+
transport=transport)
|
|
67
|
+
|
|
68
|
+
def _get(self, path: str, **params: Any) -> dict[str, Any]:
|
|
69
|
+
if self._dev_subject:
|
|
70
|
+
headers = {"x-dev-sub": self._dev_subject}
|
|
71
|
+
else:
|
|
72
|
+
token = self._access_token() if callable(self._access_token) else self._access_token
|
|
73
|
+
if not token:
|
|
74
|
+
raise ValueError("Access token provider returned an empty token")
|
|
75
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
76
|
+
if "limit" in params and params["limit"] is not None:
|
|
77
|
+
limit = params["limit"]
|
|
78
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100:
|
|
79
|
+
raise ValueError("limit must be an integer between 1 and 100")
|
|
80
|
+
with translate_transport_errors():
|
|
81
|
+
response = self._http.get(path, headers=headers,
|
|
82
|
+
params={k: v for k, v in params.items() if v is not None})
|
|
83
|
+
raise_for_status(response)
|
|
84
|
+
try:
|
|
85
|
+
return response.json()
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise GatewayError("Control plane returned an unexpected response body",
|
|
88
|
+
code="invalid_response", status=response.status_code,
|
|
89
|
+
request_id=response.headers.get("x-gateway-request-id"),
|
|
90
|
+
payload=None, headers=response.headers) from exc
|
|
91
|
+
|
|
92
|
+
def _page(self, path: str, **params: Any) -> Page:
|
|
93
|
+
return Page.from_payload(self._get(path, **params))
|
|
94
|
+
|
|
95
|
+
def me(self) -> dict[str, Any]:
|
|
96
|
+
return self._get("/me")
|
|
97
|
+
|
|
98
|
+
def list_orgs(self, **params: Any) -> Page:
|
|
99
|
+
return self._page("/orgs", **params)
|
|
100
|
+
|
|
101
|
+
def list_projects(self, org_id: str, **params: Any) -> Page:
|
|
102
|
+
return self._page(f"/orgs/{_id(org_id)}/projects", **params)
|
|
103
|
+
|
|
104
|
+
def list_api_keys(self, project_id: str, **params: Any) -> Page:
|
|
105
|
+
return self._page(f"/projects/{_id(project_id)}/api-keys", **params)
|
|
106
|
+
|
|
107
|
+
def list_providers(self, org_id: str, **params: Any) -> Page:
|
|
108
|
+
return self._page(f"/orgs/{_id(org_id)}/providers", **params)
|
|
109
|
+
|
|
110
|
+
def list_models(self, org_id: str, **params: Any) -> Page:
|
|
111
|
+
return self._page(f"/orgs/{_id(org_id)}/models", **params)
|
|
112
|
+
|
|
113
|
+
def list_routing_policies(self, org_id: str, **params: Any) -> Page:
|
|
114
|
+
return self._page(f"/orgs/{_id(org_id)}/routing-policies", **params)
|
|
115
|
+
|
|
116
|
+
def list_policy_versions(self, policy_id: str, **params: Any) -> Page:
|
|
117
|
+
return self._page(f"/routing-policies/{_id(policy_id)}/versions", **params)
|
|
118
|
+
|
|
119
|
+
def get_usage(self, org_id: str, *, from_: str | None = None,
|
|
120
|
+
to: str | None = None, group_by: str = "model") -> Page:
|
|
121
|
+
if group_by not in ("model", "provider", "project", "key", "tag"):
|
|
122
|
+
raise ValueError("Unsupported usage group_by")
|
|
123
|
+
# The current control plane only supports daily rollups (audit M4).
|
|
124
|
+
return self._page(f"/orgs/{_id(org_id)}/usage",
|
|
125
|
+
**{"from": from_, "to": to, "groupBy": group_by})
|
|
126
|
+
|
|
127
|
+
def list_requests(self, org_id: str, **params: Any) -> Page:
|
|
128
|
+
return self._page(f"/orgs/{_id(org_id)}/requests", **params)
|
|
129
|
+
|
|
130
|
+
def get_request(self, request_id: str) -> dict[str, Any]:
|
|
131
|
+
return self._get(f"/requests/{_id(request_id)}")
|
|
132
|
+
|
|
133
|
+
def get_routing_insights(self, org_id: str, *, from_: str | None = None,
|
|
134
|
+
to: str | None = None) -> dict[str, Any]:
|
|
135
|
+
return self._get(f"/orgs/{_id(org_id)}/routing/insights", **{"from": from_, "to": to})
|
|
136
|
+
|
|
137
|
+
def list_audit_logs(self, org_id: str, **params: Any) -> Page:
|
|
138
|
+
return self._page(f"/orgs/{_id(org_id)}/audit-logs", **params)
|
|
139
|
+
|
|
140
|
+
def iter_items(self, list_method: Callable[..., Page], *args: Any,
|
|
141
|
+
**params: Any) -> Iterator[dict[str, Any]]:
|
|
142
|
+
"""Traverse pages lazily; fail if the server repeats a cursor."""
|
|
143
|
+
seen = {params["cursor"]} if params.get("cursor") else set()
|
|
144
|
+
while True:
|
|
145
|
+
page = list_method(*args, **params)
|
|
146
|
+
yield from page.data
|
|
147
|
+
if not page.has_more:
|
|
148
|
+
return
|
|
149
|
+
if not page.next_cursor or page.next_cursor in seen:
|
|
150
|
+
raise ValueError("Pagination cursor did not advance")
|
|
151
|
+
seen.add(page.next_cursor)
|
|
152
|
+
params["cursor"] = page.next_cursor
|
|
153
|
+
|
|
154
|
+
def close(self) -> None:
|
|
155
|
+
self._http.close()
|
|
156
|
+
|
|
157
|
+
def __enter__(self) -> ControlPlaneClient:
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def __exit__(self, *exc) -> None:
|
|
161
|
+
self.close()
|