computer-agent-py 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.
@@ -0,0 +1,121 @@
1
+ """Canonical policy types — engine-agnostic dataclasses + the ``PolicyEngine``
2
+ Protocol that OPA and Cedar implementations satisfy.
3
+
4
+ The same :class:`PolicyInput` shape is sent to both engines so users can switch
5
+ between OPA and Cedar without changing the worker code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Any, Literal, Protocol
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PolicyPrincipal:
17
+ """Who is requesting the action — user / session / agent identifier."""
18
+
19
+ id: str
20
+ groups: list[str] = field(default_factory=list)
21
+ attributes: dict[str, Any] = field(default_factory=dict)
22
+
23
+ def to_json(self) -> dict[str, Any]:
24
+ return {
25
+ "id": self.id,
26
+ "groups": list(self.groups),
27
+ "attributes": dict(self.attributes),
28
+ }
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PolicyAction:
33
+ """What is being attempted. v0.2 covers tool_use; ``name`` is extensible."""
34
+
35
+ name: Literal["tool_use"]
36
+ tool_name: str
37
+ tool_input: dict[str, Any] = field(default_factory=dict)
38
+
39
+ def to_json(self) -> dict[str, Any]:
40
+ return {
41
+ "name": self.name,
42
+ "tool_name": self.tool_name,
43
+ "tool_input": dict(self.tool_input),
44
+ }
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class PolicyResource:
49
+ """What the action is being attempted *on* — typically the running agent."""
50
+
51
+ type: str = "agent"
52
+ agent_name: str = ""
53
+ model: str = ""
54
+ session_id: str = ""
55
+ attributes: dict[str, Any] = field(default_factory=dict)
56
+
57
+ def to_json(self) -> dict[str, Any]:
58
+ return {
59
+ "type": self.type,
60
+ "agent_name": self.agent_name,
61
+ "model": self.model,
62
+ "session_id": self.session_id,
63
+ "attributes": dict(self.attributes),
64
+ }
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class PolicyInput:
69
+ """One authorization request. Identical shape across OPA and Cedar engines."""
70
+
71
+ principal: PolicyPrincipal
72
+ action: PolicyAction
73
+ resource: PolicyResource
74
+ context: dict[str, Any] = field(default_factory=dict)
75
+
76
+ def to_json(self) -> dict[str, Any]:
77
+ return {
78
+ "principal": self.principal.to_json(),
79
+ "action": self.action.to_json(),
80
+ "resource": self.resource.to_json(),
81
+ "context": dict(self.context),
82
+ }
83
+
84
+
85
+ class PolicyDecision(Enum):
86
+ ALLOW = "allow"
87
+ DENY = "deny"
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class PolicyResult:
92
+ """Engine output — decision plus human-readable reason + optional obligations.
93
+
94
+ Obligations are advisory in v0.2 (not enforced); kept on the result so
95
+ sinks / audit logs can surface them.
96
+ """
97
+
98
+ decision: PolicyDecision
99
+ reason: str = ""
100
+ obligations: list[dict[str, Any]] = field(default_factory=list)
101
+ engine: str = "" # populated by the concrete engine ("opa" | "cedar")
102
+
103
+
104
+ class PolicyEngine(Protocol):
105
+ """Pluggable backend for tool-use authorization.
106
+
107
+ Implementations: :class:`~computeragent.policy.opa.OpaPolicyEngine` (remote
108
+ HTTP) and :class:`~computeragent.policy.cedar.CedarPolicyEngine` (in-process
109
+ via ``cedarpy``).
110
+ """
111
+
112
+ name: str
113
+ """Short identifier used in telemetry events (``"opa"`` / ``"cedar"``)."""
114
+
115
+ async def evaluate(self, input: PolicyInput) -> PolicyResult: ...
116
+
117
+ async def aclose(self) -> None: ...
118
+
119
+
120
+ FailMode = Literal["deny", "allow"]
121
+ """What to return when the engine itself raises (network error, parse error)."""
computeragent/py.typed ADDED
File without changes
@@ -0,0 +1,24 @@
1
+ """Telemetry surface — pipeline, middleware, sinks, configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .config import configure, get_config, get_pipeline, shutdown
6
+ from .event import EventKind, TelemetryEvent, new_session_id
7
+ from .middleware.guardrails import GuardrailFilter
8
+ from .middleware.pii import PiiRedactor
9
+ from .pipeline import Pipeline, TelemetryMiddleware, TelemetrySink
10
+
11
+ __all__ = [
12
+ "EventKind",
13
+ "GuardrailFilter",
14
+ "PiiRedactor",
15
+ "Pipeline",
16
+ "TelemetryEvent",
17
+ "TelemetryMiddleware",
18
+ "TelemetrySink",
19
+ "configure",
20
+ "get_config",
21
+ "get_pipeline",
22
+ "new_session_id",
23
+ "shutdown",
24
+ ]
@@ -0,0 +1,176 @@
1
+ """Module-level pipeline singleton + ``configure()`` API.
2
+
3
+ Users either call :func:`configure` explicitly, or rely on the env-driven
4
+ defaults that :func:`get_pipeline` builds on first use. Both paths are safe
5
+ to interleave (later ``configure()`` replaces the lazy default).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from typing import Any, Literal
13
+
14
+ from .pipeline import Pipeline, TelemetryMiddleware, TelemetrySink
15
+
16
+ logger = logging.getLogger("computeragent.telemetry")
17
+
18
+
19
+ # ──────────────────────────────────────────────────────────────────────────
20
+ # Globals
21
+ # ──────────────────────────────────────────────────────────────────────────
22
+
23
+ _PIPELINE: Pipeline | None = None
24
+ _CONFIG: dict[str, Any] = {}
25
+
26
+
27
+ def get_pipeline() -> Pipeline:
28
+ """Return the active pipeline, lazy-building from env if not configured."""
29
+ global _PIPELINE
30
+ if _PIPELINE is None:
31
+ _PIPELINE = _build_default_pipeline()
32
+ return _PIPELINE
33
+
34
+
35
+ def configure(
36
+ *,
37
+ middleware: list[TelemetryMiddleware] | None = None,
38
+ sinks: list[TelemetrySink] | None = None,
39
+ service_name: str | None = None,
40
+ exporter: Literal["console", "otlp-http"] | None = None,
41
+ endpoint: str | None = None,
42
+ headers: dict[str, str] | None = None,
43
+ sample_rate: float = 1.0,
44
+ capture_content: bool = False,
45
+ capture_content_mode: Literal["events", "attributes", "both"] = "events",
46
+ max_attribute_length: int = 8192,
47
+ fail_open: bool = False,
48
+ ) -> Pipeline:
49
+ """Programmatic configuration. Idempotent — replaces the active pipeline.
50
+
51
+ Auto-attaches the bundled :class:`~computeragent.telemetry.sinks.otel.OtelSink`
52
+ and :class:`~computeragent.telemetry.sinks.agentos.AgentRegistrySink` if their
53
+ extras are installed and not already in ``sinks``. Pass ``sinks=[]`` to
54
+ disable all auto-attachment.
55
+ """
56
+ global _PIPELINE, _CONFIG
57
+ _CONFIG = {
58
+ "service_name": service_name or os.environ.get("OTEL_SERVICE_NAME", "computeragent"),
59
+ "exporter": exporter,
60
+ "endpoint": endpoint or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"),
61
+ "headers": headers
62
+ if headers is not None
63
+ else _parse_headers(os.environ.get("OTEL_EXPORTER_OTLP_HEADERS")),
64
+ "sample_rate": sample_rate,
65
+ "capture_content": capture_content,
66
+ "capture_content_mode": capture_content_mode,
67
+ "max_attribute_length": max_attribute_length,
68
+ }
69
+
70
+ effective_sinks: list[TelemetrySink] = list(sinks) if sinks is not None else _auto_sinks()
71
+ _PIPELINE = Pipeline(
72
+ middleware=list(middleware or []),
73
+ sinks=effective_sinks,
74
+ fail_open=fail_open,
75
+ )
76
+ return _PIPELINE
77
+
78
+
79
+ async def shutdown(timeout: float = 5.0) -> None:
80
+ """Flush and close the active pipeline. Safe to call multiple times."""
81
+ global _PIPELINE
82
+ if _PIPELINE is None:
83
+ return
84
+ try:
85
+ await _PIPELINE.aclose()
86
+ finally:
87
+ _PIPELINE = None
88
+
89
+
90
+ # ──────────────────────────────────────────────────────────────────────────
91
+ # Internals
92
+ # ──────────────────────────────────────────────────────────────────────────
93
+
94
+
95
+ def get_config() -> dict[str, Any]:
96
+ """Expose the resolved config to sinks (read-only contract)."""
97
+ return dict(_CONFIG)
98
+
99
+
100
+ def _build_default_pipeline() -> Pipeline:
101
+ """Construct a pipeline from env vars only. No middleware by default."""
102
+ global _CONFIG
103
+ _CONFIG = {
104
+ "service_name": os.environ.get("OTEL_SERVICE_NAME", "computeragent"),
105
+ "exporter": None,
106
+ "endpoint": os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"),
107
+ "headers": _parse_headers(os.environ.get("OTEL_EXPORTER_OTLP_HEADERS")),
108
+ "sample_rate": 1.0,
109
+ "capture_content": _env_bool("COMPUTERAGENT_CAPTURE_CONTENT", default=False),
110
+ "capture_content_mode": os.environ.get("COMPUTERAGENT_CAPTURE_CONTENT_MODE", "events"),
111
+ "max_attribute_length": int(os.environ.get("COMPUTERAGENT_MAX_ATTRIBUTE_LENGTH", "8192")),
112
+ }
113
+ return Pipeline(middleware=[], sinks=_auto_sinks(), fail_open=False)
114
+
115
+
116
+ def _auto_sinks() -> list[TelemetrySink]:
117
+ """Build the sink list implied by environment variables.
118
+
119
+ OTel sink: on when ``COMPUTERAGENT_OTEL!=disabled`` (default on) AND the
120
+ ``opentelemetry-*`` packages are importable. Falls back to console
121
+ exporter when no OTLP endpoint is configured.
122
+
123
+ AgentOS sink: on when ``AGENTOS_MONGO_URL`` is set AND ``motor`` is
124
+ importable.
125
+ """
126
+ sinks: list[TelemetrySink] = []
127
+
128
+ if os.environ.get("COMPUTERAGENT_OTEL", "").lower() != "disabled":
129
+ try:
130
+ from .sinks.otel import OtelSink # noqa: WPS433
131
+
132
+ sinks.append(OtelSink())
133
+ except ImportError:
134
+ logger.debug("OtelSink not attached (computer-agent-py[otel] not installed)")
135
+
136
+ mongo_url = os.environ.get("AGENTOS_MONGO_URL")
137
+ if mongo_url:
138
+ try:
139
+ from .sinks.agentos import AgentRegistrySink # noqa: WPS433
140
+
141
+ sinks.append(AgentRegistrySink(mongo_url=mongo_url))
142
+ except ImportError:
143
+ logger.debug(
144
+ "AgentRegistrySink not attached (computer-agent-py[agentos] not installed)"
145
+ )
146
+
147
+ return sinks
148
+
149
+
150
+ def _parse_headers(raw: str | None) -> dict[str, str]:
151
+ if not raw:
152
+ return {}
153
+ out: dict[str, str] = {}
154
+ for pair in raw.split(","):
155
+ if "=" not in pair:
156
+ continue
157
+ k, _, v = pair.partition("=")
158
+ k = k.strip()
159
+ v = v.strip()
160
+ if k:
161
+ out[k] = v
162
+ return out
163
+
164
+
165
+ def _env_bool(name: str, *, default: bool) -> bool:
166
+ raw = os.environ.get(name)
167
+ if raw is None:
168
+ return default
169
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
170
+
171
+
172
+ def _reset_for_tests() -> None:
173
+ """Test hook — drop the cached pipeline so the next call rebuilds it."""
174
+ global _PIPELINE, _CONFIG
175
+ _PIPELINE = None
176
+ _CONFIG = {}
@@ -0,0 +1,355 @@
1
+ """``TelemetryEvent`` — the unit that flows through the telemetry pipeline.
2
+
3
+ One event is emitted for each meaningful thing that happens during a query:
4
+ session start, each message yielded, each tool call, the final usage snapshot,
5
+ and session end. Middleware can mutate ``payload`` (the user-visible projection)
6
+ or drop the event entirely; sinks consume the event and write it somewhere.
7
+
8
+ The ``raw`` reference points back to the original upstream object so sinks
9
+ that need the verbatim shape (e.g. a debug audit log) can grab it. Sinks that
10
+ ship content to external systems should read from ``payload`` so middleware
11
+ redaction is honoured.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import uuid
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timezone
20
+ from typing import Any, Literal
21
+
22
+ logger = logging.getLogger("computeragent.telemetry")
23
+
24
+ EventKind = Literal[
25
+ "session_started",
26
+ "user_message",
27
+ "assistant_message",
28
+ "system_message",
29
+ "tool_use",
30
+ "tool_result",
31
+ "usage_snapshot",
32
+ "session_ended",
33
+ "policy_decision",
34
+ ]
35
+
36
+
37
+ def _utcnow() -> datetime:
38
+ return datetime.now(timezone.utc)
39
+
40
+
41
+ @dataclass
42
+ class TelemetryEvent:
43
+ """Single unit of telemetry. Mutable: middleware modifies ``payload`` in place."""
44
+
45
+ kind: EventKind
46
+ session_id: str
47
+ timestamp: datetime = field(default_factory=_utcnow)
48
+ agent_name: str | None = None
49
+ payload: dict[str, Any] = field(default_factory=dict)
50
+ raw: Any = None
51
+
52
+ # ──────────────────────────────────────────────────────────────────────
53
+ # Factories — keep all the upstream-shape-to-event-shape mapping in one
54
+ # place so message_translate-style spaghetti doesn't end up scattered.
55
+ # ──────────────────────────────────────────────────────────────────────
56
+
57
+ @classmethod
58
+ def session_started(
59
+ cls,
60
+ *,
61
+ session_id: str,
62
+ agent_name: str | None,
63
+ options: Any,
64
+ prompt: Any,
65
+ ) -> TelemetryEvent:
66
+ # Capture only metadata that a sink might want — never the full options
67
+ # object, which can hold callables (hooks, can_use_tool) that don't
68
+ # serialize.
69
+ # `system_prompt` may be a string OR a preset/file object — only persist
70
+ # the string form, since the inline-source manifest we build downstream
71
+ # treats it as plain CLAUDE.md content.
72
+ sp = getattr(options, "system_prompt", None)
73
+ system_prompt = sp if isinstance(sp, str) else None
74
+ payload: dict[str, Any] = {
75
+ "model": getattr(options, "model", None),
76
+ "system_prompt": system_prompt,
77
+ "permission_mode": getattr(options, "permission_mode", None),
78
+ "allowed_tools": list(getattr(options, "allowed_tools", []) or []),
79
+ "disallowed_tools": list(getattr(options, "disallowed_tools", []) or []),
80
+ "max_turns": getattr(options, "max_turns", None),
81
+ "cwd": str(getattr(options, "cwd", "") or ""),
82
+ "add_dirs": [str(p) for p in (getattr(options, "add_dirs", []) or [])],
83
+ "prompt": prompt if isinstance(prompt, str) else "<streaming>",
84
+ }
85
+ return cls(
86
+ kind="session_started",
87
+ session_id=session_id,
88
+ agent_name=agent_name,
89
+ payload=payload,
90
+ raw=options,
91
+ )
92
+
93
+ @classmethod
94
+ def session_ended_ok(
95
+ cls,
96
+ *,
97
+ session_id: str,
98
+ agent_name: str | None,
99
+ result: Any,
100
+ duration_ms: float,
101
+ last_assistant_text: str,
102
+ ) -> TelemetryEvent:
103
+ payload: dict[str, Any] = {
104
+ "is_error": bool(getattr(result, "is_error", False)),
105
+ "subtype": getattr(result, "subtype", "success"),
106
+ "result": getattr(result, "result", "") or last_assistant_text,
107
+ "num_turns": int(getattr(result, "num_turns", 0) or 0),
108
+ "total_cost_usd": float(getattr(result, "total_cost_usd", 0.0) or 0.0),
109
+ "duration_ms": duration_ms,
110
+ "duration_api_ms": float(getattr(result, "duration_api_ms", 0.0) or 0.0),
111
+ "usage": _extract_usage(result),
112
+ }
113
+ return cls(
114
+ kind="session_ended",
115
+ session_id=session_id,
116
+ agent_name=agent_name,
117
+ payload=payload,
118
+ raw=result,
119
+ )
120
+
121
+ @classmethod
122
+ def session_ended_error(
123
+ cls,
124
+ *,
125
+ session_id: str,
126
+ agent_name: str | None,
127
+ exc: BaseException,
128
+ duration_ms: float,
129
+ ) -> TelemetryEvent:
130
+ return cls(
131
+ kind="session_ended",
132
+ session_id=session_id,
133
+ agent_name=agent_name,
134
+ payload={
135
+ "is_error": True,
136
+ "subtype": "error",
137
+ "error_type": type(exc).__name__,
138
+ "error_message": str(exc),
139
+ "duration_ms": duration_ms,
140
+ "result": "",
141
+ "num_turns": 0,
142
+ "total_cost_usd": 0.0,
143
+ "usage": {},
144
+ },
145
+ raw=exc,
146
+ )
147
+
148
+ @classmethod
149
+ def policy_decision(
150
+ cls,
151
+ *,
152
+ input: Any,
153
+ result: Any,
154
+ error: BaseException | None,
155
+ latency_ms: float,
156
+ engine_name: str,
157
+ ) -> TelemetryEvent:
158
+ """Emitted by ``PolicyToolAuthorizer`` after each authorization check.
159
+
160
+ ``input`` is a :class:`computeragent.policy.PolicyInput` and ``result``
161
+ a :class:`PolicyResult` (or ``None`` if the engine raised). Sinks pick
162
+ this up via the standard pipeline; ``OtelSink`` annotates the active
163
+ ``execute_tool`` span with the decision and reason.
164
+ """
165
+ principal = getattr(input, "principal", None)
166
+ action = getattr(input, "action", None)
167
+ resource = getattr(input, "resource", None)
168
+ decision_value = getattr(getattr(result, "decision", None), "value", None)
169
+ payload: dict[str, Any] = {
170
+ "engine": engine_name,
171
+ "decision": decision_value or ("error" if error else "unknown"),
172
+ "reason": getattr(result, "reason", "") if result is not None else "",
173
+ "latency_ms": latency_ms,
174
+ "principal_id": getattr(principal, "id", ""),
175
+ "principal_groups": list(getattr(principal, "groups", []) or []),
176
+ "tool_name": getattr(action, "tool_name", ""),
177
+ "agent_name": getattr(resource, "agent_name", ""),
178
+ "session_id": getattr(resource, "session_id", "")
179
+ or getattr(input, "context", {}).get("session_id", ""),
180
+ }
181
+ if error is not None:
182
+ payload["error_type"] = type(error).__name__
183
+ payload["error_message"] = str(error)
184
+ return cls(
185
+ kind="policy_decision",
186
+ session_id=payload["session_id"] or "unknown",
187
+ agent_name=payload["agent_name"] or None,
188
+ payload=payload,
189
+ raw=result if result is not None else error,
190
+ )
191
+
192
+ @classmethod
193
+ def from_message(
194
+ cls, msg: Any, *, session_id: str, agent_name: str | None
195
+ ) -> list[TelemetryEvent]:
196
+ """Map one upstream message to zero or more telemetry events.
197
+
198
+ The kinds emitted depend on the upstream message type and any content
199
+ blocks it carries. The translation here is deliberately tolerant: if
200
+ upstream adds a new field we don't recognise, we just don't emit
201
+ anything for it rather than raising.
202
+ """
203
+ # Lazy import — only need the types at runtime when translation runs.
204
+ from claude_agent_sdk.types import (
205
+ AssistantMessage,
206
+ ResultMessage,
207
+ SystemMessage,
208
+ ToolResultBlock,
209
+ ToolUseBlock,
210
+ UserMessage,
211
+ )
212
+
213
+ events: list[TelemetryEvent] = []
214
+
215
+ if isinstance(msg, AssistantMessage):
216
+ text_parts: list[str] = []
217
+ for block in getattr(msg, "content", []) or []:
218
+ if isinstance(block, ToolUseBlock):
219
+ events.append(
220
+ cls(
221
+ kind="tool_use",
222
+ session_id=session_id,
223
+ agent_name=agent_name,
224
+ payload={
225
+ "tool_use_id": getattr(block, "id", ""),
226
+ "tool_name": getattr(block, "name", ""),
227
+ "tool_input": dict(getattr(block, "input", {}) or {}),
228
+ },
229
+ raw=block,
230
+ )
231
+ )
232
+ else:
233
+ text = getattr(block, "text", None)
234
+ if isinstance(text, str):
235
+ text_parts.append(text)
236
+ events.append(
237
+ cls(
238
+ kind="assistant_message",
239
+ session_id=session_id,
240
+ agent_name=agent_name,
241
+ payload={
242
+ "model": getattr(msg, "model", None),
243
+ "text": "".join(text_parts),
244
+ "block_count": len(getattr(msg, "content", []) or []),
245
+ },
246
+ raw=msg,
247
+ )
248
+ )
249
+ elif isinstance(msg, UserMessage):
250
+ tool_results: list[dict[str, Any]] = []
251
+ text_parts = []
252
+ for block in getattr(msg, "content", []) or []:
253
+ if isinstance(block, ToolResultBlock):
254
+ tool_results.append(
255
+ {
256
+ "tool_use_id": getattr(block, "tool_use_id", ""),
257
+ "is_error": bool(getattr(block, "is_error", False)),
258
+ "content": getattr(block, "content", ""),
259
+ }
260
+ )
261
+ else:
262
+ text = getattr(block, "text", None)
263
+ if isinstance(text, str):
264
+ text_parts.append(text)
265
+ if tool_results:
266
+ for tr in tool_results:
267
+ events.append(
268
+ cls(
269
+ kind="tool_result",
270
+ session_id=session_id,
271
+ agent_name=agent_name,
272
+ payload=tr,
273
+ raw=msg,
274
+ )
275
+ )
276
+ if text_parts:
277
+ events.append(
278
+ cls(
279
+ kind="user_message",
280
+ session_id=session_id,
281
+ agent_name=agent_name,
282
+ payload={"text": "".join(text_parts)},
283
+ raw=msg,
284
+ )
285
+ )
286
+ elif isinstance(msg, SystemMessage):
287
+ events.append(
288
+ cls(
289
+ kind="system_message",
290
+ session_id=session_id,
291
+ agent_name=agent_name,
292
+ payload={
293
+ "subtype": getattr(msg, "subtype", ""),
294
+ "data": dict(getattr(msg, "data", {}) or {}),
295
+ },
296
+ raw=msg,
297
+ )
298
+ )
299
+ elif isinstance(msg, ResultMessage):
300
+ # ResultMessage also drives a `usage_snapshot` so token + cost
301
+ # telemetry doesn't only appear at session_ended (sinks that
302
+ # record metrics per session want the histogram measurement now).
303
+ events.append(
304
+ cls(
305
+ kind="usage_snapshot",
306
+ session_id=session_id,
307
+ agent_name=agent_name,
308
+ payload={
309
+ "model": _model_from_options_or_result(msg),
310
+ "total_cost_usd": float(getattr(msg, "total_cost_usd", 0.0) or 0.0),
311
+ "usage": _extract_usage(msg),
312
+ },
313
+ raw=msg,
314
+ )
315
+ )
316
+ # Anything else (StreamEvent, RateLimitEvent, TaskStartedMessage, …):
317
+ # skip silently. These can be added later if a sink needs them.
318
+ return events
319
+
320
+
321
+ def _extract_usage(result_like: Any) -> dict[str, int | float | None]:
322
+ """Pull tokens + cache fields out of a ResultMessage's nested usage shape."""
323
+ usage = getattr(result_like, "usage", None)
324
+ if usage is None:
325
+ return {}
326
+ # Pydantic-style models, dataclasses, or dicts — handle all three.
327
+ if hasattr(usage, "model_dump"):
328
+ try:
329
+ return dict(usage.model_dump())
330
+ except Exception: # noqa: BLE001 - defensive
331
+ logger.debug("usage.model_dump() failed", exc_info=True)
332
+ if hasattr(usage, "__dataclass_fields__"):
333
+ return {k: getattr(usage, k, None) for k in usage.__dataclass_fields__}
334
+ if isinstance(usage, dict):
335
+ return dict(usage)
336
+ # Last resort — pick up the conventional Anthropic fields if they exist.
337
+ return {
338
+ k: getattr(usage, k, None)
339
+ for k in (
340
+ "input_tokens",
341
+ "output_tokens",
342
+ "cache_read_input_tokens",
343
+ "cache_creation_input_tokens",
344
+ )
345
+ if hasattr(usage, k)
346
+ }
347
+
348
+
349
+ def _model_from_options_or_result(result: Any) -> str | None:
350
+ return getattr(result, "model", None)
351
+
352
+
353
+ def new_session_id() -> str:
354
+ """Generate a placeholder session id used before the upstream returns one."""
355
+ return f"ca_{uuid.uuid4().hex}"
@@ -0,0 +1,8 @@
1
+ """Telemetry middleware — PII redaction and guardrail filtering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .guardrails import GuardrailFilter
6
+ from .pii import PiiRedactor
7
+
8
+ __all__ = ["GuardrailFilter", "PiiRedactor"]