akhu-observer 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,46 @@
1
+ """
2
+ akhu-observer — Public API
3
+
4
+ This is the only entry point. Everything exported here IS the SDK.
5
+ Everything not exported here is internal implementation detail.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .client import AkhuClient
11
+ from .wrappers.openai import observe_openai
12
+ from .wrappers.anthropic import observe_anthropic
13
+ from ._constants import CHANNEL_ID_RE
14
+ from ._types import (
15
+ ErrorCallback,
16
+ FlushCallback,
17
+ HealthStatus,
18
+ IngestResult,
19
+ IngestResponse,
20
+ BatchIngestData,
21
+ BatchIngestResponse,
22
+ ChannelHealth,
23
+ ChannelInfo,
24
+ )
25
+
26
+ __all__ = [
27
+ # Core
28
+ "AkhuClient",
29
+ # Wrappers
30
+ "observe_openai",
31
+ "observe_anthropic",
32
+ # Constants
33
+ "CHANNEL_ID_RE",
34
+ # Types
35
+ "ErrorCallback",
36
+ "FlushCallback",
37
+ "HealthStatus",
38
+ "IngestResult",
39
+ "IngestResponse",
40
+ "BatchIngestData",
41
+ "BatchIngestResponse",
42
+ "ChannelHealth",
43
+ "ChannelInfo",
44
+ ]
45
+
46
+ __version__ = "0.1.0"
@@ -0,0 +1,91 @@
1
+ """
2
+ akhu-observer — Frozen constants
3
+
4
+ These values define the SDK's behavior at compile time.
5
+ Change here = contract change. Think before you touch.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # API
14
+ # ---------------------------------------------------------------------------
15
+
16
+ API_PREFIX = "/api/v1"
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Defaults
20
+ # ---------------------------------------------------------------------------
21
+
22
+ DEFAULT_TIMEOUT_S: float = 5.0
23
+ DEFAULT_MAX_RETRIES: int = 3
24
+ DEFAULT_BUFFER_SIZE: int = 1
25
+ DEFAULT_FLUSH_INTERVAL_S: float = 0.2
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Validation boundaries
29
+ # ---------------------------------------------------------------------------
30
+
31
+ MIN_BUFFER_SIZE: int = 1
32
+ MIN_FLUSH_INTERVAL_S: float = 0.05 # 50ms
33
+ MIN_TIMEOUT_S: float = 0.1 # 100ms
34
+ MIN_MAX_RETRIES: int = 0
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Server limits
38
+ # ---------------------------------------------------------------------------
39
+
40
+ MAX_BATCH_SIZE: int = 5_000
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Channel ID
44
+ # ---------------------------------------------------------------------------
45
+
46
+ CHANNEL_ID_RE: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$")
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Environment variables
50
+ # ---------------------------------------------------------------------------
51
+
52
+ ENV_URL = "AKHU_URL"
53
+ ENV_CHANNEL_ID = "AKHU_CHANNEL_ID"
54
+ ENV_CHANNEL_NAME = "AKHU_CHANNEL_NAME"
55
+ ENV_CAPTURE_CONTENT = "AKHU_CAPTURE_CONTENT"
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # HTTP
59
+ # ---------------------------------------------------------------------------
60
+
61
+ MAX_RESPONSE_SIZE: int = 1_048_576 # 1MB
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Adapter identifiers
65
+ # ---------------------------------------------------------------------------
66
+
67
+ DEFAULT_ADAPTER = "akhu-observer-python"
68
+ OPENAI_ADAPTER = "akhu-observer-openai"
69
+ ANTHROPIC_ADAPTER = "akhu-observer-anthropic"
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Default channel IDs per wrapper
73
+ # ---------------------------------------------------------------------------
74
+
75
+ OPENAI_DEFAULT_CHANNEL_ID = "python-openai"
76
+ ANTHROPIC_DEFAULT_CHANNEL_ID = "python-anthropic"
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Retry
80
+ # ---------------------------------------------------------------------------
81
+
82
+ BACKOFF_BASE_S: float = 0.1 # 100ms
83
+ BACKOFF_CAP_S: float = 10.0 # 10s
84
+ MAX_RETRY_AFTER_S: float = 3600.0 # 1 hour
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Redaction
88
+ # ---------------------------------------------------------------------------
89
+
90
+ MAX_REDACTION_DEPTH: int = 20
91
+ MAX_STRING_LENGTH: int = 10_000
akhu_observer/_http.py ADDED
@@ -0,0 +1,166 @@
1
+ """
2
+ akhu-observer — HTTP transport layer
3
+
4
+ Uses Python stdlib http.client. Zero external dependencies.
5
+ This is deliberate — the observer SDK must not pull in requests, httpx,
6
+ or any other HTTP library that adds attack surface and dependency risk.
7
+
8
+ Connection reuse: http.client.HTTP(S)Connection supports keepalive.
9
+ We maintain a single persistent connection per HttpTransport instance.
10
+ If the connection drops, we reconnect transparently (one retry).
11
+
12
+ Timeout: Hard timeout on the socket level. If AKHU doesn't respond
13
+ within timeout_s, the connection is destroyed. The observed system
14
+ must never wait for us.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import http.client
20
+ import json
21
+ import ssl
22
+ from typing import Any
23
+ from urllib.parse import urlparse
24
+
25
+ from ._constants import MAX_RESPONSE_SIZE
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Transport
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ class HttpTransport:
33
+ """Minimal HTTP client for AKHU API communication.
34
+
35
+ One instance per AkhuClient. Holds a persistent connection.
36
+ Thread-safe for sequential use (flush lock in client prevents concurrent sends).
37
+ """
38
+
39
+ __slots__ = ("_scheme", "_host", "_port", "_is_https", "_conn", "_destroyed")
40
+
41
+ def __init__(self, base_url: str) -> None:
42
+ parsed = urlparse(base_url)
43
+ self._scheme = parsed.scheme
44
+ self._host = parsed.hostname or "localhost"
45
+ self._port = parsed.port or (443 if self._scheme == "https" else 80)
46
+ self._is_https = self._scheme == "https"
47
+ self._conn: http.client.HTTPConnection | http.client.HTTPSConnection | None = None
48
+ self._destroyed = False
49
+
50
+ # ----- Public API -----
51
+
52
+ def send(
53
+ self,
54
+ *,
55
+ method: str,
56
+ path: str,
57
+ body: Any = None,
58
+ headers: dict[str, str] | None = None,
59
+ timeout_s: float = 5.0,
60
+ ) -> dict[str, Any]:
61
+ """Send an HTTP request and return parsed response.
62
+
63
+ Returns dict with keys: status (int), headers (dict), body (Any).
64
+ Raises ConnectionError on network failure.
65
+ """
66
+ if self._destroyed:
67
+ raise RuntimeError("HttpTransport has been destroyed")
68
+
69
+ all_headers: dict[str, str] = {"Content-Type": "application/json"}
70
+ if headers:
71
+ all_headers.update(headers)
72
+
73
+ encoded_body: bytes | None = None
74
+ if body is not None:
75
+ encoded_body = json.dumps(body).encode("utf-8")
76
+ all_headers["Content-Length"] = str(len(encoded_body))
77
+
78
+ # Try with existing connection; retry once on connection drops.
79
+ # TimeoutError is NOT retried — retrying burns another timeout_s for no gain.
80
+ for attempt in range(2):
81
+ try:
82
+ return self._attempt_request(
83
+ method, path, encoded_body, all_headers, timeout_s
84
+ )
85
+ except (http.client.HTTPException, OSError) as exc:
86
+ self._close_connection()
87
+ if attempt == 0 and not isinstance(exc, TimeoutError):
88
+ continue
89
+ raise ConnectionError(
90
+ f"Request failed after connection retry: {exc}"
91
+ ) from exc
92
+
93
+ raise ConnectionError("Request failed after connection retry") # unreachable
94
+
95
+ def _attempt_request(
96
+ self,
97
+ method: str,
98
+ path: str,
99
+ encoded_body: bytes | None,
100
+ all_headers: dict[str, str],
101
+ timeout_s: float,
102
+ ) -> dict[str, Any]:
103
+ """Execute a single HTTP request attempt. Raises on network failure."""
104
+ conn = self._get_or_create_connection(timeout_s)
105
+ conn.request(method, path, body=encoded_body, headers=all_headers)
106
+ resp = conn.getresponse()
107
+
108
+ data = resp.read(MAX_RESPONSE_SIZE + 1)
109
+ if len(data) > MAX_RESPONSE_SIZE:
110
+ raise ValueError(f"Response exceeded {MAX_RESPONSE_SIZE} bytes")
111
+
112
+ try:
113
+ parsed_body = json.loads(data.decode("utf-8"))
114
+ except (json.JSONDecodeError, UnicodeDecodeError):
115
+ parsed_body = {"raw": data.decode("utf-8", errors="replace")}
116
+
117
+ retry_after = resp.getheader("Retry-After")
118
+ return {
119
+ "status": resp.status,
120
+ "headers": ({"retry-after": retry_after} if retry_after else {}),
121
+ "body": parsed_body,
122
+ }
123
+
124
+ def destroy(self) -> None:
125
+ """Close connection and mark transport as destroyed."""
126
+ if not self._destroyed:
127
+ self._destroyed = True
128
+ self._close_connection()
129
+
130
+ @property
131
+ def destroyed(self) -> bool:
132
+ return self._destroyed
133
+
134
+ # ----- Private -----
135
+
136
+ def _get_or_create_connection(
137
+ self, timeout_s: float
138
+ ) -> http.client.HTTPConnection:
139
+ """Get existing connection or create a new one."""
140
+ if self._conn is not None:
141
+ # Update timeout for this request
142
+ self._conn.timeout = timeout_s
143
+ return self._conn
144
+
145
+ if self._is_https:
146
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
147
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
148
+ ctx.load_default_certs()
149
+ self._conn = http.client.HTTPSConnection(
150
+ self._host, self._port, timeout=timeout_s, context=ctx
151
+ )
152
+ else:
153
+ self._conn = http.client.HTTPConnection(
154
+ self._host, self._port, timeout=timeout_s
155
+ )
156
+
157
+ return self._conn
158
+
159
+ def _close_connection(self) -> None:
160
+ """Close current connection, if any."""
161
+ if self._conn is not None:
162
+ try:
163
+ self._conn.close()
164
+ except (OSError, http.client.HTTPException):
165
+ pass
166
+ self._conn = None
@@ -0,0 +1,350 @@
1
+ """
2
+ akhu-observer — Redaction utilities
3
+
4
+ Privacy by default. Content is stripped unless explicitly opted in.
5
+ This module decides what leaves the observed system and what doesn't.
6
+
7
+ Design: We use an allowlist approach, not a denylist. Only known-safe
8
+ metadata fields pass through. Everything else is stripped. This is
9
+ intentionally conservative — better to lose an observation field than
10
+ to leak user PII into an evidence ledger.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from ._constants import MAX_REDACTION_DEPTH, MAX_STRING_LENGTH
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Forbidden keys — NEVER safe, regardless of opt-in (security, not privacy)
21
+ # ---------------------------------------------------------------------------
22
+
23
+ FORBIDDEN_KEYS: frozenset[str] = frozenset(
24
+ {
25
+ "api_key",
26
+ "apiKey",
27
+ "api-key",
28
+ "authorization",
29
+ "Authorization",
30
+ "x-api-key",
31
+ "X-Api-Key",
32
+ "secret",
33
+ "password",
34
+ "token",
35
+ "access_token",
36
+ "accessToken",
37
+ "refresh_token",
38
+ "refreshToken",
39
+ "private_key",
40
+ "privateKey",
41
+ "secret_key",
42
+ "secretKey",
43
+ "session_token",
44
+ "sessionToken",
45
+ "bearer",
46
+ "credential",
47
+ "credentials",
48
+ "ssn",
49
+ "credit_card",
50
+ "creditCard",
51
+ "card_number",
52
+ "cardNumber",
53
+ }
54
+ )
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # OpenAI-specific allowlists
58
+ # ---------------------------------------------------------------------------
59
+
60
+ OPENAI_REQUEST_SAFE_FIELDS: frozenset[str] = frozenset(
61
+ {
62
+ "model",
63
+ "temperature",
64
+ "top_p",
65
+ "max_tokens",
66
+ "max_completion_tokens",
67
+ "n",
68
+ "stream",
69
+ "stop",
70
+ "presence_penalty",
71
+ "frequency_penalty",
72
+ "logit_bias",
73
+ "user",
74
+ "seed",
75
+ "response_format",
76
+ "tool_choice",
77
+ "parallel_tool_calls",
78
+ }
79
+ )
80
+
81
+ OPENAI_RESPONSE_SAFE_FIELDS: frozenset[str] = frozenset(
82
+ {
83
+ "id",
84
+ "object",
85
+ "created",
86
+ "model",
87
+ "system_fingerprint",
88
+ "usage",
89
+ "service_tier",
90
+ }
91
+ )
92
+
93
+ OPENAI_CHOICE_SAFE_FIELDS: frozenset[str] = frozenset(
94
+ {
95
+ "index",
96
+ "finish_reason",
97
+ "logprobs",
98
+ }
99
+ )
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Anthropic-specific allowlists
103
+ # ---------------------------------------------------------------------------
104
+
105
+ ANTHROPIC_REQUEST_SAFE_FIELDS: frozenset[str] = frozenset(
106
+ {
107
+ "model",
108
+ "max_tokens",
109
+ "temperature",
110
+ "top_p",
111
+ "top_k",
112
+ "stop_sequences",
113
+ "stream",
114
+ "metadata",
115
+ }
116
+ )
117
+
118
+ ANTHROPIC_RESPONSE_SAFE_FIELDS: frozenset[str] = frozenset(
119
+ {
120
+ "id",
121
+ "type",
122
+ "role",
123
+ "model",
124
+ "stop_reason",
125
+ "stop_sequence",
126
+ "usage",
127
+ }
128
+ )
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Generic redaction
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def strip_forbidden_keys(value: Any, depth: int = 0) -> Any:
136
+ """Strip forbidden keys from any value, recursively.
137
+
138
+ Always runs — this is security, not privacy.
139
+ Handles nested dicts, lists, and depth limits.
140
+ """
141
+ if depth > MAX_REDACTION_DEPTH:
142
+ return "[REDACTED:MAX_DEPTH]"
143
+
144
+ if value is None:
145
+ return value
146
+
147
+ if isinstance(value, str):
148
+ if len(value) > MAX_STRING_LENGTH:
149
+ return (
150
+ value[:MAX_STRING_LENGTH]
151
+ + f"... [truncated, original length: {len(value)}]"
152
+ )
153
+ return value
154
+
155
+ if isinstance(value, (int, float, bool)):
156
+ return value
157
+
158
+ if isinstance(value, list):
159
+ return [strip_forbidden_keys(item, depth + 1) for item in value]
160
+
161
+ if isinstance(value, dict):
162
+ result: dict[str, Any] = {}
163
+ for key, val in value.items():
164
+ if key in FORBIDDEN_KEYS:
165
+ result[key] = "[REDACTED]"
166
+ else:
167
+ result[key] = strip_forbidden_keys(val, depth + 1)
168
+ return result
169
+
170
+ # Other types (tuple, set, etc.) — return as-is
171
+ return value
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # OpenAI redaction
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def _redact_openai_choice_message(msg: dict[str, Any]) -> dict[str, Any]:
180
+ """Redact a single OpenAI choice message to metadata."""
181
+ tool_calls = msg.get("tool_calls", [])
182
+ if not isinstance(tool_calls, list):
183
+ tool_calls = []
184
+
185
+ return {
186
+ "role": msg.get("role"),
187
+ "content_length": len(msg["content"]) if isinstance(msg.get("content"), str) else None,
188
+ "has_tool_calls": len(tool_calls) > 0,
189
+ "tool_call_count": len(tool_calls),
190
+ "tool_call_names": [
191
+ (tc.get("function", {}).get("name", "unknown") if isinstance(tc, dict) else "unknown")
192
+ for tc in tool_calls
193
+ ],
194
+ "content": "[REDACTED]",
195
+ }
196
+
197
+
198
+ def _redact_openai_choice(choice: dict[str, Any]) -> dict[str, Any]:
199
+ """Redact a single OpenAI choice to metadata."""
200
+ safe_choice: dict[str, Any] = {}
201
+ for key, val in choice.items():
202
+ if key in OPENAI_CHOICE_SAFE_FIELDS:
203
+ safe_choice[key] = val
204
+ elif key == "message" and isinstance(val, dict):
205
+ safe_choice["message"] = _redact_openai_choice_message(val)
206
+ return safe_choice
207
+
208
+
209
+ def redact_openai_request(params: dict[str, Any]) -> dict[str, Any]:
210
+ """Redact an OpenAI chat completion request to metadata only.
211
+
212
+ Messages are replaced with structure info (role, count, has content).
213
+ Tool definitions are replaced with names only.
214
+ """
215
+ result: dict[str, Any] = {}
216
+
217
+ for key, val in params.items():
218
+ if key in OPENAI_REQUEST_SAFE_FIELDS:
219
+ result[key] = strip_forbidden_keys(val)
220
+ elif key == "messages" and isinstance(val, list):
221
+ result["message_count"] = len(val)
222
+ result["message_roles"] = [
223
+ (m.get("role", "unknown") if isinstance(m, dict) else "unknown")
224
+ for m in val
225
+ ]
226
+ result["messages"] = "[REDACTED]"
227
+ elif key == "tools" and isinstance(val, list):
228
+ result["tool_names"] = [
229
+ (
230
+ t.get("function", {}).get("name", "unknown")
231
+ if isinstance(t, dict)
232
+ else "unknown"
233
+ )
234
+ for t in val
235
+ ]
236
+ result["tools"] = "[REDACTED]"
237
+ elif key == "functions" and isinstance(val, list):
238
+ result["function_names"] = [
239
+ (f.get("name", "unknown") if isinstance(f, dict) else "unknown")
240
+ for f in val
241
+ ]
242
+ result["functions"] = "[REDACTED]"
243
+ # Unknown fields are silently dropped — allowlist, not denylist
244
+
245
+ return result
246
+
247
+
248
+ def redact_openai_response(response: dict[str, Any]) -> dict[str, Any]:
249
+ """Redact an OpenAI chat completion response to metadata only.
250
+
251
+ Message content is replaced with length info. Tool call arguments are stripped.
252
+ """
253
+ result: dict[str, Any] = {}
254
+
255
+ for key, val in response.items():
256
+ if key in OPENAI_RESPONSE_SAFE_FIELDS:
257
+ result[key] = strip_forbidden_keys(val)
258
+ elif key == "choices" and isinstance(val, list):
259
+ result["choices"] = [
260
+ _redact_openai_choice(c) if isinstance(c, dict) else c
261
+ for c in val
262
+ ]
263
+
264
+ return result
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Anthropic redaction
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ def redact_anthropic_request(params: dict[str, Any]) -> dict[str, Any]:
273
+ """Redact an Anthropic message create request to metadata only."""
274
+ result: dict[str, Any] = {}
275
+
276
+ for key, val in params.items():
277
+ if key in ANTHROPIC_REQUEST_SAFE_FIELDS:
278
+ result[key] = strip_forbidden_keys(val)
279
+ elif key == "messages" and isinstance(val, list):
280
+ result["message_count"] = len(val)
281
+ result["message_roles"] = [
282
+ (m.get("role", "unknown") if isinstance(m, dict) else "unknown")
283
+ for m in val
284
+ ]
285
+ result["messages"] = "[REDACTED]"
286
+ elif key == "system":
287
+ result["has_system"] = True
288
+ result["system"] = "[REDACTED]"
289
+ elif key == "tools" and isinstance(val, list):
290
+ result["tool_names"] = [
291
+ (t.get("name", "unknown") if isinstance(t, dict) else "unknown")
292
+ for t in val
293
+ ]
294
+ result["tools"] = "[REDACTED]"
295
+
296
+ return result
297
+
298
+
299
+ def redact_anthropic_response(response: dict[str, Any]) -> dict[str, Any]:
300
+ """Redact an Anthropic message response to metadata only."""
301
+ result: dict[str, Any] = {}
302
+
303
+ for key, val in response.items():
304
+ if key in ANTHROPIC_RESPONSE_SAFE_FIELDS:
305
+ result[key] = strip_forbidden_keys(val)
306
+ elif key == "content" and isinstance(val, list):
307
+ blocks: list[dict[str, Any]] = []
308
+ for block in val:
309
+ if not isinstance(block, dict):
310
+ blocks.append({"content": "[REDACTED]"})
311
+ continue
312
+ block_type = block.get("type")
313
+ if block_type == "text":
314
+ blocks.append(
315
+ {
316
+ "type": "text",
317
+ "text_length": (
318
+ len(block["text"])
319
+ if isinstance(block.get("text"), str)
320
+ else 0
321
+ ),
322
+ "text": "[REDACTED]",
323
+ }
324
+ )
325
+ elif block_type == "tool_use":
326
+ blocks.append(
327
+ {
328
+ "type": "tool_use",
329
+ "id": block.get("id"),
330
+ "name": block.get("name"),
331
+ "input": "[REDACTED]",
332
+ }
333
+ )
334
+ else:
335
+ blocks.append({"type": block_type, "content": "[REDACTED]"})
336
+ result["content"] = blocks
337
+
338
+ return result
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Full content mode (opt-in)
343
+ # ---------------------------------------------------------------------------
344
+
345
+
346
+ def pass_with_security_strip(value: Any) -> Any:
347
+ """When captureContent is true, still strip forbidden keys (security)
348
+ but pass through all other content.
349
+ """
350
+ return strip_forbidden_keys(value)