Add a python client library

We still don't have any documentation and things are in flux, but you can report your OpenAI API calls to OpenPipe now.
This commit is contained in:
Kyle Corbitt
2023-08-11 16:54:50 -07:00
parent d9db6d80ea
commit 8ed47eb4dd
32 changed files with 2698 additions and 85 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
.venv/
*.pyc

View File

@@ -37,7 +37,7 @@ export default function LoggedCalls() {
);
return (
<AppShell requireAuth>
<AppShell title="Logged Calls" requireAuth>
<PageHeaderContainer>
<Breadcrumb>
<BreadcrumbItem>

View File

@@ -35,6 +35,7 @@ export const externalApiRouter = createTRPCRouter({
method: "POST",
path: "/v1/check-cache",
description: "Check if a prompt is cached",
protect: true,
},
})
.input(
@@ -102,6 +103,7 @@ export const externalApiRouter = createTRPCRouter({
method: "POST",
path: "/v1/report",
description: "Report an API call",
protect: true,
},
})
.input(
@@ -122,6 +124,7 @@ export const externalApiRouter = createTRPCRouter({
)
.output(z.void())
.mutation(async ({ input, ctx }) => {
console.log("GOT TAGS", input.tags);
const apiKey = ctx.apiKey;
if (!apiKey) {
throw new TRPCError({ code: "UNAUTHORIZED" });

View File

@@ -64,7 +64,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
// Get the session from the server using the getServerSession wrapper function
const session = await getServerAuthSession({ req, res });
const apiKey = req.headers["x-openpipe-api-key"] as string | null;
const apiKey = req.headers.authorization?.split(" ")[1] as string | null;
return createInnerTRPCContext({
session,

Binary file not shown.

Binary file not shown.

View File

11
client-libs/python/codegen.sh Executable file
View File

@@ -0,0 +1,11 @@
#! /bin/bash
set -e
cd "$(dirname "$0")"
poetry run openapi-python-client generate --url http://localhost:3000/api/openapi.json
rm -rf openpipe/api_client
mv open-pipe-api-client/open_pipe_api_client openpipe/api_client
rm -rf open-pipe-api-client

View File

@@ -0,0 +1,10 @@
from .openai import OpenAIWrapper
from .shared import configured_client
openai = OpenAIWrapper()
def configure_openpipe(base_url=None, api_key=None):
if base_url is not None:
configured_client._base_url = base_url
if api_key is not None:
configured_client.token = api_key

View File

@@ -0,0 +1,7 @@
""" A client library for accessing OpenPipe API """
from .client import AuthenticatedClient, Client
__all__ = (
"AuthenticatedClient",
"Client",
)

View File

@@ -0,0 +1 @@
""" Contains methods for accessing the API """

View File

@@ -0,0 +1,155 @@
from http import HTTPStatus
from typing import Any, Dict, Optional, Union
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.external_api_check_cache_json_body import ExternalApiCheckCacheJsonBody
from ...models.external_api_check_cache_response_200 import ExternalApiCheckCacheResponse200
from ...types import Response
def _get_kwargs(
*,
json_body: ExternalApiCheckCacheJsonBody,
) -> Dict[str, Any]:
pass
json_json_body = json_body.to_dict()
return {
"method": "post",
"url": "/v1/check-cache",
"json": json_json_body,
}
def _parse_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Optional[ExternalApiCheckCacheResponse200]:
if response.status_code == HTTPStatus.OK:
response_200 = ExternalApiCheckCacheResponse200.from_dict(response.json())
return response_200
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Response[ExternalApiCheckCacheResponse200]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
*,
client: AuthenticatedClient,
json_body: ExternalApiCheckCacheJsonBody,
) -> Response[ExternalApiCheckCacheResponse200]:
"""Check if a prompt is cached
Args:
json_body (ExternalApiCheckCacheJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[ExternalApiCheckCacheResponse200]
"""
kwargs = _get_kwargs(
json_body=json_body,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
*,
client: AuthenticatedClient,
json_body: ExternalApiCheckCacheJsonBody,
) -> Optional[ExternalApiCheckCacheResponse200]:
"""Check if a prompt is cached
Args:
json_body (ExternalApiCheckCacheJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
ExternalApiCheckCacheResponse200
"""
return sync_detailed(
client=client,
json_body=json_body,
).parsed
async def asyncio_detailed(
*,
client: AuthenticatedClient,
json_body: ExternalApiCheckCacheJsonBody,
) -> Response[ExternalApiCheckCacheResponse200]:
"""Check if a prompt is cached
Args:
json_body (ExternalApiCheckCacheJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[ExternalApiCheckCacheResponse200]
"""
kwargs = _get_kwargs(
json_body=json_body,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
*,
client: AuthenticatedClient,
json_body: ExternalApiCheckCacheJsonBody,
) -> Optional[ExternalApiCheckCacheResponse200]:
"""Check if a prompt is cached
Args:
json_body (ExternalApiCheckCacheJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
ExternalApiCheckCacheResponse200
"""
return (
await asyncio_detailed(
client=client,
json_body=json_body,
)
).parsed

View File

@@ -0,0 +1,98 @@
from http import HTTPStatus
from typing import Any, Dict, Optional, Union
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.external_api_report_json_body import ExternalApiReportJsonBody
from ...types import Response
def _get_kwargs(
*,
json_body: ExternalApiReportJsonBody,
) -> Dict[str, Any]:
pass
json_json_body = json_body.to_dict()
return {
"method": "post",
"url": "/v1/report",
"json": json_json_body,
}
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Any]:
if response.status_code == HTTPStatus.OK:
return None
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Any]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
*,
client: AuthenticatedClient,
json_body: ExternalApiReportJsonBody,
) -> Response[Any]:
"""Report an API call
Args:
json_body (ExternalApiReportJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Any]
"""
kwargs = _get_kwargs(
json_body=json_body,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
async def asyncio_detailed(
*,
client: AuthenticatedClient,
json_body: ExternalApiReportJsonBody,
) -> Response[Any]:
"""Report an API call
Args:
json_body (ExternalApiReportJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Any]
"""
kwargs = _get_kwargs(
json_body=json_body,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)

View File

@@ -0,0 +1,268 @@
import ssl
from typing import Any, Dict, Optional, Union
import httpx
from attrs import define, evolve, field
@define
class Client:
"""A class for keeping track of data related to the API
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
``cookies``: A dictionary of cookies to be sent with every request
``headers``: A dictionary of headers to be sent with every request
``timeout``: The maximum amount of a time a request can take. API functions will raise
httpx.TimeoutException if this is exceeded.
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
but can be set to False for testing purposes.
``follow_redirects``: Whether or not to follow redirects. Default value is False.
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
Attributes:
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
argument to the constructor.
"""
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
_base_url: str
_cookies: Dict[str, str] = field(factory=dict, kw_only=True)
_headers: Dict[str, str] = field(factory=dict, kw_only=True)
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True)
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True)
_follow_redirects: bool = field(default=False, kw_only=True)
_httpx_args: Dict[str, Any] = field(factory=dict, kw_only=True)
_client: Optional[httpx.Client] = field(default=None, init=False)
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
def with_headers(self, headers: Dict[str, str]) -> "Client":
"""Get a new client matching this one with additional headers"""
if self._client is not None:
self._client.headers.update(headers)
if self._async_client is not None:
self._async_client.headers.update(headers)
return evolve(self, headers={**self._headers, **headers})
def with_cookies(self, cookies: Dict[str, str]) -> "Client":
"""Get a new client matching this one with additional cookies"""
if self._client is not None:
self._client.cookies.update(cookies)
if self._async_client is not None:
self._async_client.cookies.update(cookies)
return evolve(self, cookies={**self._cookies, **cookies})
def with_timeout(self, timeout: httpx.Timeout) -> "Client":
"""Get a new client matching this one with a new timeout (in seconds)"""
if self._client is not None:
self._client.timeout = timeout
if self._async_client is not None:
self._async_client.timeout = timeout
return evolve(self, timeout=timeout)
def set_httpx_client(self, client: httpx.Client) -> "Client":
"""Manually the underlying httpx.Client
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._client = client
return self
def get_httpx_client(self) -> httpx.Client:
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
if self._client is None:
self._client = httpx.Client(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._client
def __enter__(self) -> "Client":
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
self.get_httpx_client().__enter__()
return self
def __exit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
self.get_httpx_client().__exit__(*args, **kwargs)
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client":
"""Manually the underlying httpx.AsyncClient
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._async_client = async_client
return self
def get_async_httpx_client(self) -> httpx.AsyncClient:
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
if self._async_client is None:
self._async_client = httpx.AsyncClient(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._async_client
async def __aenter__(self) -> "Client":
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
await self.get_async_httpx_client().__aenter__()
return self
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
await self.get_async_httpx_client().__aexit__(*args, **kwargs)
@define
class AuthenticatedClient:
"""A Client which has been authenticated for use on secured endpoints
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
``cookies``: A dictionary of cookies to be sent with every request
``headers``: A dictionary of headers to be sent with every request
``timeout``: The maximum amount of a time a request can take. API functions will raise
httpx.TimeoutException if this is exceeded.
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
but can be set to False for testing purposes.
``follow_redirects``: Whether or not to follow redirects. Default value is False.
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
Attributes:
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
argument to the constructor.
token: The token to use for authentication
prefix: The prefix to use for the Authorization header
auth_header_name: The name of the Authorization header
"""
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
_base_url: str
_cookies: Dict[str, str] = field(factory=dict, kw_only=True)
_headers: Dict[str, str] = field(factory=dict, kw_only=True)
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True)
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True)
_follow_redirects: bool = field(default=False, kw_only=True)
_httpx_args: Dict[str, Any] = field(factory=dict, kw_only=True)
_client: Optional[httpx.Client] = field(default=None, init=False)
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
token: str
prefix: str = "Bearer"
auth_header_name: str = "Authorization"
def with_headers(self, headers: Dict[str, str]) -> "AuthenticatedClient":
"""Get a new client matching this one with additional headers"""
if self._client is not None:
self._client.headers.update(headers)
if self._async_client is not None:
self._async_client.headers.update(headers)
return evolve(self, headers={**self._headers, **headers})
def with_cookies(self, cookies: Dict[str, str]) -> "AuthenticatedClient":
"""Get a new client matching this one with additional cookies"""
if self._client is not None:
self._client.cookies.update(cookies)
if self._async_client is not None:
self._async_client.cookies.update(cookies)
return evolve(self, cookies={**self._cookies, **cookies})
def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient":
"""Get a new client matching this one with a new timeout (in seconds)"""
if self._client is not None:
self._client.timeout = timeout
if self._async_client is not None:
self._async_client.timeout = timeout
return evolve(self, timeout=timeout)
def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient":
"""Manually the underlying httpx.Client
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._client = client
return self
def get_httpx_client(self) -> httpx.Client:
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
if self._client is None:
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
self._client = httpx.Client(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._client
def __enter__(self) -> "AuthenticatedClient":
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
self.get_httpx_client().__enter__()
return self
def __exit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
self.get_httpx_client().__exit__(*args, **kwargs)
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient":
"""Manually the underlying httpx.AsyncClient
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._async_client = async_client
return self
def get_async_httpx_client(self) -> httpx.AsyncClient:
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
if self._async_client is None:
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
self._async_client = httpx.AsyncClient(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._async_client
async def __aenter__(self) -> "AuthenticatedClient":
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
await self.get_async_httpx_client().__aenter__()
return self
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
await self.get_async_httpx_client().__aexit__(*args, **kwargs)

View File

@@ -0,0 +1,14 @@
""" Contains shared errors types that can be raised from API functions """
class UnexpectedStatus(Exception):
"""Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True"""
def __init__(self, status_code: int, content: bytes):
self.status_code = status_code
self.content = content
super().__init__(f"Unexpected status code: {status_code}")
__all__ = ["UnexpectedStatus"]

View File

@@ -0,0 +1,15 @@
""" Contains all the data models used in inputs/outputs """
from .external_api_check_cache_json_body import ExternalApiCheckCacheJsonBody
from .external_api_check_cache_json_body_tags import ExternalApiCheckCacheJsonBodyTags
from .external_api_check_cache_response_200 import ExternalApiCheckCacheResponse200
from .external_api_report_json_body import ExternalApiReportJsonBody
from .external_api_report_json_body_tags import ExternalApiReportJsonBodyTags
__all__ = (
"ExternalApiCheckCacheJsonBody",
"ExternalApiCheckCacheJsonBodyTags",
"ExternalApiCheckCacheResponse200",
"ExternalApiReportJsonBody",
"ExternalApiReportJsonBodyTags",
)

View File

@@ -0,0 +1,70 @@
from typing import TYPE_CHECKING, Any, Dict, Type, TypeVar, Union
from attrs import define
from ..types import UNSET, Unset
if TYPE_CHECKING:
from ..models.external_api_check_cache_json_body_tags import ExternalApiCheckCacheJsonBodyTags
T = TypeVar("T", bound="ExternalApiCheckCacheJsonBody")
@define
class ExternalApiCheckCacheJsonBody:
"""
Attributes:
requested_at (float): Unix timestamp in milliseconds
req_payload (Union[Unset, Any]): JSON-encoded request payload
tags (Union[Unset, ExternalApiCheckCacheJsonBodyTags]): Extra tags to attach to the call for filtering. Eg {
"userId": "123", "promptId": "populate-title" }
"""
requested_at: float
req_payload: Union[Unset, Any] = UNSET
tags: Union[Unset, "ExternalApiCheckCacheJsonBodyTags"] = UNSET
def to_dict(self) -> Dict[str, Any]:
requested_at = self.requested_at
req_payload = self.req_payload
tags: Union[Unset, Dict[str, Any]] = UNSET
if not isinstance(self.tags, Unset):
tags = self.tags.to_dict()
field_dict: Dict[str, Any] = {}
field_dict.update(
{
"requestedAt": requested_at,
}
)
if req_payload is not UNSET:
field_dict["reqPayload"] = req_payload
if tags is not UNSET:
field_dict["tags"] = tags
return field_dict
@classmethod
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
from ..models.external_api_check_cache_json_body_tags import ExternalApiCheckCacheJsonBodyTags
d = src_dict.copy()
requested_at = d.pop("requestedAt")
req_payload = d.pop("reqPayload", UNSET)
_tags = d.pop("tags", UNSET)
tags: Union[Unset, ExternalApiCheckCacheJsonBodyTags]
if isinstance(_tags, Unset):
tags = UNSET
else:
tags = ExternalApiCheckCacheJsonBodyTags.from_dict(_tags)
external_api_check_cache_json_body = cls(
requested_at=requested_at,
req_payload=req_payload,
tags=tags,
)
return external_api_check_cache_json_body

View File

@@ -0,0 +1,43 @@
from typing import Any, Dict, List, Type, TypeVar
from attrs import define, field
T = TypeVar("T", bound="ExternalApiCheckCacheJsonBodyTags")
@define
class ExternalApiCheckCacheJsonBodyTags:
"""Extra tags to attach to the call for filtering. Eg { "userId": "123", "promptId": "populate-title" }"""
additional_properties: Dict[str, str] = field(init=False, factory=dict)
def to_dict(self) -> Dict[str, Any]:
field_dict: Dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update({})
return field_dict
@classmethod
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
d = src_dict.copy()
external_api_check_cache_json_body_tags = cls()
external_api_check_cache_json_body_tags.additional_properties = d
return external_api_check_cache_json_body_tags
@property
def additional_keys(self) -> List[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> str:
return self.additional_properties[key]
def __setitem__(self, key: str, value: str) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View File

@@ -0,0 +1,38 @@
from typing import Any, Dict, Type, TypeVar, Union
from attrs import define
from ..types import UNSET, Unset
T = TypeVar("T", bound="ExternalApiCheckCacheResponse200")
@define
class ExternalApiCheckCacheResponse200:
"""
Attributes:
resp_payload (Union[Unset, Any]): JSON-encoded response payload
"""
resp_payload: Union[Unset, Any] = UNSET
def to_dict(self) -> Dict[str, Any]:
resp_payload = self.resp_payload
field_dict: Dict[str, Any] = {}
field_dict.update({})
if resp_payload is not UNSET:
field_dict["respPayload"] = resp_payload
return field_dict
@classmethod
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
d = src_dict.copy()
resp_payload = d.pop("respPayload", UNSET)
external_api_check_cache_response_200 = cls(
resp_payload=resp_payload,
)
return external_api_check_cache_response_200

View File

@@ -0,0 +1,101 @@
from typing import TYPE_CHECKING, Any, Dict, Type, TypeVar, Union
from attrs import define
from ..types import UNSET, Unset
if TYPE_CHECKING:
from ..models.external_api_report_json_body_tags import ExternalApiReportJsonBodyTags
T = TypeVar("T", bound="ExternalApiReportJsonBody")
@define
class ExternalApiReportJsonBody:
"""
Attributes:
requested_at (float): Unix timestamp in milliseconds
received_at (float): Unix timestamp in milliseconds
req_payload (Union[Unset, Any]): JSON-encoded request payload
resp_payload (Union[Unset, Any]): JSON-encoded response payload
status_code (Union[Unset, float]): HTTP status code of response
error_message (Union[Unset, str]): User-friendly error message
tags (Union[Unset, ExternalApiReportJsonBodyTags]): Extra tags to attach to the call for filtering. Eg {
"userId": "123", "promptId": "populate-title" }
"""
requested_at: float
received_at: float
req_payload: Union[Unset, Any] = UNSET
resp_payload: Union[Unset, Any] = UNSET
status_code: Union[Unset, float] = UNSET
error_message: Union[Unset, str] = UNSET
tags: Union[Unset, "ExternalApiReportJsonBodyTags"] = UNSET
def to_dict(self) -> Dict[str, Any]:
requested_at = self.requested_at
received_at = self.received_at
req_payload = self.req_payload
resp_payload = self.resp_payload
status_code = self.status_code
error_message = self.error_message
tags: Union[Unset, Dict[str, Any]] = UNSET
if not isinstance(self.tags, Unset):
tags = self.tags.to_dict()
field_dict: Dict[str, Any] = {}
field_dict.update(
{
"requestedAt": requested_at,
"receivedAt": received_at,
}
)
if req_payload is not UNSET:
field_dict["reqPayload"] = req_payload
if resp_payload is not UNSET:
field_dict["respPayload"] = resp_payload
if status_code is not UNSET:
field_dict["statusCode"] = status_code
if error_message is not UNSET:
field_dict["errorMessage"] = error_message
if tags is not UNSET:
field_dict["tags"] = tags
return field_dict
@classmethod
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
from ..models.external_api_report_json_body_tags import ExternalApiReportJsonBodyTags
d = src_dict.copy()
requested_at = d.pop("requestedAt")
received_at = d.pop("receivedAt")
req_payload = d.pop("reqPayload", UNSET)
resp_payload = d.pop("respPayload", UNSET)
status_code = d.pop("statusCode", UNSET)
error_message = d.pop("errorMessage", UNSET)
_tags = d.pop("tags", UNSET)
tags: Union[Unset, ExternalApiReportJsonBodyTags]
if isinstance(_tags, Unset):
tags = UNSET
else:
tags = ExternalApiReportJsonBodyTags.from_dict(_tags)
external_api_report_json_body = cls(
requested_at=requested_at,
received_at=received_at,
req_payload=req_payload,
resp_payload=resp_payload,
status_code=status_code,
error_message=error_message,
tags=tags,
)
return external_api_report_json_body

View File

@@ -0,0 +1,43 @@
from typing import Any, Dict, List, Type, TypeVar
from attrs import define, field
T = TypeVar("T", bound="ExternalApiReportJsonBodyTags")
@define
class ExternalApiReportJsonBodyTags:
"""Extra tags to attach to the call for filtering. Eg { "userId": "123", "promptId": "populate-title" }"""
additional_properties: Dict[str, str] = field(init=False, factory=dict)
def to_dict(self) -> Dict[str, Any]:
field_dict: Dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update({})
return field_dict
@classmethod
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
d = src_dict.copy()
external_api_report_json_body_tags = cls()
external_api_report_json_body_tags.additional_properties = d
return external_api_report_json_body_tags
@property
def additional_keys(self) -> List[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> str:
return self.additional_properties[key]
def __setitem__(self, key: str, value: str) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View File

@@ -0,0 +1 @@
# Marker file for PEP 561

View File

@@ -0,0 +1,44 @@
""" Contains some shared types for properties """
from http import HTTPStatus
from typing import BinaryIO, Generic, Literal, MutableMapping, Optional, Tuple, TypeVar
from attrs import define
class Unset:
def __bool__(self) -> Literal[False]:
return False
UNSET: Unset = Unset()
FileJsonType = Tuple[Optional[str], BinaryIO, Optional[str]]
@define
class File:
"""Contains information for file uploads"""
payload: BinaryIO
file_name: Optional[str] = None
mime_type: Optional[str] = None
def to_tuple(self) -> FileJsonType:
"""Return a tuple representation that httpx will accept for multipart/form-data"""
return self.file_name, self.payload, self.mime_type
T = TypeVar("T")
@define
class Response(Generic[T]):
"""A response from an endpoint"""
status_code: HTTPStatus
content: bytes
headers: MutableMapping[str, str]
parsed: Optional[T]
__all__ = ["File", "Response", "FileJsonType"]

View File

@@ -0,0 +1,42 @@
from typing import Any, Optional
def merge_streamed_chunks(base: Optional[Any], chunk: Any) -> Any:
if base is None:
return merge_streamed_chunks({**chunk, "choices": []}, chunk)
choices = base["choices"].copy()
for choice in chunk["choices"]:
base_choice = next((c for c in choices if c["index"] == choice["index"]), None)
if base_choice:
base_choice["finish_reason"] = (
choice.get("finish_reason") or base_choice["finish_reason"]
)
base_choice["message"] = base_choice.get("message") or {"role": "assistant"}
if choice.get("delta") and choice["delta"].get("content"):
base_choice["message"]["content"] = (
base_choice["message"].get("content") or ""
) + (choice["delta"].get("content") or "")
if choice.get("delta") and choice["delta"].get("function_call"):
fn_call = base_choice["message"].get("function_call") or {}
fn_call["name"] = (fn_call.get("name") or "") + (
choice["delta"]["function_call"].get("name") or ""
)
fn_call["arguments"] = (fn_call.get("arguments") or "") + (
choice["delta"]["function_call"].get("arguments") or ""
)
else:
# Here, we'll have to handle the omitted property "delta" manually
new_choice = {k: v for k, v in choice.items() if k != "delta"}
choices.append(
{**new_choice, "message": {"role": "assistant", **choice["delta"]}}
)
merged = {
**base,
"choices": choices,
}
return merged

View File

@@ -0,0 +1,164 @@
import openai as original_openai
import time
import inspect
from openpipe.merge_openai_chunks import merge_streamed_chunks
from .shared import report_async, report
class ChatCompletionWrapper:
def __getattr__(self, name):
return getattr(original_openai.ChatCompletion, name)
def __setattr__(self, name, value):
return setattr(original_openai.ChatCompletion, name, value)
@classmethod
def create(cls, *args, **kwargs):
openpipe_options = kwargs.pop("openpipe", {})
requested_at = int(time.time() * 1000)
try:
chat_completion = original_openai.ChatCompletion.create(*args, **kwargs)
if inspect.isgenerator(chat_completion):
def _gen():
assembled_completion = None
for chunk in chat_completion:
assembled_completion = merge_streamed_chunks(
assembled_completion, chunk
)
yield chunk
received_at = int(time.time() * 1000)
report(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=assembled_completion,
status_code=200,
)
return _gen()
else:
received_at = int(time.time() * 1000)
report(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=chat_completion,
status_code=200,
)
return chat_completion
except Exception as e:
received_at = int(time.time() * 1000)
if isinstance(e, original_openai.OpenAIError):
report(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=e.json_body,
error_message=str(e),
status_code=e.http_status,
)
else:
report(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
error_message=str(e),
)
raise e
@classmethod
async def acreate(cls, *args, **kwargs):
openpipe_options = kwargs.pop("openpipe", {})
requested_at = int(time.time() * 1000)
try:
chat_completion = original_openai.ChatCompletion.acreate(*args, **kwargs)
if inspect.isgenerator(chat_completion):
def _gen():
assembled_completion = None
for chunk in chat_completion:
assembled_completion = merge_streamed_chunks(
assembled_completion, chunk
)
yield chunk
received_at = int(time.time() * 1000)
report_async(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=assembled_completion,
status_code=200,
)
return _gen()
else:
received_at = int(time.time() * 1000)
report_async(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=chat_completion,
status_code=200,
)
return chat_completion
except Exception as e:
received_at = int(time.time() * 1000)
if isinstance(e, original_openai.OpenAIError):
report_async(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=e.json_body,
error_message=str(e),
status_code=e.http_status,
)
else:
report_async(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
error_message=str(e),
)
raise e
class OpenAIWrapper:
ChatCompletion = ChatCompletionWrapper()
def __getattr__(self, name):
return getattr(original_openai, name)
def __setattr__(self, name, value):
return setattr(original_openai, name, value)
def __dir__(self):
return dir(original_openai) + ["openpipe_base_url", "openpipe_api_key"]

View File

@@ -0,0 +1,56 @@
from openpipe.api_client.api.default import external_api_report
from openpipe.api_client.client import AuthenticatedClient
from openpipe.api_client.models.external_api_report_json_body_tags import (
ExternalApiReportJsonBodyTags,
)
import toml
version = toml.load("pyproject.toml")["tool"]["poetry"]["version"]
configured_client = AuthenticatedClient(
base_url="https://app.openpipe.ai/api/v1", token=""
)
def _get_tags(openpipe_options):
tags = openpipe_options.get("tags") or {}
tags["$sdk"] = "python"
tags["$sdk_version"] = version
return ExternalApiReportJsonBodyTags.from_dict(tags)
def report(
openpipe_options={},
**kwargs,
):
try:
external_api_report.sync_detailed(
client=configured_client,
json_body=external_api_report.ExternalApiReportJsonBody(
**kwargs,
tags=_get_tags(openpipe_options),
),
)
except Exception as e:
# We don't want to break client apps if our API is down for some reason
print(f"Error reporting to OpenPipe: {e}")
print(e)
async def report_async(
openpipe_options={},
**kwargs,
):
try:
await external_api_report.asyncio_detailed(
client=configured_client,
json_body=external_api_report.ExternalApiReportJsonBody(
**kwargs,
tags=_get_tags(openpipe_options),
),
)
except Exception as e:
# We don't want to break client apps if our API is down for some reason
print(f"Error reporting to OpenPipe: {e}")
print(e)

View File

@@ -0,0 +1,76 @@
from dotenv import load_dotenv
from . import openai, configure_openpipe
import os
import pytest
load_dotenv()
openai.api_key = os.getenv("OPENAI_API_KEY")
configure_openpipe(
base_url="http://localhost:3000/api", api_key=os.getenv("OPENPIPE_API_KEY")
)
@pytest.mark.skip
def test_sync():
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[{"role": "system", "content": "count to 10"}],
)
print(completion.choices[0].message.content)
@pytest.mark.skip
def test_streaming():
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[{"role": "system", "content": "count to 10"}],
stream=True,
)
for chunk in completion:
print(chunk)
@pytest.mark.skip
async def test_async():
acompletion = await openai.ChatCompletion.acreate(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "count down from 5"}],
)
print(acompletion.choices[0].message.content)
@pytest.mark.skip
async def test_async_streaming():
acompletion = await openai.ChatCompletion.acreate(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "count down from 5"}],
stream=True,
)
async for chunk in acompletion:
print(chunk)
def test_sync_with_tags():
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[{"role": "system", "content": "count to 10"}],
openpipe={"tags": {"promptId": "testprompt"}},
)
print("finished")
print(completion.choices[0].message.content)
@pytest.mark.focus
def test_bad_call():
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo-blaster",
messages=[{"role": "system", "content": "count to 10"}],
stream=True,
)

1370
client-libs/python/poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
[tool.poetry]
name = "openpipe"
version = "0.1.0"
description = ""
authors = ["Kyle Corbitt <kyle@corbt.com>"]
license = "Apache-2.0"
[tool.poetry.dependencies]
python = "^3.9"
openai = "^0.27.8"
httpx = "^0.24.1"
attrs = "^23.1.0"
python-dateutil = "^2.8.2"
toml = "^0.10.2"
[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
openapi-python-client = "^0.15.0"
black = "^23.7.0"
isort = "^5.12.0"
autoflake = "^2.2.0"
pytest = "^7.4.0"
python-dotenv = "^1.0.0"
pytest-asyncio = "^0.21.1"
pytest-watch = "^4.2.0"
pytest-testmon = "^2.0.12"
[tool.pytest.ini_options]
asyncio_mode = "auto"
markers = "focus"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

View File

@@ -5,16 +5,13 @@
"description": "The public API for reporting API calls to OpenPipe",
"version": "0.1.0"
},
"servers": [
{
"url": "https://app.openpipe.ai/api"
}
],
"servers": [{ "url": "https://app.openpipe.ai/api" }],
"paths": {
"/v1/check-cache": {
"post": {
"operationId": "externalApi-checkCache",
"description": "Check if a prompt is cached",
"security": [{ "Authorization": [] }],
"requestBody": {
"required": true,
"content": {
@@ -22,24 +19,18 @@
"schema": {
"type": "object",
"properties": {
"requestedAt": {
"startTime": {
"type": "number",
"description": "Unix timestamp in milliseconds"
},
"reqPayload": {
"description": "JSON-encoded request payload"
},
"reqPayload": { "description": "JSON-encoded request payload" },
"tags": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"additionalProperties": { "type": "string" },
"description": "Extra tags to attach to the call for filtering. Eg { \"userId\": \"123\", \"promptId\": \"populate-title\" }"
}
},
"required": [
"requestedAt"
],
"required": ["startTime"],
"additionalProperties": false
}
}
@@ -54,18 +45,14 @@
"schema": {
"type": "object",
"properties": {
"respPayload": {
"description": "JSON-encoded response payload"
}
"respPayload": { "description": "JSON-encoded response payload" }
},
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
"default": { "$ref": "#/components/responses/error" }
}
}
},
@@ -73,6 +60,7 @@
"post": {
"operationId": "externalApi-report",
"description": "Report an API call",
"security": [{ "Authorization": [] }],
"requestBody": {
"required": true,
"content": {
@@ -80,40 +68,22 @@
"schema": {
"type": "object",
"properties": {
"requestedAt": {
"startTime": {
"type": "number",
"description": "Unix timestamp in milliseconds"
},
"receivedAt": {
"type": "number",
"description": "Unix timestamp in milliseconds"
},
"reqPayload": {
"description": "JSON-encoded request payload"
},
"respPayload": {
"description": "JSON-encoded response payload"
},
"statusCode": {
"type": "number",
"description": "HTTP status code of response"
},
"errorMessage": {
"type": "string",
"description": "User-friendly error message"
},
"endTime": { "type": "number", "description": "Unix timestamp in milliseconds" },
"reqPayload": { "description": "JSON-encoded request payload" },
"respPayload": { "description": "JSON-encoded response payload" },
"respStatus": { "type": "number", "description": "HTTP status code of response" },
"error": { "type": "string", "description": "User-friendly error message" },
"tags": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"additionalProperties": { "type": "string" },
"description": "Extra tags to attach to the call for filtering. Eg { \"userId\": \"123\", \"promptId\": \"populate-title\" }"
}
},
"required": [
"requestedAt",
"receivedAt"
],
"required": ["startTime", "endTime"],
"additionalProperties": false
}
}
@@ -123,26 +93,15 @@
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
"content": { "application/json": { "schema": {} } }
},
"default": {
"$ref": "#/components/responses/error"
}
"default": { "$ref": "#/components/responses/error" }
}
}
}
},
"components": {
"securitySchemes": {
"Authorization": {
"type": "http",
"scheme": "bearer"
}
},
"securitySchemes": { "Authorization": { "type": "http", "scheme": "bearer" } },
"responses": {
"error": {
"description": "Error response",
@@ -151,32 +110,19 @@
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"code": {
"type": "string"
},
"message": { "type": "string" },
"code": { "type": "string" },
"issues": {
"type": "array",
"items": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
],
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}
}
},
"required": [
"message",
"code"
],
"required": ["message", "code"],
"additionalProperties": false
}
}

View File

@@ -16,8 +16,7 @@ export class OpenAI extends openai.OpenAI {
constructor({
openPipeApiKey = readEnv("OPENPIPE_API_KEY"),
openPipeBaseUrl = readEnv("OPENPIPE_BASE_URL") ??
`https://app.openpipe.ai/v1`,
openPipeBaseUrl = readEnv("OPENPIPE_BASE_URL") ?? `https://app.openpipe.ai/v1`,
...opts
}: ClientOptions = {}) {
super({ ...opts });
@@ -26,7 +25,7 @@ export class OpenAI extends openai.OpenAI {
const axiosInstance = axios.create({
baseURL: openPipeBaseUrl,
headers: {
"x-openpipe-api-key": openPipeApiKey,
Authorization: `Bearer ${openPipeApiKey}`,
},
});
this.openPipeApi = new openPipeClient.DefaultApi(