Skip to content

Commit 377213a

Browse files
njbrakeclaude
andauthored
Add ergonomic aliases for control-plane methods (#7)
The control-plane resources previously exposed only the OpenAPI-generator-derived method names (for example keys.create_key_v1_keys_post(...)), which leaked generator naming into the public management surface. The migrated SDK shell explicitly flagged friendlier aliases as a follow-up. Wrap each generated control-plane API (keys, users, budgets, pricing, usage) in a hand-written resource exposing ergonomic aliases (create, get, list, update, delete, plus get_usage / set / get_history). Aliases delegate to the generated methods and forward request options as kwargs. The generated core is untouched, so regeneration is unaffected, and the raw generated methods stay reachable via a `raw` accessor on each resource as an escape hatch. Fixes #107 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 56c3848 commit 377213a

3 files changed

Lines changed: 311 additions & 47 deletions

File tree

src/otari/control_plane.py

Lines changed: 176 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@
66
``Authorization: Bearer <admin/master key>``, which is distinct from the
77
``Otari-Key`` virtual key used for inference. Obtain an instance via
88
:attr:`otari.OtariClient.control_plane`.
9+
10+
Each resource accessor (``keys``, ``users``, ``budgets``, ``pricing``,
11+
``usage``) exposes ergonomic aliases (``create``, ``get``, ``list``,
12+
``update``, ``delete``, ...) that delegate to the generator-derived methods.
13+
The raw generated API object stays reachable via the ``raw`` attribute on each
14+
resource (for example
15+
``client.control_plane.keys.raw.create_key_v1_keys_post(...)``), so the full
16+
generated surface remains available as an escape hatch.
917
"""
1018

1119
from __future__ import annotations
1220

1321
from functools import cached_property
14-
from typing import Any, cast
22+
from typing import TYPE_CHECKING, Any, cast
1523

1624
from otari import _client as _cp
1725
from otari._client.api.budgets_api import BudgetsApi
@@ -20,13 +28,167 @@
2028
from otari._client.api.usage_api import UsageApi
2129
from otari._client.api.users_api import UsersApi
2230

31+
if TYPE_CHECKING:
32+
from datetime import datetime
33+
34+
from otari._client import (
35+
BudgetResponse,
36+
CreateBudgetRequest,
37+
CreateKeyRequest,
38+
CreateKeyResponse,
39+
CreateUserRequest,
40+
KeyInfo,
41+
PricingResponse,
42+
SetPricingRequest,
43+
UpdateBudgetRequest,
44+
UpdateKeyRequest,
45+
UpdateUserRequest,
46+
UsageEntry,
47+
UsageLogResponse,
48+
UserResponse,
49+
)
50+
51+
52+
class KeysResource:
53+
"""Ergonomic accessors for the API-keys management endpoints.
54+
55+
Aliases delegate to the generated :class:`KeysApi`, which stays reachable
56+
via :attr:`raw` for the full generated surface.
57+
"""
58+
59+
def __init__(self, api: KeysApi) -> None:
60+
self.raw = api
61+
62+
def create(self, request: CreateKeyRequest, **kwargs: Any) -> CreateKeyResponse:
63+
return self.raw.create_key_v1_keys_post(request, **kwargs)
64+
65+
def get(self, key_id: str, **kwargs: Any) -> KeyInfo:
66+
return self.raw.get_key_v1_keys_key_id_get(key_id, **kwargs)
67+
68+
def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[KeyInfo]:
69+
return self.raw.list_keys_v1_keys_get(skip, limit, **kwargs)
70+
71+
def update(self, key_id: str, request: UpdateKeyRequest, **kwargs: Any) -> KeyInfo:
72+
return self.raw.update_key_v1_keys_key_id_patch(key_id, request, **kwargs)
73+
74+
def delete(self, key_id: str, **kwargs: Any) -> None:
75+
self.raw.delete_key_v1_keys_key_id_delete(key_id, **kwargs)
76+
77+
78+
class UsersResource:
79+
"""Ergonomic accessors for the users management endpoints.
80+
81+
Aliases delegate to the generated :class:`UsersApi`, which stays reachable
82+
via :attr:`raw` for the full generated surface.
83+
"""
84+
85+
def __init__(self, api: UsersApi) -> None:
86+
self.raw = api
87+
88+
def create(self, request: CreateUserRequest, **kwargs: Any) -> UserResponse:
89+
return self.raw.create_user_v1_users_post(request, **kwargs)
90+
91+
def get(self, user_id: str, **kwargs: Any) -> UserResponse:
92+
return self.raw.get_user_v1_users_user_id_get(user_id, **kwargs)
93+
94+
def update(self, user_id: str, request: UpdateUserRequest, **kwargs: Any) -> UserResponse:
95+
return self.raw.update_user_v1_users_user_id_patch(user_id, request, **kwargs)
96+
97+
def delete(self, user_id: str, **kwargs: Any) -> None:
98+
self.raw.delete_user_v1_users_user_id_delete(user_id, **kwargs)
99+
100+
def get_usage(self, user_id: str, **kwargs: Any) -> list[UsageLogResponse]:
101+
return self.raw.get_user_usage_v1_users_user_id_usage_get(user_id, **kwargs)
102+
103+
# Defined last: a method named ``list`` shadows the ``list`` builtin for any
104+
# ``list[...]`` annotation that follows it in this class body.
105+
def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[UserResponse]:
106+
return self.raw.list_users_v1_users_get(skip, limit, **kwargs)
107+
108+
109+
class BudgetsResource:
110+
"""Ergonomic accessors for the budgets management endpoints.
111+
112+
Aliases delegate to the generated :class:`BudgetsApi`, which stays reachable
113+
via :attr:`raw` for the full generated surface.
114+
"""
115+
116+
def __init__(self, api: BudgetsApi) -> None:
117+
self.raw = api
118+
119+
def create(self, request: CreateBudgetRequest, **kwargs: Any) -> BudgetResponse:
120+
return self.raw.create_budget_v1_budgets_post(request, **kwargs)
121+
122+
def get(self, budget_id: str, **kwargs: Any) -> BudgetResponse:
123+
return self.raw.get_budget_v1_budgets_budget_id_get(budget_id, **kwargs)
124+
125+
def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[BudgetResponse]:
126+
return self.raw.list_budgets_v1_budgets_get(skip, limit, **kwargs)
127+
128+
def update(self, budget_id: str, request: UpdateBudgetRequest, **kwargs: Any) -> BudgetResponse:
129+
return self.raw.update_budget_v1_budgets_budget_id_patch(budget_id, request, **kwargs)
130+
131+
def delete(self, budget_id: str, **kwargs: Any) -> None:
132+
self.raw.delete_budget_v1_budgets_budget_id_delete(budget_id, **kwargs)
133+
134+
135+
class PricingResource:
136+
"""Ergonomic accessors for the model-pricing management endpoints.
137+
138+
Aliases delegate to the generated :class:`PricingApi`, which stays reachable
139+
via :attr:`raw` for the full generated surface.
140+
"""
141+
142+
def __init__(self, api: PricingApi) -> None:
143+
self.raw = api
144+
145+
def get(self, model_key: str, **kwargs: Any) -> PricingResponse:
146+
return self.raw.get_pricing_v1_pricing_model_key_get(model_key, **kwargs)
147+
148+
def set(self, request: SetPricingRequest, **kwargs: Any) -> PricingResponse:
149+
return self.raw.set_pricing_v1_pricing_post(request, **kwargs)
150+
151+
def delete(self, model_key: str, **kwargs: Any) -> None:
152+
self.raw.delete_pricing_v1_pricing_model_key_delete(model_key, **kwargs)
153+
154+
def get_history(self, model_key: str, **kwargs: Any) -> list[PricingResponse]:
155+
return self.raw.get_pricing_history_v1_pricing_model_key_history_get(model_key, **kwargs)
156+
157+
# Defined last: a method named ``list`` shadows the ``list`` builtin for any
158+
# ``list[...]`` annotation that follows it in this class body.
159+
def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[PricingResponse]:
160+
return self.raw.list_pricing_v1_pricing_get(skip, limit, **kwargs)
161+
162+
163+
class UsageResource:
164+
"""Ergonomic accessors for the usage-log management endpoints.
165+
166+
Aliases delegate to the generated :class:`UsageApi`, which stays reachable
167+
via :attr:`raw` for the full generated surface.
168+
"""
169+
170+
def __init__(self, api: UsageApi) -> None:
171+
self.raw = api
172+
173+
def list(
174+
self,
175+
start_date: datetime | None = None,
176+
end_date: datetime | None = None,
177+
user_id: str | None = None,
178+
skip: int | None = None,
179+
limit: int | None = None,
180+
**kwargs: Any,
181+
) -> list[UsageEntry]:
182+
return self.raw.list_usage_v1_usage_get(start_date, end_date, user_id, skip, limit, **kwargs)
183+
23184

24185
class ControlPlane:
25186
"""Accessors for the gateway management endpoints, sharing one authenticated client.
26187
27-
Method names on the underlying API objects are generator-derived (for
28-
example ``keys.create_key_v1_keys_post(...)``); friendlier aliases are a
29-
planned follow-up.
188+
Each accessor returns a resource wrapper exposing ergonomic aliases (for
189+
example ``keys.create(...)``, ``users.list(...)``, ``budgets.get(...)``).
190+
The generator-derived methods stay reachable via the ``raw`` attribute on
191+
each resource (for example ``keys.raw.create_key_v1_keys_post(...)``).
30192
"""
31193

32194
def __init__(self, base_url: str, bearer_token: str) -> None:
@@ -37,24 +199,24 @@ def __init__(self, base_url: str, bearer_token: str) -> None:
37199
self._api_client.set_default_header("Authorization", f"Bearer {bearer_token}")
38200

39201
@cached_property
40-
def keys(self) -> KeysApi:
41-
return KeysApi(self._api_client)
202+
def keys(self) -> KeysResource:
203+
return KeysResource(KeysApi(self._api_client))
42204

43205
@cached_property
44-
def users(self) -> UsersApi:
45-
return UsersApi(self._api_client)
206+
def users(self) -> UsersResource:
207+
return UsersResource(UsersApi(self._api_client))
46208

47209
@cached_property
48-
def budgets(self) -> BudgetsApi:
49-
return BudgetsApi(self._api_client)
210+
def budgets(self) -> BudgetsResource:
211+
return BudgetsResource(BudgetsApi(self._api_client))
50212

51213
@cached_property
52-
def pricing(self) -> PricingApi:
53-
return PricingApi(self._api_client)
214+
def pricing(self) -> PricingResource:
215+
return PricingResource(PricingApi(self._api_client))
54216

55217
@cached_property
56-
def usage(self) -> UsageApi:
57-
return UsageApi(self._api_client)
218+
def usage(self) -> UsageResource:
219+
return UsageResource(UsageApi(self._api_client))
58220

59221
def close(self) -> None:
60222
self._api_client.__exit__(None, None, None)

tests/integration/test_control_plane_generated.py

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
33
These drive ``OtariClient.control_plane`` through a full CRUD lifecycle for every
44
management endpoint (keys, users, budgets, pricing, usage), exercising the manual
5-
wiring (Bearer auth + the generated client) end to end. They start a real gateway
6-
on SQLite with a master key, so no provider credentials or database server are
7-
needed: control-plane endpoints never call an LLM provider.
5+
wiring (Bearer auth + the generated client) end to end via the ergonomic aliases
6+
(``keys.create(...)`` etc.), plus the ``raw`` escape hatch. They start a real
7+
gateway on SQLite with a master key, so no provider credentials or database
8+
server are needed: control-plane endpoints never call an LLM provider.
89
910
Run requirements:
1011
- The ``gateway`` console script on PATH (set ``OTARI_GATEWAY_CMD`` to override),
@@ -101,81 +102,90 @@ def client(gateway_url: str) -> Iterator[OtariClient]:
101102

102103

103104
def test_budgets_lifecycle(client: OtariClient) -> None:
104-
api = client.control_plane.budgets
105-
created = api.create_budget_v1_budgets_post(CreateBudgetRequest(max_budget=100.0, budget_duration_sec=3600))
105+
budgets = client.control_plane.budgets
106+
created = budgets.create(CreateBudgetRequest(max_budget=100.0, budget_duration_sec=3600))
106107
assert created.budget_id
107108
assert created.max_budget == 100.0
108109
bid = created.budget_id
109110

110-
assert any(b.budget_id == bid for b in api.list_budgets_v1_budgets_get())
111-
assert api.get_budget_v1_budgets_budget_id_get(bid).budget_id == bid
111+
assert any(b.budget_id == bid for b in budgets.list())
112+
assert budgets.get(bid).budget_id == bid
112113

113-
updated = api.update_budget_v1_budgets_budget_id_patch(bid, UpdateBudgetRequest(max_budget=250.0))
114+
updated = budgets.update(bid, UpdateBudgetRequest(max_budget=250.0))
114115
assert updated.max_budget == 250.0
115116

116-
api.delete_budget_v1_budgets_budget_id_delete(bid)
117+
budgets.delete(bid)
117118
with pytest.raises(NotFoundException):
118-
api.get_budget_v1_budgets_budget_id_get(bid)
119+
budgets.get(bid)
119120

120121

121122
def test_users_lifecycle(client: OtariClient) -> None:
122-
api = client.control_plane.users
123-
created = api.create_user_v1_users_post(CreateUserRequest(user_id="itest-user", alias="Alice"))
123+
users = client.control_plane.users
124+
created = users.create(CreateUserRequest(user_id="itest-user", alias="Alice"))
124125
assert created.user_id == "itest-user"
125126
assert created.alias == "Alice"
126127

127-
assert any(u.user_id == "itest-user" for u in api.list_users_v1_users_get())
128-
assert api.get_user_v1_users_user_id_get("itest-user").user_id == "itest-user"
128+
assert any(u.user_id == "itest-user" for u in users.list())
129+
assert users.get("itest-user").user_id == "itest-user"
129130

130-
updated = api.update_user_v1_users_user_id_patch("itest-user", UpdateUserRequest(alias="Alice2"))
131+
updated = users.update("itest-user", UpdateUserRequest(alias="Alice2"))
131132
assert updated.alias == "Alice2"
132133

133-
api.get_user_usage_v1_users_user_id_usage_get("itest-user")
134+
users.get_usage("itest-user")
134135

135-
api.delete_user_v1_users_user_id_delete("itest-user")
136+
users.delete("itest-user")
136137
with pytest.raises(NotFoundException):
137-
api.get_user_v1_users_user_id_get("itest-user")
138+
users.get("itest-user")
138139

139140

140141
def test_keys_lifecycle_returns_secret_on_create(client: OtariClient) -> None:
141-
api = client.control_plane.keys
142-
created = api.create_key_v1_keys_post(CreateKeyRequest(key_name="itest-key"))
142+
keys = client.control_plane.keys
143+
created = keys.create(CreateKeyRequest(key_name="itest-key"))
143144
assert created.id
144145
# The one-time key value must be present on create (manually-created surface).
145146
assert getattr(created, "key", None), "create_key must return the key secret"
146147
kid = created.id
147148

148-
assert any(k.id == kid for k in api.list_keys_v1_keys_get())
149-
assert api.get_key_v1_keys_key_id_get(kid).id == kid
149+
assert any(k.id == kid for k in keys.list())
150+
assert keys.get(kid).id == kid
150151

151-
updated = api.update_key_v1_keys_key_id_patch(kid, UpdateKeyRequest(key_name="itest-key-renamed"))
152+
updated = keys.update(kid, UpdateKeyRequest(key_name="itest-key-renamed"))
152153
assert updated.key_name == "itest-key-renamed"
153154

154-
api.delete_key_v1_keys_key_id_delete(kid)
155+
keys.delete(kid)
155156
with pytest.raises(NotFoundException):
156-
api.get_key_v1_keys_key_id_get(kid)
157+
keys.get(kid)
157158

158159

159160
def test_pricing_lifecycle(client: OtariClient) -> None:
160-
api = client.control_plane.pricing
161+
pricing = client.control_plane.pricing
161162
model_key = "openai:itest-model"
162-
created = api.set_pricing_v1_pricing_post(
163+
created = pricing.set(
163164
SetPricingRequest(model_key=model_key, input_price_per_million=1.0, output_price_per_million=2.0)
164165
)
165166
assert created.model_key == model_key
166167

167-
assert any(p.model_key == model_key for p in api.list_pricing_v1_pricing_get())
168-
assert api.get_pricing_v1_pricing_model_key_get(model_key).model_key == model_key
169-
assert api.get_pricing_history_v1_pricing_model_key_history_get(model_key) is not None
168+
assert any(p.model_key == model_key for p in pricing.list())
169+
assert pricing.get(model_key).model_key == model_key
170+
assert pricing.get_history(model_key) is not None
170171

171-
api.delete_pricing_v1_pricing_model_key_delete(model_key)
172+
pricing.delete(model_key)
172173
with pytest.raises(NotFoundException):
173-
api.get_pricing_v1_pricing_model_key_get(model_key)
174+
pricing.get(model_key)
174175

175176

176177
def test_usage_is_readable(client: OtariClient) -> None:
177178
# Fresh gateway: usage list is readable, proving the typed GET works through the client.
178-
assert client.control_plane.usage.list_usage_v1_usage_get() is not None
179+
assert client.control_plane.usage.list() is not None
180+
181+
182+
def test_raw_escape_hatch_reaches_generated_methods(client: OtariClient) -> None:
183+
# The generator-derived methods stay reachable via ``raw`` as an escape hatch.
184+
keys = client.control_plane.keys
185+
created = keys.raw.create_key_v1_keys_post(CreateKeyRequest(key_name="itest-raw-key"))
186+
assert created.id
187+
assert any(k.id == created.id for k in keys.raw.list_keys_v1_keys_get())
188+
keys.raw.delete_key_v1_keys_key_id_delete(created.id)
179189

180190

181191
def test_control_plane_requires_admin_credential(gateway_url: str) -> None:

0 commit comments

Comments
 (0)