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.
- akhu_observer/__init__.py +46 -0
- akhu_observer/_constants.py +91 -0
- akhu_observer/_http.py +166 -0
- akhu_observer/_redact.py +350 -0
- akhu_observer/_retry.py +174 -0
- akhu_observer/_types.py +81 -0
- akhu_observer/client.py +621 -0
- akhu_observer/py.typed +0 -0
- akhu_observer/wrappers/__init__.py +6 -0
- akhu_observer/wrappers/anthropic.py +246 -0
- akhu_observer/wrappers/openai.py +281 -0
- akhu_observer-0.1.0.dist-info/METADATA +108 -0
- akhu_observer-0.1.0.dist-info/RECORD +14 -0
- akhu_observer-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|
akhu_observer/_redact.py
ADDED
|
@@ -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)
|