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 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()