avowa-runtime 0.11.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.
- avowa_runtime/__init__.py +1613 -0
- avowa_runtime/adapters.py +181 -0
- avowa_runtime/auto.py +327 -0
- avowa_runtime/bench.py +119 -0
- avowa_runtime/bootstrap/sitecustomize.py +36 -0
- avowa_runtime/langchain.py +102 -0
- avowa_runtime/run.py +139 -0
- avowa_runtime-0.11.0.dist-info/METADATA +358 -0
- avowa_runtime-0.11.0.dist-info/RECORD +14 -0
- avowa_runtime-0.11.0.dist-info/WHEEL +5 -0
- avowa_runtime-0.11.0.dist-info/entry_points.txt +2 -0
- avowa_runtime-0.11.0.dist-info/licenses/LICENSE +202 -0
- avowa_runtime-0.11.0.dist-info/licenses/NOTICE +4 -0
- avowa_runtime-0.11.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1613 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 Avowa, Inc.
|
|
3
|
+
"""
|
|
4
|
+
Avowa Runtime SDK — emit live AI-inference signals to Avowa as evidence.
|
|
5
|
+
|
|
6
|
+
Drop this into your inference path. Every call records the signal that PROVES
|
|
7
|
+
your AI-trust claims at runtime: which model, which region, whether customer data
|
|
8
|
+
is used for training, guardrail outcomes — the evidence posture tools can't see.
|
|
9
|
+
|
|
10
|
+
Stdlib-only (urllib) so it adds no dependencies to your app. Sends are
|
|
11
|
+
non-blocking by default (a background worker), so it adds ~0ms to inference.
|
|
12
|
+
|
|
13
|
+
from avowa_runtime import AvowaRuntime
|
|
14
|
+
avowa = AvowaRuntime(ingest_key="avk_runtime_...", region="us-east-1")
|
|
15
|
+
|
|
16
|
+
# Option A — auto-instrument the Anthropic client (captures every call):
|
|
17
|
+
avowa.instrument_anthropic(client)
|
|
18
|
+
|
|
19
|
+
# Option B — wrap a block:
|
|
20
|
+
with avowa.track(model="claude-opus-4-8"):
|
|
21
|
+
resp = client.messages.create(...)
|
|
22
|
+
|
|
23
|
+
# Option C — record explicitly:
|
|
24
|
+
avowa.capture(model="claude-opus-4-8", region="us-east-1", train_on_data=False)
|
|
25
|
+
"""
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import json
|
|
30
|
+
import time
|
|
31
|
+
import hmac
|
|
32
|
+
import hashlib
|
|
33
|
+
import queue
|
|
34
|
+
import atexit
|
|
35
|
+
import inspect
|
|
36
|
+
import threading
|
|
37
|
+
import contextvars
|
|
38
|
+
import urllib.request
|
|
39
|
+
from contextlib import contextmanager
|
|
40
|
+
from urllib.parse import urlsplit
|
|
41
|
+
|
|
42
|
+
__version__ = "0.11.0"
|
|
43
|
+
__all__ = ["AvowaRuntime"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_CARD_RX = re.compile(r"\b(?:\d[ -]*?){13,16}\b")
|
|
47
|
+
|
|
48
|
+
_PII_PATTERNS = (
|
|
49
|
+
re.compile(r"(?<![\w.+-])[\w.+-]{1,64}@[\w-]{1,63}\.[\w.]{2,63}"),
|
|
50
|
+
re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
|
|
51
|
+
_CARD_RX,
|
|
52
|
+
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
53
|
+
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
|
54
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{10,4096}\.[A-Za-z0-9_-]{10,4096}\."),
|
|
55
|
+
re.compile(r"\b(?:sk|rk)-[A-Za-z0-9]{20,200}\b"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
SCAN_LIMIT = 32 * 1024
|
|
59
|
+
|
|
60
|
+
DEFERRED_SCAN_MAX = 256
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cap(text, keep="head"):
|
|
64
|
+
if not isinstance(text, str) or len(text) <= SCAN_LIMIT:
|
|
65
|
+
return text
|
|
66
|
+
return text[-SCAN_LIMIT:] if keep == "tail" else text[:SCAN_LIMIT]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_TRUE = ("1", "true", "yes", "on")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _disabled():
|
|
73
|
+
return os.getenv("AVOWA_DISABLE", "").strip().lower() in _TRUE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _private_host(host):
|
|
77
|
+
h = (host or "").strip("[]").lower()
|
|
78
|
+
if not h:
|
|
79
|
+
return False
|
|
80
|
+
if h in ("localhost",) or h.endswith(".localhost") or "." not in h and ":" not in h:
|
|
81
|
+
return True
|
|
82
|
+
try:
|
|
83
|
+
import ipaddress
|
|
84
|
+
ip = ipaddress.ip_address(h)
|
|
85
|
+
return ip.is_loopback or ip.is_private or ip.is_link_local
|
|
86
|
+
except ValueError:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _checked_base(url):
|
|
91
|
+
if not url:
|
|
92
|
+
return None, "AVOWA_BASE_URL is not set"
|
|
93
|
+
u = str(url).strip()
|
|
94
|
+
try:
|
|
95
|
+
parts = urlsplit(u if "://" in u else "https://" + u)
|
|
96
|
+
except Exception:
|
|
97
|
+
return None, "AVOWA_BASE_URL is not a valid URL"
|
|
98
|
+
if parts.scheme == "https":
|
|
99
|
+
return u.rstrip("/"), None
|
|
100
|
+
if parts.scheme == "http" and (_private_host(parts.hostname)
|
|
101
|
+
or os.getenv("AVOWA_ALLOW_HTTP", "").strip().lower() in _TRUE):
|
|
102
|
+
return u.rstrip("/"), None
|
|
103
|
+
return None, ("AVOWA_BASE_URL uses %s:// to %s — signals carry your ingest key, so only https:// "
|
|
104
|
+
"is accepted (plain http only to localhost or a private network)"
|
|
105
|
+
% (parts.scheme or "?", parts.hostname or "?"))
|
|
106
|
+
|
|
107
|
+
_INJECTION_PATTERNS = (
|
|
108
|
+
re.compile(r"ignore\s+(?:all\s+)?(?:previous|prior|above)\s+instructions?", re.I),
|
|
109
|
+
re.compile(r"disregard\s+(?:all\s+)?(?:previous|prior|your)\s+(?:instructions?|rules?)", re.I),
|
|
110
|
+
re.compile(r"(?:reveal|print|repeat|show)\s+(?:me\s+)?(?:your\s+)?system\s+prompt", re.I),
|
|
111
|
+
re.compile(r"you\s+are\s+now\s+(?:a|an|in)\b", re.I),
|
|
112
|
+
re.compile(r"\bdeveloper\s+mode\b|\bDAN\s+mode\b", re.I),
|
|
113
|
+
re.compile(r"</?(?:system|instructions?)>", re.I),
|
|
114
|
+
re.compile(r"pretend\s+(?:that\s+)?you\s+(?:are|have)\b", re.I),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _luhn_ok(digits):
|
|
119
|
+
total, alt = 0, False
|
|
120
|
+
for ch in reversed(digits):
|
|
121
|
+
d = ord(ch) - 48
|
|
122
|
+
if alt:
|
|
123
|
+
d *= 2
|
|
124
|
+
if d > 9:
|
|
125
|
+
d -= 9
|
|
126
|
+
total += d
|
|
127
|
+
alt = not alt
|
|
128
|
+
return total % 10 == 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _looks_like_card(text):
|
|
132
|
+
for m in _CARD_RX.finditer(text or ""):
|
|
133
|
+
digits = re.sub(r"[^0-9]", "", m.group(0))
|
|
134
|
+
if 13 <= len(digits) <= 16 and _luhn_ok(digits):
|
|
135
|
+
return True
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_DIGIT = re.compile(r"\d")
|
|
140
|
+
_INJECTION_GATES = (
|
|
141
|
+
lambda f: "ignore" in f, lambda f: "disregard" in f, lambda f: "system" in f, lambda f: "now" in f,
|
|
142
|
+
lambda f: "mode" in f, lambda f: "system>" in f or "instruction" in f, lambda f: "pretend" in f,
|
|
143
|
+
)
|
|
144
|
+
_PII_GATES = (
|
|
145
|
+
lambda t, d: "@" in t, lambda t, d: d and "-" in t, lambda t, d: d, lambda t, d: "AKIA" in t,
|
|
146
|
+
lambda t, d: "-----BEGIN" in t, lambda t, d: "eyJ" in t, lambda t, d: "sk-" in t or "rk-" in t,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _count_matches(text, patterns):
|
|
151
|
+
if not text or not isinstance(text, str):
|
|
152
|
+
return 0
|
|
153
|
+
n = 0
|
|
154
|
+
for rx in patterns:
|
|
155
|
+
if rx is _CARD_RX:
|
|
156
|
+
n += 1 if _looks_like_card(text) else 0
|
|
157
|
+
elif rx.search(text):
|
|
158
|
+
n += 1
|
|
159
|
+
return n
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_PII_NAMES = ("email", "us_ssn", "payment_card", "aws_access_key", "private_key", "jwt", "provider_secret_key")
|
|
163
|
+
_INJECTION_NAMES = ("ignore_instructions", "disregard_rules", "reveal_system_prompt", "role_override",
|
|
164
|
+
"developer_mode", "delimiter_injection", "pretend")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _apply_scan(evt, scan):
|
|
168
|
+
if not scan:
|
|
169
|
+
return
|
|
170
|
+
prompt, output = scan
|
|
171
|
+
if prompt is not None and "injection_suspected" not in evt:
|
|
172
|
+
fam = injection_families(prompt)
|
|
173
|
+
evt["injection_suspected"], evt["injection_families"] = len(fam), fam
|
|
174
|
+
if output is not None and "pii_detected" not in evt:
|
|
175
|
+
fam = pii_families(output)
|
|
176
|
+
evt["pii_detected"], evt["pii_families"] = len(fam), fam
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def pii_families(text):
|
|
180
|
+
"""Names of the PII/secret pattern families present in `text`. Runs locally; text is never sent."""
|
|
181
|
+
if not text or not isinstance(text, str):
|
|
182
|
+
return []
|
|
183
|
+
d = any(c in text for c in "0123456789") or (not text.isascii() and bool(_DIGIT.search(text)))
|
|
184
|
+
return [name for name, gate, rx in zip(_PII_NAMES, _PII_GATES, _PII_PATTERNS)
|
|
185
|
+
if gate(text, d) and (_looks_like_card(text) if rx is _CARD_RX else rx.search(text))]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def injection_families(text):
|
|
189
|
+
"""Names of the prompt-injection pattern families present in `text`. Runs locally; never sent."""
|
|
190
|
+
if not text or not isinstance(text, str):
|
|
191
|
+
return []
|
|
192
|
+
f = text.casefold()
|
|
193
|
+
return [name for name, gate, rx in zip(_INJECTION_NAMES, _INJECTION_GATES, _INJECTION_PATTERNS)
|
|
194
|
+
if gate(f) and rx.search(text)]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def scan_pii(text):
|
|
198
|
+
"""Distinct PII/secret pattern families present in `text`. Runs locally; text is never sent."""
|
|
199
|
+
return len(pii_families(text))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def scan_injection(text):
|
|
203
|
+
"""Distinct prompt-injection pattern families present in `text`. Runs locally; text is never sent."""
|
|
204
|
+
return len(injection_families(text))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
_ENV_NAMES = ("avowa.env", ".env")
|
|
208
|
+
_env_loaded = False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def load_env(path=None, _search_up=3):
|
|
212
|
+
"""Fill in missing AVOWA_* variables from the `avowa.env` shipped in your SDK download.
|
|
213
|
+
|
|
214
|
+
The zip arrives with your keys already in it, so requiring people to re-type them into the
|
|
215
|
+
shell is a step that buys nothing — and it is the step where integrations die (`set` instead
|
|
216
|
+
of `export`, a var exported in one terminal and the app started in another, a Compose file
|
|
217
|
+
that never passed it through).
|
|
218
|
+
|
|
219
|
+
Two rules make this safe to do automatically:
|
|
220
|
+
• **Only `AVOWA_*` keys are read.** Applications keep database passwords and API keys in
|
|
221
|
+
the same `.env`; a telemetry SDK has no business parsing them, so it doesn't.
|
|
222
|
+
• **The real environment always wins.** Anything already exported is left alone, so a
|
|
223
|
+
Kubernetes Secret or Secrets Manager injection is never silently overridden by a file
|
|
224
|
+
someone forgot to delete.
|
|
225
|
+
|
|
226
|
+
Set AVOWA_ENV_FILE to point at a specific file, or AVOWA_NO_ENV_FILE=1 to switch this off.
|
|
227
|
+
Returns the path actually loaded, or None.
|
|
228
|
+
"""
|
|
229
|
+
if os.getenv("AVOWA_NO_ENV_FILE", "").lower() in ("1", "true", "yes", "on"):
|
|
230
|
+
return None
|
|
231
|
+
candidates = []
|
|
232
|
+
explicit = path or os.getenv("AVOWA_ENV_FILE")
|
|
233
|
+
if explicit:
|
|
234
|
+
candidates.append(explicit)
|
|
235
|
+
else:
|
|
236
|
+
d = os.getcwd()
|
|
237
|
+
for _ in range(max(1, _search_up)):
|
|
238
|
+
candidates.extend(os.path.join(d, n) for n in _ENV_NAMES)
|
|
239
|
+
parent = os.path.dirname(d)
|
|
240
|
+
if parent == d:
|
|
241
|
+
break
|
|
242
|
+
d = parent
|
|
243
|
+
for cand in candidates:
|
|
244
|
+
try:
|
|
245
|
+
if not os.path.isfile(cand):
|
|
246
|
+
continue
|
|
247
|
+
with open(cand) as fh:
|
|
248
|
+
lines = fh.readlines()
|
|
249
|
+
except Exception:
|
|
250
|
+
continue
|
|
251
|
+
found = False
|
|
252
|
+
for line in lines:
|
|
253
|
+
line = line.strip()
|
|
254
|
+
if not line or line.startswith("#"):
|
|
255
|
+
continue
|
|
256
|
+
if line.startswith("export "):
|
|
257
|
+
line = line[7:].lstrip()
|
|
258
|
+
key, sep, val = line.partition("=")
|
|
259
|
+
key = key.strip()
|
|
260
|
+
if not sep or not key.startswith("AVOWA_"):
|
|
261
|
+
continue
|
|
262
|
+
val = val.strip().strip('"').strip("'")
|
|
263
|
+
found = True
|
|
264
|
+
os.environ.setdefault(key, val)
|
|
265
|
+
if found:
|
|
266
|
+
return cand
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _autoload_env():
|
|
271
|
+
global _env_loaded
|
|
272
|
+
if not _env_loaded:
|
|
273
|
+
_env_loaded = True
|
|
274
|
+
try:
|
|
275
|
+
return load_env()
|
|
276
|
+
except Exception:
|
|
277
|
+
return None
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
_warned = set()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _debug_on():
|
|
285
|
+
return os.getenv("AVOWA_DEBUG", "").lower() in ("1", "true", "yes", "on")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _dbg(msg):
|
|
289
|
+
if _debug_on():
|
|
290
|
+
try:
|
|
291
|
+
sys.stderr.write("[avowa:debug] " + msg + "\n")
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _evt_summary(evt):
|
|
297
|
+
seen = evt.get("_sdk_observed") or []
|
|
298
|
+
return "%s via %s (%sms)%s" % (evt.get("model") or "?", evt.get("endpoint") or "?",
|
|
299
|
+
evt.get("latency_ms"),
|
|
300
|
+
(" · derived " + ",".join(seen)) if seen else "")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _warn_once(key, msg):
|
|
304
|
+
if key not in _warned:
|
|
305
|
+
_warned.add(key)
|
|
306
|
+
try:
|
|
307
|
+
sys.stderr.write("[avowa] " + msg + "\n")
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
INTERNAL_ERRORS = {}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _strict():
|
|
316
|
+
return os.environ.get("AVOWA_STRICT", "").strip().lower() in _TRUE
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _internal_error(where, exc, fatal_in_strict=True):
|
|
320
|
+
key = f"{where}:{type(exc).__name__}"
|
|
321
|
+
INTERNAL_ERRORS[key] = INTERNAL_ERRORS.get(key, 0) + 1
|
|
322
|
+
if fatal_in_strict and _strict():
|
|
323
|
+
raise exc
|
|
324
|
+
_warn_once("internal:" + key, f"internal error in {where} ({type(exc).__name__}: {str(exc)[:120]}) — "
|
|
325
|
+
"this signal was skipped; your app is unaffected. Please tell security@avowa.ai.")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class _Obj:
|
|
329
|
+
def __init__(self, **kw):
|
|
330
|
+
self.__dict__.update(kw)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class _StreamWatch:
|
|
334
|
+
def __init__(self, record, kwargs, start, label):
|
|
335
|
+
self._record, self._kwargs, self._start, self._label = record, kwargs, start, label
|
|
336
|
+
self._model, self._stop, self._text, self._size, self._done = None, None, [], 0, False
|
|
337
|
+
self._ttft = None
|
|
338
|
+
|
|
339
|
+
def see(self, c):
|
|
340
|
+
if self._ttft is None:
|
|
341
|
+
self._ttft = int((time.time() - self._start) * 1000)
|
|
342
|
+
try:
|
|
343
|
+
t = getattr(c, "type", None) or ""
|
|
344
|
+
msg = getattr(c, "message", None)
|
|
345
|
+
if t == "message_start" and getattr(msg, "model", None):
|
|
346
|
+
self._model = msg.model
|
|
347
|
+
delta = getattr(c, "delta", None)
|
|
348
|
+
if t == "message_delta" and getattr(delta, "stop_reason", None):
|
|
349
|
+
self._stop = delta.stop_reason
|
|
350
|
+
if t == "content_block_delta" and getattr(delta, "text", None):
|
|
351
|
+
self._keep(delta.text)
|
|
352
|
+
if t.endswith("output_text.delta") and isinstance(delta, str):
|
|
353
|
+
self._keep(delta)
|
|
354
|
+
if t == "response.completed" and getattr(getattr(c, "response", None), "model", None):
|
|
355
|
+
self._model = c.response.model
|
|
356
|
+
if getattr(c, "model", None) and not t:
|
|
357
|
+
self._model = c.model
|
|
358
|
+
choices = getattr(c, "choices", None) or []
|
|
359
|
+
if choices:
|
|
360
|
+
ch = choices[0]
|
|
361
|
+
fr = getattr(ch, "finish_reason", None)
|
|
362
|
+
if fr:
|
|
363
|
+
self._stop = fr
|
|
364
|
+
content = getattr(getattr(ch, "delta", None), "content", None)
|
|
365
|
+
if isinstance(content, str) and content:
|
|
366
|
+
self._keep(content)
|
|
367
|
+
except Exception:
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
def _keep(self, s):
|
|
371
|
+
if self._size < SCAN_LIMIT:
|
|
372
|
+
self._text.append(s)
|
|
373
|
+
self._size += len(s)
|
|
374
|
+
|
|
375
|
+
def finish(self, err):
|
|
376
|
+
if self._done:
|
|
377
|
+
return
|
|
378
|
+
self._done = True
|
|
379
|
+
text = "".join(self._text)
|
|
380
|
+
if self._label.startswith("anthropic"):
|
|
381
|
+
summary = _Obj(model=self._model, stop_reason=self._stop, content=[_Obj(type="text", text=text)])
|
|
382
|
+
else:
|
|
383
|
+
summary = _Obj(model=self._model, output_text=text,
|
|
384
|
+
choices=[_Obj(finish_reason=self._stop, message=_Obj(content=text, tool_calls=None))])
|
|
385
|
+
try:
|
|
386
|
+
self._record(summary, err, int((time.time() - self._start) * 1000), self._kwargs, ttft_ms=self._ttft)
|
|
387
|
+
except Exception as e:
|
|
388
|
+
_internal_error("stream.finish", e)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _observe_stream(resp, watch):
|
|
392
|
+
it = getattr(resp, "_iterator", None)
|
|
393
|
+
if it is None:
|
|
394
|
+
return _StreamProxy(resp, watch)
|
|
395
|
+
try:
|
|
396
|
+
if hasattr(it, "__anext__"):
|
|
397
|
+
async def _agen():
|
|
398
|
+
try:
|
|
399
|
+
async for c in it:
|
|
400
|
+
watch.see(c)
|
|
401
|
+
yield c
|
|
402
|
+
except Exception as e:
|
|
403
|
+
watch.finish(e)
|
|
404
|
+
raise
|
|
405
|
+
finally:
|
|
406
|
+
watch.finish(None)
|
|
407
|
+
resp._iterator = _agen()
|
|
408
|
+
else:
|
|
409
|
+
def _gen():
|
|
410
|
+
try:
|
|
411
|
+
for c in it:
|
|
412
|
+
watch.see(c)
|
|
413
|
+
yield c
|
|
414
|
+
except Exception as e:
|
|
415
|
+
watch.finish(e)
|
|
416
|
+
raise
|
|
417
|
+
finally:
|
|
418
|
+
watch.finish(None)
|
|
419
|
+
resp._iterator = _gen()
|
|
420
|
+
close = getattr(resp, "close", None)
|
|
421
|
+
if callable(close):
|
|
422
|
+
def _close(*a, **k):
|
|
423
|
+
watch.finish(None)
|
|
424
|
+
return close(*a, **k)
|
|
425
|
+
resp.close = _close
|
|
426
|
+
return resp
|
|
427
|
+
except Exception:
|
|
428
|
+
return _StreamProxy(resp, watch)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class _StreamProxy:
|
|
432
|
+
def __init__(self, inner, watch):
|
|
433
|
+
self._inner, self._watch, self._it = inner, watch, None
|
|
434
|
+
|
|
435
|
+
def __getattr__(self, name):
|
|
436
|
+
return getattr(self._inner, name)
|
|
437
|
+
|
|
438
|
+
def __enter__(self):
|
|
439
|
+
getattr(self._inner, "__enter__", lambda: None)()
|
|
440
|
+
return self
|
|
441
|
+
|
|
442
|
+
def __exit__(self, *exc):
|
|
443
|
+
self._watch.finish(exc[1] if exc and exc[1] else None)
|
|
444
|
+
return getattr(self._inner, "__exit__", lambda *a: None)(*exc)
|
|
445
|
+
|
|
446
|
+
async def __aenter__(self):
|
|
447
|
+
ae = getattr(self._inner, "__aenter__", None)
|
|
448
|
+
if ae:
|
|
449
|
+
await ae()
|
|
450
|
+
return self
|
|
451
|
+
|
|
452
|
+
async def __aexit__(self, *exc):
|
|
453
|
+
self._watch.finish(exc[1] if exc and exc[1] else None)
|
|
454
|
+
ax = getattr(self._inner, "__aexit__", None)
|
|
455
|
+
return await ax(*exc) if ax else None
|
|
456
|
+
|
|
457
|
+
def close(self):
|
|
458
|
+
self._watch.finish(None)
|
|
459
|
+
c = getattr(self._inner, "close", None)
|
|
460
|
+
if c:
|
|
461
|
+
return c()
|
|
462
|
+
|
|
463
|
+
def __iter__(self):
|
|
464
|
+
return self
|
|
465
|
+
|
|
466
|
+
def __next__(self):
|
|
467
|
+
try:
|
|
468
|
+
if self._it is None:
|
|
469
|
+
self._it = self._inner if hasattr(self._inner, "__next__") else iter(self._inner)
|
|
470
|
+
chunk = next(self._it)
|
|
471
|
+
except StopIteration:
|
|
472
|
+
self._watch.finish(None)
|
|
473
|
+
raise
|
|
474
|
+
except Exception as e:
|
|
475
|
+
self._watch.finish(e)
|
|
476
|
+
raise
|
|
477
|
+
self._watch.see(chunk)
|
|
478
|
+
return chunk
|
|
479
|
+
|
|
480
|
+
def __aiter__(self):
|
|
481
|
+
return self
|
|
482
|
+
|
|
483
|
+
async def __anext__(self):
|
|
484
|
+
try:
|
|
485
|
+
chunk = await self._inner.__anext__()
|
|
486
|
+
except StopAsyncIteration:
|
|
487
|
+
self._watch.finish(None)
|
|
488
|
+
raise
|
|
489
|
+
except Exception as e:
|
|
490
|
+
self._watch.finish(e)
|
|
491
|
+
raise
|
|
492
|
+
self._watch.see(chunk)
|
|
493
|
+
return chunk
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _wrap_call(original, record, label):
|
|
497
|
+
record = _with_call_facts(record, label)
|
|
498
|
+
|
|
499
|
+
def wrapped(*args, **kwargs):
|
|
500
|
+
if kwargs.get("stream"):
|
|
501
|
+
start = time.time()
|
|
502
|
+
try:
|
|
503
|
+
resp = original(*args, **kwargs)
|
|
504
|
+
except Exception as e:
|
|
505
|
+
record(None, e, int((time.time() - start) * 1000), kwargs)
|
|
506
|
+
raise
|
|
507
|
+
if inspect.isawaitable(resp):
|
|
508
|
+
async def _awaited_stream():
|
|
509
|
+
try:
|
|
510
|
+
real = await resp
|
|
511
|
+
except Exception as e:
|
|
512
|
+
record(None, e, int((time.time() - start) * 1000), kwargs)
|
|
513
|
+
raise
|
|
514
|
+
return _observe_stream(real, _StreamWatch(record, kwargs, start, label))
|
|
515
|
+
return _awaited_stream()
|
|
516
|
+
return _observe_stream(resp, _StreamWatch(record, kwargs, start, label))
|
|
517
|
+
|
|
518
|
+
start = time.time()
|
|
519
|
+
try:
|
|
520
|
+
resp = original(*args, **kwargs)
|
|
521
|
+
except Exception as e:
|
|
522
|
+
record(None, e, int((time.time() - start) * 1000), kwargs)
|
|
523
|
+
raise
|
|
524
|
+
|
|
525
|
+
if inspect.isawaitable(resp):
|
|
526
|
+
async def _awaited():
|
|
527
|
+
try:
|
|
528
|
+
real = await resp
|
|
529
|
+
except Exception as e:
|
|
530
|
+
record(None, e, int((time.time() - start) * 1000), kwargs)
|
|
531
|
+
raise
|
|
532
|
+
record(real, None, int((time.time() - start) * 1000), kwargs)
|
|
533
|
+
return real
|
|
534
|
+
return _awaited()
|
|
535
|
+
|
|
536
|
+
record(resp, None, int((time.time() - start) * 1000), kwargs)
|
|
537
|
+
return resp
|
|
538
|
+
|
|
539
|
+
wrapped._avowa_wrapped = True
|
|
540
|
+
return wrapped
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
_CEILING_KEYS = ("max_tokens", "max_completion_tokens", "max_output_tokens")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
_IMAGE_PARTS = ("image", "image_url", "input_image")
|
|
547
|
+
_FILE_PARTS = ("document", "file", "input_file")
|
|
548
|
+
_AUDIO_PARTS = ("input_audio", "audio")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _part_kind(b):
|
|
552
|
+
t = b.get("type") if isinstance(b, dict) else getattr(b, "type", None)
|
|
553
|
+
if t is None and isinstance(b, dict):
|
|
554
|
+
t = next((k for k in ("image", "document", "audio", "text") if k in b), None)
|
|
555
|
+
return t
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _facts_from_request(kwargs):
|
|
559
|
+
kw = kwargs or {}
|
|
560
|
+
out = {}
|
|
561
|
+
for k in _CEILING_KEYS:
|
|
562
|
+
v = kw.get(k)
|
|
563
|
+
if isinstance(v, int) and not isinstance(v, bool) and v > 0:
|
|
564
|
+
out["token_ceiling"] = v
|
|
565
|
+
break
|
|
566
|
+
msgs = kw.get("messages")
|
|
567
|
+
if msgs is None and isinstance(kw.get("input"), list):
|
|
568
|
+
msgs = kw.get("input")
|
|
569
|
+
roles = {"user": 0, "assistant": 0, "tool": 0}
|
|
570
|
+
kinds = {"image": 0, "file": 0, "audio": 0}
|
|
571
|
+
chars, system = 0, bool(kw.get("system") or kw.get("instructions"))
|
|
572
|
+
if isinstance(msgs, list):
|
|
573
|
+
for m in msgs:
|
|
574
|
+
role = m.get("role") if isinstance(m, dict) else getattr(m, "role", None)
|
|
575
|
+
content = m.get("content") if isinstance(m, dict) else getattr(m, "content", None)
|
|
576
|
+
if role in ("system", "developer"):
|
|
577
|
+
system = True
|
|
578
|
+
elif role in roles:
|
|
579
|
+
roles[role] += 1
|
|
580
|
+
if isinstance(content, str):
|
|
581
|
+
chars += len(content)
|
|
582
|
+
elif isinstance(content, list):
|
|
583
|
+
for b in content:
|
|
584
|
+
k = _part_kind(b)
|
|
585
|
+
if k in _IMAGE_PARTS:
|
|
586
|
+
kinds["image"] += 1
|
|
587
|
+
elif k in _FILE_PARTS:
|
|
588
|
+
kinds["file"] += 1
|
|
589
|
+
elif k in _AUDIO_PARTS:
|
|
590
|
+
kinds["audio"] += 1
|
|
591
|
+
elif k == "tool_result":
|
|
592
|
+
roles["tool"] += 1
|
|
593
|
+
txt = b.get("text") if isinstance(b, dict) else getattr(b, "text", None)
|
|
594
|
+
if isinstance(txt, str):
|
|
595
|
+
chars += len(txt)
|
|
596
|
+
out.update({"messages_user": roles["user"], "messages_assistant": roles["assistant"],
|
|
597
|
+
"messages_tool": roles["tool"], "input_images": kinds["image"],
|
|
598
|
+
"input_files": kinds["file"], "input_audio": kinds["audio"], "prompt_chars": chars})
|
|
599
|
+
if msgs is not None or system:
|
|
600
|
+
out["system_prompt_present"] = system
|
|
601
|
+
tc = kw.get("tool_choice")
|
|
602
|
+
if tc is not None:
|
|
603
|
+
if isinstance(tc, str):
|
|
604
|
+
out["tool_choice"] = tc[:20]
|
|
605
|
+
else:
|
|
606
|
+
t = tc.get("type") if isinstance(tc, dict) else getattr(tc, "type", None)
|
|
607
|
+
out["tool_choice"] = str(t or "specific")[:20]
|
|
608
|
+
if kw.get("stream") is not None:
|
|
609
|
+
out["streaming"] = bool(kw.get("stream"))
|
|
610
|
+
rf = kw.get("response_format") or kw.get("output_format")
|
|
611
|
+
if rf is not None:
|
|
612
|
+
t = rf.get("type") if isinstance(rf, dict) else getattr(rf, "type", None)
|
|
613
|
+
out["response_format_type"] = str(t or getattr(rf, "__name__", "schema"))[:30]
|
|
614
|
+
return out, list(out)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _client_facts(client):
|
|
618
|
+
out = {}
|
|
619
|
+
r = getattr(client, "max_retries", None)
|
|
620
|
+
if isinstance(r, int) and not isinstance(r, bool):
|
|
621
|
+
out["client_max_retries"] = r
|
|
622
|
+
t = getattr(client, "timeout", None)
|
|
623
|
+
t = getattr(t, "read", None) if t is not None and not isinstance(t, (int, float)) else t
|
|
624
|
+
if isinstance(t, (int, float)) and not isinstance(t, bool):
|
|
625
|
+
out["client_timeout_s"] = float(t)
|
|
626
|
+
return out
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
_USAGE_KEYS = {
|
|
630
|
+
"input_tokens": ("input_tokens", "prompt_tokens", "inputTokens", "prompt_token_count"),
|
|
631
|
+
"output_tokens": ("output_tokens", "completion_tokens", "outputTokens", "candidates_token_count"),
|
|
632
|
+
"cache_read_tokens": ("cache_read_input_tokens", "cacheReadInputTokens", "cached_content_token_count"),
|
|
633
|
+
"cache_write_tokens": ("cache_creation_input_tokens", "cacheWriteInputTokens"),
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
_CODE = re.compile(r"^[A-Za-z0-9_.:-]{1,60}$")
|
|
638
|
+
_REGION = re.compile(r"^[A-Za-z0-9 _-]{2,30}$")
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _g(obj, *path):
|
|
642
|
+
for k in path:
|
|
643
|
+
if obj is None:
|
|
644
|
+
return None
|
|
645
|
+
try:
|
|
646
|
+
if isinstance(obj, (list, tuple)) and k.isdigit():
|
|
647
|
+
obj = obj[int(k)] if int(k) < len(obj) else None
|
|
648
|
+
else:
|
|
649
|
+
obj = obj.get(k) if isinstance(obj, dict) else getattr(obj, k, None)
|
|
650
|
+
except Exception:
|
|
651
|
+
return None
|
|
652
|
+
return obj
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _num_or_none(v):
|
|
656
|
+
return v if isinstance(v, (int, float)) and not isinstance(v, bool) and v == v else None
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _label(v, n=30):
|
|
660
|
+
return v[:n] if isinstance(v, str) and v and _CODE.match(v[:n]) else None
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _param_facts(kwargs):
|
|
664
|
+
kw = kwargs or {}
|
|
665
|
+
cfg = kw.get("config")
|
|
666
|
+
inf = kw.get("inferenceConfig") if isinstance(kw.get("inferenceConfig"), dict) else {}
|
|
667
|
+
out = {}
|
|
668
|
+
for fact, val in (("temperature", kw["temperature"] if "temperature" in kw else _g(cfg, "temperature") if cfg is not None else inf.get("temperature")),
|
|
669
|
+
("top_p", kw["top_p"] if "top_p" in kw else _g(cfg, "top_p") if cfg is not None else inf.get("topP")),
|
|
670
|
+
("seed", kw["seed"] if "seed" in kw else _g(cfg, "seed") if cfg is not None else None),
|
|
671
|
+
("choices_n", kw["n"] if "n" in kw else _g(cfg, "candidate_count") if cfg is not None else None)):
|
|
672
|
+
v = _num_or_none(val)
|
|
673
|
+
if v is not None:
|
|
674
|
+
out[fact] = v
|
|
675
|
+
stops = kw.get("stop_sequences", kw.get("stop", inf.get("stopSequences")))
|
|
676
|
+
if isinstance(stops, str):
|
|
677
|
+
stops = [stops]
|
|
678
|
+
if isinstance(stops, (list, tuple)):
|
|
679
|
+
out["stop_sequences"] = len(stops)
|
|
680
|
+
for fact, key in (("parallel_tool_calls", "parallel_tool_calls"), ("store_requested", "store"),
|
|
681
|
+
("logprobs", "logprobs")):
|
|
682
|
+
if isinstance(kw.get(key), bool):
|
|
683
|
+
out[fact] = kw[key]
|
|
684
|
+
tier = _label(kw.get("service_tier"), 20)
|
|
685
|
+
if tier:
|
|
686
|
+
out["service_tier_requested"] = tier
|
|
687
|
+
meta = kw.get("metadata")
|
|
688
|
+
if meta is not None:
|
|
689
|
+
out["metadata_present"] = bool(meta)
|
|
690
|
+
if kw.get("user") or kw.get("safety_identifier") or _g(meta, "user_id"):
|
|
691
|
+
out["end_user_id_present"] = True
|
|
692
|
+
elif "user" in kw or "safety_identifier" in kw or meta is not None:
|
|
693
|
+
out["end_user_id_present"] = False
|
|
694
|
+
effort = _label(kw.get("reasoning_effort") or _g(kw.get("reasoning"), "effort"), 20)
|
|
695
|
+
if effort:
|
|
696
|
+
out["reasoning_effort"] = effort
|
|
697
|
+
th = kw.get("thinking")
|
|
698
|
+
if isinstance(th, bool):
|
|
699
|
+
out["thinking_enabled"] = th
|
|
700
|
+
elif th is not None:
|
|
701
|
+
out["thinking_enabled"] = _g(th, "type") not in (None, "disabled")
|
|
702
|
+
if kw.get("guardrailConfig") is not None:
|
|
703
|
+
out["guardrail_configured"] = True
|
|
704
|
+
ss = _g(cfg, "safety_settings") if cfg is not None else None
|
|
705
|
+
if isinstance(ss, (list, tuple)):
|
|
706
|
+
out["safety_settings"] = len(ss)
|
|
707
|
+
return out
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _filter_categories(results):
|
|
711
|
+
out = []
|
|
712
|
+
for r in (results if isinstance(results, list) else [results]):
|
|
713
|
+
inner = _g(r, "content_filter_results")
|
|
714
|
+
r = inner if inner is not None else r
|
|
715
|
+
if isinstance(r, dict):
|
|
716
|
+
out += [k for k, v in r.items() if isinstance(v, dict) and v.get("filtered") is True and _CODE.match(k)]
|
|
717
|
+
return out
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _response_meta_facts(resp):
|
|
721
|
+
if resp is None:
|
|
722
|
+
return {}
|
|
723
|
+
out = {}
|
|
724
|
+
extra = getattr(resp, "model_extra", None)
|
|
725
|
+
extra = extra if isinstance(extra, dict) else {}
|
|
726
|
+
fp = _label(_g(resp, "system_fingerprint") or extra.get("system_fingerprint"), 60)
|
|
727
|
+
if fp:
|
|
728
|
+
out["system_fingerprint"] = fp
|
|
729
|
+
tier = _label(_g(resp, "service_tier") or _g(resp, "usage", "service_tier") or extra.get("service_tier"), 20)
|
|
730
|
+
if tier:
|
|
731
|
+
out["service_tier"] = tier
|
|
732
|
+
cats, present = [], False
|
|
733
|
+
pf = _g(resp, "prompt_filter_results") or extra.get("prompt_filter_results")
|
|
734
|
+
if pf is not None:
|
|
735
|
+
present = True
|
|
736
|
+
cats += _filter_categories(pf)
|
|
737
|
+
choices = _g(resp, "choices")
|
|
738
|
+
for c in (choices if isinstance(choices, (list, tuple)) else [])[:4]:
|
|
739
|
+
cextra = getattr(c, "model_extra", None)
|
|
740
|
+
cf = _g(c, "content_filter_results") or (cextra.get("content_filter_results") if isinstance(cextra, dict) else None)
|
|
741
|
+
if cf is not None:
|
|
742
|
+
present = True
|
|
743
|
+
cats += _filter_categories(cf)
|
|
744
|
+
block = _g(resp, "prompt_feedback", "block_reason")
|
|
745
|
+
if block is not None:
|
|
746
|
+
present = True
|
|
747
|
+
out["provider_block_reason"] = _label(str(getattr(block, "name", block)), 40) or "blocked"
|
|
748
|
+
if isinstance(resp, dict) and _g(resp, "trace", "guardrail") is not None:
|
|
749
|
+
present = True
|
|
750
|
+
if present:
|
|
751
|
+
out["provider_filtered"] = bool(cats) or "provider_block_reason" in out
|
|
752
|
+
if cats:
|
|
753
|
+
out["provider_filter_categories"] = sorted(set(cats))[:10]
|
|
754
|
+
return out
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _error_facts(e):
|
|
758
|
+
out = {}
|
|
759
|
+
resp = getattr(e, "response", None)
|
|
760
|
+
status = next((v for v in (_num_or_none(getattr(e, "status_code", None)),
|
|
761
|
+
_num_or_none(_g(resp, "status_code")),
|
|
762
|
+
_num_or_none(_g(resp, "ResponseMetadata", "HTTPStatusCode")),
|
|
763
|
+
_num_or_none(getattr(e, "code", None))) if v is not None), None)
|
|
764
|
+
if status is not None and 100 <= status <= 599:
|
|
765
|
+
out["http_status"] = int(status)
|
|
766
|
+
body = getattr(e, "body", None)
|
|
767
|
+
code = None
|
|
768
|
+
if isinstance(body, dict):
|
|
769
|
+
code = _g(body, "error", "type") or (body.get("type") if body.get("type") != "error" else None) \
|
|
770
|
+
or _g(body, "code")
|
|
771
|
+
for cand in (code, getattr(e, "code", None), _g(resp, "Error", "Code"), getattr(e, "status", None)):
|
|
772
|
+
c = _label(cand if isinstance(cand, str) else None, 60)
|
|
773
|
+
if c:
|
|
774
|
+
out["provider_error_code"] = c
|
|
775
|
+
break
|
|
776
|
+
try:
|
|
777
|
+
headers = _g(resp, "headers")
|
|
778
|
+
ra = headers.get("retry-after") if headers is not None else None
|
|
779
|
+
if ra is not None:
|
|
780
|
+
out["retry_after_s"] = float(ra)
|
|
781
|
+
except (TypeError, ValueError, AttributeError):
|
|
782
|
+
pass
|
|
783
|
+
return out
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
_PROVIDER_LIBS = {"openai": "openai", "anthropic": "anthropic", "bedrock": "boto3", "gemini": "google-genai"}
|
|
787
|
+
_LIB_VERSIONS = {}
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _provider_lib_facts(label):
|
|
791
|
+
lib = _PROVIDER_LIBS.get((label or "").split(".", 1)[0])
|
|
792
|
+
if not lib:
|
|
793
|
+
return {}
|
|
794
|
+
if lib not in _LIB_VERSIONS:
|
|
795
|
+
try:
|
|
796
|
+
from importlib.metadata import version
|
|
797
|
+
_LIB_VERSIONS[lib] = version(lib)[:30]
|
|
798
|
+
except Exception:
|
|
799
|
+
_LIB_VERSIONS[lib] = None
|
|
800
|
+
out = {"provider_lib": lib}
|
|
801
|
+
if _LIB_VERSIONS[lib]:
|
|
802
|
+
out["provider_lib_version"] = _LIB_VERSIONS[lib]
|
|
803
|
+
return out
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
_REGION_VARS = ("AWS_REGION", "AWS_DEFAULT_REGION", "GOOGLE_CLOUD_REGION", "FUNCTION_REGION", "CLOUD_RUN_REGION",
|
|
807
|
+
"REGION_NAME", "AZURE_REGION", "FLY_REGION", "VERCEL_REGION", "RAILWAY_REPLICA_REGION", "RENDER_REGION")
|
|
808
|
+
_FRAMEWORKS = ("langchain", "langgraph", "llama_index", "crewai", "autogen", "haystack", "semantic_kernel", "dspy")
|
|
809
|
+
_ENV_FACTS = None
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _env_facts():
|
|
813
|
+
global _ENV_FACTS
|
|
814
|
+
if _ENV_FACTS is None:
|
|
815
|
+
import platform
|
|
816
|
+
out = {"python_version": platform.python_version()}
|
|
817
|
+
for var in _REGION_VARS:
|
|
818
|
+
v = os.getenv(var)
|
|
819
|
+
if v and _REGION.match(v):
|
|
820
|
+
out["app_region"], out["app_region_source"] = v, var
|
|
821
|
+
break
|
|
822
|
+
_ENV_FACTS = out
|
|
823
|
+
fw = [f for f in _FRAMEWORKS if f in sys.modules]
|
|
824
|
+
return {**_ENV_FACTS, **({"app_frameworks": fw} if fw else {})}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
_RF_DENY = frozenset({
|
|
828
|
+
"content", "contents", "text", "texts", "message", "messages", "input", "inputs", "output", "outputs",
|
|
829
|
+
"output_text", "prompt", "prompts", "system", "instructions", "arguments", "args", "delta", "parts",
|
|
830
|
+
"part", "body", "data", "completion", "completions", "document", "documents", "query", "queries",
|
|
831
|
+
"value", "values", "raw", "html", "markdown", "transcript", "summary", "reasoning_content", "thinking",
|
|
832
|
+
})
|
|
833
|
+
_RF_NAME = re.compile(r"^[a-z][a-z0-9_]{1,40}$")
|
|
834
|
+
_RF_SEG = re.compile(r"^(?:[A-Za-z_][A-Za-z0-9_]{0,40}|\d{1,2})$")
|
|
835
|
+
_RF_PROVIDERS = ("*", "openai", "anthropic", "bedrock", "gemini")
|
|
836
|
+
_RF_MAX = 20
|
|
837
|
+
_REMOTE = {"v": None, "fields": ()}
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _remote_on():
|
|
841
|
+
return (os.getenv("AVOWA_REMOTE_FIELDS") or "on").strip().lower() not in ("off", "0", "false", "no")
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _rf_ok(f):
|
|
845
|
+
if not isinstance(f, dict):
|
|
846
|
+
return False
|
|
847
|
+
name, prov, src, path = f.get("name"), f.get("provider"), f.get("from"), f.get("path")
|
|
848
|
+
if not (isinstance(name, str) and _RF_NAME.match(name) and prov in _RF_PROVIDERS
|
|
849
|
+
and src in ("request", "response", "error") and isinstance(path, str)):
|
|
850
|
+
return False
|
|
851
|
+
segs = path.split(".")
|
|
852
|
+
return 1 <= len(segs) <= 6 and all(
|
|
853
|
+
_RF_SEG.match(x) and not x.startswith("_") and x.lower() not in _RF_DENY for x in segs)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _rf_value(v):
|
|
857
|
+
if isinstance(v, bool):
|
|
858
|
+
return v
|
|
859
|
+
if isinstance(v, (int, float)):
|
|
860
|
+
return v if v == v and v not in (float("inf"), float("-inf")) else None
|
|
861
|
+
return _label(v, 40) if isinstance(v, str) else None
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _accept_fields(block, secret):
|
|
865
|
+
try:
|
|
866
|
+
doc, mac = block.get("doc"), block.get("mac")
|
|
867
|
+
if not (isinstance(doc, str) and isinstance(mac, str) and secret):
|
|
868
|
+
return
|
|
869
|
+
want = hmac.new(secret.encode(), b"avowa-fields." + doc.encode(), hashlib.sha256).hexdigest()
|
|
870
|
+
if not hmac.compare_digest(want, mac):
|
|
871
|
+
_warn_once("rf-mac", "ignored a metadata-field list that did not verify under this connection's secret.")
|
|
872
|
+
return
|
|
873
|
+
d = json.loads(doc)
|
|
874
|
+
fields = tuple(f for f in (d.get("fields") or [])[:_RF_MAX] if _rf_ok(f))
|
|
875
|
+
_REMOTE["v"], _REMOTE["fields"] = str(d.get("v") or "")[:32] or None, fields
|
|
876
|
+
_dbg("metadata-field list %s: %d field(s)" % (_REMOTE["v"], len(fields)))
|
|
877
|
+
except Exception as e:
|
|
878
|
+
_internal_error("remote_fields", e)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def remote_fields():
|
|
882
|
+
"""The server-defined metadata fields this process is currently reading (empty when off)."""
|
|
883
|
+
return [dict(f) for f in _REMOTE["fields"]] if _remote_on() else []
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _remote_facts(label, resp, kwargs, exc):
|
|
887
|
+
if not _REMOTE["fields"] or not _remote_on():
|
|
888
|
+
return {}
|
|
889
|
+
prov = (label or "").split(".", 1)[0]
|
|
890
|
+
src = {"request": kwargs, "response": resp, "error": exc}
|
|
891
|
+
out = {}
|
|
892
|
+
for f in _REMOTE["fields"]:
|
|
893
|
+
if f["provider"] in ("*", prov) and src.get(f["from"]) is not None:
|
|
894
|
+
v = _rf_value(_g(src[f["from"]], *f["path"].split(".")))
|
|
895
|
+
if v is not None:
|
|
896
|
+
out["rf_" + f["name"]] = v
|
|
897
|
+
return out
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
_CALL_FACTS = contextvars.ContextVar("avowa_call_facts", default=None)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _with_call_facts(record, label):
|
|
904
|
+
def rec(resp, err, ms, kwargs, ttft_ms=None):
|
|
905
|
+
facts = {}
|
|
906
|
+
try:
|
|
907
|
+
if isinstance(err, BaseException):
|
|
908
|
+
facts.update(_error_facts(err))
|
|
909
|
+
facts.update(_param_facts(kwargs))
|
|
910
|
+
facts.update(_response_meta_facts(resp))
|
|
911
|
+
facts.update(_provider_lib_facts(label))
|
|
912
|
+
if ttft_ms is not None:
|
|
913
|
+
facts["ttft_ms"] = ttft_ms
|
|
914
|
+
facts.update(_remote_facts(label, resp, kwargs, err if isinstance(err, BaseException) else None))
|
|
915
|
+
except Exception as e:
|
|
916
|
+
_internal_error("call_facts", e)
|
|
917
|
+
if isinstance(err, BaseException):
|
|
918
|
+
err = type(err).__name__
|
|
919
|
+
tok = _CALL_FACTS.set(facts)
|
|
920
|
+
try:
|
|
921
|
+
return record(resp, err, ms, kwargs)
|
|
922
|
+
finally:
|
|
923
|
+
_CALL_FACTS.reset(tok)
|
|
924
|
+
return rec
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _usage_facts(resp):
|
|
928
|
+
u = getattr(resp, "usage", None) or getattr(resp, "usage_metadata", None)
|
|
929
|
+
if u is None and isinstance(resp, dict):
|
|
930
|
+
u = resp.get("usage")
|
|
931
|
+
if u is None:
|
|
932
|
+
return {}
|
|
933
|
+
get = (lambda k: u.get(k)) if isinstance(u, dict) else (lambda k: getattr(u, k, None))
|
|
934
|
+
out = {}
|
|
935
|
+
for fact, names in _USAGE_KEYS.items():
|
|
936
|
+
for n in names:
|
|
937
|
+
v = get(n)
|
|
938
|
+
if isinstance(v, int) and not isinstance(v, bool):
|
|
939
|
+
out[fact] = v
|
|
940
|
+
break
|
|
941
|
+
if "cache_read_tokens" not in out:
|
|
942
|
+
for d in ("prompt_tokens_details", "input_tokens_details"):
|
|
943
|
+
det = get(d)
|
|
944
|
+
v = (det.get("cached_tokens") if isinstance(det, dict) else getattr(det, "cached_tokens", None)) if det else None
|
|
945
|
+
if isinstance(v, int) and not isinstance(v, bool):
|
|
946
|
+
out["cache_read_tokens"] = v
|
|
947
|
+
break
|
|
948
|
+
return out
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _output_chars(resp):
|
|
952
|
+
n = 0
|
|
953
|
+
try:
|
|
954
|
+
txt = getattr(resp, "output_text", None)
|
|
955
|
+
if isinstance(txt, str):
|
|
956
|
+
return len(txt)
|
|
957
|
+
for b in (getattr(resp, "content", None) or []):
|
|
958
|
+
t = getattr(b, "text", None)
|
|
959
|
+
if isinstance(t, str):
|
|
960
|
+
n += len(t)
|
|
961
|
+
for c in (getattr(resp, "choices", None) or []):
|
|
962
|
+
t = getattr(getattr(c, "message", None), "content", None)
|
|
963
|
+
if isinstance(t, str):
|
|
964
|
+
n += len(t)
|
|
965
|
+
except Exception:
|
|
966
|
+
return None
|
|
967
|
+
return n
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _facts_from_response(resp, kwargs, salt=None):
|
|
971
|
+
out, seen = {}, []
|
|
972
|
+
if resp is None:
|
|
973
|
+
return out, seen
|
|
974
|
+
asked = kwargs.get("model")
|
|
975
|
+
if asked:
|
|
976
|
+
out["requested_model"] = str(asked)[:120]
|
|
977
|
+
seen.append("requested_model")
|
|
978
|
+
stop = getattr(resp, "stop_reason", None)
|
|
979
|
+
if stop is None:
|
|
980
|
+
try:
|
|
981
|
+
stop = getattr(resp.choices[0], "finish_reason", None)
|
|
982
|
+
except Exception:
|
|
983
|
+
stop = None
|
|
984
|
+
if stop is None and getattr(resp, "status", None) is not None and hasattr(resp, "output"):
|
|
985
|
+
stop = getattr(getattr(resp, "incomplete_details", None), "reason", None) or resp.status
|
|
986
|
+
part_types = {getattr(part, "type", None) for item in (getattr(resp, "output", None) or [])
|
|
987
|
+
for part in (getattr(item, "content", None) or [])}
|
|
988
|
+
if "refusal" in part_types:
|
|
989
|
+
out["refusal_part"] = True
|
|
990
|
+
seen.append("refusal_part")
|
|
991
|
+
if stop is not None:
|
|
992
|
+
out["stop_reason"] = str(stop)[:40]
|
|
993
|
+
seen.append("stop_reason")
|
|
994
|
+
usage = _usage_facts(resp)
|
|
995
|
+
out.update(usage)
|
|
996
|
+
seen.extend(usage)
|
|
997
|
+
size = _output_chars(resp)
|
|
998
|
+
if size is not None:
|
|
999
|
+
out["output_chars"] = size
|
|
1000
|
+
seen.append("output_chars")
|
|
1001
|
+
offered = _tool_names(kwargs.get("tools"))
|
|
1002
|
+
if offered:
|
|
1003
|
+
out["tools_offered"] = offered
|
|
1004
|
+
seen.append("tools_offered")
|
|
1005
|
+
invoked = _tools_invoked(resp)
|
|
1006
|
+
if offered or invoked:
|
|
1007
|
+
out["tools_invoked"] = invoked
|
|
1008
|
+
seen.append("tools_invoked")
|
|
1009
|
+
history = kwargs.get("messages") or kwargs.get("input")
|
|
1010
|
+
if offered:
|
|
1011
|
+
depth = _autonomy_depth(history)
|
|
1012
|
+
if depth is not None:
|
|
1013
|
+
out["autonomy_depth"] = depth
|
|
1014
|
+
seen.append("autonomy_depth")
|
|
1015
|
+
key = _run_key(history, salt)
|
|
1016
|
+
if key:
|
|
1017
|
+
out["agent_run_key"] = key
|
|
1018
|
+
seen.append("agent_run_key")
|
|
1019
|
+
return out, seen
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
_TOOL_CAP = 60
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _tool_names(tools):
|
|
1026
|
+
out = []
|
|
1027
|
+
for t in (tools or []):
|
|
1028
|
+
n = None
|
|
1029
|
+
if isinstance(t, dict):
|
|
1030
|
+
n = t.get("name") or (t.get("function") or {}).get("name")
|
|
1031
|
+
else:
|
|
1032
|
+
n = getattr(t, "name", None) or getattr(getattr(t, "function", None), "name", None)
|
|
1033
|
+
if n:
|
|
1034
|
+
out.append(str(n)[:64])
|
|
1035
|
+
return sorted(set(out))[:_TOOL_CAP]
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _tools_invoked(resp):
|
|
1039
|
+
names = []
|
|
1040
|
+
try:
|
|
1041
|
+
for b in (getattr(resp, "content", None) or []):
|
|
1042
|
+
if getattr(b, "type", None) == "tool_use" and getattr(b, "name", None):
|
|
1043
|
+
names.append(str(b.name)[:64])
|
|
1044
|
+
except Exception:
|
|
1045
|
+
pass
|
|
1046
|
+
try:
|
|
1047
|
+
for tc in (getattr(resp.choices[0].message, "tool_calls", None) or []):
|
|
1048
|
+
fn = getattr(getattr(tc, "function", None), "name", None) or getattr(tc, "name", None)
|
|
1049
|
+
if fn:
|
|
1050
|
+
names.append(str(fn)[:64])
|
|
1051
|
+
except Exception:
|
|
1052
|
+
pass
|
|
1053
|
+
try:
|
|
1054
|
+
for item in (getattr(resp, "output", None) or []):
|
|
1055
|
+
if getattr(item, "type", None) in ("function_call", "custom_tool_call"):
|
|
1056
|
+
fn = getattr(item, "name", None)
|
|
1057
|
+
if fn:
|
|
1058
|
+
names.append(str(fn)[:64])
|
|
1059
|
+
except Exception:
|
|
1060
|
+
pass
|
|
1061
|
+
return sorted(set(names))[:_TOOL_CAP]
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _run_key(messages, salt=None):
|
|
1065
|
+
if not isinstance(messages, list):
|
|
1066
|
+
return None
|
|
1067
|
+
for m in messages:
|
|
1068
|
+
if not isinstance(m, dict):
|
|
1069
|
+
try:
|
|
1070
|
+
m = {"role": getattr(m, "role", None), "content": getattr(m, "content", None)}
|
|
1071
|
+
except Exception:
|
|
1072
|
+
continue
|
|
1073
|
+
if m.get("role") != "user":
|
|
1074
|
+
continue
|
|
1075
|
+
c = m.get("content")
|
|
1076
|
+
if isinstance(c, list):
|
|
1077
|
+
parts = []
|
|
1078
|
+
for b in c:
|
|
1079
|
+
if isinstance(b, dict):
|
|
1080
|
+
parts.append(str(b.get("text") or b.get("content") or ""))
|
|
1081
|
+
else:
|
|
1082
|
+
parts.append(str(getattr(b, "text", "") or ""))
|
|
1083
|
+
c = "".join(parts)
|
|
1084
|
+
c = str(c or "").strip()
|
|
1085
|
+
if not c:
|
|
1086
|
+
continue
|
|
1087
|
+
data = c.encode("utf-8", "replace")
|
|
1088
|
+
if salt:
|
|
1089
|
+
return hmac.new(str(salt).encode("utf-8"), data, hashlib.sha256).hexdigest()[:12]
|
|
1090
|
+
return hashlib.sha256(data).hexdigest()[:12]
|
|
1091
|
+
return None
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _autonomy_depth(messages):
|
|
1095
|
+
if not isinstance(messages, list):
|
|
1096
|
+
return None
|
|
1097
|
+
depth = 0
|
|
1098
|
+
for m in messages:
|
|
1099
|
+
if not isinstance(m, dict):
|
|
1100
|
+
try:
|
|
1101
|
+
m = {"role": getattr(m, "role", None), "content": getattr(m, "content", None),
|
|
1102
|
+
"tool_calls": getattr(m, "tool_calls", None)}
|
|
1103
|
+
except Exception:
|
|
1104
|
+
continue
|
|
1105
|
+
if m.get("role") != "assistant":
|
|
1106
|
+
continue
|
|
1107
|
+
if m.get("tool_calls"):
|
|
1108
|
+
depth += 1
|
|
1109
|
+
continue
|
|
1110
|
+
c = m.get("content")
|
|
1111
|
+
if isinstance(c, list) and any(
|
|
1112
|
+
(b.get("type") if isinstance(b, dict) else getattr(b, "type", None)) == "tool_use"
|
|
1113
|
+
for b in c):
|
|
1114
|
+
depth += 1
|
|
1115
|
+
return depth
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _host_of(url):
|
|
1119
|
+
if not url:
|
|
1120
|
+
return None
|
|
1121
|
+
try:
|
|
1122
|
+
u = str(url)
|
|
1123
|
+
return (urlsplit(u if "://" in u else "https://" + u).hostname or None)
|
|
1124
|
+
except Exception:
|
|
1125
|
+
return None
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
class AvowaRuntime:
|
|
1129
|
+
def __init__(self, ingest_key=None, ingest_secret=None, base_url=None, region=None,
|
|
1130
|
+
default_model=None, async_send=True, timeout=5):
|
|
1131
|
+
self.disabled = _disabled()
|
|
1132
|
+
if self.disabled:
|
|
1133
|
+
self.key = self.secret = self.base = self.region = self.default_model = None
|
|
1134
|
+
self.timeout, self.async_send, self._q, self._worker, self._pid = timeout, False, None, None, None
|
|
1135
|
+
return
|
|
1136
|
+
if not (ingest_key and ingest_secret):
|
|
1137
|
+
_autoload_env()
|
|
1138
|
+
self.key = ingest_key or os.getenv("AVOWA_RUNTIME_KEY")
|
|
1139
|
+
self.secret = ingest_secret or os.getenv("AVOWA_RUNTIME_SECRET")
|
|
1140
|
+
self.base, problem = _checked_base(base_url or os.getenv("AVOWA_BASE_URL"))
|
|
1141
|
+
if problem and self.key:
|
|
1142
|
+
_warn_once("base", problem + " — no signals will be sent. Your app is unaffected.")
|
|
1143
|
+
self.region = region or os.getenv("AVOWA_REGION")
|
|
1144
|
+
self.default_model = default_model
|
|
1145
|
+
self.timeout = timeout
|
|
1146
|
+
self.async_send = async_send
|
|
1147
|
+
self._q = queue.Queue(maxsize=10000) if async_send else None
|
|
1148
|
+
self._worker = None
|
|
1149
|
+
self._pid = None
|
|
1150
|
+
if async_send:
|
|
1151
|
+
self._start_worker()
|
|
1152
|
+
atexit.register(self.flush)
|
|
1153
|
+
if hasattr(os, "register_at_fork"):
|
|
1154
|
+
try:
|
|
1155
|
+
os.register_at_fork(after_in_child=self._reset_after_fork)
|
|
1156
|
+
except Exception:
|
|
1157
|
+
pass
|
|
1158
|
+
|
|
1159
|
+
def _start_worker(self):
|
|
1160
|
+
self._pid = os.getpid()
|
|
1161
|
+
self._worker = threading.Thread(target=self._run, daemon=True)
|
|
1162
|
+
self._worker.start()
|
|
1163
|
+
|
|
1164
|
+
def _reset_after_fork(self):
|
|
1165
|
+
self._q = queue.Queue(maxsize=10000)
|
|
1166
|
+
self._start_worker()
|
|
1167
|
+
|
|
1168
|
+
def _ensure_worker(self):
|
|
1169
|
+
if self._pid != os.getpid() or self._worker is None or not self._worker.is_alive():
|
|
1170
|
+
self._reset_after_fork()
|
|
1171
|
+
|
|
1172
|
+
def capture(self, model=None, region=None, train_on_data=None,
|
|
1173
|
+
guardrail_blocked=None, latency_ms=None, guardrail=None,
|
|
1174
|
+
decision_logged=None, human_reviewed=None, rationale_present=None,
|
|
1175
|
+
ai_disclosed=None, uncertainty_flagged=None, fairness_checked=None,
|
|
1176
|
+
blocked_category=None, pii_redacted=None, grounded=None, refused=None,
|
|
1177
|
+
injection_blocked=None, output_validated=None, system_prompt_protected=None,
|
|
1178
|
+
rate_limited=None, tool_authorized=None, action_authorized=None,
|
|
1179
|
+
model_pinned=None, eval_score=None,
|
|
1180
|
+
agent_run=None, tool_called=None, autonomy_depth=None, action_class=None,
|
|
1181
|
+
tools_offered=None, tools_invoked=None, agent_run_key=None,
|
|
1182
|
+
approval_required=None, approved_by_human=None, action_blocked=None,
|
|
1183
|
+
action_logged=None,
|
|
1184
|
+
endpoint=None, scan_prompt=None, scan_output=None,
|
|
1185
|
+
error=None, fallback_model=None, retries=None,
|
|
1186
|
+
injection_suspected=None, pii_detected=None,
|
|
1187
|
+
token_ceiling=None, provider_throttled=None,
|
|
1188
|
+
requested_model=None, stop_reason=None, structured_output=None, refusal_part=None,
|
|
1189
|
+
input_tokens=None, output_tokens=None, cache_read_tokens=None, cache_write_tokens=None,
|
|
1190
|
+
system_prompt_present=None, tool_choice=None, messages_user=None, messages_assistant=None,
|
|
1191
|
+
messages_tool=None, input_images=None, input_files=None, input_audio=None, streaming=None,
|
|
1192
|
+
response_format_type=None, prompt_chars=None, output_chars=None, client_max_retries=None,
|
|
1193
|
+
client_timeout_s=None, pii_families=None, injection_families=None,
|
|
1194
|
+
_sdk_observed=None,
|
|
1195
|
+
**unknown):
|
|
1196
|
+
"""Record one inference signal (fire-and-forget).
|
|
1197
|
+
|
|
1198
|
+
Basic fields (model/region/train_on_data/guardrail_blocked) prove model provenance,
|
|
1199
|
+
inference region, no-train and monitoring. The optional behavioural flags below are the
|
|
1200
|
+
Responsible-AI moat — pass the ones your app enforces and Avowa live-verifies that principle:
|
|
1201
|
+
guardrail str — guardrail type applied (e.g. "pii-redact", "prompt-injection")
|
|
1202
|
+
decision_logged bool — this decision + inputs + user action were logged (Accountability)
|
|
1203
|
+
human_reviewed bool — output was routed for human approval/override (Human Oversight)
|
|
1204
|
+
rationale_present bool — the response carried rationale / citations (Explainability)
|
|
1205
|
+
ai_disclosed bool — the response disclosed it is AI (Transparency)
|
|
1206
|
+
uncertainty_flagged bool — the model flagged uncertainty / fell back (Reliability)
|
|
1207
|
+
fairness_checked bool — a fairness / bias check ran on this call (Fairness)
|
|
1208
|
+
blocked_category str — WHAT a guardrail caught: "prompt_injection" | "jailbreak" |
|
|
1209
|
+
"pii" | "toxic" | "off_topic" … (feeds live red-teaming + guardrail depth)
|
|
1210
|
+
pii_redacted bool — PII was detected & redacted on this call (Privacy)
|
|
1211
|
+
grounded bool — the answer was grounded in retrieved context (RAG) (hallucination resistance)
|
|
1212
|
+
refused bool — the model refused an unsafe / out-of-policy request (abuse resistance)
|
|
1213
|
+
|
|
1214
|
+
AI SECURITY — OWASP LLM Top 10 + agentic. Each is proven live from the call path; a static
|
|
1215
|
+
pentest or posture tool cannot show these. Pass the ones your inference path enforces:
|
|
1216
|
+
injection_blocked bool — a prompt-injection attempt was blocked on this call (LLM01)
|
|
1217
|
+
pii_redacted bool — sensitive data redacted before egress (LLM02)
|
|
1218
|
+
model_pinned bool — model version pinned / provider integrity verified (LLM03)
|
|
1219
|
+
output_validated bool — output was schema-validated / encoded before use (LLM05)
|
|
1220
|
+
tool_authorized bool — an agent tool call was within its allowed scope (LLM06)
|
|
1221
|
+
action_authorized bool — a high-impact / irreversible action was gated/approved (agentic)
|
|
1222
|
+
system_prompt_protected bool — system-prompt-leak attempt blocked / protection on (LLM07)
|
|
1223
|
+
grounded bool — answer grounded in retrieved context (also LLM09)
|
|
1224
|
+
rate_limited bool — a rate / token-cost ceiling was enforced on this call (LLM10)
|
|
1225
|
+
eval_score number — a continuous quality-eval score for this sampled call (model eval)
|
|
1226
|
+
AGENTIC OVERSIGHT — for products that ship agents, not just a chat box. This answers the
|
|
1227
|
+
questions a buyer asks about autonomy: what can it DO, under whose authority, bounded how,
|
|
1228
|
+
and who watched. Nothing here can be captured by a questionnaire or a posture scanner.
|
|
1229
|
+
agent_run bool — this call was part of an agentic run
|
|
1230
|
+
tool_called str — the tool actually invoked ("send_email", "post_invoice")
|
|
1231
|
+
autonomy_depth int — steps taken in this run without human input
|
|
1232
|
+
action_class str — consequence tier: "read" | "write" | "external" | "financial".
|
|
1233
|
+
Anything above "read" counts as consequential, and the human-
|
|
1234
|
+
approval rate is measured over those ONLY (approving read-only
|
|
1235
|
+
calls would inflate the number and prove nothing).
|
|
1236
|
+
approval_required bool — policy required a human before this action
|
|
1237
|
+
approved_by_human bool — a human actually approved it
|
|
1238
|
+
action_blocked bool — the action was blocked by policy in the execution path
|
|
1239
|
+
action_logged bool — the action was written to an attributable audit trail
|
|
1240
|
+
|
|
1241
|
+
OBSERVED fields — these are the ones Avowa grades as evidence it saw for itself (L3), rather
|
|
1242
|
+
than a claim your application made about itself (L2 "runtime-asserted"). Prefer these:
|
|
1243
|
+
endpoint str — the host you called ("api.anthropic.com"). Becomes your OBSERVED
|
|
1244
|
+
sub-processor list and, where the host encodes one, your observed
|
|
1245
|
+
inference region. Pass a host or a URL; only the hostname is sent.
|
|
1246
|
+
scan_prompt str — prompt text to scan LOCALLY for injection patterns. NEVER transmitted;
|
|
1247
|
+
only the integer count of matched pattern families is sent.
|
|
1248
|
+
scan_output str — output text to scan LOCALLY for PII/secrets, after your own redaction
|
|
1249
|
+
has run. NEVER transmitted; only the count is sent.
|
|
1250
|
+
error str — error class if the call failed
|
|
1251
|
+
fallback_model str — the model that actually served the request when the primary failed
|
|
1252
|
+
retries int — retry attempts made for this call
|
|
1253
|
+
The two scan_* arguments are read, matched, and dropped inside your process. Avowa receives
|
|
1254
|
+
`pii_detected` / `injection_suspected` counts and nothing else — which is what lets a leak or
|
|
1255
|
+
an attack be evidenced without Avowa ever processing your prompts.
|
|
1256
|
+
|
|
1257
|
+
Fields you don't pass are NOT sent — so you only ever claim what you actually instrument.
|
|
1258
|
+
A product with no agents simply never passes these, and its agentic claims stay
|
|
1259
|
+
"not applicable" rather than showing a hollow pass.
|
|
1260
|
+
Adversarial blocks (injection_blocked / blocked_category prompt_injection|jailbreak) + refusals
|
|
1261
|
+
become LIVE red-teaming + attack-surface evidence — proven on real traffic, not a report.
|
|
1262
|
+
"""
|
|
1263
|
+
if unknown:
|
|
1264
|
+
_warn_once("unknown:" + ",".join(sorted(unknown)),
|
|
1265
|
+
"capture() ignored unknown field(s): %s — the rest of the signal was sent." % ", ".join(sorted(unknown)))
|
|
1266
|
+
if not (self.key and self.base):
|
|
1267
|
+
return
|
|
1268
|
+
evt = {"model": model or self.default_model,
|
|
1269
|
+
"region": region or self.region,
|
|
1270
|
+
"latency_ms": latency_ms}
|
|
1271
|
+
if train_on_data is not None:
|
|
1272
|
+
evt["train_on_data"] = bool(train_on_data)
|
|
1273
|
+
for k, v in (("guardrail_blocked", guardrail_blocked),
|
|
1274
|
+
("guardrail", guardrail), ("decision_logged", decision_logged),
|
|
1275
|
+
("human_reviewed", human_reviewed), ("rationale_present", rationale_present),
|
|
1276
|
+
("ai_disclosed", ai_disclosed), ("uncertainty_flagged", uncertainty_flagged),
|
|
1277
|
+
("fairness_checked", fairness_checked), ("blocked_category", blocked_category),
|
|
1278
|
+
("pii_redacted", pii_redacted), ("grounded", grounded), ("refused", refused),
|
|
1279
|
+
("injection_blocked", injection_blocked), ("output_validated", output_validated),
|
|
1280
|
+
("system_prompt_protected", system_prompt_protected), ("rate_limited", rate_limited),
|
|
1281
|
+
("tool_authorized", tool_authorized), ("action_authorized", action_authorized),
|
|
1282
|
+
("model_pinned", model_pinned), ("eval_score", eval_score),
|
|
1283
|
+
("agent_run", agent_run), ("tool_called", tool_called),
|
|
1284
|
+
("autonomy_depth", autonomy_depth), ("action_class", action_class),
|
|
1285
|
+
("tools_offered", tools_offered), ("tools_invoked", tools_invoked),
|
|
1286
|
+
("agent_run_key", agent_run_key),
|
|
1287
|
+
("approval_required", approval_required), ("approved_by_human", approved_by_human),
|
|
1288
|
+
("action_blocked", action_blocked), ("action_logged", action_logged),
|
|
1289
|
+
("endpoint", _host_of(endpoint)), ("error", error),
|
|
1290
|
+
("fallback_model", fallback_model), ("retries", retries),
|
|
1291
|
+
("token_ceiling", token_ceiling), ("provider_throttled", provider_throttled),
|
|
1292
|
+
("requested_model", requested_model), ("stop_reason", stop_reason),
|
|
1293
|
+
("structured_output", structured_output), ("refusal_part", refusal_part),
|
|
1294
|
+
("input_tokens", input_tokens), ("output_tokens", output_tokens),
|
|
1295
|
+
("cache_read_tokens", cache_read_tokens), ("cache_write_tokens", cache_write_tokens),
|
|
1296
|
+
("system_prompt_present", system_prompt_present), ("tool_choice", tool_choice),
|
|
1297
|
+
("messages_user", messages_user), ("messages_assistant", messages_assistant),
|
|
1298
|
+
("messages_tool", messages_tool), ("input_images", input_images),
|
|
1299
|
+
("input_files", input_files), ("input_audio", input_audio), ("streaming", streaming),
|
|
1300
|
+
("response_format_type", response_format_type), ("prompt_chars", prompt_chars),
|
|
1301
|
+
("output_chars", output_chars), ("client_max_retries", client_max_retries),
|
|
1302
|
+
("client_timeout_s", client_timeout_s), ("pii_families", pii_families),
|
|
1303
|
+
("injection_families", injection_families)):
|
|
1304
|
+
if v is not None:
|
|
1305
|
+
evt[k] = v
|
|
1306
|
+
call = _CALL_FACTS.get() or {}
|
|
1307
|
+
more = {**_env_facts(), **call}
|
|
1308
|
+
for k, v in more.items():
|
|
1309
|
+
evt.setdefault(k, v)
|
|
1310
|
+
if call or _sdk_observed:
|
|
1311
|
+
_sdk_observed = list(_sdk_observed or []) + list(more)
|
|
1312
|
+
scan = None
|
|
1313
|
+
if scan_prompt is not None or scan_output is not None:
|
|
1314
|
+
scan = (_cap(scan_prompt, "tail") if scan_prompt is not None else None,
|
|
1315
|
+
_cap(scan_output, "head") if scan_output is not None else None)
|
|
1316
|
+
if injection_suspected is not None:
|
|
1317
|
+
evt["injection_suspected"] = int(injection_suspected)
|
|
1318
|
+
if pii_detected is not None:
|
|
1319
|
+
evt["pii_detected"] = int(pii_detected)
|
|
1320
|
+
if _sdk_observed:
|
|
1321
|
+
evt["_sdk_observed"] = sorted(set(_sdk_observed))
|
|
1322
|
+
if INTERNAL_ERRORS:
|
|
1323
|
+
evt["sdk_errors"] = sum(INTERNAL_ERRORS.values())
|
|
1324
|
+
evt["sdk_error_kinds"] = sorted(INTERNAL_ERRORS)[:10]
|
|
1325
|
+
if self.async_send:
|
|
1326
|
+
self._ensure_worker()
|
|
1327
|
+
if scan is not None and self._q.qsize() >= DEFERRED_SCAN_MAX:
|
|
1328
|
+
_apply_scan(evt, scan)
|
|
1329
|
+
scan = None
|
|
1330
|
+
try:
|
|
1331
|
+
self._q.put_nowait((evt, scan))
|
|
1332
|
+
_dbg("captured " + _evt_summary(evt))
|
|
1333
|
+
except queue.Full:
|
|
1334
|
+
_warn_once("qfull", "signal queue is full — signals are being dropped. The "
|
|
1335
|
+
"backend is unreachable or far slower than your traffic.")
|
|
1336
|
+
else:
|
|
1337
|
+
_apply_scan(evt, scan)
|
|
1338
|
+
self._post(evt)
|
|
1339
|
+
|
|
1340
|
+
@contextmanager
|
|
1341
|
+
def track(self, model=None, region=None, train_on_data=None, **asserts):
|
|
1342
|
+
"""Time a block and capture a signal when it exits. An exception inside the block is
|
|
1343
|
+
recorded as `error=<ExceptionType>` — it used to be recorded as `guardrail_blocked=True`,
|
|
1344
|
+
which turned every timeout and bad API key into evidence of a working guardrail. Pass
|
|
1345
|
+
`guardrail_blocked=True` yourself when your guardrail actually blocked the call."""
|
|
1346
|
+
start = time.time()
|
|
1347
|
+
err = None
|
|
1348
|
+
try:
|
|
1349
|
+
yield
|
|
1350
|
+
except Exception as e:
|
|
1351
|
+
err = type(e).__name__
|
|
1352
|
+
raise
|
|
1353
|
+
finally:
|
|
1354
|
+
self.capture(model=model, region=region, train_on_data=train_on_data, error=err,
|
|
1355
|
+
latency_ms=int((time.time() - start) * 1000), **asserts)
|
|
1356
|
+
|
|
1357
|
+
def instrument_openai(self, client, region=None, scan=True, asserts=None):
|
|
1358
|
+
"""Monkey-patch an OpenAI (or Azure OpenAI) client so every model call is captured.
|
|
1359
|
+
|
|
1360
|
+
Same contract as `instrument_anthropic`: everything recorded is OBSERVED — the model the
|
|
1361
|
+
API actually returned, the host reached, latency and errors — plus, with `scan=True`, an
|
|
1362
|
+
in-process injection/PII scan that transmits counts only.
|
|
1363
|
+
|
|
1364
|
+
Covers all three call shapes, because a vendor rarely uses only one:
|
|
1365
|
+
• `chat.completions.create` — the common path
|
|
1366
|
+
• `chat.completions.parse` — structured output (beta namespace on some versions)
|
|
1367
|
+
• `responses.create` — the newer Responses API
|
|
1368
|
+
Wrapping only `chat.completions.create` would silently miss the others and under-report
|
|
1369
|
+
coverage, which is the same trap `parse()` set on the Anthropic side.
|
|
1370
|
+
|
|
1371
|
+
AZURE OPENAI works through this call too: it is the same client class with a different
|
|
1372
|
+
`base_url`, so the host recorded is the Azure endpoint — which is exactly what proves the
|
|
1373
|
+
inference region rather than assuming it.
|
|
1374
|
+
|
|
1375
|
+
Streaming (`stream=True`) is captured when the stream ends, on the provider's own stream
|
|
1376
|
+
object — the app receives exactly what it would without Avowa.
|
|
1377
|
+
"""
|
|
1378
|
+
if self.disabled:
|
|
1379
|
+
return client
|
|
1380
|
+
rt = self
|
|
1381
|
+
host = _host_of(getattr(client, "base_url", None)) or "api.openai.com"
|
|
1382
|
+
client_facts = _client_facts(client)
|
|
1383
|
+
|
|
1384
|
+
def _text_of(msgs):
|
|
1385
|
+
parts = []
|
|
1386
|
+
for m in (msgs or []):
|
|
1387
|
+
c = m.get("content") if isinstance(m, dict) else None
|
|
1388
|
+
if isinstance(c, str):
|
|
1389
|
+
parts.append(c)
|
|
1390
|
+
elif isinstance(c, list):
|
|
1391
|
+
parts.extend(b.get("text", "") for b in c if isinstance(b, dict))
|
|
1392
|
+
return "\n".join(p for p in parts if p)
|
|
1393
|
+
|
|
1394
|
+
def _out_of(resp):
|
|
1395
|
+
try:
|
|
1396
|
+
txt = getattr(resp, "output_text", None)
|
|
1397
|
+
if txt:
|
|
1398
|
+
return txt
|
|
1399
|
+
choices = getattr(resp, "choices", None) or []
|
|
1400
|
+
return "\n".join((getattr(c.message, "content", "") or "") for c in choices)
|
|
1401
|
+
except Exception:
|
|
1402
|
+
return None
|
|
1403
|
+
|
|
1404
|
+
def _wrap(original, kind):
|
|
1405
|
+
def record(resp, err, ms, kwargs):
|
|
1406
|
+
try:
|
|
1407
|
+
model = getattr(resp, "model", None) or kwargs.get("model")
|
|
1408
|
+
extra = dict(asserts or {})
|
|
1409
|
+
facts, seen = _facts_from_response(resp, kwargs, rt.secret)
|
|
1410
|
+
extra.update(facts)
|
|
1411
|
+
req, req_seen = _facts_from_request(kwargs)
|
|
1412
|
+
extra.update(req); seen += req_seen
|
|
1413
|
+
extra.update(client_facts); seen += list(client_facts)
|
|
1414
|
+
if kind == "parse" or kwargs.get("response_format") is not None:
|
|
1415
|
+
extra["structured_output"] = True
|
|
1416
|
+
seen.append("structured_output")
|
|
1417
|
+
seen += ["model", "endpoint", "latency_ms"] + (["error"] if err else [])
|
|
1418
|
+
if scan:
|
|
1419
|
+
seen += ["pii_detected", "injection_suspected", "pii_families", "injection_families"]
|
|
1420
|
+
extra["_sdk_observed"] = seen
|
|
1421
|
+
prompt = kwargs.get("messages")
|
|
1422
|
+
if prompt is None and isinstance(kwargs.get("input"), list):
|
|
1423
|
+
prompt = kwargs.get("input")
|
|
1424
|
+
rt.capture(model=model, region=region or rt.region,
|
|
1425
|
+
latency_ms=ms, endpoint=host, error=err,
|
|
1426
|
+
scan_prompt=_text_of(prompt) if scan else None,
|
|
1427
|
+
scan_output=_out_of(resp) if (scan and resp is not None) else None,
|
|
1428
|
+
**extra)
|
|
1429
|
+
except Exception as e:
|
|
1430
|
+
_internal_error("openai.record", e)
|
|
1431
|
+
return _wrap_call(original, record, "openai." + kind)
|
|
1432
|
+
|
|
1433
|
+
targets = []
|
|
1434
|
+
chat = getattr(getattr(client, "chat", None), "completions", None)
|
|
1435
|
+
if chat is not None:
|
|
1436
|
+
targets += [(chat, "create", "create"), (chat, "parse", "parse")]
|
|
1437
|
+
beta_chat = getattr(getattr(getattr(client, "beta", None), "chat", None), "completions", None)
|
|
1438
|
+
if beta_chat is not None:
|
|
1439
|
+
targets.append((beta_chat, "parse", "parse"))
|
|
1440
|
+
responses = getattr(client, "responses", None)
|
|
1441
|
+
if responses is not None:
|
|
1442
|
+
targets += [(responses, "create", "create"), (responses, "parse", "parse")]
|
|
1443
|
+
|
|
1444
|
+
for holder, name, kind in targets:
|
|
1445
|
+
fn = getattr(holder, name, None)
|
|
1446
|
+
if callable(fn) and not getattr(fn, "_avowa_wrapped", False):
|
|
1447
|
+
try:
|
|
1448
|
+
setattr(holder, name, _wrap(fn, kind))
|
|
1449
|
+
except Exception as e:
|
|
1450
|
+
_internal_error(f"openai.patch.{name}", e, fatal_in_strict=False)
|
|
1451
|
+
return client
|
|
1452
|
+
|
|
1453
|
+
def instrument_anthropic(self, client, region=None, scan=True, asserts=None):
|
|
1454
|
+
"""Monkey-patch an Anthropic client so every message call is captured.
|
|
1455
|
+
|
|
1456
|
+
Wraps BOTH `messages.create` and `messages.parse`. This matters: `parse()` does not route
|
|
1457
|
+
through `create()` — it posts directly — so wrapping only `create` would silently miss every
|
|
1458
|
+
structured-output call and quietly under-report your coverage.
|
|
1459
|
+
|
|
1460
|
+
Everything recorded here is OBSERVED by the SDK — the model the API actually returned, the
|
|
1461
|
+
host it was reached on, latency, errors and retries. With `scan=True` (default) the prompt
|
|
1462
|
+
and response are also scanned in-process for injection and PII patterns; only the counts are
|
|
1463
|
+
sent. Pass `scan=False` to disable the local scan entirely.
|
|
1464
|
+
|
|
1465
|
+
`asserts` is a dict of behavioural flags your app genuinely enforces on EVERY call through
|
|
1466
|
+
this client (e.g. {"model_pinned": True} when the model id is a pinned constant). They are
|
|
1467
|
+
recorded as self-reported, not observed — pass only what is true unconditionally.
|
|
1468
|
+
|
|
1469
|
+
A `parse()` call that supplies an `output_format` is recorded as structured output (a fact
|
|
1470
|
+
about the call); Avowa grades output validation from it.
|
|
1471
|
+
|
|
1472
|
+
Streaming: `messages.create(stream=True)` is captured when the stream ends, on the provider's
|
|
1473
|
+
own stream object (nothing is swapped in front of it). The `messages.stream()` helper is not
|
|
1474
|
+
wrapped — call `avowa.capture(...)` after it closes if you use it.
|
|
1475
|
+
"""
|
|
1476
|
+
if self.disabled:
|
|
1477
|
+
return client
|
|
1478
|
+
rt = self
|
|
1479
|
+
host = _host_of(getattr(client, "base_url", None)) or "api.anthropic.com"
|
|
1480
|
+
client_facts = _client_facts(client)
|
|
1481
|
+
|
|
1482
|
+
def _text_of(msgs):
|
|
1483
|
+
parts = []
|
|
1484
|
+
for m in (msgs or []):
|
|
1485
|
+
c = m.get("content") if isinstance(m, dict) else None
|
|
1486
|
+
if isinstance(c, str):
|
|
1487
|
+
parts.append(c)
|
|
1488
|
+
elif isinstance(c, list):
|
|
1489
|
+
parts.extend(b.get("text", "") for b in c if isinstance(b, dict))
|
|
1490
|
+
return "\n".join(parts)
|
|
1491
|
+
|
|
1492
|
+
def _wrap(original, method_name):
|
|
1493
|
+
def record(resp, err, ms, kwargs):
|
|
1494
|
+
try:
|
|
1495
|
+
model = getattr(resp, "model", None) or kwargs.get("model")
|
|
1496
|
+
out_text = None
|
|
1497
|
+
if scan and resp is not None:
|
|
1498
|
+
try:
|
|
1499
|
+
out_text = "\n".join(getattr(b, "text", "") or ""
|
|
1500
|
+
for b in (getattr(resp, "content", None) or []))
|
|
1501
|
+
except Exception:
|
|
1502
|
+
out_text = None
|
|
1503
|
+
extra = dict(asserts or {})
|
|
1504
|
+
facts, seen = _facts_from_response(resp, kwargs, rt.secret)
|
|
1505
|
+
extra.update(facts)
|
|
1506
|
+
req, req_seen = _facts_from_request(kwargs)
|
|
1507
|
+
extra.update(req); seen += req_seen
|
|
1508
|
+
extra.update(client_facts); seen += list(client_facts)
|
|
1509
|
+
if method_name == "parse" and kwargs.get("output_format") is not None:
|
|
1510
|
+
extra["structured_output"] = True
|
|
1511
|
+
seen.append("structured_output")
|
|
1512
|
+
seen += ["model", "endpoint", "latency_ms"] + (["error"] if err else [])
|
|
1513
|
+
if scan:
|
|
1514
|
+
seen += ["pii_detected", "injection_suspected", "pii_families", "injection_families"]
|
|
1515
|
+
extra["_sdk_observed"] = seen
|
|
1516
|
+
rt.capture(model=model, region=region or rt.region,
|
|
1517
|
+
latency_ms=ms, endpoint=host, error=err,
|
|
1518
|
+
scan_prompt=_text_of(kwargs.get("messages")) if scan else None,
|
|
1519
|
+
scan_output=out_text, **extra)
|
|
1520
|
+
except Exception as e:
|
|
1521
|
+
_internal_error("anthropic.record", e)
|
|
1522
|
+
return _wrap_call(original, record, "anthropic." + method_name)
|
|
1523
|
+
|
|
1524
|
+
for name in ("create", "parse"):
|
|
1525
|
+
fn = getattr(getattr(client, "messages", None), name, None)
|
|
1526
|
+
if callable(fn) and not getattr(fn, "_avowa_wrapped", False):
|
|
1527
|
+
try:
|
|
1528
|
+
setattr(client.messages, name, _wrap(fn, name))
|
|
1529
|
+
except Exception as e:
|
|
1530
|
+
_internal_error(f"anthropic.patch.{name}", e, fatal_in_strict=False)
|
|
1531
|
+
return client
|
|
1532
|
+
|
|
1533
|
+
def flush(self, timeout=5):
|
|
1534
|
+
"""Block until queued signals are sent, or until `timeout` seconds pass — whichever is
|
|
1535
|
+
first. Called automatically at exit. The timeout used to be ignored and `q.join()` has no
|
|
1536
|
+
bound of its own, so a process exiting with egress blackholed and a full queue could hang
|
|
1537
|
+
for up to 10,000 × the request timeout. Evidence is never worth holding an exit hostage."""
|
|
1538
|
+
if not (self.async_send and self._q is not None):
|
|
1539
|
+
return
|
|
1540
|
+
deadline = time.time() + max(0.0, float(timeout or 0))
|
|
1541
|
+
try:
|
|
1542
|
+
while self._q.unfinished_tasks and time.time() < deadline:
|
|
1543
|
+
time.sleep(0.05)
|
|
1544
|
+
except Exception:
|
|
1545
|
+
pass
|
|
1546
|
+
|
|
1547
|
+
def _run(self):
|
|
1548
|
+
while True:
|
|
1549
|
+
item = self._q.get()
|
|
1550
|
+
try:
|
|
1551
|
+
if item is None:
|
|
1552
|
+
return
|
|
1553
|
+
evt, scan = item
|
|
1554
|
+
_apply_scan(evt, scan)
|
|
1555
|
+
self._post(evt)
|
|
1556
|
+
except Exception as e:
|
|
1557
|
+
_internal_error("sender", e, fatal_in_strict=False)
|
|
1558
|
+
finally:
|
|
1559
|
+
self._q.task_done()
|
|
1560
|
+
|
|
1561
|
+
def _post(self, evt):
|
|
1562
|
+
body = json.dumps(evt, separators=(",", ":"))
|
|
1563
|
+
headers = {"Content-Type": "application/json", "X-Ingest-Key": self.key,
|
|
1564
|
+
"User-Agent": f"avowa-runtime-python/{__version__} (+https://avowa.ai)"}
|
|
1565
|
+
if self.secret:
|
|
1566
|
+
ts = str(int(time.time()))
|
|
1567
|
+
nonce = os.urandom(12).hex()
|
|
1568
|
+
sig = hmac.new(self.secret.encode(), f"{ts}.{nonce}.{body}".encode(), hashlib.sha256).hexdigest()
|
|
1569
|
+
headers["X-Avowa-Timestamp"] = ts
|
|
1570
|
+
headers["X-Avowa-Nonce"] = nonce
|
|
1571
|
+
headers["X-Avowa-Signature"] = sig
|
|
1572
|
+
if self.secret and _remote_on():
|
|
1573
|
+
headers["X-Avowa-Fields"] = _REMOTE["v"] or "none"
|
|
1574
|
+
url = self.base + "/ingest/runtime"
|
|
1575
|
+
req = urllib.request.Request(url, data=body.encode(), method="POST", headers=headers)
|
|
1576
|
+
try:
|
|
1577
|
+
resp = urllib.request.urlopen(req, timeout=self.timeout)
|
|
1578
|
+
raw = resp.read()
|
|
1579
|
+
reply = None
|
|
1580
|
+
if raw[:1] == b"{":
|
|
1581
|
+
try:
|
|
1582
|
+
reply = json.loads(raw)
|
|
1583
|
+
except Exception:
|
|
1584
|
+
reply = None
|
|
1585
|
+
if isinstance(reply, dict):
|
|
1586
|
+
block = reply.get("sdk_fields")
|
|
1587
|
+
if self.secret and _remote_on() and isinstance(block, dict):
|
|
1588
|
+
_accept_fields(block, self.secret)
|
|
1589
|
+
latest = reply.get("sdk_latest")
|
|
1590
|
+
if isinstance(latest, str) and re.match(r"^\d+\.\d+\.\d+$", latest):
|
|
1591
|
+
_warn_once("latest", "avowa_runtime %s is available (this is %s). Nothing is wrong — "
|
|
1592
|
+
"upgrade when convenient (your Avowa Connections page has the download)." % (latest, __version__))
|
|
1593
|
+
_dbg("sent %s → HTTP %s" % (_evt_summary(evt), getattr(resp, "status", None) or resp.getcode()))
|
|
1594
|
+
except Exception as e:
|
|
1595
|
+
detail = type(e).__name__
|
|
1596
|
+
code = getattr(e, "code", None)
|
|
1597
|
+
if code:
|
|
1598
|
+
hint = {401: "the ingest key or secret is wrong for this connection",
|
|
1599
|
+
403: "this key is not permitted to write signals",
|
|
1600
|
+
404: "no /ingest/runtime at this AVOWA_BASE_URL — check the URL",
|
|
1601
|
+
422: "the server rejected the signal's shape"}.get(
|
|
1602
|
+
code, "the server rejected the signal")
|
|
1603
|
+
if code == 401 and self.secret:
|
|
1604
|
+
hint += " (or the clock is off — signatures expire after 5 minutes)"
|
|
1605
|
+
detail = "HTTP %s — %s" % (code, hint)
|
|
1606
|
+
elif isinstance(e, (OSError,)):
|
|
1607
|
+
detail = "%s — cannot reach %s (DNS, egress rules or the wrong host)" % (
|
|
1608
|
+
type(e).__name__, self.base)
|
|
1609
|
+
_warn_once("post:" + str(code or type(e).__name__),
|
|
1610
|
+
"signal NOT delivered: %s. Your app is unaffected; evidence is not being "
|
|
1611
|
+
"recorded. Set AVOWA_DEBUG=1 for per-call detail." % detail)
|
|
1612
|
+
_dbg("POST %s failed: %r" % (url, e))
|
|
1613
|
+
raise
|