zexl 0.1.0__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.
zexl/__init__.py ADDED
@@ -0,0 +1,103 @@
1
+ """Zexl SDK: records metadata about model calls. Prompt and response text never leave the process.
2
+
3
+ import zexl.auto # reads ZEXL_API_KEY and starts capturing
4
+
5
+ zexl.set_customer("acct_123") # optional
6
+ with zexl.run("research"): # optional: group calls into one run explicitly
7
+ ...
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import threading
14
+ from typing import Any
15
+
16
+ from . import _config
17
+ from ._context import feature, run, set_customer
18
+ from ._export import Transport
19
+
20
+ __version__ = "0.1.0"
21
+ __all__ = ["__version__", "feature", "flush", "init", "run", "set_customer", "shutdown", "status"]
22
+
23
+ log = logging.getLogger("zexl")
24
+ _lock = threading.Lock()
25
+ _runtime: Any = None
26
+ _patches: Any = None
27
+
28
+
29
+ def init(
30
+ api_key: str | None = None,
31
+ *,
32
+ endpoint: str | None = None,
33
+ environment: str | None = None,
34
+ _transport: Transport | None = None,
35
+ ) -> bool:
36
+ """Starts capture. Safe to call more than once. Returns whether capture is on.
37
+
38
+ Never raises: with a missing or invalid key the SDK logs one warning and stays off.
39
+ """
40
+ global _runtime, _patches
41
+ with _lock:
42
+ if _runtime is not None:
43
+ return True
44
+ if _config.disabled_by_env():
45
+ return False
46
+ try:
47
+ config = _config.load(api_key, endpoint, environment)
48
+ except _config.ConfigError as e:
49
+ log.warning("zexl: disabled (%s)", e)
50
+ return False
51
+ try:
52
+ from . import _hooks
53
+ from ._core import Runtime
54
+ from ._export import Exporter
55
+
56
+ runtime = Runtime(config, Exporter(config, __version__, _transport))
57
+ _patches = _hooks.install(runtime)
58
+ _runtime = runtime
59
+ except Exception: # noqa: BLE001 - starting Zexl must never break the application
60
+ log.warning("zexl: disabled (could not start)", exc_info=True)
61
+ return False
62
+ return True
63
+
64
+
65
+ def flush(timeout: float = 5.0) -> bool:
66
+ """Sends queued records now. Call before a short-lived process (serverless) exits."""
67
+ rt = _runtime
68
+ if rt is None:
69
+ return True
70
+ try:
71
+ return bool(rt.exporter.flush(timeout))
72
+ except Exception: # noqa: BLE001
73
+ return False
74
+
75
+
76
+ def shutdown() -> None:
77
+ """Flushes, removes the hooks and stops the SDK."""
78
+ global _runtime, _patches
79
+ with _lock:
80
+ rt, patches = _runtime, _patches
81
+ _runtime = _patches = None
82
+ try:
83
+ if patches is not None:
84
+ patches.undo()
85
+ if rt is not None:
86
+ rt.exporter.shutdown()
87
+ except Exception: # noqa: BLE001, S110
88
+ pass
89
+
90
+
91
+ def status() -> dict[str, Any]:
92
+ """What the SDK is doing: whether it is on, which hooks are active, and delivery counts."""
93
+ rt = _runtime
94
+ if rt is None:
95
+ return {"enabled": False}
96
+ return {
97
+ "enabled": True,
98
+ "endpoint": rt.config.endpoint,
99
+ "environment": rt.config.environment,
100
+ "hooks": dict(rt.hooks),
101
+ "sent": rt.exporter.sent,
102
+ "dropped": rt.exporter.dropped,
103
+ }
zexl/_canon.py ADDED
@@ -0,0 +1,193 @@
1
+ """Canonical form of chat content.
2
+
3
+ The same content must produce the same canonical value whether it arrives as a provider SDK
4
+ object, a dumped dict, or a hand-built dict, and whichever provider shape it uses. Rules (each
5
+ one was needed by a case in the M1 hash-stability spike):
6
+
7
+ - A message is a role plus an ordered list of parts: text, tool call, tool result, other.
8
+ - Ids are left out, so id formats cannot break a match.
9
+ - Tool arguments are parsed and re-serialised with sorted keys.
10
+ - A string and a single text part are the same content; adjacent text parts merge.
11
+ - Reasoning blocks are ignored, because callers may or may not send them back.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Any
18
+
19
+ Part = list[str]
20
+ Message = dict[str, Any]
21
+
22
+ _TEXT_TYPES = ("text", "input_text", "output_text")
23
+
24
+
25
+ def dumps(value: Any) -> str:
26
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
27
+
28
+
29
+ def plain(obj: Any) -> Any:
30
+ """Pydantic models, typed dicts and plain dicts all become plain JSON-like data."""
31
+ if isinstance(obj, (str, int, float, bool)) or obj is None:
32
+ return obj
33
+ if isinstance(obj, dict):
34
+ return {k: plain(v) for k, v in obj.items() if v is not None}
35
+ if isinstance(obj, (list, tuple)):
36
+ return [plain(v) for v in obj]
37
+ dump = getattr(obj, "model_dump", None)
38
+ if dump is not None:
39
+ try:
40
+ return dump(mode="json", exclude_none=True, warnings=False)
41
+ except Exception: # noqa: BLE001, S110 - fall through to a best-effort dict
42
+ pass
43
+ to_dict = getattr(obj, "__dict__", None)
44
+ if isinstance(to_dict, dict):
45
+ return {k: plain(v) for k, v in to_dict.items() if v is not None and not k.startswith("_")}
46
+ return str(obj)
47
+
48
+
49
+ def canon_args(value: Any) -> str:
50
+ """Tool arguments as canonical JSON, whether given as a JSON string or as data."""
51
+ if isinstance(value, str):
52
+ try:
53
+ value = json.loads(value)
54
+ except ValueError:
55
+ return str(value)
56
+ return dumps(value)
57
+
58
+
59
+ def text_of(content: Any) -> str:
60
+ if content is None:
61
+ return ""
62
+ if isinstance(content, str):
63
+ return content
64
+ if not isinstance(content, list):
65
+ return dumps(content)
66
+ out: list[str] = []
67
+ for part in content:
68
+ if isinstance(part, str):
69
+ out.append(part)
70
+ elif isinstance(part, dict) and part.get("type") in _TEXT_TYPES:
71
+ out.append(str(part.get("text", "")))
72
+ else:
73
+ out.append(dumps(part))
74
+ return "".join(out)
75
+
76
+
77
+ def parts_of(content: Any) -> list[Part]:
78
+ """Message content (already plain) as ordered parts."""
79
+ if content is None:
80
+ return []
81
+ if isinstance(content, str):
82
+ return [["text", content]] if content else []
83
+ if not isinstance(content, list):
84
+ return [["other", dumps(content)]]
85
+ parts: list[Part] = []
86
+ for p in content:
87
+ if isinstance(p, str):
88
+ if p:
89
+ _add_text(parts, p)
90
+ continue
91
+ if not isinstance(p, dict):
92
+ parts.append(["other", dumps(p)])
93
+ continue
94
+ t = p.get("type")
95
+ if t in _TEXT_TYPES:
96
+ if p.get("text"):
97
+ _add_text(parts, str(p["text"]))
98
+ elif t == "tool_use":
99
+ parts.append(["tool_call", str(p.get("name", "")), canon_args(p.get("input", {}))])
100
+ elif t == "tool_result":
101
+ parts.append(["tool_result", text_of(p.get("content"))])
102
+ elif t in ("thinking", "redacted_thinking"):
103
+ continue
104
+ else:
105
+ rest = {k: v for k, v in p.items() if k not in ("id", "cache_control")}
106
+ parts.append(["other", dumps(rest)])
107
+ return parts
108
+
109
+
110
+ def _add_text(parts: list[Part], text: str) -> None:
111
+ if parts and parts[-1][0] == "text":
112
+ parts[-1][1] += text
113
+ else:
114
+ parts.append(["text", text])
115
+
116
+
117
+ def canon_message(msg: Any) -> Message:
118
+ """One chat message (OpenAI or Anthropic shape, object or dict)."""
119
+ m = plain(msg)
120
+ if not isinstance(m, dict):
121
+ return {"role": "", "parts": [["other", dumps(m)]]}
122
+ role = str(m.get("role", ""))
123
+ if role == "tool": # OpenAI tool result message
124
+ return {"role": role, "parts": [["tool_result", text_of(m.get("content"))]]}
125
+ parts = parts_of(m.get("content"))
126
+ for tc in m.get("tool_calls") or []: # OpenAI assistant tool calls
127
+ fn = (tc.get("function") if isinstance(tc, dict) else None) or {}
128
+ parts.append(["tool_call", str(fn.get("name", "")), canon_args(fn.get("arguments", "{}"))])
129
+ return {"role": role, "parts": parts}
130
+
131
+
132
+ def canon_messages(messages: Any) -> list[Message]:
133
+ if isinstance(messages, str):
134
+ return [{"role": "user", "parts": [["text", messages]]}]
135
+ out = [canon_message(m) for m in messages or []]
136
+ # An assistant message with no content carries nothing and is dropped by some callers.
137
+ return [m for m in out if m["parts"] or m["role"] != "assistant"]
138
+
139
+
140
+ def canon_response_items(items: Any) -> list[Message]:
141
+ """OpenAI Responses `input` or `output` (a string or an item list)."""
142
+ if items is None:
143
+ return []
144
+ if isinstance(items, str):
145
+ return [{"role": "user", "parts": [["text", items]]}] if items else []
146
+ out: list[Message] = []
147
+ for item in plain(items):
148
+ if not isinstance(item, dict):
149
+ out.append({"role": "other", "parts": [["other", dumps(item)]]})
150
+ continue
151
+ t = item.get("type", "message")
152
+ if t == "message":
153
+ out.append({"role": str(item.get("role", "")), "parts": parts_of(item.get("content"))})
154
+ elif t == "function_call":
155
+ call = ["tool_call", str(item.get("name", "")), canon_args(item.get("arguments", "{}"))]
156
+ out.append({"role": "assistant", "parts": [call]})
157
+ elif t == "function_call_output":
158
+ out.append({"role": "tool", "parts": [["tool_result", text_of(item.get("output"))]]})
159
+ elif t == "reasoning":
160
+ continue
161
+ else:
162
+ rest = {k: v for k, v in item.items() if k not in ("id", "status")}
163
+ out.append({"role": "other", "parts": [["other", dumps(rest)]]})
164
+ return out
165
+
166
+
167
+ def canon_system(system: Any) -> list[Part]:
168
+ return parts_of(plain(system))
169
+
170
+
171
+ def canon_tools(tools: Any) -> list[Any]:
172
+ plain_tools = [plain(t) for t in tools or []]
173
+ return sorted(plain_tools, key=dumps)
174
+
175
+
176
+ def text_chars(messages: list[Message]) -> int:
177
+ return sum(len(p[-1]) for m in messages for p in m["parts"])
178
+
179
+
180
+ def message_text(message: Message) -> str:
181
+ return "".join(p[1] for p in message["parts"] if p[0] == "text")
182
+
183
+
184
+ def tool_calls(message: Message) -> list[tuple[str, str]]:
185
+ return [(p[1], p[2]) for p in message["parts"] if p[0] == "tool_call"]
186
+
187
+
188
+ def split_at_last_assistant(messages: list[Message]) -> int:
189
+ """Index just past the last assistant message; 0 when there is none."""
190
+ for i in range(len(messages) - 1, -1, -1):
191
+ if messages[i]["role"] == "assistant":
192
+ return i + 1
193
+ return 0
zexl/_config.py ADDED
@@ -0,0 +1,76 @@
1
+ """Configuration and the two-part API key."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import os
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+ DEFAULT_ENDPOINT = "https://zexl-intake.fly.dev"
13
+ _KEY_RE = re.compile(r"^(zx_[A-Za-z0-9]+_[A-Za-z0-9]+)\.([A-Za-z0-9_\-]{22,})$")
14
+ _TRUE = {"1", "true", "yes", "on"}
15
+
16
+
17
+ class ConfigError(ValueError):
18
+ """The SDK cannot start with the given settings. Never raised into customer calls."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Config:
23
+ token: str
24
+ """The part of the key that is sent to Zexl for authentication."""
25
+ salt: bytes
26
+ """The part of the key that never leaves this process. Keys every hash."""
27
+ endpoint: str
28
+ environment: str
29
+ openai_stream_usage: bool
30
+
31
+ @property
32
+ def salt_check(self) -> str:
33
+ """Short value that lets the server notice two keys with different salts. Reveals nothing
34
+ useful about the salt: 32 bits of an HMAC keyed with 128+ bits of entropy."""
35
+ return hmac.new(self.salt, b"zexl-salt-check", hashlib.sha256).hexdigest()[:8]
36
+
37
+
38
+ def parse_key(key: str) -> tuple[str, bytes]:
39
+ m = _KEY_RE.match(key.strip())
40
+ if not m:
41
+ if key.strip().startswith("zx_") and "." not in key:
42
+ raise ConfigError(
43
+ "ZEXL_API_KEY has no salt part. Use the full key shown at creation "
44
+ "(zx_<id>_<secret>.<salt>); the part after the dot stays on this machine."
45
+ )
46
+ raise ConfigError("ZEXL_API_KEY is not a valid Zexl key")
47
+ token, salt_text = m.group(1), m.group(2)
48
+ try:
49
+ salt = base64.urlsafe_b64decode(salt_text + "=" * (-len(salt_text) % 4))
50
+ except ValueError:
51
+ salt = salt_text.encode()
52
+ if len(salt) < 16:
53
+ raise ConfigError("ZEXL_API_KEY salt part is too short")
54
+ return token, salt
55
+
56
+
57
+ def load(
58
+ api_key: str | None = None,
59
+ endpoint: str | None = None,
60
+ environment: str | None = None,
61
+ ) -> Config:
62
+ key = api_key or os.environ.get("ZEXL_API_KEY", "")
63
+ if not key:
64
+ raise ConfigError("ZEXL_API_KEY is not set")
65
+ token, salt = parse_key(key)
66
+ return Config(
67
+ token=token,
68
+ salt=salt,
69
+ endpoint=(endpoint or os.environ.get("ZEXL_ENDPOINT") or DEFAULT_ENDPOINT).rstrip("/"),
70
+ environment=(environment or os.environ.get("ZEXL_ENV") or "production")[:64],
71
+ openai_stream_usage=os.environ.get("ZEXL_OPENAI_STREAM_USAGE", "").lower() in _TRUE,
72
+ )
73
+
74
+
75
+ def disabled_by_env() -> bool:
76
+ return os.environ.get("ZEXL_DISABLED", "").lower() in _TRUE
zexl/_context.py ADDED
@@ -0,0 +1,210 @@
1
+ """Run, feature and customer labels carried in context variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ import functools
7
+ import inspect
8
+ import os
9
+ import sys
10
+ import sysconfig
11
+ import threading
12
+ from collections import OrderedDict
13
+ from collections.abc import Callable
14
+ from types import CodeType, TracebackType
15
+ from typing import Any, TypeVar
16
+
17
+ from ._stitch import new_run_id
18
+
19
+ F = TypeVar("F", bound=Callable[..., Any])
20
+
21
+ _LABEL_MAX = 128
22
+
23
+
24
+ class _RunState:
25
+ __slots__ = ("run_id", "_step", "_lock")
26
+
27
+ def __init__(self) -> None:
28
+ self.run_id = new_run_id()
29
+ self._step = 0
30
+ self._lock = threading.Lock()
31
+
32
+ def next_step(self) -> int:
33
+ with self._lock:
34
+ self._step += 1
35
+ return self._step
36
+
37
+
38
+ _run: contextvars.ContextVar[_RunState | None] = contextvars.ContextVar("zexl_run", default=None)
39
+ _feature: contextvars.ContextVar[str | None] = contextvars.ContextVar("zexl_feature", default=None)
40
+ _customer: contextvars.ContextVar[str | None] = contextvars.ContextVar(
41
+ "zexl_customer", default=None
42
+ )
43
+
44
+
45
+ def clean_label(value: Any) -> str:
46
+ text = " ".join(str(value).split())
47
+ return text[:_LABEL_MAX]
48
+
49
+
50
+ class _Scope:
51
+ """Context manager and decorator that sets context variables for its body."""
52
+
53
+ def __init__(self, new_run: bool, feature: str | None) -> None:
54
+ self._new_run = new_run
55
+ self._feature = clean_label(feature) if feature else None
56
+ self._tokens: list[tuple[contextvars.ContextVar[Any], contextvars.Token[Any]]] = []
57
+
58
+ def __enter__(self) -> _Scope:
59
+ if self._new_run:
60
+ self._tokens.append((_run, _run.set(_RunState())))
61
+ if self._feature:
62
+ self._tokens.append((_feature, _feature.set(self._feature)))
63
+ return self
64
+
65
+ def __exit__(
66
+ self,
67
+ exc_type: type[BaseException] | None,
68
+ exc: BaseException | None,
69
+ tb: TracebackType | None,
70
+ ) -> None:
71
+ while self._tokens:
72
+ var, token = self._tokens.pop()
73
+ try:
74
+ var.reset(token)
75
+ except ValueError:
76
+ # Exited in a different context (for example a generator moved between tasks).
77
+ var.set(None)
78
+
79
+ async def __aenter__(self) -> _Scope:
80
+ return self.__enter__()
81
+
82
+ async def __aexit__(
83
+ self,
84
+ exc_type: type[BaseException] | None,
85
+ exc: BaseException | None,
86
+ tb: TracebackType | None,
87
+ ) -> None:
88
+ self.__exit__(exc_type, exc, tb)
89
+
90
+ def __call__(self, fn: F) -> F:
91
+ new_run, feature = self._new_run, self._feature or clean_label(fn.__qualname__)
92
+ if inspect.iscoroutinefunction(fn):
93
+
94
+ @functools.wraps(fn)
95
+ async def awrapper(*a: Any, **kw: Any) -> Any:
96
+ with _Scope(new_run, feature):
97
+ return await fn(*a, **kw)
98
+
99
+ return awrapper # type: ignore[return-value]
100
+
101
+ @functools.wraps(fn)
102
+ def wrapper(*a: Any, **kw: Any) -> Any:
103
+ with _Scope(new_run, feature):
104
+ return fn(*a, **kw)
105
+
106
+ return wrapper # type: ignore[return-value]
107
+
108
+
109
+ def run(name: str | None = None, *, feature: str | None = None) -> _Scope:
110
+ """Marks one unit of agent work. Calls inside share a run id and count steps from 1.
111
+
112
+ Use as `with zexl.run("research"):` or as a decorator. `name` is used as the feature label
113
+ unless `feature` is given.
114
+ """
115
+ return _Scope(True, feature or name)
116
+
117
+
118
+ def feature(name: str) -> _Scope:
119
+ """Sets the feature label for calls inside, without starting a run."""
120
+ return _Scope(False, name)
121
+
122
+
123
+ def set_customer(customer_id: str | None) -> None:
124
+ """Attributes later calls in this context to a customer. Pass an id, not personal data."""
125
+ _customer.set(clean_label(customer_id) if customer_id else None)
126
+
127
+
128
+ def current_customer() -> str | None:
129
+ return _customer.get()
130
+
131
+
132
+ def explicit_run() -> _RunState | None:
133
+ return _run.get()
134
+
135
+
136
+ def explicit_feature() -> str | None:
137
+ return _feature.get()
138
+
139
+
140
+ # --------------------------------------------------------------------------- ambient trace
141
+
142
+ _trace_steps: OrderedDict[str, int] = OrderedDict()
143
+ _trace_lock = threading.Lock()
144
+
145
+
146
+ def ambient_trace() -> tuple[str, int, str | None] | None:
147
+ """(trace id, step, http route) of the application's own active OpenTelemetry span, if any."""
148
+ otel = sys.modules.get("opentelemetry.trace")
149
+ if otel is None:
150
+ return None
151
+ try:
152
+ span = otel.get_current_span()
153
+ ctx = span.get_span_context()
154
+ if not ctx.is_valid:
155
+ return None
156
+ trace_id = format(ctx.trace_id, "032x")
157
+ attrs = getattr(span, "attributes", None) or {}
158
+ route = attrs.get("http.route")
159
+ except Exception: # noqa: BLE001
160
+ return None
161
+ with _trace_lock:
162
+ step = _trace_steps.get(trace_id, 0) + 1
163
+ _trace_steps[trace_id] = step
164
+ _trace_steps.move_to_end(trace_id)
165
+ while len(_trace_steps) > 4096:
166
+ _trace_steps.popitem(last=False)
167
+ return trace_id, step, clean_label(route) if route else None
168
+
169
+
170
+ # --------------------------------------------------------------------------- caller frame
171
+
172
+ _PKG_DIR = os.path.dirname(os.path.abspath(__file__))
173
+ _STDLIB = sysconfig.get_paths().get("stdlib", "\0")
174
+ _frame_cache: dict[CodeType, str | None] = {}
175
+
176
+
177
+ def _frame_label(code: CodeType) -> str | None:
178
+ filename = code.co_filename
179
+ if (
180
+ filename.startswith(("<", _PKG_DIR, _STDLIB))
181
+ or "site-packages" in filename
182
+ or "dist-packages" in filename
183
+ ):
184
+ return None
185
+ module = os.path.splitext(os.path.basename(filename))[0]
186
+ name = getattr(code, "co_qualname", code.co_name)
187
+ if name == "<module>":
188
+ return clean_label(module)
189
+ return clean_label(f"{module}.{name}".replace(".<locals>", ""))
190
+
191
+
192
+ def caller_feature() -> str | None:
193
+ """`module.function` of the nearest frame that is application code."""
194
+ try:
195
+ frame = sys._getframe(2)
196
+ except ValueError:
197
+ return None
198
+ depth = 0
199
+ while frame is not None and depth < 80:
200
+ code = frame.f_code
201
+ if code not in _frame_cache:
202
+ if len(_frame_cache) > 2048:
203
+ _frame_cache.clear()
204
+ _frame_cache[code] = _frame_label(code)
205
+ label = _frame_cache[code]
206
+ if label:
207
+ return label
208
+ frame = frame.f_back # type: ignore[assignment]
209
+ depth += 1
210
+ return None