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
27 changes: 26 additions & 1 deletion py/src/braintrust/api/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
"""Braintrust API client package."""
"""Public Braintrust API client package."""

from ._routing import EndpointRouter, RequestTarget
from ._service import ClientContext
from .attachments import AttachmentsAPI
from .auth import AuthAPI, LoginResult, OrganizationInfo
from .client import BraintrustClient
from .datasets import DatasetsAPI
from .errors import (
BraintrustAPIError,
BraintrustHTTPError,
Expand All@@ -8,16 +14,35 @@
BraintrustTransportError,
BraintrustTransportRetryExhaustedError,
)
from .experiments import ExperimentsAPI
from .functions import FunctionsAPI
from .policies import RetryMode, RetryPolicy
from .projects import ProjectsAPI
from .prompts import PromptsAPI
from .queries import QueriesAPI


__all__ = [
"AttachmentsAPI",
"AuthAPI",
"BraintrustAPIError",
"BraintrustClient",
"BraintrustHTTPError",
"BraintrustResponseError",
"BraintrustRetryExhaustedError",
"BraintrustTransportError",
"BraintrustTransportRetryExhaustedError",
"ClientContext",
"DatasetsAPI",
"EndpointRouter",
"ExperimentsAPI",
"FunctionsAPI",
"LoginResult",
"OrganizationInfo",
"ProjectsAPI",
"PromptsAPI",
"QueriesAPI",
"RequestTarget",
"RetryMode",
"RetryPolicy",
]
69 changes: 69 additions & 0 deletions py/src/braintrust/api/_routing.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""Endpoint routing for Braintrust API requests."""

import enum
from dataclasses import dataclass

from ..util import _urljoin


_V1_PROXY_SUFFIX = "/v1/proxy"


class RequestTarget(enum.Enum):
"""A logical Braintrust request destination."""

APP = "app"
API = "api"
PROXY = "proxy"


def normalize_proxy_url(proxy_url: str) -> str:
"""Normalize a Universal Proxy URL to the API host used by SDK routes."""

if proxy_url.endswith(_V1_PROXY_SUFFIX):
return proxy_url[: -len(_V1_PROXY_SUFFIX)]
return proxy_url


@dataclass
class EndpointRouter:
"""Resolve logical Braintrust targets without changing their configured origins."""

app_url: str
api_url: str | None = None
proxy_url: str | None = None
is_universal_api: bool = False

def configure(
self,
*,
api_url: str | None,
proxy_url: str | None,
is_universal_api: bool = False,
) -> None:
"""Apply URLs discovered during authentication."""

self.api_url = api_url
self.proxy_url = proxy_url
self.is_universal_api = is_universal_api

def base_url(self, target: RequestTarget) -> str:
"""Return the configured origin for ``target``."""

if target is RequestTarget.APP:
return self.app_url
if target is RequestTarget.API:
if not self.api_url:
raise RuntimeError("API URL is unavailable before organization discovery")
return self.api_url
if target is RequestTarget.PROXY:
base_url = self.proxy_url or self.api_url
if not base_url:
raise RuntimeError("Proxy URL is unavailable before organization discovery")
return normalize_proxy_url(base_url)
raise ValueError(f"Unknown request target: {target!r}")

def resolve(self, target: RequestTarget, path: str) -> str:
"""Resolve ``path`` against the origin for ``target``."""

return _urljoin(self.base_url(target), path)
51 changes: 51 additions & 0 deletions py/src/braintrust/api/_service.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
"""Shared resource service primitives."""

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from ._routing import EndpointRouter, RequestTarget
from ._transport import Transport


@dataclass(frozen=True)
class ClientContext:
"""Organization and credential context shared by resource services."""

org_id: str
org_name: str


class ResourceAPI:
"""Base class for synchronous resource services."""

def __init__(
self,
transport: Transport,
router: EndpointRouter,
context: ClientContext,
api_key: str,
):
self._transport = transport
self._router = router
self._context = context
self._api_key = api_key

def _request_json(
self,
target: RequestTarget,
method: str,
path: str,
*,
headers: Mapping[str, str] | None = None,
**kwargs: Any,
) -> Any:
request_headers = {"Authorization": f"Bearer {self._api_key}"}
if headers:
request_headers.update(headers)
return self._transport.request_json(
method,
self._router.resolve(target, path),
headers=request_headers,
**kwargs,
)
9 changes: 9 additions & 0 deletions py/src/braintrust/api/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Legacy and policy-aware HTTP transport primitives for the Braintrust SDK."""

import datetime
import http.cookiejar
import logging
import sys
import time
Expand DownExpand Up@@ -28,6 +29,11 @@
logger = logging.getLogger(__name__)


class _RejectCookiesPolicy(http.cookiejar.DefaultCookiePolicy):
def set_ok(self, cookie: Any, request: Any) -> bool:
return False


class RetryRequestExceptionsAdapter(HTTPAdapter):
"""An HTTP adapter that automatically retries requests on connection exceptions.

Expand DownExpand Up@@ -189,13 +195,16 @@ def __init__(
session: requests.Session | None = None,
adapter: HTTPAdapter | None = None,
enable_sdk_retries: bool | None = None,
persist_cookies: bool = True,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
):
custom_transport = session is not None or adapter is not None
self._owns_session = session is None
self.session = session if session is not None else requests.Session()
if not persist_cookies and self._owns_session:
self.session.cookies.set_policy(_RejectCookiesPolicy())
self._sdk_retries_enabled = not custom_transport if enable_sdk_retries is None else enable_sdk_retries
if adapter is not None:
self.session.mount("http://", adapter)
Expand Down
10 changes: 10 additions & 0 deletions py/src/braintrust/api/attachments.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
"""Attachment metadata API service."""

from ._service import ResourceAPI


class AttachmentsAPI(ResourceAPI):
"""Synchronous attachment metadata operations.

Signed object-storage traffic remains outside the routed transport.
"""
122 changes: 122 additions & 0 deletions py/src/braintrust/api/auth.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
"""Authentication and organization discovery service."""

from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any

from ..env import BraintrustEnv
from ._routing import EndpointRouter, RequestTarget
from ._transport import HTTPConnection, Transport
from .policies import RetryMode


@dataclass(frozen=True)
class OrganizationInfo:
"""Organization routing information returned by API-key login."""

id: str
name: str
api_url: str | None
proxy_url: str | None
realtime_url: str | None
is_universal_api: bool
git_metadata: Mapping[str, Any] | None
raw: Mapping[str, Any]

@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "OrganizationInfo":
"""Parse an additive login response while retaining unknown fields."""

org_id = value.get("id")
name = value.get("name")
if not isinstance(org_id, str) or not isinstance(name, str):
raise ValueError("Organization login data must include string id and name fields")

def optional_string(field: str) -> str | None:
result = value.get(field)
return result if isinstance(result, str) and result else None

git_metadata = value.get("git_metadata")
if not isinstance(git_metadata, Mapping):
git_metadata = None

return cls(
id=org_id,
name=name,
api_url=optional_string("api_url"),
proxy_url=optional_string("proxy_url"),
realtime_url=optional_string("realtime_url"),
is_universal_api=bool(value.get("is_universal_api", False)),
git_metadata=MappingProxyType(dict(git_metadata)) if git_metadata is not None else None,
raw=MappingProxyType(dict(value)),
)


@dataclass(frozen=True)
class LoginResult:
"""Selected organization and the complete login response."""

organization: OrganizationInfo
response: Mapping[str, Any]


class AuthAPI:
"""Authenticate an API key and configure an endpoint router."""

def __init__(self, transport: Transport, router: EndpointRouter):
self._transport = transport
self._router = router

def login(
self,
api_key: str,
*,
org_name: str | None = None,
api_url: str | None = None,
proxy_url: str | None = None,
) -> LoginResult:
"""Log in, select an organization, and apply routing override precedence."""

api_key = HTTPConnection.sanitize_token(api_key)
response = self._transport.request_json(
"POST",
self._router.resolve(RequestTarget.APP, "/api/apikey/login"),
headers={"Authorization": f"Bearer {api_key}"},
retry_mode=RetryMode.SAFE_READ,
)
if not isinstance(response, Mapping):
raise ValueError("API-key login returned a non-object response")
raw_orgs = response.get("org_info")
if not isinstance(raw_orgs, Sequence) or isinstance(raw_orgs, (str, bytes)):
raise ValueError("API-key login response did not include an organization list")

organizations = [OrganizationInfo.from_dict(org) for org in raw_orgs if isinstance(org, Mapping)]
organization = self._select_organization(organizations, org_name)

resolved_api_url = api_url or BraintrustEnv.API_URL.get(organization.api_url)
resolved_proxy_url = proxy_url or BraintrustEnv.PROXY_URL.get(organization.proxy_url)
if not resolved_api_url:
if org_name:
raise ValueError(
f"Unable to log into organization '{org_name}'."
" Are you sure this credential is scoped to the organization?"
)
raise ValueError("Unable to log into any organization with the provided credential.")

self._router.configure(
api_url=resolved_api_url,
proxy_url=resolved_proxy_url,
is_universal_api=organization.is_universal_api,
)
return LoginResult(organization=organization, response=MappingProxyType(dict(response)))

@staticmethod
def _select_organization(organizations: Sequence[OrganizationInfo], org_name: str | None) -> OrganizationInfo:
if not organizations:
raise ValueError("This user is not part of any organizations.")
for organization in organizations:
if org_name is None or organization.name == org_name:
return organization
choices = ", ".join(organization.name for organization in organizations)
raise ValueError(f"Organization {org_name} not found. Must be one of {choices}")
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(api): add client bootstrap and service facade by AbhiPrasad · Pull Request #655 · braintrustdata/braintrust-sdk-python · GitHub
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
27 changes: 26 additions & 1 deletion py/src/braintrust/api/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
"""Braintrust API client package."""
"""Public Braintrust API client package."""

from ._routing import EndpointRouter, RequestTarget
from ._service import ClientContext
from .attachments import AttachmentsAPI
from .auth import AuthAPI, LoginResult, OrganizationInfo
from .client import BraintrustClient
from .datasets import DatasetsAPI
from .errors import (
BraintrustAPIError,
BraintrustHTTPError,
Expand All@@ -8,16 +14,35 @@
BraintrustTransportError,
BraintrustTransportRetryExhaustedError,
)
from .experiments import ExperimentsAPI
from .functions import FunctionsAPI
from .policies import RetryMode, RetryPolicy
from .projects import ProjectsAPI
from .prompts import PromptsAPI
from .queries import QueriesAPI


__all__ = [
"AttachmentsAPI",
"AuthAPI",
"BraintrustAPIError",
"BraintrustClient",
"BraintrustHTTPError",
"BraintrustResponseError",
"BraintrustRetryExhaustedError",
"BraintrustTransportError",
"BraintrustTransportRetryExhaustedError",
"ClientContext",
"DatasetsAPI",
"EndpointRouter",
"ExperimentsAPI",
"FunctionsAPI",
"LoginResult",
"OrganizationInfo",
"ProjectsAPI",
"PromptsAPI",
"QueriesAPI",
"RequestTarget",
"RetryMode",
"RetryPolicy",
]
69 changes: 69 additions & 0 deletions py/src/braintrust/api/_routing.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""Endpoint routing for Braintrust API requests."""

import enum
from dataclasses import dataclass

from ..util import _urljoin


_V1_PROXY_SUFFIX = "/v1/proxy"


class RequestTarget(enum.Enum):
"""A logical Braintrust request destination."""

APP = "app"
API = "api"
PROXY = "proxy"


def normalize_proxy_url(proxy_url: str) -> str:
"""Normalize a Universal Proxy URL to the API host used by SDK routes."""

if proxy_url.endswith(_V1_PROXY_SUFFIX):
return proxy_url[: -len(_V1_PROXY_SUFFIX)]
return proxy_url


@dataclass
class EndpointRouter:
"""Resolve logical Braintrust targets without changing their configured origins."""

app_url: str
api_url: str | None = None
proxy_url: str | None = None
is_universal_api: bool = False

def configure(
self,
*,
api_url: str | None,
proxy_url: str | None,
is_universal_api: bool = False,
) -> None:
"""Apply URLs discovered during authentication."""

self.api_url = api_url
self.proxy_url = proxy_url
self.is_universal_api = is_universal_api

def base_url(self, target: RequestTarget) -> str:
"""Return the configured origin for ``target``."""

if target is RequestTarget.APP:
return self.app_url
if target is RequestTarget.API:
if not self.api_url:
raise RuntimeError("API URL is unavailable before organization discovery")
return self.api_url
if target is RequestTarget.PROXY:
base_url = self.proxy_url or self.api_url
if not base_url:
raise RuntimeError("Proxy URL is unavailable before organization discovery")
return normalize_proxy_url(base_url)
raise ValueError(f"Unknown request target: {target!r}")

def resolve(self, target: RequestTarget, path: str) -> str:
"""Resolve ``path`` against the origin for ``target``."""

return _urljoin(self.base_url(target), path)
51 changes: 51 additions & 0 deletions py/src/braintrust/api/_service.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
"""Shared resource service primitives."""

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from ._routing import EndpointRouter, RequestTarget
from ._transport import Transport


@dataclass(frozen=True)
class ClientContext:
"""Organization and credential context shared by resource services."""

org_id: str
org_name: str


class ResourceAPI:
"""Base class for synchronous resource services."""

def __init__(
self,
transport: Transport,
router: EndpointRouter,
context: ClientContext,
api_key: str,
):
self._transport = transport
self._router = router
self._context = context
self._api_key = api_key

def _request_json(
self,
target: RequestTarget,
method: str,
path: str,
*,
headers: Mapping[str, str] | None = None,
**kwargs: Any,
) -> Any:
request_headers = {"Authorization": f"Bearer {self._api_key}"}
if headers:
request_headers.update(headers)
return self._transport.request_json(
method,
self._router.resolve(target, path),
headers=request_headers,
**kwargs,
)
9 changes: 9 additions & 0 deletions py/src/braintrust/api/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Legacy and policy-aware HTTP transport primitives for the Braintrust SDK."""

import datetime
import http.cookiejar
import logging
import sys
import time
Expand DownExpand Up@@ -28,6 +29,11 @@
logger = logging.getLogger(__name__)


class _RejectCookiesPolicy(http.cookiejar.DefaultCookiePolicy):
def set_ok(self, cookie: Any, request: Any) -> bool:
return False


class RetryRequestExceptionsAdapter(HTTPAdapter):
"""An HTTP adapter that automatically retries requests on connection exceptions.

Expand DownExpand Up@@ -189,13 +195,16 @@ def __init__(
session: requests.Session | None = None,
adapter: HTTPAdapter | None = None,
enable_sdk_retries: bool | None = None,
persist_cookies: bool = True,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
):
custom_transport = session is not None or adapter is not None
self._owns_session = session is None
self.session = session if session is not None else requests.Session()
if not persist_cookies and self._owns_session:
self.session.cookies.set_policy(_RejectCookiesPolicy())
self._sdk_retries_enabled = not custom_transport if enable_sdk_retries is None else enable_sdk_retries
if adapter is not None:
self.session.mount("http://", adapter)
Expand Down
10 changes: 10 additions & 0 deletions py/src/braintrust/api/attachments.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
"""Attachment metadata API service."""

from ._service import ResourceAPI


class AttachmentsAPI(ResourceAPI):
"""Synchronous attachment metadata operations.

Signed object-storage traffic remains outside the routed transport.
"""
122 changes: 122 additions & 0 deletions py/src/braintrust/api/auth.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
"""Authentication and organization discovery service."""

from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any

from ..env import BraintrustEnv
from ._routing import EndpointRouter, RequestTarget
from ._transport import HTTPConnection, Transport
from .policies import RetryMode


@dataclass(frozen=True)
class OrganizationInfo:
"""Organization routing information returned by API-key login."""

id: str
name: str
api_url: str | None
proxy_url: str | None
realtime_url: str | None
is_universal_api: bool
git_metadata: Mapping[str, Any] | None
raw: Mapping[str, Any]

@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "OrganizationInfo":
"""Parse an additive login response while retaining unknown fields."""

org_id = value.get("id")
name = value.get("name")
if not isinstance(org_id, str) or not isinstance(name, str):
raise ValueError("Organization login data must include string id and name fields")

def optional_string(field: str) -> str | None:
result = value.get(field)
return result if isinstance(result, str) and result else None

git_metadata = value.get("git_metadata")
if not isinstance(git_metadata, Mapping):
git_metadata = None

return cls(
id=org_id,
name=name,
api_url=optional_string("api_url"),
proxy_url=optional_string("proxy_url"),
realtime_url=optional_string("realtime_url"),
is_universal_api=bool(value.get("is_universal_api", False)),
git_metadata=MappingProxyType(dict(git_metadata)) if git_metadata is not None else None,
raw=MappingProxyType(dict(value)),
)


@dataclass(frozen=True)
class LoginResult:
"""Selected organization and the complete login response."""

organization: OrganizationInfo
response: Mapping[str, Any]


class AuthAPI:
"""Authenticate an API key and configure an endpoint router."""

def __init__(self, transport: Transport, router: EndpointRouter):
self._transport = transport
self._router = router

def login(
self,
api_key: str,
*,
org_name: str | None = None,
api_url: str | None = None,
proxy_url: str | None = None,
) -> LoginResult:
"""Log in, select an organization, and apply routing override precedence."""

api_key = HTTPConnection.sanitize_token(api_key)
response = self._transport.request_json(
"POST",
self._router.resolve(RequestTarget.APP, "/api/apikey/login"),
headers={"Authorization": f"Bearer {api_key}"},
retry_mode=RetryMode.SAFE_READ,
)
if not isinstance(response, Mapping):
raise ValueError("API-key login returned a non-object response")
raw_orgs = response.get("org_info")
if not isinstance(raw_orgs, Sequence) or isinstance(raw_orgs, (str, bytes)):
raise ValueError("API-key login response did not include an organization list")

organizations = [OrganizationInfo.from_dict(org) for org in raw_orgs if isinstance(org, Mapping)]
organization = self._select_organization(organizations, org_name)

resolved_api_url = api_url or BraintrustEnv.API_URL.get(organization.api_url)
resolved_proxy_url = proxy_url or BraintrustEnv.PROXY_URL.get(organization.proxy_url)
if not resolved_api_url:
if org_name:
raise ValueError(
f"Unable to log into organization '{org_name}'."
" Are you sure this credential is scoped to the organization?"
)
raise ValueError("Unable to log into any organization with the provided credential.")

self._router.configure(
api_url=resolved_api_url,
proxy_url=resolved_proxy_url,
is_universal_api=organization.is_universal_api,
)
return LoginResult(organization=organization, response=MappingProxyType(dict(response)))

@staticmethod
def _select_organization(organizations: Sequence[OrganizationInfo], org_name: str | None) -> OrganizationInfo:
if not organizations:
raise ValueError("This user is not part of any organizations.")
for organization in organizations:
if org_name is None or organization.name == org_name:
return organization
choices = ", ".join(organization.name for organization in organizations)
raise ValueError(f"Organization {org_name} not found. Must be one of {choices}")
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(api): add client bootstrap and service facade by AbhiPrasad · Pull Request #655 · braintrustdata/braintrust-sdk-python · GitHub
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
27 changes: 26 additions & 1 deletion py/src/braintrust/api/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
"""Braintrust API client package."""
"""Public Braintrust API client package."""

from ._routing import EndpointRouter, RequestTarget
from ._service import ClientContext
from .attachments import AttachmentsAPI
from .auth import AuthAPI, LoginResult, OrganizationInfo
from .client import BraintrustClient
from .datasets import DatasetsAPI
from .errors import (
BraintrustAPIError,
BraintrustHTTPError,
Expand All@@ -8,16 +14,35 @@
BraintrustTransportError,
BraintrustTransportRetryExhaustedError,
)
from .experiments import ExperimentsAPI
from .functions import FunctionsAPI
from .policies import RetryMode, RetryPolicy
from .projects import ProjectsAPI
from .prompts import PromptsAPI
from .queries import QueriesAPI


__all__ = [
"AttachmentsAPI",
"AuthAPI",
"BraintrustAPIError",
"BraintrustClient",
"BraintrustHTTPError",
"BraintrustResponseError",
"BraintrustRetryExhaustedError",
"BraintrustTransportError",
"BraintrustTransportRetryExhaustedError",
"ClientContext",
"DatasetsAPI",
"EndpointRouter",
"ExperimentsAPI",
"FunctionsAPI",
"LoginResult",
"OrganizationInfo",
"ProjectsAPI",
"PromptsAPI",
"QueriesAPI",
"RequestTarget",
"RetryMode",
"RetryPolicy",
]
69 changes: 69 additions & 0 deletions py/src/braintrust/api/_routing.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""Endpoint routing for Braintrust API requests."""

import enum
from dataclasses import dataclass

from ..util import _urljoin


_V1_PROXY_SUFFIX = "/v1/proxy"


class RequestTarget(enum.Enum):
"""A logical Braintrust request destination."""

APP = "app"
API = "api"
PROXY = "proxy"


def normalize_proxy_url(proxy_url: str) -> str:
"""Normalize a Universal Proxy URL to the API host used by SDK routes."""

if proxy_url.endswith(_V1_PROXY_SUFFIX):
return proxy_url[: -len(_V1_PROXY_SUFFIX)]
return proxy_url


@dataclass
class EndpointRouter:
"""Resolve logical Braintrust targets without changing their configured origins."""

app_url: str
api_url: str | None = None
proxy_url: str | None = None
is_universal_api: bool = False

def configure(
self,
*,
api_url: str | None,
proxy_url: str | None,
is_universal_api: bool = False,
) -> None:
"""Apply URLs discovered during authentication."""

self.api_url = api_url
self.proxy_url = proxy_url
self.is_universal_api = is_universal_api

def base_url(self, target: RequestTarget) -> str:
"""Return the configured origin for ``target``."""

if target is RequestTarget.APP:
return self.app_url
if target is RequestTarget.API:
if not self.api_url:
raise RuntimeError("API URL is unavailable before organization discovery")
return self.api_url
if target is RequestTarget.PROXY:
base_url = self.proxy_url or self.api_url
if not base_url:
raise RuntimeError("Proxy URL is unavailable before organization discovery")
return normalize_proxy_url(base_url)
raise ValueError(f"Unknown request target: {target!r}")

def resolve(self, target: RequestTarget, path: str) -> str:
"""Resolve ``path`` against the origin for ``target``."""

return _urljoin(self.base_url(target), path)
51 changes: 51 additions & 0 deletions py/src/braintrust/api/_service.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
"""Shared resource service primitives."""

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from ._routing import EndpointRouter, RequestTarget
from ._transport import Transport


@dataclass(frozen=True)
class ClientContext:
"""Organization and credential context shared by resource services."""

org_id: str
org_name: str


class ResourceAPI:
"""Base class for synchronous resource services."""

def __init__(
self,
transport: Transport,
router: EndpointRouter,
context: ClientContext,
api_key: str,
):
self._transport = transport
self._router = router
self._context = context
self._api_key = api_key

def _request_json(
self,
target: RequestTarget,
method: str,
path: str,
*,
headers: Mapping[str, str] | None = None,
**kwargs: Any,
) -> Any:
request_headers = {"Authorization": f"Bearer {self._api_key}"}
if headers:
request_headers.update(headers)
return self._transport.request_json(
method,
self._router.resolve(target, path),
headers=request_headers,
**kwargs,
)
9 changes: 9 additions & 0 deletions py/src/braintrust/api/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Legacy and policy-aware HTTP transport primitives for the Braintrust SDK."""

import datetime
import http.cookiejar
import logging
import sys
import time
Expand DownExpand Up@@ -28,6 +29,11 @@
logger = logging.getLogger(__name__)


class _RejectCookiesPolicy(http.cookiejar.DefaultCookiePolicy):
def set_ok(self, cookie: Any, request: Any) -> bool:
return False


class RetryRequestExceptionsAdapter(HTTPAdapter):
"""An HTTP adapter that automatically retries requests on connection exceptions.

Expand DownExpand Up@@ -189,13 +195,16 @@ def __init__(
session: requests.Session | None = None,
adapter: HTTPAdapter | None = None,
enable_sdk_retries: bool | None = None,
persist_cookies: bool = True,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
):
custom_transport = session is not None or adapter is not None
self._owns_session = session is None
self.session = session if session is not None else requests.Session()
if not persist_cookies and self._owns_session:
self.session.cookies.set_policy(_RejectCookiesPolicy())
self._sdk_retries_enabled = not custom_transport if enable_sdk_retries is None else enable_sdk_retries
if adapter is not None:
self.session.mount("http://", adapter)
Expand Down
10 changes: 10 additions & 0 deletions py/src/braintrust/api/attachments.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
"""Attachment metadata API service."""

from ._service import ResourceAPI


class AttachmentsAPI(ResourceAPI):
"""Synchronous attachment metadata operations.

Signed object-storage traffic remains outside the routed transport.
"""
122 changes: 122 additions & 0 deletions py/src/braintrust/api/auth.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
"""Authentication and organization discovery service."""

from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any

from ..env import BraintrustEnv
from ._routing import EndpointRouter, RequestTarget
from ._transport import HTTPConnection, Transport
from .policies import RetryMode


@dataclass(frozen=True)
class OrganizationInfo:
"""Organization routing information returned by API-key login."""

id: str
name: str
api_url: str | None
proxy_url: str | None
realtime_url: str | None
is_universal_api: bool
git_metadata: Mapping[str, Any] | None
raw: Mapping[str, Any]

@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "OrganizationInfo":
"""Parse an additive login response while retaining unknown fields."""

org_id = value.get("id")
name = value.get("name")
if not isinstance(org_id, str) or not isinstance(name, str):
raise ValueError("Organization login data must include string id and name fields")

def optional_string(field: str) -> str | None:
result = value.get(field)
return result if isinstance(result, str) and result else None

git_metadata = value.get("git_metadata")
if not isinstance(git_metadata, Mapping):
git_metadata = None

return cls(
id=org_id,
name=name,
api_url=optional_string("api_url"),
proxy_url=optional_string("proxy_url"),
realtime_url=optional_string("realtime_url"),
is_universal_api=bool(value.get("is_universal_api", False)),
git_metadata=MappingProxyType(dict(git_metadata)) if git_metadata is not None else None,
raw=MappingProxyType(dict(value)),
)


@dataclass(frozen=True)
class LoginResult:
"""Selected organization and the complete login response."""

organization: OrganizationInfo
response: Mapping[str, Any]


class AuthAPI:
"""Authenticate an API key and configure an endpoint router."""

def __init__(self, transport: Transport, router: EndpointRouter):
self._transport = transport
self._router = router

def login(
self,
api_key: str,
*,
org_name: str | None = None,
api_url: str | None = None,
proxy_url: str | None = None,
) -> LoginResult:
"""Log in, select an organization, and apply routing override precedence."""

api_key = HTTPConnection.sanitize_token(api_key)
response = self._transport.request_json(
"POST",
self._router.resolve(RequestTarget.APP, "/api/apikey/login"),
headers={"Authorization": f"Bearer {api_key}"},
retry_mode=RetryMode.SAFE_READ,
)
if not isinstance(response, Mapping):
raise ValueError("API-key login returned a non-object response")
raw_orgs = response.get("org_info")
if not isinstance(raw_orgs, Sequence) or isinstance(raw_orgs, (str, bytes)):
raise ValueError("API-key login response did not include an organization list")

organizations = [OrganizationInfo.from_dict(org) for org in raw_orgs if isinstance(org, Mapping)]
organization = self._select_organization(organizations, org_name)

resolved_api_url = api_url or BraintrustEnv.API_URL.get(organization.api_url)
resolved_proxy_url = proxy_url or BraintrustEnv.PROXY_URL.get(organization.proxy_url)
if not resolved_api_url:
if org_name:
raise ValueError(
f"Unable to log into organization '{org_name}'."
" Are you sure this credential is scoped to the organization?"
)
raise ValueError("Unable to log into any organization with the provided credential.")

self._router.configure(
api_url=resolved_api_url,
proxy_url=resolved_proxy_url,
is_universal_api=organization.is_universal_api,
)
return LoginResult(organization=organization, response=MappingProxyType(dict(response)))

@staticmethod
def _select_organization(organizations: Sequence[OrganizationInfo], org_name: str | None) -> OrganizationInfo:
if not organizations:
raise ValueError("This user is not part of any organizations.")
for organization in organizations:
if org_name is None or organization.name == org_name:
return organization
choices = ", ".join(organization.name for organization in organizations)
raise ValueError(f"Organization {org_name} not found. Must be one of {choices}")
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(api): add client bootstrap and service facade by AbhiPrasad · Pull Request #655 · braintrustdata/braintrust-sdk-python · GitHub
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
27 changes: 26 additions & 1 deletion py/src/braintrust/api/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
"""Braintrust API client package."""
"""Public Braintrust API client package."""

from ._routing import EndpointRouter, RequestTarget
from ._service import ClientContext
from .attachments import AttachmentsAPI
from .auth import AuthAPI, LoginResult, OrganizationInfo
from .client import BraintrustClient
from .datasets import DatasetsAPI
from .errors import (
BraintrustAPIError,
BraintrustHTTPError,
Expand All@@ -8,16 +14,35 @@
BraintrustTransportError,
BraintrustTransportRetryExhaustedError,
)
from .experiments import ExperimentsAPI
from .functions import FunctionsAPI
from .policies import RetryMode, RetryPolicy
from .projects import ProjectsAPI
from .prompts import PromptsAPI
from .queries import QueriesAPI


__all__ = [
"AttachmentsAPI",
"AuthAPI",
"BraintrustAPIError",
"BraintrustClient",
"BraintrustHTTPError",
"BraintrustResponseError",
"BraintrustRetryExhaustedError",
"BraintrustTransportError",
"BraintrustTransportRetryExhaustedError",
"ClientContext",
"DatasetsAPI",
"EndpointRouter",
"ExperimentsAPI",
"FunctionsAPI",
"LoginResult",
"OrganizationInfo",
"ProjectsAPI",
"PromptsAPI",
"QueriesAPI",
"RequestTarget",
"RetryMode",
"RetryPolicy",
]
69 changes: 69 additions & 0 deletions py/src/braintrust/api/_routing.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""Endpoint routing for Braintrust API requests."""

import enum
from dataclasses import dataclass

from ..util import _urljoin


_V1_PROXY_SUFFIX = "/v1/proxy"


class RequestTarget(enum.Enum):
"""A logical Braintrust request destination."""

APP = "app"
API = "api"
PROXY = "proxy"


def normalize_proxy_url(proxy_url: str) -> str:
"""Normalize a Universal Proxy URL to the API host used by SDK routes."""

if proxy_url.endswith(_V1_PROXY_SUFFIX):
return proxy_url[: -len(_V1_PROXY_SUFFIX)]
return proxy_url


@dataclass
class EndpointRouter:
"""Resolve logical Braintrust targets without changing their configured origins."""

app_url: str
api_url: str | None = None
proxy_url: str | None = None
is_universal_api: bool = False

def configure(
self,
*,
api_url: str | None,
proxy_url: str | None,
is_universal_api: bool = False,
) -> None:
"""Apply URLs discovered during authentication."""

self.api_url = api_url
self.proxy_url = proxy_url
self.is_universal_api = is_universal_api

def base_url(self, target: RequestTarget) -> str:
"""Return the configured origin for ``target``."""

if target is RequestTarget.APP:
return self.app_url
if target is RequestTarget.API:
if not self.api_url:
raise RuntimeError("API URL is unavailable before organization discovery")
return self.api_url
if target is RequestTarget.PROXY:
base_url = self.proxy_url or self.api_url
if not base_url:
raise RuntimeError("Proxy URL is unavailable before organization discovery")
return normalize_proxy_url(base_url)
raise ValueError(f"Unknown request target: {target!r}")

def resolve(self, target: RequestTarget, path: str) -> str:
"""Resolve ``path`` against the origin for ``target``."""

return _urljoin(self.base_url(target), path)
51 changes: 51 additions & 0 deletions py/src/braintrust/api/_service.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
"""Shared resource service primitives."""

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from ._routing import EndpointRouter, RequestTarget
from ._transport import Transport


@dataclass(frozen=True)
class ClientContext:
"""Organization and credential context shared by resource services."""

org_id: str
org_name: str


class ResourceAPI:
"""Base class for synchronous resource services."""

def __init__(
self,
transport: Transport,
router: EndpointRouter,
context: ClientContext,
api_key: str,
):
self._transport = transport
self._router = router
self._context = context
self._api_key = api_key

def _request_json(
self,
target: RequestTarget,
method: str,
path: str,
*,
headers: Mapping[str, str] | None = None,
**kwargs: Any,
) -> Any:
request_headers = {"Authorization": f"Bearer {self._api_key}"}
if headers:
request_headers.update(headers)
return self._transport.request_json(
method,
self._router.resolve(target, path),
headers=request_headers,
**kwargs,
)
9 changes: 9 additions & 0 deletions py/src/braintrust/api/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Legacy and policy-aware HTTP transport primitives for the Braintrust SDK."""

import datetime
import http.cookiejar
import logging
import sys
import time
Expand DownExpand Up@@ -28,6 +29,11 @@
logger = logging.getLogger(__name__)


class _RejectCookiesPolicy(http.cookiejar.DefaultCookiePolicy):
def set_ok(self, cookie: Any, request: Any) -> bool:
return False


class RetryRequestExceptionsAdapter(HTTPAdapter):
"""An HTTP adapter that automatically retries requests on connection exceptions.

Expand DownExpand Up@@ -189,13 +195,16 @@ def __init__(
session: requests.Session | None = None,
adapter: HTTPAdapter | None = None,
enable_sdk_retries: bool | None = None,
persist_cookies: bool = True,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
):
custom_transport = session is not None or adapter is not None
self._owns_session = session is None
self.session = session if session is not None else requests.Session()
if not persist_cookies and self._owns_session:
self.session.cookies.set_policy(_RejectCookiesPolicy())
self._sdk_retries_enabled = not custom_transport if enable_sdk_retries is None else enable_sdk_retries
if adapter is not None:
self.session.mount("http://", adapter)
Expand Down
10 changes: 10 additions & 0 deletions py/src/braintrust/api/attachments.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
"""Attachment metadata API service."""

from ._service import ResourceAPI


class AttachmentsAPI(ResourceAPI):
"""Synchronous attachment metadata operations.

Signed object-storage traffic remains outside the routed transport.
"""
122 changes: 122 additions & 0 deletions py/src/braintrust/api/auth.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
"""Authentication and organization discovery service."""

from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any

from ..env import BraintrustEnv
from ._routing import EndpointRouter, RequestTarget
from ._transport import HTTPConnection, Transport
from .policies import RetryMode


@dataclass(frozen=True)
class OrganizationInfo:
"""Organization routing information returned by API-key login."""

id: str
name: str
api_url: str | None
proxy_url: str | None
realtime_url: str | None
is_universal_api: bool
git_metadata: Mapping[str, Any] | None
raw: Mapping[str, Any]

@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "OrganizationInfo":
"""Parse an additive login response while retaining unknown fields."""

org_id = value.get("id")
name = value.get("name")
if not isinstance(org_id, str) or not isinstance(name, str):
raise ValueError("Organization login data must include string id and name fields")

def optional_string(field: str) -> str | None:
result = value.get(field)
return result if isinstance(result, str) and result else None

git_metadata = value.get("git_metadata")
if not isinstance(git_metadata, Mapping):
git_metadata = None

return cls(
id=org_id,
name=name,
api_url=optional_string("api_url"),
proxy_url=optional_string("proxy_url"),
realtime_url=optional_string("realtime_url"),
is_universal_api=bool(value.get("is_universal_api", False)),
git_metadata=MappingProxyType(dict(git_metadata)) if git_metadata is not None else None,
raw=MappingProxyType(dict(value)),
)


@dataclass(frozen=True)
class LoginResult:
"""Selected organization and the complete login response."""

organization: OrganizationInfo
response: Mapping[str, Any]


class AuthAPI:
"""Authenticate an API key and configure an endpoint router."""

def __init__(self, transport: Transport, router: EndpointRouter):
self._transport = transport
self._router = router

def login(
self,
api_key: str,
*,
org_name: str | None = None,
api_url: str | None = None,
proxy_url: str | None = None,
) -> LoginResult:
"""Log in, select an organization, and apply routing override precedence."""

api_key = HTTPConnection.sanitize_token(api_key)
response = self._transport.request_json(
"POST",
self._router.resolve(RequestTarget.APP, "/api/apikey/login"),
headers={"Authorization": f"Bearer {api_key}"},
retry_mode=RetryMode.SAFE_READ,
)
if not isinstance(response, Mapping):
raise ValueError("API-key login returned a non-object response")
raw_orgs = response.get("org_info")
if not isinstance(raw_orgs, Sequence) or isinstance(raw_orgs, (str, bytes)):
raise ValueError("API-key login response did not include an organization list")

organizations = [OrganizationInfo.from_dict(org) for org in raw_orgs if isinstance(org, Mapping)]
organization = self._select_organization(organizations, org_name)

resolved_api_url = api_url or BraintrustEnv.API_URL.get(organization.api_url)
resolved_proxy_url = proxy_url or BraintrustEnv.PROXY_URL.get(organization.proxy_url)
if not resolved_api_url:
if org_name:
raise ValueError(
f"Unable to log into organization '{org_name}'."
" Are you sure this credential is scoped to the organization?"
)
raise ValueError("Unable to log into any organization with the provided credential.")

self._router.configure(
api_url=resolved_api_url,
proxy_url=resolved_proxy_url,
is_universal_api=organization.is_universal_api,
)
return LoginResult(organization=organization, response=MappingProxyType(dict(response)))

@staticmethod
def _select_organization(organizations: Sequence[OrganizationInfo], org_name: str | None) -> OrganizationInfo:
if not organizations:
raise ValueError("This user is not part of any organizations.")
for organization in organizations:
if org_name is None or organization.name == org_name:
return organization
choices = ", ".join(organization.name for organization in organizations)
raise ValueError(f"Organization {org_name} not found. Must be one of {choices}")
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(api): add client bootstrap and service facade by AbhiPrasad · Pull Request #655 · braintrustdata/braintrust-sdk-python · GitHub
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
27 changes: 26 additions & 1 deletion py/src/braintrust/api/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
"""Braintrust API client package."""
"""Public Braintrust API client package."""

from ._routing import EndpointRouter, RequestTarget
from ._service import ClientContext
from .attachments import AttachmentsAPI
from .auth import AuthAPI, LoginResult, OrganizationInfo
from .client import BraintrustClient
from .datasets import DatasetsAPI
from .errors import (
BraintrustAPIError,
BraintrustHTTPError,
Expand All@@ -8,16 +14,35 @@
BraintrustTransportError,
BraintrustTransportRetryExhaustedError,
)
from .experiments import ExperimentsAPI
from .functions import FunctionsAPI
from .policies import RetryMode, RetryPolicy
from .projects import ProjectsAPI
from .prompts import PromptsAPI
from .queries import QueriesAPI


__all__ = [
"AttachmentsAPI",
"AuthAPI",
"BraintrustAPIError",
"BraintrustClient",
"BraintrustHTTPError",
"BraintrustResponseError",
"BraintrustRetryExhaustedError",
"BraintrustTransportError",
"BraintrustTransportRetryExhaustedError",
"ClientContext",
"DatasetsAPI",
"EndpointRouter",
"ExperimentsAPI",
"FunctionsAPI",
"LoginResult",
"OrganizationInfo",
"ProjectsAPI",
"PromptsAPI",
"QueriesAPI",
"RequestTarget",
"RetryMode",
"RetryPolicy",
]
69 changes: 69 additions & 0 deletions py/src/braintrust/api/_routing.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""Endpoint routing for Braintrust API requests."""

import enum
from dataclasses import dataclass

from ..util import _urljoin


_V1_PROXY_SUFFIX = "/v1/proxy"


class RequestTarget(enum.Enum):
"""A logical Braintrust request destination."""

APP = "app"
API = "api"
PROXY = "proxy"


def normalize_proxy_url(proxy_url: str) -> str:
"""Normalize a Universal Proxy URL to the API host used by SDK routes."""

if proxy_url.endswith(_V1_PROXY_SUFFIX):
return proxy_url[: -len(_V1_PROXY_SUFFIX)]
return proxy_url


@dataclass
class EndpointRouter:
"""Resolve logical Braintrust targets without changing their configured origins."""

app_url: str
api_url: str | None = None
proxy_url: str | None = None
is_universal_api: bool = False

def configure(
self,
*,
api_url: str | None,
proxy_url: str | None,
is_universal_api: bool = False,
) -> None:
"""Apply URLs discovered during authentication."""

self.api_url = api_url
self.proxy_url = proxy_url
self.is_universal_api = is_universal_api

def base_url(self, target: RequestTarget) -> str:
"""Return the configured origin for ``target``."""

if target is RequestTarget.APP:
return self.app_url
if target is RequestTarget.API:
if not self.api_url:
raise RuntimeError("API URL is unavailable before organization discovery")
return self.api_url
if target is RequestTarget.PROXY:
base_url = self.proxy_url or self.api_url
if not base_url:
raise RuntimeError("Proxy URL is unavailable before organization discovery")
return normalize_proxy_url(base_url)
raise ValueError(f"Unknown request target: {target!r}")

def resolve(self, target: RequestTarget, path: str) -> str:
"""Resolve ``path`` against the origin for ``target``."""

return _urljoin(self.base_url(target), path)
51 changes: 51 additions & 0 deletions py/src/braintrust/api/_service.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
"""Shared resource service primitives."""

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from ._routing import EndpointRouter, RequestTarget
from ._transport import Transport


@dataclass(frozen=True)
class ClientContext:
"""Organization and credential context shared by resource services."""

org_id: str
org_name: str


class ResourceAPI:
"""Base class for synchronous resource services."""

def __init__(
self,
transport: Transport,
router: EndpointRouter,
context: ClientContext,
api_key: str,
):
self._transport = transport
self._router = router
self._context = context
self._api_key = api_key

def _request_json(
self,
target: RequestTarget,
method: str,
path: str,
*,
headers: Mapping[str, str] | None = None,
**kwargs: Any,
) -> Any:
request_headers = {"Authorization": f"Bearer {self._api_key}"}
if headers:
request_headers.update(headers)
return self._transport.request_json(
method,
self._router.resolve(target, path),
headers=request_headers,
**kwargs,
)
9 changes: 9 additions & 0 deletions py/src/braintrust/api/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Legacy and policy-aware HTTP transport primitives for the Braintrust SDK."""

import datetime
import http.cookiejar
import logging
import sys
import time
Expand DownExpand Up@@ -28,6 +29,11 @@
logger = logging.getLogger(__name__)


class _RejectCookiesPolicy(http.cookiejar.DefaultCookiePolicy):
def set_ok(self, cookie: Any, request: Any) -> bool:
return False


class RetryRequestExceptionsAdapter(HTTPAdapter):
"""An HTTP adapter that automatically retries requests on connection exceptions.

Expand DownExpand Up@@ -189,13 +195,16 @@ def __init__(
session: requests.Session | None = None,
adapter: HTTPAdapter | None = None,
enable_sdk_retries: bool | None = None,
persist_cookies: bool = True,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
):
custom_transport = session is not None or adapter is not None
self._owns_session = session is None
self.session = session if session is not None else requests.Session()
if not persist_cookies and self._owns_session:
self.session.cookies.set_policy(_RejectCookiesPolicy())
self._sdk_retries_enabled = not custom_transport if enable_sdk_retries is None else enable_sdk_retries
if adapter is not None:
self.session.mount("http://", adapter)
Expand Down
10 changes: 10 additions & 0 deletions py/src/braintrust/api/attachments.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
"""Attachment metadata API service."""

from ._service import ResourceAPI


class AttachmentsAPI(ResourceAPI):
"""Synchronous attachment metadata operations.

Signed object-storage traffic remains outside the routed transport.
"""
122 changes: 122 additions & 0 deletions py/src/braintrust/api/auth.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
"""Authentication and organization discovery service."""

from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any

from ..env import BraintrustEnv
from ._routing import EndpointRouter, RequestTarget
from ._transport import HTTPConnection, Transport
from .policies import RetryMode


@dataclass(frozen=True)
class OrganizationInfo:
"""Organization routing information returned by API-key login."""

id: str
name: str
api_url: str | None
proxy_url: str | None
realtime_url: str | None
is_universal_api: bool
git_metadata: Mapping[str, Any] | None
raw: Mapping[str, Any]

@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "OrganizationInfo":
"""Parse an additive login response while retaining unknown fields."""

org_id = value.get("id")
name = value.get("name")
if not isinstance(org_id, str) or not isinstance(name, str):
raise ValueError("Organization login data must include string id and name fields")

def optional_string(field: str) -> str | None:
result = value.get(field)
return result if isinstance(result, str) and result else None

git_metadata = value.get("git_metadata")
if not isinstance(git_metadata, Mapping):
git_metadata = None

return cls(
id=org_id,
name=name,
api_url=optional_string("api_url"),
proxy_url=optional_string("proxy_url"),
realtime_url=optional_string("realtime_url"),
is_universal_api=bool(value.get("is_universal_api", False)),
git_metadata=MappingProxyType(dict(git_metadata)) if git_metadata is not None else None,
raw=MappingProxyType(dict(value)),
)


@dataclass(frozen=True)
class LoginResult:
"""Selected organization and the complete login response."""

organization: OrganizationInfo
response: Mapping[str, Any]


class AuthAPI:
"""Authenticate an API key and configure an endpoint router."""

def __init__(self, transport: Transport, router: EndpointRouter):
self._transport = transport
self._router = router

def login(
self,
api_key: str,
*,
org_name: str | None = None,
api_url: str | None = None,
proxy_url: str | None = None,
) -> LoginResult:
"""Log in, select an organization, and apply routing override precedence."""

api_key = HTTPConnection.sanitize_token(api_key)
response = self._transport.request_json(
"POST",
self._router.resolve(RequestTarget.APP, "/api/apikey/login"),
headers={"Authorization": f"Bearer {api_key}"},
retry_mode=RetryMode.SAFE_READ,
)
if not isinstance(response, Mapping):
raise ValueError("API-key login returned a non-object response")
raw_orgs = response.get("org_info")
if not isinstance(raw_orgs, Sequence) or isinstance(raw_orgs, (str, bytes)):
raise ValueError("API-key login response did not include an organization list")

organizations = [OrganizationInfo.from_dict(org) for org in raw_orgs if isinstance(org, Mapping)]
organization = self._select_organization(organizations, org_name)

resolved_api_url = api_url or BraintrustEnv.API_URL.get(organization.api_url)
resolved_proxy_url = proxy_url or BraintrustEnv.PROXY_URL.get(organization.proxy_url)
if not resolved_api_url:
if org_name:
raise ValueError(
f"Unable to log into organization '{org_name}'."
" Are you sure this credential is scoped to the organization?"
)
raise ValueError("Unable to log into any organization with the provided credential.")

self._router.configure(
api_url=resolved_api_url,
proxy_url=resolved_proxy_url,
is_universal_api=organization.is_universal_api,
)
return LoginResult(organization=organization, response=MappingProxyType(dict(response)))

@staticmethod
def _select_organization(organizations: Sequence[OrganizationInfo], org_name: str | None) -> OrganizationInfo:
if not organizations:
raise ValueError("This user is not part of any organizations.")
for organization in organizations:
if org_name is None or organization.name == org_name:
return organization
choices = ", ".join(organization.name for organization in organizations)
raise ValueError(f"Organization {org_name} not found. Must be one of {choices}")
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(api): add client bootstrap and service facade by AbhiPrasad · Pull Request #655 · braintrustdata/braintrust-sdk-python · GitHub
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
27 changes: 26 additions & 1 deletion py/src/braintrust/api/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
"""Braintrust API client package."""
"""Public Braintrust API client package."""

from ._routing import EndpointRouter, RequestTarget
from ._service import ClientContext
from .attachments import AttachmentsAPI
from .auth import AuthAPI, LoginResult, OrganizationInfo
from .client import BraintrustClient
from .datasets import DatasetsAPI
from .errors import (
BraintrustAPIError,
BraintrustHTTPError,
Expand All@@ -8,16 +14,35 @@
BraintrustTransportError,
BraintrustTransportRetryExhaustedError,
)
from .experiments import ExperimentsAPI
from .functions import FunctionsAPI
from .policies import RetryMode, RetryPolicy
from .projects import ProjectsAPI
from .prompts import PromptsAPI
from .queries import QueriesAPI


__all__ = [
"AttachmentsAPI",
"AuthAPI",
"BraintrustAPIError",
"BraintrustClient",
"BraintrustHTTPError",
"BraintrustResponseError",
"BraintrustRetryExhaustedError",
"BraintrustTransportError",
"BraintrustTransportRetryExhaustedError",
"ClientContext",
"DatasetsAPI",
"EndpointRouter",
"ExperimentsAPI",
"FunctionsAPI",
"LoginResult",
"OrganizationInfo",
"ProjectsAPI",
"PromptsAPI",
"QueriesAPI",
"RequestTarget",
"RetryMode",
"RetryPolicy",
]
69 changes: 69 additions & 0 deletions py/src/braintrust/api/_routing.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""Endpoint routing for Braintrust API requests."""

import enum
from dataclasses import dataclass

from ..util import _urljoin


_V1_PROXY_SUFFIX = "/v1/proxy"


class RequestTarget(enum.Enum):
"""A logical Braintrust request destination."""

APP = "app"
API = "api"
PROXY = "proxy"


def normalize_proxy_url(proxy_url: str) -> str:
"""Normalize a Universal Proxy URL to the API host used by SDK routes."""

if proxy_url.endswith(_V1_PROXY_SUFFIX):
return proxy_url[: -len(_V1_PROXY_SUFFIX)]
return proxy_url


@dataclass
class EndpointRouter:
"""Resolve logical Braintrust targets without changing their configured origins."""

app_url: str
api_url: str | None = None
proxy_url: str | None = None
is_universal_api: bool = False

def configure(
self,
*,
api_url: str | None,
proxy_url: str | None,
is_universal_api: bool = False,
) -> None:
"""Apply URLs discovered during authentication."""

self.api_url = api_url
self.proxy_url = proxy_url
self.is_universal_api = is_universal_api

def base_url(self, target: RequestTarget) -> str:
"""Return the configured origin for ``target``."""

if target is RequestTarget.APP:
return self.app_url
if target is RequestTarget.API:
if not self.api_url:
raise RuntimeError("API URL is unavailable before organization discovery")
return self.api_url
if target is RequestTarget.PROXY:
base_url = self.proxy_url or self.api_url
if not base_url:
raise RuntimeError("Proxy URL is unavailable before organization discovery")
return normalize_proxy_url(base_url)
raise ValueError(f"Unknown request target: {target!r}")

def resolve(self, target: RequestTarget, path: str) -> str:
"""Resolve ``path`` against the origin for ``target``."""

return _urljoin(self.base_url(target), path)
51 changes: 51 additions & 0 deletions py/src/braintrust/api/_service.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
"""Shared resource service primitives."""

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from ._routing import EndpointRouter, RequestTarget
from ._transport import Transport


@dataclass(frozen=True)
class ClientContext:
"""Organization and credential context shared by resource services."""

org_id: str
org_name: str


class ResourceAPI:
"""Base class for synchronous resource services."""

def __init__(
self,
transport: Transport,
router: EndpointRouter,
context: ClientContext,
api_key: str,
):
self._transport = transport
self._router = router
self._context = context
self._api_key = api_key

def _request_json(
self,
target: RequestTarget,
method: str,
path: str,
*,
headers: Mapping[str, str] | None = None,
**kwargs: Any,
) -> Any:
request_headers = {"Authorization": f"Bearer {self._api_key}"}
if headers:
request_headers.update(headers)
return self._transport.request_json(
method,
self._router.resolve(target, path),
headers=request_headers,
**kwargs,
)
9 changes: 9 additions & 0 deletions py/src/braintrust/api/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Legacy and policy-aware HTTP transport primitives for the Braintrust SDK."""

import datetime
import http.cookiejar
import logging
import sys
import time
Expand DownExpand Up@@ -28,6 +29,11 @@
logger = logging.getLogger(__name__)


class _RejectCookiesPolicy(http.cookiejar.DefaultCookiePolicy):
def set_ok(self, cookie: Any, request: Any) -> bool:
return False


class RetryRequestExceptionsAdapter(HTTPAdapter):
"""An HTTP adapter that automatically retries requests on connection exceptions.

Expand DownExpand Up@@ -189,13 +195,16 @@ def __init__(
session: requests.Session | None = None,
adapter: HTTPAdapter | None = None,
enable_sdk_retries: bool | None = None,
persist_cookies: bool = True,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
):
custom_transport = session is not None or adapter is not None
self._owns_session = session is None
self.session = session if session is not None else requests.Session()
if not persist_cookies and self._owns_session:
self.session.cookies.set_policy(_RejectCookiesPolicy())
self._sdk_retries_enabled = not custom_transport if enable_sdk_retries is None else enable_sdk_retries
if adapter is not None:
self.session.mount("http://", adapter)
Expand Down
10 changes: 10 additions & 0 deletions py/src/braintrust/api/attachments.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
"""Attachment metadata API service."""

from ._service import ResourceAPI


class AttachmentsAPI(ResourceAPI):
"""Synchronous attachment metadata operations.

Signed object-storage traffic remains outside the routed transport.
"""
122 changes: 122 additions & 0 deletions py/src/braintrust/api/auth.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
"""Authentication and organization discovery service."""

from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any

from ..env import BraintrustEnv
from ._routing import EndpointRouter, RequestTarget
from ._transport import HTTPConnection, Transport
from .policies import RetryMode


@dataclass(frozen=True)
class OrganizationInfo:
"""Organization routing information returned by API-key login."""

id: str
name: str
api_url: str | None
proxy_url: str | None
realtime_url: str | None
is_universal_api: bool
git_metadata: Mapping[str, Any] | None
raw: Mapping[str, Any]

@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "OrganizationInfo":
"""Parse an additive login response while retaining unknown fields."""

org_id = value.get("id")
name = value.get("name")
if not isinstance(org_id, str) or not isinstance(name, str):
raise ValueError("Organization login data must include string id and name fields")

def optional_string(field: str) -> str | None:
result = value.get(field)
return result if isinstance(result, str) and result else None

git_metadata = value.get("git_metadata")
if not isinstance(git_metadata, Mapping):
git_metadata = None

return cls(
id=org_id,
name=name,
api_url=optional_string("api_url"),
proxy_url=optional_string("proxy_url"),
realtime_url=optional_string("realtime_url"),
is_universal_api=bool(value.get("is_universal_api", False)),
git_metadata=MappingProxyType(dict(git_metadata)) if git_metadata is not None else None,
raw=MappingProxyType(dict(value)),
)


@dataclass(frozen=True)
class LoginResult:
"""Selected organization and the complete login response."""

organization: OrganizationInfo
response: Mapping[str, Any]


class AuthAPI:
"""Authenticate an API key and configure an endpoint router."""

def __init__(self, transport: Transport, router: EndpointRouter):
self._transport = transport
self._router = router

def login(
self,
api_key: str,
*,
org_name: str | None = None,
api_url: str | None = None,
proxy_url: str | None = None,
) -> LoginResult:
"""Log in, select an organization, and apply routing override precedence."""

api_key = HTTPConnection.sanitize_token(api_key)
response = self._transport.request_json(
"POST",
self._router.resolve(RequestTarget.APP, "/api/apikey/login"),
headers={"Authorization": f"Bearer {api_key}"},
retry_mode=RetryMode.SAFE_READ,
)
if not isinstance(response, Mapping):
raise ValueError("API-key login returned a non-object response")
raw_orgs = response.get("org_info")
if not isinstance(raw_orgs, Sequence) or isinstance(raw_orgs, (str, bytes)):
raise ValueError("API-key login response did not include an organization list")

organizations = [OrganizationInfo.from_dict(org) for org in raw_orgs if isinstance(org, Mapping)]
organization = self._select_organization(organizations, org_name)

resolved_api_url = api_url or BraintrustEnv.API_URL.get(organization.api_url)
resolved_proxy_url = proxy_url or BraintrustEnv.PROXY_URL.get(organization.proxy_url)
if not resolved_api_url:
if org_name:
raise ValueError(
f"Unable to log into organization '{org_name}'."
" Are you sure this credential is scoped to the organization?"
)
raise ValueError("Unable to log into any organization with the provided credential.")

self._router.configure(
api_url=resolved_api_url,
proxy_url=resolved_proxy_url,
is_universal_api=organization.is_universal_api,
)
return LoginResult(organization=organization, response=MappingProxyType(dict(response)))

@staticmethod
def _select_organization(organizations: Sequence[OrganizationInfo], org_name: str | None) -> OrganizationInfo:
if not organizations:
raise ValueError("This user is not part of any organizations.")
for organization in organizations:
if org_name is None or organization.name == org_name:
return organization
choices = ", ".join(organization.name for organization in organizations)
raise ValueError(f"Organization {org_name} not found. Must be one of {choices}")
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(api): add client bootstrap and service facade by AbhiPrasad · Pull Request #655 · braintrustdata/braintrust-sdk-python · GitHub
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
27 changes: 26 additions & 1 deletion py/src/braintrust/api/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
"""Braintrust API client package."""
"""Public Braintrust API client package."""

from ._routing import EndpointRouter, RequestTarget
from ._service import ClientContext
from .attachments import AttachmentsAPI
from .auth import AuthAPI, LoginResult, OrganizationInfo
from .client import BraintrustClient
from .datasets import DatasetsAPI
from .errors import (
BraintrustAPIError,
BraintrustHTTPError,
Expand All@@ -8,16 +14,35 @@
BraintrustTransportError,
BraintrustTransportRetryExhaustedError,
)
from .experiments import ExperimentsAPI
from .functions import FunctionsAPI
from .policies import RetryMode, RetryPolicy
from .projects import ProjectsAPI
from .prompts import PromptsAPI
from .queries import QueriesAPI


__all__ = [
"AttachmentsAPI",
"AuthAPI",
"BraintrustAPIError",
"BraintrustClient",
"BraintrustHTTPError",
"BraintrustResponseError",
"BraintrustRetryExhaustedError",
"BraintrustTransportError",
"BraintrustTransportRetryExhaustedError",
"ClientContext",
"DatasetsAPI",
"EndpointRouter",
"ExperimentsAPI",
"FunctionsAPI",
"LoginResult",
"OrganizationInfo",
"ProjectsAPI",
"PromptsAPI",
"QueriesAPI",
"RequestTarget",
"RetryMode",
"RetryPolicy",
]
69 changes: 69 additions & 0 deletions py/src/braintrust/api/_routing.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""Endpoint routing for Braintrust API requests."""

import enum
from dataclasses import dataclass

from ..util import _urljoin


_V1_PROXY_SUFFIX = "/v1/proxy"


class RequestTarget(enum.Enum):
"""A logical Braintrust request destination."""

APP = "app"
API = "api"
PROXY = "proxy"


def normalize_proxy_url(proxy_url: str) -> str:
"""Normalize a Universal Proxy URL to the API host used by SDK routes."""

if proxy_url.endswith(_V1_PROXY_SUFFIX):
return proxy_url[: -len(_V1_PROXY_SUFFIX)]
return proxy_url


@dataclass
class EndpointRouter:
"""Resolve logical Braintrust targets without changing their configured origins."""

app_url: str
api_url: str | None = None
proxy_url: str | None = None
is_universal_api: bool = False

def configure(
self,
*,
api_url: str | None,
proxy_url: str | None,
is_universal_api: bool = False,
) -> None:
"""Apply URLs discovered during authentication."""

self.api_url = api_url
self.proxy_url = proxy_url
self.is_universal_api = is_universal_api

def base_url(self, target: RequestTarget) -> str:
"""Return the configured origin for ``target``."""

if target is RequestTarget.APP:
return self.app_url
if target is RequestTarget.API:
if not self.api_url:
raise RuntimeError("API URL is unavailable before organization discovery")
return self.api_url
if target is RequestTarget.PROXY:
base_url = self.proxy_url or self.api_url
if not base_url:
raise RuntimeError("Proxy URL is unavailable before organization discovery")
return normalize_proxy_url(base_url)
raise ValueError(f"Unknown request target: {target!r}")

def resolve(self, target: RequestTarget, path: str) -> str:
"""Resolve ``path`` against the origin for ``target``."""

return _urljoin(self.base_url(target), path)
51 changes: 51 additions & 0 deletions py/src/braintrust/api/_service.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
"""Shared resource service primitives."""

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from ._routing import EndpointRouter, RequestTarget
from ._transport import Transport


@dataclass(frozen=True)
class ClientContext:
"""Organization and credential context shared by resource services."""

org_id: str
org_name: str


class ResourceAPI:
"""Base class for synchronous resource services."""

def __init__(
self,
transport: Transport,
router: EndpointRouter,
context: ClientContext,
api_key: str,
):
self._transport = transport
self._router = router
self._context = context
self._api_key = api_key

def _request_json(
self,
target: RequestTarget,
method: str,
path: str,
*,
headers: Mapping[str, str] | None = None,
**kwargs: Any,
) -> Any:
request_headers = {"Authorization": f"Bearer {self._api_key}"}
if headers:
request_headers.update(headers)
return self._transport.request_json(
method,
self._router.resolve(target, path),
headers=request_headers,
**kwargs,
)
9 changes: 9 additions & 0 deletions py/src/braintrust/api/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Legacy and policy-aware HTTP transport primitives for the Braintrust SDK."""

import datetime
import http.cookiejar
import logging
import sys
import time
Expand DownExpand Up@@ -28,6 +29,11 @@
logger = logging.getLogger(__name__)


class _RejectCookiesPolicy(http.cookiejar.DefaultCookiePolicy):
def set_ok(self, cookie: Any, request: Any) -> bool:
return False


class RetryRequestExceptionsAdapter(HTTPAdapter):
"""An HTTP adapter that automatically retries requests on connection exceptions.

Expand DownExpand Up@@ -189,13 +195,16 @@ def __init__(
session: requests.Session | None = None,
adapter: HTTPAdapter | None = None,
enable_sdk_retries: bool | None = None,
persist_cookies: bool = True,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
):
custom_transport = session is not None or adapter is not None
self._owns_session = session is None
self.session = session if session is not None else requests.Session()
if not persist_cookies and self._owns_session:
self.session.cookies.set_policy(_RejectCookiesPolicy())
self._sdk_retries_enabled = not custom_transport if enable_sdk_retries is None else enable_sdk_retries
if adapter is not None:
self.session.mount("http://", adapter)
Expand Down
10 changes: 10 additions & 0 deletions py/src/braintrust/api/attachments.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
"""Attachment metadata API service."""

from ._service import ResourceAPI


class AttachmentsAPI(ResourceAPI):
"""Synchronous attachment metadata operations.

Signed object-storage traffic remains outside the routed transport.
"""
122 changes: 122 additions & 0 deletions py/src/braintrust/api/auth.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
"""Authentication and organization discovery service."""

from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any

from ..env import BraintrustEnv
from ._routing import EndpointRouter, RequestTarget
from ._transport import HTTPConnection, Transport
from .policies import RetryMode


@dataclass(frozen=True)
class OrganizationInfo:
"""Organization routing information returned by API-key login."""

id: str
name: str
api_url: str | None
proxy_url: str | None
realtime_url: str | None
is_universal_api: bool
git_metadata: Mapping[str, Any] | None
raw: Mapping[str, Any]

@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "OrganizationInfo":
"""Parse an additive login response while retaining unknown fields."""

org_id = value.get("id")
name = value.get("name")
if not isinstance(org_id, str) or not isinstance(name, str):
raise ValueError("Organization login data must include string id and name fields")

def optional_string(field: str) -> str | None:
result = value.get(field)
return result if isinstance(result, str) and result else None

git_metadata = value.get("git_metadata")
if not isinstance(git_metadata, Mapping):
git_metadata = None

return cls(
id=org_id,
name=name,
api_url=optional_string("api_url"),
proxy_url=optional_string("proxy_url"),
realtime_url=optional_string("realtime_url"),
is_universal_api=bool(value.get("is_universal_api", False)),
git_metadata=MappingProxyType(dict(git_metadata)) if git_metadata is not None else None,
raw=MappingProxyType(dict(value)),
)


@dataclass(frozen=True)
class LoginResult:
"""Selected organization and the complete login response."""

organization: OrganizationInfo
response: Mapping[str, Any]


class AuthAPI:
"""Authenticate an API key and configure an endpoint router."""

def __init__(self, transport: Transport, router: EndpointRouter):
self._transport = transport
self._router = router

def login(
self,
api_key: str,
*,
org_name: str | None = None,
api_url: str | None = None,
proxy_url: str | None = None,
) -> LoginResult:
"""Log in, select an organization, and apply routing override precedence."""

api_key = HTTPConnection.sanitize_token(api_key)
response = self._transport.request_json(
"POST",
self._router.resolve(RequestTarget.APP, "/api/apikey/login"),
headers={"Authorization": f"Bearer {api_key}"},
retry_mode=RetryMode.SAFE_READ,
)
if not isinstance(response, Mapping):
raise ValueError("API-key login returned a non-object response")
raw_orgs = response.get("org_info")
if not isinstance(raw_orgs, Sequence) or isinstance(raw_orgs, (str, bytes)):
raise ValueError("API-key login response did not include an organization list")

organizations = [OrganizationInfo.from_dict(org) for org in raw_orgs if isinstance(org, Mapping)]
organization = self._select_organization(organizations, org_name)

resolved_api_url = api_url or BraintrustEnv.API_URL.get(organization.api_url)
resolved_proxy_url = proxy_url or BraintrustEnv.PROXY_URL.get(organization.proxy_url)
if not resolved_api_url:
if org_name:
raise ValueError(
f"Unable to log into organization '{org_name}'."
" Are you sure this credential is scoped to the organization?"
)
raise ValueError("Unable to log into any organization with the provided credential.")

self._router.configure(
api_url=resolved_api_url,
proxy_url=resolved_proxy_url,
is_universal_api=organization.is_universal_api,
)
return LoginResult(organization=organization, response=MappingProxyType(dict(response)))

@staticmethod
def _select_organization(organizations: Sequence[OrganizationInfo], org_name: str | None) -> OrganizationInfo:
if not organizations:
raise ValueError("This user is not part of any organizations.")
for organization in organizations:
if org_name is None or organization.name == org_name:
return organization
choices = ", ".join(organization.name for organization in organizations)
raise ValueError(f"Organization {org_name} not found. Must be one of {choices}")
Loading