abto 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
abto/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """`abto` — ABTO server-side SDK for Python.
2
+
3
+ Thin gateway header helper: carries x-abto-* identifiers via contextvars and
4
+ injects them into outbound provider calls (httpx event hooks). The gateway owns
5
+ token, cost, latency, request_id, and variant assignment.
6
+ """
7
+
8
+ from .client import Abto, abto_request_hook, create_abto
9
+ from .context import (
10
+ ABTO_HEADER,
11
+ AbtoContext,
12
+ create_trace_id,
13
+ create_traceparent,
14
+ get_context,
15
+ get_headers,
16
+ set_context,
17
+ with_context,
18
+ )
19
+
20
+ __version__ = "0.0.1"
21
+
22
+ __all__ = [
23
+ "Abto",
24
+ "create_abto",
25
+ "abto_request_hook",
26
+ "AbtoContext",
27
+ "ABTO_HEADER",
28
+ "with_context",
29
+ "get_context",
30
+ "set_context",
31
+ "get_headers",
32
+ "create_trace_id",
33
+ "create_traceparent",
34
+ "__version__",
35
+ ]
abto/client.py ADDED
@@ -0,0 +1,62 @@
1
+ """Thin server facade: gateway baseURL + ABTO header injection.
2
+
3
+ It does not compute token/cost/latency. It routes provider SDK calls through the
4
+ ABTO Gateway and carries the x-abto-* identifiers from the current context.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Callable, Dict, List, Optional
10
+
11
+ from .context import AbtoContext, create_trace_id, get_headers, with_context
12
+
13
+
14
+ def abto_request_hook() -> Callable[[Any], None]:
15
+ """httpx request event hook: inject ABTO headers from the current context."""
16
+
17
+ def hook(request: Any) -> None:
18
+ for key, value in get_headers().items():
19
+ request.headers.setdefault(key, value)
20
+
21
+ return hook
22
+
23
+
24
+ class Abto:
25
+ def __init__(self, api_key: Optional[str] = None, gateway_base_url: Optional[str] = None) -> None:
26
+ self.api_key = api_key
27
+ self.gateway_base_url = gateway_base_url
28
+
29
+ def get_headers(self, ctx: Optional[AbtoContext] = None) -> Dict[str, str]:
30
+ return get_headers(ctx)
31
+
32
+ def with_context(self, **kwargs: Optional[str]):
33
+ return with_context(**kwargs)
34
+
35
+ def create_trace_id(self) -> str:
36
+ return create_trace_id()
37
+
38
+ def httpx_event_hooks(self) -> Dict[str, List[Callable[[Any], None]]]:
39
+ return {"request": [abto_request_hook()]}
40
+
41
+ def openai(self, **client_kwargs: Any) -> Any:
42
+ """Construct an OpenAI client pointed at the gateway with header injection.
43
+
44
+ Requires the optional `openai` and `httpx` extras.
45
+ """
46
+ try:
47
+ import httpx
48
+ from openai import OpenAI
49
+ except ImportError as exc: # pragma: no cover - optional dependency
50
+ raise ImportError("abto.openai() requires the 'openai' extra: pip install 'abto[openai]'") from exc
51
+
52
+ http_client = httpx.Client(event_hooks=self.httpx_event_hooks())
53
+ return OpenAI(
54
+ api_key=self.api_key,
55
+ base_url=self.gateway_base_url,
56
+ http_client=http_client,
57
+ **client_kwargs,
58
+ )
59
+
60
+
61
+ def create_abto(api_key: Optional[str] = None, gateway_base_url: Optional[str] = None) -> Abto:
62
+ return Abto(api_key=api_key, gateway_base_url=gateway_base_url)
abto/context.py ADDED
@@ -0,0 +1,83 @@
1
+ """Request-scoped ABTO identifier context for the `abto` Python package.
2
+
3
+ Mirrors @abto-app/sdk/server: carries the gateway identifier headers via
4
+ contextvars so outbound provider calls can attach them. The gateway remains the
5
+ source of truth for token, cost, latency, request_id, and variant assignment.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextvars
11
+ import secrets
12
+ from contextlib import contextmanager
13
+ from dataclasses import dataclass, replace
14
+ from typing import Dict, Iterator, Optional
15
+
16
+ ABTO_HEADER = {
17
+ "user_id": "x-abto-device-id",
18
+ "node_id": "x-abto-node-key",
19
+ "traceparent": "traceparent",
20
+ }
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class AbtoContext:
25
+ """End-user id, "feature.node" node id, and end-user action trace id."""
26
+
27
+ user_id: Optional[str] = None
28
+ node_id: Optional[str] = None
29
+ trace_id: Optional[str] = None
30
+
31
+
32
+ _current: contextvars.ContextVar[AbtoContext] = contextvars.ContextVar(
33
+ "abto_context", default=AbtoContext()
34
+ )
35
+
36
+
37
+ def get_context() -> AbtoContext:
38
+ return _current.get()
39
+
40
+
41
+ def set_context(
42
+ user_id: Optional[str] = None,
43
+ node_id: Optional[str] = None,
44
+ trace_id: Optional[str] = None,
45
+ ) -> None:
46
+ patch = {k: v for k, v in dict(user_id=user_id, node_id=node_id, trace_id=trace_id).items() if v is not None}
47
+ _current.set(replace(_current.get(), **patch))
48
+
49
+
50
+ @contextmanager
51
+ def with_context(
52
+ user_id: Optional[str] = None,
53
+ node_id: Optional[str] = None,
54
+ trace_id: Optional[str] = None,
55
+ ) -> Iterator[AbtoContext]:
56
+ patch = {k: v for k, v in dict(user_id=user_id, node_id=node_id, trace_id=trace_id).items() if v is not None}
57
+ merged = replace(_current.get(), **patch)
58
+ token = _current.set(merged)
59
+ try:
60
+ yield merged
61
+ finally:
62
+ _current.reset(token)
63
+
64
+
65
+ def create_trace_id() -> str:
66
+ """32-hex-char trace id, per W3C trace-context."""
67
+ return secrets.token_hex(16)
68
+
69
+
70
+ def create_traceparent(trace_id: str) -> str:
71
+ return f"00-{trace_id}-{secrets.token_hex(8)}-01"
72
+
73
+
74
+ def get_headers(ctx: Optional[AbtoContext] = None) -> Dict[str, str]:
75
+ c = ctx if ctx is not None else _current.get()
76
+ headers: Dict[str, str] = {}
77
+ if c.user_id:
78
+ headers[ABTO_HEADER["user_id"]] = c.user_id
79
+ if c.node_id:
80
+ headers[ABTO_HEADER["node_id"]] = c.node_id
81
+ if c.trace_id:
82
+ headers[ABTO_HEADER["traceparent"]] = create_traceparent(c.trace_id)
83
+ return headers