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
29 changes: 27 additions & 2 deletions src/otari/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,29 @@ def _url_encode(value: str) -> str:
return urllib.parse.quote(value, safe="")


_ENVELOPE_KEYS = frozenset({"type", "message", "param", "code"})


def _envelope_message(detail: dict[str, Any]) -> str | None:
"""Unwrap a recognized error envelope without discarding diagnostic fields."""
if detail.keys() <= {"type", "error"}:
nested = detail.get("error")
if isinstance(nested, dict):
detail = nested
if detail.keys() <= _ENVELOPE_KEYS:
message = detail.get("message")
if isinstance(message, str):
return message
return None


def extract_detail(error: ApiException) -> str:
"""Pull the gateway's human-readable detail from an ``ApiException`` body."""
"""Pull the gateway's human-readable detail from an ``ApiException`` body.

Recognizes string details and OpenAI-style ``error.message`` or
Anthropic-style ``detail.error.message`` envelopes. Other structured details,
including validation lists, remain JSON with non-ASCII text preserved.
"""
body = error.body
if isinstance(body, (bytes, bytearray)):
body = body.decode("utf-8", "replace")
Expand All @@ -277,8 +298,12 @@ def extract_detail(error: ApiException) -> str:
detail = parsed.get("detail") or parsed.get("message") or parsed.get("error")
if isinstance(detail, str):
return detail
if isinstance(detail, dict):
message = _envelope_message(detail)
if message is not None:
return message
if detail is not None:
return str(detail)
return json.dumps(detail, ensure_ascii=False)
return body
return error.reason or "An error occurred"

Expand Down
31 changes: 31 additions & 0 deletions tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import annotations

import json
from typing import Any

import httpx
Expand Down Expand Up @@ -385,6 +386,36 @@ def test_status_maps_to_error(


class TestErrorDetails:
@pytest.mark.parametrize(
"credentials", [{"platform_token": "tk_test"}, {"api_key": "vk"}], ids=["platform", "self-hosted"]
)
def test_message_error_unwraps_nested_envelope(self, mock_rest: Any, credentials: dict[str, str]) -> None:
mock_rest(
status=429,
body={"detail": {"type": "error", "error": {"type": "rate_limit_error", "message": "Rate limit exceeded"}}},
headers={"retry-after": "30"},
)
client = OtariClient(api_base="http://localhost:8000", **credentials)
with pytest.raises(RateLimitError) as exc_info:
client.message(model="m", messages=[{"role": "user", "content": "Hi"}], max_tokens=10)
assert exc_info.value.message == "Rate limit exceeded"
assert exc_info.value.retry_after == "30"

@pytest.mark.parametrize(
"credentials", [{"platform_token": "tk_test"}, {"api_key": "vk"}], ids=["platform", "self-hosted"]
)
def test_guardrail_error_preserves_diagnostics(self, mock_rest: Any, credentials: dict[str, str]) -> None:
detail = {
"message": "Request blocked by guardrail policy.",
"code": "guardrail_violation",
"guardrails": [{"profile": "safety", "explanation": "Request violates policy", "score": 0.95}],
}
mock_rest(status=403, body={"detail": detail})
client = OtariClient(api_base="http://localhost:8000", **credentials)
with pytest.raises(AuthenticationError) as exc_info:
client.completion(model="m", messages=[{"role": "user", "content": "Hi"}])
assert json.loads(exc_info.value.message) == detail

def test_rate_limit_carries_retry_after(self, mock_rest: Any) -> None:
mock_rest(status=429, body={"detail": "slow down"}, headers={"retry-after": "30"})
client = OtariClient(api_base="http://localhost:8000", api_key="vk")
Expand Down
100 changes: 96 additions & 4 deletions tests/unit/test_errors.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"""Tests for the otari error hierarchy.

Mirrors the TypeScript SDK's ``errors.test.ts``.
"""
"""Tests for the otari error hierarchy and gateway error details."""

from __future__ import annotations

import json
from typing import Any

import pytest

from otari._base import extract_detail
from otari._client.exceptions import ApiException
from otari.errors import (
AuthenticationError,
BatchNotCompleteError,
Expand Down Expand Up @@ -175,3 +179,91 @@ def test_str_with_provider_name(self) -> None:
provider="anthropic",
)
assert str(err) == "[gateway] not supported"


class TestExtractDetail:
"""Unwrap recognized error envelopes without dropping diagnostic fields."""

def test_nested_openai_error_object_unwraps_to_message(self) -> None:
body = json.dumps(
{
"error": {
"type": "invalid_request_error",
"message": "context length exceeded",
"param": None,
"code": 400,
}
}
)
err = ApiException(status=400, body=body, reason="Bad Request")
assert extract_detail(err) == "context length exceeded"

def test_fastapi_detail_shape_unchanged(self) -> None:
body = json.dumps({"detail": "boom"})
err = ApiException(status=400, body=body, reason="Bad Request")
assert extract_detail(err) == "boom"

def test_flat_error_string_unchanged(self) -> None:
body = json.dumps({"error": "flat string"})
err = ApiException(status=400, body=body, reason="Bad Request")
assert extract_detail(err) == "flat string"

def test_top_level_message_unchanged(self) -> None:
body = json.dumps({"message": "top level message"})
err = ApiException(status=400, body=body, reason="Bad Request")
assert extract_detail(err) == "top level message"

def test_nested_error_object_without_message_falls_back_to_json(self) -> None:
detail = {"code": "provider's_error"}
body = json.dumps({"error": detail})
err = ApiException(status=400, body=body, reason="Bad Request")
assert json.loads(extract_detail(err)) == detail

def test_non_json_body_returned_verbatim(self) -> None:
err = ApiException(status=500, body="plain text failure", reason="Server Error")
assert extract_detail(err) == "plain text failure"

def test_nested_anthropic_error_unwraps_to_message(self) -> None:
body = json.dumps(
{"detail": {"type": "error", "error": {"type": "rate_limit_error", "message": "Rate limit exceeded"}}}
)
err = ApiException(status=429, body=body)
assert extract_detail(err) == "Rate limit exceeded"

def test_guardrail_detail_preserves_diagnostics(self) -> None:
detail = {
"message": "Request blocked by guardrail policy.",
"code": "guardrail_violation",
"guardrails": [{"profile": "safety", "explanation": "Request violates policy", "score": 0.95}],
}
err = ApiException(status=403, body=json.dumps({"detail": detail}))
assert json.loads(extract_detail(err)) == detail

@pytest.mark.parametrize(
"detail",
[
{"type": "error", "error": {"message": "blocked"}, "request_id": "req-1"},
{"type": "error", "error": {"message": "blocked", "request_id": "req-1"}},
{"message": "blocked", "error": {"message": "nested"}},
],
ids=["outer-diagnostics", "inner-diagnostics", "mixed-message-and-error"],
)
def test_nested_error_preserves_other_fields(self, detail: dict[str, Any]) -> None:
err = ApiException(status=400, body=json.dumps({"detail": detail}))
assert json.loads(extract_detail(err)) == detail

@pytest.mark.parametrize("message", [None, 400])
def test_non_string_nested_message_preserves_envelope(self, message: Any) -> None:
detail = {"type": "error", "error": {"message": message}}
err = ApiException(status=400, body=json.dumps({"detail": detail}))
assert json.loads(extract_detail(err)) == detail

def test_validation_list_preserves_unicode_in_json(self) -> None:
detail = [
{"loc": ["body", "messages"], "msg": "Entrée invalide: l'utilisateur dit 日本語", "type": "value_error"}
]
err = ApiException(status=422, body=json.dumps({"detail": detail}))
result = extract_detail(err)
assert "Entrée invalide" in result
assert "日本語" in result
assert json.loads(result) == detail