Skip to content
Merged
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,26 @@ message = client.message(
print(message.content)
```

### Response metadata

Use `with_response_metadata` to access the gateway's `X-Otari-Request-ID`:

```python
result = client.with_response_metadata.message(
model="anthropic:claude-3-5-sonnet",
messages=[{"role": "user", "content": "Hello!"}],
max_tokens=256,
)
print(result.request_id, result.data.content)
```

For sync and async streams, `stream.request_id` is populated when iteration
starts, before the first event is yielded.

Only a gateway running in platform mode sends this header, so `request_id` is
`None` when you call a standalone self-hosted gateway. On a stream, `None` also
means iteration has not opened the response yet.

### Embeddings

```python
Expand Down
4 changes: 4 additions & 0 deletions src/otari/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
UnsupportedCapabilityError,
UpstreamProviderError,
)
from otari.response_metadata import AsyncOtariStream, OtariResponse, OtariStream
from otari.types import (
BatchRequestItem,
BatchResult,
Expand All @@ -62,6 +63,7 @@

__all__ = [
"AsyncOtariClient",
"AsyncOtariStream",
"AuthenticationError",
"BatchNotCompleteError",
"BatchRequestItem",
Expand All @@ -83,6 +85,8 @@
"OtariClient",
"OtariClientOptions",
"OtariError",
"OtariResponse",
"OtariStream",
"RateLimitError",
"RerankResponse",
"TranscriptionResult",
Expand Down
204 changes: 202 additions & 2 deletions src/otari/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@

import asyncio
from functools import cached_property
from typing import TYPE_CHECKING, Any, cast, overload
from typing import TYPE_CHECKING, Any, Literal, cast, overload

import httpx

from otari._base import _BaseOtariClient, build_request
from otari._base import _BaseOtariClient, _header_get, build_request
from otari._client import ApiClient, Configuration
from otari._client.api.batches_api import BatchesApi
from otari._client.api.chat_api import ChatApi
Expand All @@ -54,6 +54,7 @@
from otari._streaming import aiter_sse
from otari.control_plane import ControlPlane
from otari.errors import OtariError
from otari.response_metadata import AsyncOtariStream, OtariResponse

if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable
Expand All @@ -63,6 +64,7 @@
from otari._client.models.count_tokens_response import CountTokensResponse
from otari._client.models.create_embedding_response import CreateEmbeddingResponse
from otari._client.models.images_response import ImagesResponse
from otari._client.models.message_response import MessageResponse
from otari._client.models.model_object import ModelObject
from otari._client.models.moderation_response import ModerationResponse
from otari._client.models.rerank_response import RerankResponse
Expand Down Expand Up @@ -148,6 +150,11 @@ def control_plane(self) -> ControlPlane:
raise OtariError(msg)
return ControlPlane(self._gateway_root_url, self._admin_token)

@cached_property
def with_response_metadata(self) -> AsyncOtariClientWithResponseMetadata:
"""Inference methods that return per-request Otari response metadata."""
return AsyncOtariClientWithResponseMetadata(self)

# -- Chat completions ---------------------------------------------------

@overload
Expand Down Expand Up @@ -470,6 +477,17 @@ async def _call(self, fn: Callable[[], Any]) -> Any:
except ApiException as exc:
raise self._map_api_exception(exc) from exc

async def _call_with_response_metadata(
self,
fn: Callable[[], Any],
) -> OtariResponse[Any]:
"""Run a generated HTTP-info call and preserve its Otari request ID."""
response = await self._call(fn)
return OtariResponse(
data=response.data,
request_id=_header_get(response.headers, "X-Otari-Request-ID"),
)

async def _post(
self,
path: str,
Expand All @@ -494,6 +512,26 @@ async def _post(

async def _stream(self, path: str, body: dict[str, Any], kind: Any) -> AsyncIterator[Any]:
"""Open a raw async streaming POST and yield parsed SSE chunks."""
async for chunk in self._iter_stream(path, body, kind):
yield chunk

def _stream_with_response_metadata(
self,
path: str,
body: dict[str, Any],
kind: Any,
) -> AsyncOtariStream[Any]:
"""Open a stream that exposes metadata for its individual request."""
return AsyncOtariStream(lambda stream: self._iter_stream(path, body, kind, stream))

async def _iter_stream(
self,
path: str,
body: dict[str, Any],
kind: Any,
stream: AsyncOtariStream[Any] | None = None,
) -> AsyncIterator[Any]:
"""Issue and parse the raw HTTP streaming request."""
url = f"{self._base_url}{path}"
headers = {
"Content-Type": "application/json",
Expand All @@ -504,6 +542,8 @@ async def _stream(self, path: str, body: dict[str, Any], kind: Any) -> AsyncIter
if response.status_code >= 400:
raw = await response.aread()
raise self._map_streaming_response(response, raw)
if stream is not None:
stream._set_request_id(_header_get(response.headers, "X-Otari-Request-ID"))
async for chunk in aiter_sse(response, kind):
yield chunk

Expand All @@ -519,3 +559,163 @@ async def __aenter__(self) -> AsyncOtariClient:

async def __aexit__(self, *args: Any) -> None:
await self.close()


class AsyncOtariClientWithResponseMetadata:
"""Opt-in async inference API that retains metadata for each HTTP response."""

def __init__(self, client: AsyncOtariClient) -> None:
self._client = client

@overload
async def completion(
self,
*,
model: str,
messages: list[dict[str, Any]],
stream: Literal[False] | None = None,
**kwargs: Any,
) -> OtariResponse[ChatCompletion]: ...

@overload
async def completion(
self,
*,
model: str,
messages: list[dict[str, Any]],
stream: Literal[True],
**kwargs: Any,
) -> AsyncOtariStream[ChatCompletionChunk]: ...

@overload
async def completion(
self,
*,
model: str,
messages: list[dict[str, Any]],
stream: bool | None,
**kwargs: Any,
) -> OtariResponse[ChatCompletion] | AsyncOtariStream[ChatCompletionChunk]: ...

async def completion(
self,
*,
model: str,
messages: list[dict[str, Any]],
stream: bool | None = None,
**kwargs: Any,
) -> OtariResponse[ChatCompletion] | AsyncOtariStream[ChatCompletionChunk]:
Comment thread
HareeshBahuleyan marked this conversation as resolved.
"""Create a chat completion and retain its Otari request ID."""
body = {"model": model, "messages": messages, **kwargs}
if stream:
body["stream"] = True
return self._client._stream_with_response_metadata("/chat/completions", body, "chat")
request = build_request(ChatCompletionRequest, body)
return await self._client._call_with_response_metadata(
lambda: self._client._chat.chat_completions_v1_chat_completions_post_with_http_info(
request
)
)

@overload
async def response(
self,
*,
model: str,
input: Any,
stream: Literal[False] | None = None,
**kwargs: Any,
) -> OtariResponse[Any]: ...

@overload
async def response(
self,
*,
model: str,
input: Any,
stream: Literal[True],
**kwargs: Any,
) -> AsyncOtariStream[dict[str, Any]]: ...

@overload
async def response(
self,
*,
model: str,
input: Any,
stream: bool | None,
**kwargs: Any,
) -> OtariResponse[Any] | AsyncOtariStream[dict[str, Any]]: ...

async def response(
Comment thread
HareeshBahuleyan marked this conversation as resolved.
self,
*,
model: str,
input: Any, # noqa: A002
stream: bool | None = None,
**kwargs: Any,
) -> OtariResponse[Any] | AsyncOtariStream[dict[str, Any]]:
"""Create an OpenAI-style response and retain its Otari request ID."""
body = {"model": model, "input": input, **kwargs}
if stream:
body["stream"] = True
return self._client._stream_with_response_metadata("/responses", body, "responses")
return await self._client._call_with_response_metadata(
lambda: self._client._responses.create_response_v1_responses_post_with_http_info(
body # type: ignore[arg-type]
)
)

@overload
async def message(
self,
*,
model: str,
messages: list[dict[str, Any]],
max_tokens: int,
stream: Literal[False] | None = None,
**kwargs: Any,
) -> OtariResponse[MessageResponse]: ...

@overload
async def message(
self,
*,
model: str,
messages: list[dict[str, Any]],
max_tokens: int,
stream: Literal[True],
**kwargs: Any,
) -> AsyncOtariStream[dict[str, Any]]: ...

@overload
async def message(
self,
*,
model: str,
messages: list[dict[str, Any]],
max_tokens: int,
stream: bool | None,
**kwargs: Any,
) -> OtariResponse[MessageResponse] | AsyncOtariStream[dict[str, Any]]: ...

async def message(
self,
*,
model: str,
messages: list[dict[str, Any]],
max_tokens: int,
stream: bool | None = None,
**kwargs: Any,
) -> OtariResponse[MessageResponse] | AsyncOtariStream[dict[str, Any]]:
"""Create an Anthropic-style message and retain its Otari request ID."""
body = {"model": model, "messages": messages, "max_tokens": max_tokens, **kwargs}
if stream:
body["stream"] = True
return self._client._stream_with_response_metadata("/messages", body, "messages")
request = build_request(MessagesRequest, body)
return await self._client._call_with_response_metadata(
lambda: self._client._messages.create_message_v1_messages_post_with_http_info(
request
)
)
Loading
Loading