diff --git a/sdk-endpoints.txt b/sdk-endpoints.txt index aadf669..08dae16 100644 --- a/sdk-endpoints.txt +++ b/sdk-endpoints.txt @@ -68,3 +68,31 @@ GET /v1/files # not yet wrapped GET /v1/files/{file_id} # not yet wrapped GET /v1/files/{file_id}/content # not yet wrapped DELETE /v1/files/{file_id} # not yet wrapped + +# Dashboard / admin management surface: present in the gateway spec, not yet +# wrapped by any SDK shell. Deferred here so the drift gate stays green; move to +# [covered] when a shell surfaces them. Kept identical across all four SDKs. +# Model aliases +GET /v1/aliases # not yet wrapped +POST /v1/aliases # not yet wrapped +DELETE /v1/aliases/{name} # not yet wrapped +# Provider credentials (runtime provider management) +GET /v1/provider-credentials # not yet wrapped +POST /v1/provider-credentials # not yet wrapped +POST /v1/provider-credentials/test # not yet wrapped +PATCH /v1/provider-credentials/{instance} # not yet wrapped +DELETE /v1/provider-credentials/{instance} # not yet wrapped +POST /v1/provider-credentials/{instance}/test # not yet wrapped +# Providers catalogue +GET /v1/providers # not yet wrapped +GET /v1/providers/catalog # not yet wrapped +# Runtime settings +GET /v1/settings # not yet wrapped +PATCH /v1/settings # not yet wrapped +# Model discovery / metadata +GET /v1/models/discoverable # not yet wrapped +GET /v1/models/metadata # not yet wrapped +# Other control-plane extensions of already-covered resources +GET /v1/usage/count # not yet wrapped +POST /v1/keys/{key_id}/rotate # not yet wrapped +GET /v1/budgets/{budget_id}/reset-logs # not yet wrapped diff --git a/src/otari/_base.py b/src/otari/_base.py index 0a56a62..af33103 100644 --- a/src/otari/_base.py +++ b/src/otari/_base.py @@ -154,98 +154,16 @@ def __init__( def _map_api_exception(self, error: ApiException) -> OtariError: """Map a generated ``ApiException`` to a typed otari exception. - ``ApiException`` carries ``.status`` (int) and ``.body`` (the raw JSON - string the gateway returned) plus ``.headers``. The gateway encodes the - human-readable reason under the ``detail`` key (FastAPI convention). - - Most status mappings only apply in platform mode; in non-platform mode - the generic :class:`OtariError` is raised so the caller still gets a - single SDK exception type. The one cross-mode case is - :class:`UnsupportedCapabilityError`, surfaced in both modes. + Thin wrapper over the module-level :func:`map_api_exception` so the + control-plane resources can reuse the same mapping without a client + instance. """ - status = error.status if isinstance(error.status, int) else 0 - headers = error.headers or {} - detail = self._extract_detail(error) - correlation_id = _header_get(headers, "x-correlation-id") - retry_after = _header_get(headers, "retry-after") - - full = f"{detail} (correlation_id={correlation_id})" if correlation_id else detail - - # Unsupported-capability is surfaced regardless of mode. - if status == 400 and _UNSUPPORTED_MODERATION_RE.search(detail): - provider = _parse_unsupported_provider(detail) - capability = "multimodal_moderation" if "multimodal" in detail else "moderation" - return UnsupportedCapabilityError( - full, - status_code=status, - original_error=error, - provider_name=PROVIDER_NAME, - provider=provider, - capability=capability, - ) - - if status in (401, 403): - return AuthenticationError( - full, status_code=status, original_error=error, provider_name=PROVIDER_NAME - ) - if status == 402: - return InsufficientFundsError( - full, status_code=status, original_error=error, provider_name=PROVIDER_NAME - ) - if status == 404: - return ModelNotFoundError( - full, status_code=status, original_error=error, provider_name=PROVIDER_NAME - ) - if status == 409: - return BatchNotCompleteError( - full, - status_code=status, - original_error=error, - provider_name=PROVIDER_NAME, - batch_id=_extract_batch_id(detail), - batch_status=_extract_status(detail), - ) - if status == 429: - return RateLimitError( - full, - status_code=status, - original_error=error, - provider_name=PROVIDER_NAME, - retry_after=retry_after, - ) - if status == 504: - return GatewayTimeoutError( - full, status_code=status, original_error=error, provider_name=PROVIDER_NAME - ) - # 502 and any other 5xx are upstream-provider failures. - if status == 502 or 500 <= status < 600: - return UpstreamProviderError( - full, status_code=status, original_error=error, provider_name=PROVIDER_NAME - ) - - return OtariError( - full, status_code=status, original_error=error, provider_name=PROVIDER_NAME - ) + return map_api_exception(error) @staticmethod def _extract_detail(error: ApiException) -> str: """Pull the gateway's human-readable detail from an ``ApiException`` body.""" - body = error.body - if isinstance(body, (bytes, bytearray)): - body = body.decode("utf-8", "replace") - if isinstance(body, str) and body: - try: - parsed = json.loads(body) - except (ValueError, TypeError): - return body - if isinstance(parsed, dict): - detail = parsed.get("detail") or parsed.get("message") or parsed.get("error") - if isinstance(detail, str): - return detail - if detail is not None: - return str(detail) - return body - return error.reason or "An error occurred" + return extract_detail(error) def _map_streaming_response(self, response: httpx.Response, body: bytes) -> OtariError: """Map a failed raw streaming response to a typed otari exception. @@ -331,3 +249,101 @@ def _extract_status(message: str) -> str | None: def _url_encode(value: str) -> str: """Percent-encode a single URL component.""" return urllib.parse.quote(value, safe="") + + +def extract_detail(error: ApiException) -> str: + """Pull the gateway's human-readable detail from an ``ApiException`` body.""" + body = error.body + if isinstance(body, (bytes, bytearray)): + body = body.decode("utf-8", "replace") + if isinstance(body, str) and body: + try: + parsed = json.loads(body) + except (ValueError, TypeError): + return body + if isinstance(parsed, dict): + detail = parsed.get("detail") or parsed.get("message") or parsed.get("error") + if isinstance(detail, str): + return detail + if detail is not None: + return str(detail) + return body + return error.reason or "An error occurred" + + +def map_api_exception(error: ApiException) -> OtariError: + """Map a generated ``ApiException`` to a typed otari exception. + + ``ApiException`` carries ``.status`` (int) and ``.body`` (the raw JSON + string the gateway returned) plus ``.headers``. The gateway encodes the + human-readable reason under the ``detail`` key (FastAPI convention). + + Most status mappings only apply in platform mode; in non-platform mode + the generic :class:`OtariError` is raised so the caller still gets a + single SDK exception type. The one cross-mode case is + :class:`UnsupportedCapabilityError`, surfaced in both modes. + """ + status = error.status if isinstance(error.status, int) else 0 + headers = error.headers or {} + detail = extract_detail(error) + correlation_id = _header_get(headers, "x-correlation-id") + retry_after = _header_get(headers, "retry-after") + + full = f"{detail} (correlation_id={correlation_id})" if correlation_id else detail + + # Unsupported-capability is surfaced regardless of mode. + if status == 400 and _UNSUPPORTED_MODERATION_RE.search(detail): + provider = _parse_unsupported_provider(detail) + capability = "multimodal_moderation" if "multimodal" in detail else "moderation" + return UnsupportedCapabilityError( + full, + status_code=status, + original_error=error, + provider_name=PROVIDER_NAME, + provider=provider, + capability=capability, + ) + + if status in (401, 403): + return AuthenticationError( + full, status_code=status, original_error=error, provider_name=PROVIDER_NAME + ) + if status == 402: + return InsufficientFundsError( + full, status_code=status, original_error=error, provider_name=PROVIDER_NAME + ) + if status == 404: + return ModelNotFoundError( + full, status_code=status, original_error=error, provider_name=PROVIDER_NAME + ) + if status == 409: + return BatchNotCompleteError( + full, + status_code=status, + original_error=error, + provider_name=PROVIDER_NAME, + batch_id=_extract_batch_id(detail), + batch_status=_extract_status(detail), + ) + if status == 429: + return RateLimitError( + full, + status_code=status, + original_error=error, + provider_name=PROVIDER_NAME, + retry_after=retry_after, + ) + if status == 504: + return GatewayTimeoutError( + full, status_code=status, original_error=error, provider_name=PROVIDER_NAME + ) + # 502 and any other 5xx are upstream-provider failures. + if status == 502 or 500 <= status < 600: + return UpstreamProviderError( + full, status_code=status, original_error=error, provider_name=PROVIDER_NAME + ) + + return OtariError( + full, status_code=status, original_error=error, provider_name=PROVIDER_NAME + ) + diff --git a/src/otari/control_plane.py b/src/otari/control_plane.py index 7f4a3fa..28e8593 100644 --- a/src/otari/control_plane.py +++ b/src/otari/control_plane.py @@ -18,17 +18,20 @@ from __future__ import annotations -from functools import cached_property -from typing import TYPE_CHECKING, Any, cast +from functools import cached_property, wraps +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast from otari import _client as _cp +from otari._base import map_api_exception 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._client.exceptions import ApiException if TYPE_CHECKING: + from collections.abc import Callable from datetime import datetime from otari._client import ( @@ -49,6 +52,29 @@ ) +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def _translate(fn: Callable[_P, _R]) -> Callable[_P, _R]: + """Map a generated ``ApiException`` to a typed :class:`otari.errors.OtariError`. + + The inference client maps generated exceptions in ``client.py``; the + control-plane ergonomic aliases get the same treatment here so callers see a + single SDK error type instead of the raw generated ``ApiException``. The + ``raw`` escape hatch is intentionally left unwrapped. + """ + + @wraps(fn) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + try: + return fn(*args, **kwargs) + except ApiException as exc: + raise map_api_exception(exc) from exc + + return wrapper + + class KeysResource: """Ergonomic accessors for the API-keys management endpoints. @@ -59,18 +85,23 @@ class KeysResource: def __init__(self, api: KeysApi) -> None: self.raw = api + @_translate def create(self, request: CreateKeyRequest, **kwargs: Any) -> CreateKeyResponse: return self.raw.create_key_v1_keys_post(request, **kwargs) + @_translate def get(self, key_id: str, **kwargs: Any) -> KeyInfo: return self.raw.get_key_v1_keys_key_id_get(key_id, **kwargs) + @_translate 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) + @_translate 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) + @_translate def delete(self, key_id: str, **kwargs: Any) -> None: self.raw.delete_key_v1_keys_key_id_delete(key_id, **kwargs) @@ -85,23 +116,29 @@ class UsersResource: def __init__(self, api: UsersApi) -> None: self.raw = api + @_translate def create(self, request: CreateUserRequest, **kwargs: Any) -> UserResponse: return self.raw.create_user_v1_users_post(request, **kwargs) + @_translate def get(self, user_id: str, **kwargs: Any) -> UserResponse: return self.raw.get_user_v1_users_user_id_get(user_id, **kwargs) + @_translate 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) + @_translate def delete(self, user_id: str, **kwargs: Any) -> None: self.raw.delete_user_v1_users_user_id_delete(user_id, **kwargs) + @_translate 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. + @_translate 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) @@ -116,18 +153,23 @@ class BudgetsResource: def __init__(self, api: BudgetsApi) -> None: self.raw = api + @_translate def create(self, request: CreateBudgetRequest, **kwargs: Any) -> BudgetResponse: return self.raw.create_budget_v1_budgets_post(request, **kwargs) + @_translate def get(self, budget_id: str, **kwargs: Any) -> BudgetResponse: return self.raw.get_budget_v1_budgets_budget_id_get(budget_id, **kwargs) + @_translate 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) + @_translate 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) + @_translate def delete(self, budget_id: str, **kwargs: Any) -> None: self.raw.delete_budget_v1_budgets_budget_id_delete(budget_id, **kwargs) @@ -142,20 +184,25 @@ class PricingResource: def __init__(self, api: PricingApi) -> None: self.raw = api + @_translate def get(self, model_key: str, **kwargs: Any) -> PricingResponse: return self.raw.get_pricing_v1_pricing_model_key_get(model_key, **kwargs) + @_translate def set(self, request: SetPricingRequest, **kwargs: Any) -> PricingResponse: return self.raw.set_pricing_v1_pricing_post(request, **kwargs) + @_translate def delete(self, model_key: str, **kwargs: Any) -> None: self.raw.delete_pricing_v1_pricing_model_key_delete(model_key, **kwargs) + @_translate 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. + @_translate 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) @@ -170,6 +217,7 @@ class UsageResource: def __init__(self, api: UsageApi) -> None: self.raw = api + @_translate def list( self, start_date: datetime | None = None, diff --git a/tests/unit/test_control_plane_aliases.py b/tests/unit/test_control_plane_aliases.py index 9b5c176..6e11ba8 100644 --- a/tests/unit/test_control_plane_aliases.py +++ b/tests/unit/test_control_plane_aliases.py @@ -20,7 +20,9 @@ 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._client.exceptions import ApiException from otari.control_plane import ControlPlane +from otari.errors import AuthenticationError # (resource, alias, alias_args, generated_method, expected_forwarded_args) CASES: list[tuple[str, str, tuple[Any, ...], str, tuple[Any, ...]]] = [ @@ -90,3 +92,41 @@ def test_raw_exposes_generated_api(control_plane: ControlPlane) -> None: assert isinstance(control_plane.pricing.raw, PricingApi) assert isinstance(control_plane.usage.raw, UsageApi) control_plane.close() + + +# (resource, alias, generated_method, alias_args) covering every alias shape. +MAP_CASES: list[tuple[str, str, str, tuple[Any, ...]]] = [ + ("keys", "list", "list_keys_v1_keys_get", ()), + ("users", "get", "get_user_v1_users_user_id_get", ("u1",)), + ("budgets", "create", "create_budget_v1_budgets_post", ("req",)), + ("pricing", "delete", "delete_pricing_v1_pricing_model_key_delete", ("m1",)), + ("usage", "list", "list_usage_v1_usage_get", ()), +] + + +@pytest.mark.parametrize(("resource", "alias", "generated_method", "alias_args"), MAP_CASES) +def test_alias_maps_api_exception_to_typed_error( + control_plane: ControlPlane, + resource: str, + alias: str, + generated_method: str, + alias_args: tuple[Any, ...], +) -> None: + """Control-plane aliases surface a generated ``ApiException`` as a typed ``OtariError``. + + Mirrors the inference path (``client.py`` maps ``ApiException`` already), so + a bad master key yields a clean ``AuthenticationError`` instead of leaking + the raw generated exception (and a CLI traceback). + """ + res = getattr(control_plane, resource) + res.raw = MagicMock() + getattr(res.raw, generated_method).side_effect = ApiException( + status=401, reason="Unauthorized", body='{"detail":"Invalid master key"}' + ) + + with pytest.raises(AuthenticationError) as excinfo: + getattr(res, alias)(*alias_args) + + assert not isinstance(excinfo.value, ApiException) + assert excinfo.value.status_code == 401 + assert "Invalid master key" in str(excinfo.value)