Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 177 additions & 15 deletions src/otari/control_plane.py
Original file line number Diff line number Diff line change
@@ -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 <admin/master key>``, 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
Expand All @@ -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:
Expand All @@ -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)
76 changes: 43 additions & 33 deletions tests/integration/test_control_plane_generated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading