diff --git a/src/otari/control_plane.py b/src/otari/control_plane.py index 27a37ea..7f4a3fa 100644 --- a/src/otari/control_plane.py +++ b/src/otari/control_plane.py @@ -1,17 +1,25 @@ """Typed client for the gateway control-plane (management) endpoints. Wraps the OpenAPI-generated :mod:`otari._client` core (the same core that backs -the inference path under Option C). The control-plane endpoints (API keys, +the inference path). The control-plane endpoints (API keys, users, budgets, pricing, usage) authenticate with ``Authorization: Bearer ``, which is distinct from the ``Otari-Key`` virtual key used for inference. Obtain an instance via :attr:`otari.OtariClient.control_plane`. + +Each resource accessor (``keys``, ``users``, ``budgets``, ``pricing``, +``usage``) exposes ergonomic aliases (``create``, ``get``, ``list``, +``update``, ``delete``, ...) that delegate to the generator-derived methods. +The raw generated API object stays reachable via the ``raw`` attribute on each +resource (for example +``client.control_plane.keys.raw.create_key_v1_keys_post(...)``), so the full +generated surface remains available as an escape hatch. """ from __future__ import annotations from functools import cached_property -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from otari import _client as _cp from otari._client.api.budgets_api import BudgetsApi @@ -20,13 +28,167 @@ from otari._client.api.usage_api import UsageApi from otari._client.api.users_api import UsersApi +if TYPE_CHECKING: + from datetime import datetime + + from otari._client import ( + BudgetResponse, + CreateBudgetRequest, + CreateKeyRequest, + CreateKeyResponse, + CreateUserRequest, + KeyInfo, + PricingResponse, + SetPricingRequest, + UpdateBudgetRequest, + UpdateKeyRequest, + UpdateUserRequest, + UsageEntry, + UsageLogResponse, + UserResponse, + ) + + +class KeysResource: + """Ergonomic accessors for the API-keys management endpoints. + + Aliases delegate to the generated :class:`KeysApi`, which stays reachable + via :attr:`raw` for the full generated surface. + """ + + def __init__(self, api: KeysApi) -> None: + self.raw = api + + def create(self, request: CreateKeyRequest, **kwargs: Any) -> CreateKeyResponse: + return self.raw.create_key_v1_keys_post(request, **kwargs) + + def get(self, key_id: str, **kwargs: Any) -> KeyInfo: + return self.raw.get_key_v1_keys_key_id_get(key_id, **kwargs) + + def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[KeyInfo]: + return self.raw.list_keys_v1_keys_get(skip, limit, **kwargs) + + def update(self, key_id: str, request: UpdateKeyRequest, **kwargs: Any) -> KeyInfo: + return self.raw.update_key_v1_keys_key_id_patch(key_id, request, **kwargs) + + def delete(self, key_id: str, **kwargs: Any) -> None: + self.raw.delete_key_v1_keys_key_id_delete(key_id, **kwargs) + + +class UsersResource: + """Ergonomic accessors for the users management endpoints. + + Aliases delegate to the generated :class:`UsersApi`, which stays reachable + via :attr:`raw` for the full generated surface. + """ + + def __init__(self, api: UsersApi) -> None: + self.raw = api + + def create(self, request: CreateUserRequest, **kwargs: Any) -> UserResponse: + return self.raw.create_user_v1_users_post(request, **kwargs) + + def get(self, user_id: str, **kwargs: Any) -> UserResponse: + return self.raw.get_user_v1_users_user_id_get(user_id, **kwargs) + + def update(self, user_id: str, request: UpdateUserRequest, **kwargs: Any) -> UserResponse: + return self.raw.update_user_v1_users_user_id_patch(user_id, request, **kwargs) + + def delete(self, user_id: str, **kwargs: Any) -> None: + self.raw.delete_user_v1_users_user_id_delete(user_id, **kwargs) + + def get_usage(self, user_id: str, **kwargs: Any) -> list[UsageLogResponse]: + return self.raw.get_user_usage_v1_users_user_id_usage_get(user_id, **kwargs) + + # Defined last: a method named ``list`` shadows the ``list`` builtin for any + # ``list[...]`` annotation that follows it in this class body. + def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[UserResponse]: + return self.raw.list_users_v1_users_get(skip, limit, **kwargs) + + +class BudgetsResource: + """Ergonomic accessors for the budgets management endpoints. + + Aliases delegate to the generated :class:`BudgetsApi`, which stays reachable + via :attr:`raw` for the full generated surface. + """ + + def __init__(self, api: BudgetsApi) -> None: + self.raw = api + + def create(self, request: CreateBudgetRequest, **kwargs: Any) -> BudgetResponse: + return self.raw.create_budget_v1_budgets_post(request, **kwargs) + + def get(self, budget_id: str, **kwargs: Any) -> BudgetResponse: + return self.raw.get_budget_v1_budgets_budget_id_get(budget_id, **kwargs) + + def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[BudgetResponse]: + return self.raw.list_budgets_v1_budgets_get(skip, limit, **kwargs) + + def update(self, budget_id: str, request: UpdateBudgetRequest, **kwargs: Any) -> BudgetResponse: + return self.raw.update_budget_v1_budgets_budget_id_patch(budget_id, request, **kwargs) + + def delete(self, budget_id: str, **kwargs: Any) -> None: + self.raw.delete_budget_v1_budgets_budget_id_delete(budget_id, **kwargs) + + +class PricingResource: + """Ergonomic accessors for the model-pricing management endpoints. + + Aliases delegate to the generated :class:`PricingApi`, which stays reachable + via :attr:`raw` for the full generated surface. + """ + + def __init__(self, api: PricingApi) -> None: + self.raw = api + + def get(self, model_key: str, **kwargs: Any) -> PricingResponse: + return self.raw.get_pricing_v1_pricing_model_key_get(model_key, **kwargs) + + def set(self, request: SetPricingRequest, **kwargs: Any) -> PricingResponse: + return self.raw.set_pricing_v1_pricing_post(request, **kwargs) + + def delete(self, model_key: str, **kwargs: Any) -> None: + self.raw.delete_pricing_v1_pricing_model_key_delete(model_key, **kwargs) + + def get_history(self, model_key: str, **kwargs: Any) -> list[PricingResponse]: + return self.raw.get_pricing_history_v1_pricing_model_key_history_get(model_key, **kwargs) + + # Defined last: a method named ``list`` shadows the ``list`` builtin for any + # ``list[...]`` annotation that follows it in this class body. + def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[PricingResponse]: + return self.raw.list_pricing_v1_pricing_get(skip, limit, **kwargs) + + +class UsageResource: + """Ergonomic accessors for the usage-log management endpoints. + + Aliases delegate to the generated :class:`UsageApi`, which stays reachable + via :attr:`raw` for the full generated surface. + """ + + def __init__(self, api: UsageApi) -> None: + self.raw = api + + def list( + self, + start_date: datetime | None = None, + end_date: datetime | None = None, + user_id: str | None = None, + skip: int | None = None, + limit: int | None = None, + **kwargs: Any, + ) -> list[UsageEntry]: + return self.raw.list_usage_v1_usage_get(start_date, end_date, user_id, skip, limit, **kwargs) + class ControlPlane: """Accessors for the gateway management endpoints, sharing one authenticated client. - Method names on the underlying API objects are generator-derived (for - example ``keys.create_key_v1_keys_post(...)``); friendlier aliases are a - planned follow-up. + Each accessor returns a resource wrapper exposing ergonomic aliases (for + example ``keys.create(...)``, ``users.list(...)``, ``budgets.get(...)``). + The generator-derived methods stay reachable via the ``raw`` attribute on + each resource (for example ``keys.raw.create_key_v1_keys_post(...)``). """ def __init__(self, base_url: str, bearer_token: str) -> None: @@ -37,24 +199,24 @@ def __init__(self, base_url: str, bearer_token: str) -> None: self._api_client.set_default_header("Authorization", f"Bearer {bearer_token}") @cached_property - def keys(self) -> KeysApi: - return KeysApi(self._api_client) + def keys(self) -> KeysResource: + return KeysResource(KeysApi(self._api_client)) @cached_property - def users(self) -> UsersApi: - return UsersApi(self._api_client) + def users(self) -> UsersResource: + return UsersResource(UsersApi(self._api_client)) @cached_property - def budgets(self) -> BudgetsApi: - return BudgetsApi(self._api_client) + def budgets(self) -> BudgetsResource: + return BudgetsResource(BudgetsApi(self._api_client)) @cached_property - def pricing(self) -> PricingApi: - return PricingApi(self._api_client) + def pricing(self) -> PricingResource: + return PricingResource(PricingApi(self._api_client)) @cached_property - def usage(self) -> UsageApi: - return UsageApi(self._api_client) + def usage(self) -> UsageResource: + return UsageResource(UsageApi(self._api_client)) def close(self) -> None: self._api_client.__exit__(None, None, None) diff --git a/tests/integration/test_control_plane_generated.py b/tests/integration/test_control_plane_generated.py index 4125ccf..56999da 100644 --- a/tests/integration/test_control_plane_generated.py +++ b/tests/integration/test_control_plane_generated.py @@ -2,9 +2,10 @@ These drive ``OtariClient.control_plane`` through a full CRUD lifecycle for every management endpoint (keys, users, budgets, pricing, usage), exercising the manual -wiring (Bearer auth + the generated client) end to end. They start a real gateway -on SQLite with a master key, so no provider credentials or database server are -needed: control-plane endpoints never call an LLM provider. +wiring (Bearer auth + the generated client) end to end via the ergonomic aliases +(``keys.create(...)`` etc.), plus the ``raw`` escape hatch. They start a real +gateway on SQLite with a master key, so no provider credentials or database +server are needed: control-plane endpoints never call an LLM provider. Run requirements: - The ``gateway`` console script on PATH (set ``OTARI_GATEWAY_CMD`` to override), @@ -101,81 +102,90 @@ def client(gateway_url: str) -> Iterator[OtariClient]: def test_budgets_lifecycle(client: OtariClient) -> None: - api = client.control_plane.budgets - created = api.create_budget_v1_budgets_post(CreateBudgetRequest(max_budget=100.0, budget_duration_sec=3600)) + budgets = client.control_plane.budgets + created = budgets.create(CreateBudgetRequest(max_budget=100.0, budget_duration_sec=3600)) assert created.budget_id assert created.max_budget == 100.0 bid = created.budget_id - assert any(b.budget_id == bid for b in api.list_budgets_v1_budgets_get()) - assert api.get_budget_v1_budgets_budget_id_get(bid).budget_id == bid + assert any(b.budget_id == bid for b in budgets.list()) + assert budgets.get(bid).budget_id == bid - updated = api.update_budget_v1_budgets_budget_id_patch(bid, UpdateBudgetRequest(max_budget=250.0)) + updated = budgets.update(bid, UpdateBudgetRequest(max_budget=250.0)) assert updated.max_budget == 250.0 - api.delete_budget_v1_budgets_budget_id_delete(bid) + budgets.delete(bid) with pytest.raises(NotFoundException): - api.get_budget_v1_budgets_budget_id_get(bid) + budgets.get(bid) def test_users_lifecycle(client: OtariClient) -> None: - api = client.control_plane.users - created = api.create_user_v1_users_post(CreateUserRequest(user_id="itest-user", alias="Alice")) + users = client.control_plane.users + created = users.create(CreateUserRequest(user_id="itest-user", alias="Alice")) assert created.user_id == "itest-user" assert created.alias == "Alice" - assert any(u.user_id == "itest-user" for u in api.list_users_v1_users_get()) - assert api.get_user_v1_users_user_id_get("itest-user").user_id == "itest-user" + assert any(u.user_id == "itest-user" for u in users.list()) + assert users.get("itest-user").user_id == "itest-user" - updated = api.update_user_v1_users_user_id_patch("itest-user", UpdateUserRequest(alias="Alice2")) + updated = users.update("itest-user", UpdateUserRequest(alias="Alice2")) assert updated.alias == "Alice2" - api.get_user_usage_v1_users_user_id_usage_get("itest-user") + users.get_usage("itest-user") - api.delete_user_v1_users_user_id_delete("itest-user") + users.delete("itest-user") with pytest.raises(NotFoundException): - api.get_user_v1_users_user_id_get("itest-user") + users.get("itest-user") def test_keys_lifecycle_returns_secret_on_create(client: OtariClient) -> None: - api = client.control_plane.keys - created = api.create_key_v1_keys_post(CreateKeyRequest(key_name="itest-key")) + keys = client.control_plane.keys + created = keys.create(CreateKeyRequest(key_name="itest-key")) assert created.id # The one-time key value must be present on create (manually-created surface). assert getattr(created, "key", None), "create_key must return the key secret" kid = created.id - assert any(k.id == kid for k in api.list_keys_v1_keys_get()) - assert api.get_key_v1_keys_key_id_get(kid).id == kid + assert any(k.id == kid for k in keys.list()) + assert keys.get(kid).id == kid - updated = api.update_key_v1_keys_key_id_patch(kid, UpdateKeyRequest(key_name="itest-key-renamed")) + updated = keys.update(kid, UpdateKeyRequest(key_name="itest-key-renamed")) assert updated.key_name == "itest-key-renamed" - api.delete_key_v1_keys_key_id_delete(kid) + keys.delete(kid) with pytest.raises(NotFoundException): - api.get_key_v1_keys_key_id_get(kid) + keys.get(kid) def test_pricing_lifecycle(client: OtariClient) -> None: - api = client.control_plane.pricing + pricing = client.control_plane.pricing model_key = "openai:itest-model" - created = api.set_pricing_v1_pricing_post( + created = pricing.set( SetPricingRequest(model_key=model_key, input_price_per_million=1.0, output_price_per_million=2.0) ) assert created.model_key == model_key - assert any(p.model_key == model_key for p in api.list_pricing_v1_pricing_get()) - assert api.get_pricing_v1_pricing_model_key_get(model_key).model_key == model_key - assert api.get_pricing_history_v1_pricing_model_key_history_get(model_key) is not None + assert any(p.model_key == model_key for p in pricing.list()) + assert pricing.get(model_key).model_key == model_key + assert pricing.get_history(model_key) is not None - api.delete_pricing_v1_pricing_model_key_delete(model_key) + pricing.delete(model_key) with pytest.raises(NotFoundException): - api.get_pricing_v1_pricing_model_key_get(model_key) + pricing.get(model_key) def test_usage_is_readable(client: OtariClient) -> None: # Fresh gateway: usage list is readable, proving the typed GET works through the client. - assert client.control_plane.usage.list_usage_v1_usage_get() is not None + assert client.control_plane.usage.list() is not None + + +def test_raw_escape_hatch_reaches_generated_methods(client: OtariClient) -> None: + # The generator-derived methods stay reachable via ``raw`` as an escape hatch. + keys = client.control_plane.keys + created = keys.raw.create_key_v1_keys_post(CreateKeyRequest(key_name="itest-raw-key")) + assert created.id + assert any(k.id == created.id for k in keys.raw.list_keys_v1_keys_get()) + keys.raw.delete_key_v1_keys_key_id_delete(created.id) def test_control_plane_requires_admin_credential(gateway_url: str) -> None: diff --git a/tests/unit/test_control_plane_aliases.py b/tests/unit/test_control_plane_aliases.py new file mode 100644 index 0000000..9b5c176 --- /dev/null +++ b/tests/unit/test_control_plane_aliases.py @@ -0,0 +1,92 @@ +"""Unit tests for the control-plane ergonomic aliases. + +Each resource accessor (``keys``, ``users``, ``budgets``, ``pricing``, +``usage``) exposes short aliases (``create``, ``get``, ``list``, ...) that +delegate to the generator-derived methods on the underlying generated API, +which stays reachable via ``raw``. These tests stub ``raw`` and assert each +alias forwards to the right generated method with the right arguments, without +needing a live gateway. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from otari._client.api.budgets_api import BudgetsApi +from otari._client.api.keys_api import KeysApi +from otari._client.api.pricing_api import PricingApi +from otari._client.api.usage_api import UsageApi +from otari._client.api.users_api import UsersApi +from otari.control_plane import ControlPlane + +# (resource, alias, alias_args, generated_method, expected_forwarded_args) +CASES: list[tuple[str, str, tuple[Any, ...], str, tuple[Any, ...]]] = [ + ("keys", "create", ("req",), "create_key_v1_keys_post", ("req",)), + ("keys", "get", ("k1",), "get_key_v1_keys_key_id_get", ("k1",)), + ("keys", "list", (1, 2), "list_keys_v1_keys_get", (1, 2)), + ("keys", "update", ("k1", "req"), "update_key_v1_keys_key_id_patch", ("k1", "req")), + ("keys", "delete", ("k1",), "delete_key_v1_keys_key_id_delete", ("k1",)), + ("users", "create", ("req",), "create_user_v1_users_post", ("req",)), + ("users", "get", ("u1",), "get_user_v1_users_user_id_get", ("u1",)), + ("users", "list", (3, 4), "list_users_v1_users_get", (3, 4)), + ("users", "update", ("u1", "req"), "update_user_v1_users_user_id_patch", ("u1", "req")), + ("users", "delete", ("u1",), "delete_user_v1_users_user_id_delete", ("u1",)), + ("users", "get_usage", ("u1",), "get_user_usage_v1_users_user_id_usage_get", ("u1",)), + ("budgets", "create", ("req",), "create_budget_v1_budgets_post", ("req",)), + ("budgets", "get", ("b1",), "get_budget_v1_budgets_budget_id_get", ("b1",)), + ("budgets", "list", (5, 6), "list_budgets_v1_budgets_get", (5, 6)), + ("budgets", "update", ("b1", "req"), "update_budget_v1_budgets_budget_id_patch", ("b1", "req")), + ("budgets", "delete", ("b1",), "delete_budget_v1_budgets_budget_id_delete", ("b1",)), + ("pricing", "list", (7, 8), "list_pricing_v1_pricing_get", (7, 8)), + ("pricing", "get", ("m1",), "get_pricing_v1_pricing_model_key_get", ("m1",)), + ("pricing", "set", ("req",), "set_pricing_v1_pricing_post", ("req",)), + ("pricing", "delete", ("m1",), "delete_pricing_v1_pricing_model_key_delete", ("m1",)), + ("pricing", "get_history", ("m1",), "get_pricing_history_v1_pricing_model_key_history_get", ("m1",)), + ("usage", "list", (None, None, "u1", 0, 10), "list_usage_v1_usage_get", (None, None, "u1", 0, 10)), +] + + +@pytest.fixture +def control_plane() -> ControlPlane: + return ControlPlane("http://localhost:8000", "master") + + +@pytest.mark.parametrize(("resource", "alias", "alias_args", "generated_method", "forwarded"), CASES) +def test_alias_delegates_to_generated_method( + control_plane: ControlPlane, + resource: str, + alias: str, + alias_args: tuple[Any, ...], + generated_method: str, + forwarded: tuple[Any, ...], +) -> None: + res = getattr(control_plane, resource) + res.raw = MagicMock() + sentinel = object() + getattr(res.raw, generated_method).return_value = sentinel + + result = getattr(res, alias)(*alias_args) + + getattr(res.raw, generated_method).assert_called_once_with(*forwarded) + # ``delete`` aliases return ``None``; the rest return the generated result. + assert result is (None if alias == "delete" else sentinel) + + +def test_alias_forwards_request_options_as_kwargs(control_plane: ControlPlane) -> None: + control_plane.keys.raw = MagicMock() + control_plane.keys.get("k1", _request_timeout=5.0, _headers={"X": "Y"}) + control_plane.keys.raw.get_key_v1_keys_key_id_get.assert_called_once_with( + "k1", _request_timeout=5.0, _headers={"X": "Y"} + ) + + +def test_raw_exposes_generated_api(control_plane: ControlPlane) -> None: + assert isinstance(control_plane.keys.raw, KeysApi) + assert isinstance(control_plane.users.raw, UsersApi) + assert isinstance(control_plane.budgets.raw, BudgetsApi) + assert isinstance(control_plane.pricing.raw, PricingApi) + assert isinstance(control_plane.usage.raw, UsageApi) + control_plane.close()