Skip to content

Commit 2b991dd

Browse files
authored
Phase 0: per-call GatewayCallAudit on the canonical inference gateway (#45)
* feat(gateway): per-call GatewayCallAudit on the canonical inference chokepoint Memory mesh is the single inference gateway both estate planes route through, so caller identity, model, token usage/cost, and epistemic labeling should be recorded here once rather than per-consumer. This adds that audit record. - schemas/gateway-call-audit.schema.json: GatewayCallAudit v0.1 (Draft 2020-12) - adapters/litellm/gateway_audit.py: pure builder; raw completions default to epistemicLevel=speculative (never defaulted up), hash-bound receipt - adapters/litellm/memory_mesh_hooks.py: emit audit on success AND on failure (async_log_failure_event was a no-op — failures went unaudited); best-effort POST to memoryd /v1/audit, non-fatal; x-memory-mesh-audit-id response header - scripts/validate_gateway_call_audit.py + examples: house-style validator - Makefile: validate-gateway-call-audit wired into validate - LICENSE: add MIT (repo had none) Part of Integration Mission epic SocioProphet/socioprophet#490 (Phase 0). validate-gateway-call-audit green; 9 builder tests pass. Closes #44 * fix(gateway): audit every successful completion, not just recall-hit calls Adversarial review found the audit call sat behind three early returns (writeback disabled, no recall envelope, empty text), so a successful call with no recall hits emitted no audit — defeating 'every call is audited'. Restructure: writeback stays independently gated, but the audit always fires on success (rebuilding a minimal envelope when recall produced no hits). Add hook-level tests (litellm stubbed) proving audit fires for the no-hits and writeback-disabled cases plus the failure event. * fix(gateway): builder robustness from Copilot review - _normalize_usage: a non-int-castable total_tokens fell outside the try and would raise into the gateway call path; coerce defensively, fall back to prompt+completion. - _caller_from_envelope: a missing/blank user_id produced an empty string, violating the schema (caller.user_id minLength 1); use an explicit 'unknown' sentinel so output stays schema-valid. - build_gateway_call_audit: validate call_type against the schema enum (fail fast) to keep the 'schema-valid output' contract. Add tests for all three.
1 parent 535e7b4 commit 2b991dd

10 files changed

Lines changed: 832 additions & 33 deletions

File tree

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 SocioProphet
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
PYTHON ?= python
22

3-
.PHONY: validate-upstreams validate-python validate-deploy-assets validate-agent-learning-proposal validate-scenario-learning-binding validate-governed-learning-lifecycle validate-workspace-recall-promotion validate-channel-provenance-write-gate validate-wallguard-memory-compartment-gate validate-memory-distribution-grant validate-prophet-mesh-scope-mirror validate local-preflight local-up local-smoke local-debug local-down
3+
.PHONY: validate-upstreams validate-python validate-deploy-assets validate-agent-learning-proposal validate-scenario-learning-binding validate-governed-learning-lifecycle validate-workspace-recall-promotion validate-channel-provenance-write-gate validate-wallguard-memory-compartment-gate validate-memory-distribution-grant validate-gateway-call-audit validate-prophet-mesh-scope-mirror validate local-preflight local-up local-smoke local-debug local-down
44

55
validate-upstreams:
66
$(PYTHON) scripts/validate_upstreams.py third_party/upstreams.lock.yaml
@@ -36,7 +36,11 @@ validate-wallguard-memory-compartment-gate:
3636
validate-memory-distribution-grant:
3737
$(PYTHON) scripts/validate_memory_distribution_grant.py
3838

39-
validate: validate-upstreams validate-python validate-deploy-assets validate-wallguard-memory-compartment-gate validate-memory-distribution-grant validate-prophet-mesh-scope-mirror
39+
validate-gateway-call-audit:
40+
$(PYTHON) scripts/validate_gateway_call_audit.py
41+
$(PYTHON) -m pytest -q adapters/litellm/tests/test_gateway_audit.py
42+
43+
validate: validate-upstreams validate-python validate-deploy-assets validate-wallguard-memory-compartment-gate validate-memory-distribution-grant validate-gateway-call-audit validate-prophet-mesh-scope-mirror
4044

4145
local-preflight:
4246
bash deploy/local/scripts/preflight-podman-m2.sh

adapters/litellm/gateway_audit.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Gateway call audit builder.
2+
3+
Memory mesh is the canonical inference chokepoint. Every completion that passes
4+
through the LiteLLM gateway should produce exactly one `GatewayCallAudit` record
5+
carrying caller identity, model, token usage / cost, and an epistemic label —
6+
so labeling, cost signal, and audit happen here once rather than per-consumer.
7+
8+
This module is a pure builder (no I/O) so it is trivially testable and can be
9+
called from both the success and failure paths of the gateway hook.
10+
11+
Epistemic default: a raw gateway completion is `speculative` — an ungrounded
12+
model output — unless the caller explicitly supplies a higher-warrant level
13+
(e.g. a promoted/proved model route). We never default *up*.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import hashlib
19+
import json
20+
import uuid
21+
from datetime import datetime, timezone
22+
from typing import Any, Mapping, Optional
23+
24+
SCHEMA_VERSION = "memory-mesh.gateway-call-audit.v0.1"
25+
RECORD_TYPE = "GatewayCallAudit"
26+
27+
ALLOWED_EPISTEMIC_LEVELS = {
28+
"proved",
29+
"bounded",
30+
"empirical",
31+
"synthetic",
32+
"speculative",
33+
"rejected",
34+
}
35+
DEFAULT_EPISTEMIC_LEVEL = "speculative"
36+
37+
38+
def _utc_now_iso() -> str:
39+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
40+
41+
42+
def _caller_from_envelope(envelope: Mapping[str, Any]) -> dict[str, str]:
43+
# The schema requires caller.user_id to be non-empty (minLength: 1); a
44+
# missing/blank id becomes an explicit "unknown" sentinel so the record
45+
# stays schema-valid and the gap is visible rather than silently empty.
46+
user_id = str(envelope.get("user_id") or "").strip() or "unknown"
47+
return {
48+
"user_id": user_id,
49+
"agent_id": str(envelope.get("agent_id") or ""),
50+
"run_id": str(envelope.get("run_id") or ""),
51+
"workload_id": str(envelope.get("workload_id") or ""),
52+
"source_interface": str(envelope.get("source_interface") or ""),
53+
}
54+
55+
56+
def _normalize_usage(usage: Optional[Mapping[str, Any]]) -> Optional[dict[str, int]]:
57+
if not usage:
58+
return None
59+
try:
60+
prompt = int(usage.get("prompt_tokens", 0) or 0)
61+
completion = int(usage.get("completion_tokens", 0) or 0)
62+
except (TypeError, ValueError):
63+
return None
64+
total_raw = usage.get("total_tokens")
65+
if total_raw is None:
66+
total = prompt + completion
67+
else:
68+
# Defensive: a non-int-castable total must not raise into the gateway
69+
# call path — fall back to prompt+completion instead of breaking the call.
70+
try:
71+
total = int(total_raw)
72+
except (TypeError, ValueError):
73+
total = prompt + completion
74+
return {
75+
"prompt_tokens": prompt,
76+
"completion_tokens": completion,
77+
"total_tokens": total,
78+
}
79+
80+
81+
def _resolve_epistemic_level(requested: Optional[str]) -> str:
82+
if requested and requested in ALLOWED_EPISTEMIC_LEVELS:
83+
return requested
84+
return DEFAULT_EPISTEMIC_LEVEL
85+
86+
87+
def _receipt_hash(core: Mapping[str, Any]) -> str:
88+
canonical = json.dumps(core, sort_keys=True, separators=(",", ":")).encode("utf-8")
89+
return "sha256:" + hashlib.sha256(canonical).hexdigest()
90+
91+
92+
def build_gateway_call_audit(
93+
*,
94+
envelope: Mapping[str, Any],
95+
call_type: str,
96+
outcome: str,
97+
model: Optional[str] = None,
98+
usage: Optional[Mapping[str, Any]] = None,
99+
cost_usd: Optional[float] = None,
100+
epistemic_level: Optional[str] = None,
101+
recalled_count: int = 0,
102+
written: int = 0,
103+
error: Optional[str] = None,
104+
occurred_at: Optional[str] = None,
105+
call_id: Optional[str] = None,
106+
) -> dict[str, Any]:
107+
"""Build a schema-valid GatewayCallAudit record.
108+
109+
`outcome` is "success" or "failure"; a failure record must carry an error
110+
string. The returned record is stable and hash-bound via `receipt_hash`.
111+
"""
112+
if outcome not in {"success", "failure"}:
113+
raise ValueError(f"outcome must be 'success' or 'failure', got {outcome!r}")
114+
if call_type not in {"completion", "text_completion"}:
115+
# Keep the "schema-valid output" contract: fail fast if a new call path
116+
# passes a type the schema does not allow, rather than emitting an
117+
# invalid record.
118+
raise ValueError(
119+
f"call_type must be 'completion' or 'text_completion', got {call_type!r}"
120+
)
121+
if outcome == "failure" and not error:
122+
# Fail closed on our own audit: a failure with no reason is not auditable.
123+
error = "unspecified_gateway_failure"
124+
125+
caller = _caller_from_envelope(envelope)
126+
normalized_usage = _normalize_usage(usage)
127+
level = _resolve_epistemic_level(epistemic_level)
128+
when = occurred_at or _utc_now_iso()
129+
cid = call_id or f"gateway-call:{uuid.uuid4()}"
130+
131+
core = {
132+
"callId": cid,
133+
"call_type": call_type,
134+
"outcome": outcome,
135+
"caller": caller,
136+
"model": model,
137+
"usage": normalized_usage,
138+
"cost_usd": cost_usd,
139+
"epistemicLevel": level,
140+
"recalled_count": int(recalled_count),
141+
"written": int(written),
142+
"error": error,
143+
"occurred_at": when,
144+
}
145+
146+
record = {
147+
"schemaVersion": SCHEMA_VERSION,
148+
"recordType": RECORD_TYPE,
149+
**core,
150+
"receipt_hash": _receipt_hash(core),
151+
}
152+
return record

adapters/litellm/memory_mesh_hooks.py

Lines changed: 123 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
from __future__ import annotations
22

33
import os
4+
import sys
5+
from pathlib import Path
46
from typing import Any, Dict, Iterable, Literal, Optional
57

68
import httpx
79
from litellm.integrations.custom_logger import CustomLogger
810
from litellm.proxy.proxy_server import DualCache, UserAPIKeyAuth
911

12+
# gateway_audit is a sibling module; ensure it is importable regardless of how
13+
# LiteLLM loads this hook (by file path or by module name).
14+
_ADAPTER_DIR = str(Path(__file__).resolve().parent)
15+
if _ADAPTER_DIR not in sys.path:
16+
sys.path.insert(0, _ADAPTER_DIR)
17+
18+
from gateway_audit import build_gateway_call_audit # noqa: E402
19+
1020

1121
class MemoryMeshHook(CustomLogger):
1222
def __init__(self) -> None:
@@ -89,46 +99,126 @@ async def async_pre_call_hook(
8999
return data
90100

91101
async def async_post_call_success_hook(self, data: dict, user_api_key_dict: UserAPIKeyAuth, response: Any) -> Any:
92-
if not self.writeback_enabled:
93-
return response
94-
95-
metadata = data.get("metadata") or {}
102+
metadata = data.setdefault("metadata", {})
96103
envelope = metadata.get("memory_mesh_envelope")
97104
if not envelope:
98-
return self._attach_headers(response, recalled=0, written=0)
105+
# Recall may not have produced hits (or was skipped for a non-completion),
106+
# so no envelope was stashed. Rebuild a minimal one so that EVERY
107+
# successful completion is audited, not just recall-hit calls.
108+
user_id = data.get("user")
109+
envelope = self._build_envelope(data=data, user_id=str(user_id)) if user_id else None
99110

100111
recalled = int(metadata.get("memory_mesh_hit_count") or 0)
101-
assistant_text = self._extract_assistant_text(response)
102-
user_text = self._build_recall_query(data)
103-
if not assistant_text and not user_text:
104-
return self._attach_headers(response, recalled=recalled, written=0)
105-
106-
content = self._build_interaction_record(user_text=user_text, assistant_text=assistant_text)
107-
payload = {
108-
"envelope": envelope,
109-
"content": content,
110-
"memory_class": self.default_writeback_class,
111-
"persist_to_backend": True,
112-
"metadata": {
113-
"model": data.get("model"),
114-
"recalled_count": recalled,
115-
"source": "litellm-hook",
116-
},
117-
"tags": ["litellm", "interaction"],
118-
}
119112

113+
# Writeback is independently gated (flag + envelope + non-empty text);
114+
# it must never gate the audit below.
120115
written = 0
116+
if self.writeback_enabled and envelope:
117+
assistant_text = self._extract_assistant_text(response)
118+
user_text = self._build_recall_query(data)
119+
if assistant_text or user_text:
120+
content = self._build_interaction_record(user_text=user_text, assistant_text=assistant_text)
121+
payload = {
122+
"envelope": envelope,
123+
"content": content,
124+
"memory_class": self.default_writeback_class,
125+
"persist_to_backend": True,
126+
"metadata": {
127+
"model": data.get("model"),
128+
"recalled_count": recalled,
129+
"source": "litellm-hook",
130+
},
131+
"tags": ["litellm", "interaction"],
132+
}
133+
try:
134+
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
135+
write_response = await client.post(f"{self.base_url}/v1/write", json=payload, headers=self.headers)
136+
write_response.raise_for_status()
137+
written = 1
138+
except Exception as exc: # pragma: no cover
139+
metadata["memory_mesh_write_error"] = str(exc)
140+
141+
# Audit ALWAYS fires on a successful completion — independent of recall
142+
# hits or writeback. Only a request with no resolvable caller is skipped.
143+
if envelope is None:
144+
return self._attach_headers(response, recalled=recalled, written=written)
145+
146+
audit = build_gateway_call_audit(
147+
envelope=envelope,
148+
call_type="completion",
149+
outcome="success",
150+
model=data.get("model"),
151+
usage=self._extract_usage(response),
152+
cost_usd=self._extract_cost(response),
153+
epistemic_level=metadata.get("epistemic_level"),
154+
recalled_count=recalled,
155+
written=written,
156+
)
157+
await self._emit_audit(audit)
158+
metadata["memory_mesh_audit_id"] = audit["callId"]
159+
return self._attach_headers(response, recalled=recalled, written=written, audit_id=audit["callId"])
160+
161+
async def async_log_failure_event(self, kwargs: dict, response_obj: Any, start_time: Any, end_time: Any) -> None:
162+
# A failed gateway call must still be audited (fail-closed on observability).
163+
data = kwargs.get("litellm_params", {}).get("metadata", {}) or {}
164+
envelope = data.get("memory_mesh_envelope")
165+
if not isinstance(envelope, dict):
166+
model = kwargs.get("model")
167+
envelope = {
168+
"user_id": str(kwargs.get("user") or "unknown"),
169+
"agent_id": self.default_agent_id,
170+
"run_id": f"litellm:{kwargs.get('user') or 'unknown'}",
171+
"workload_id": self.default_workload_id,
172+
"source_interface": self.default_source_interface,
173+
}
174+
else:
175+
model = (kwargs.get("model") or envelope.get("metadata", {}).get("request_model"))
176+
exception = kwargs.get("exception")
177+
audit = build_gateway_call_audit(
178+
envelope=envelope,
179+
call_type="completion",
180+
outcome="failure",
181+
model=model,
182+
epistemic_level=data.get("epistemic_level"),
183+
error=str(exception) if exception else "gateway_call_failed",
184+
)
185+
await self._emit_audit(audit)
186+
return None
187+
188+
async def _emit_audit(self, audit: Dict[str, Any]) -> None:
189+
"""Best-effort POST of the audit record to memoryd; never fatal to the call."""
121190
try:
122191
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
123-
write_response = await client.post(f"{self.base_url}/v1/write", json=payload, headers=self.headers)
124-
write_response.raise_for_status()
125-
written = 1
126-
except Exception as exc: # pragma: no cover
127-
metadata["memory_mesh_write_error"] = str(exc)
192+
response = await client.post(f"{self.base_url}/v1/audit", json=audit, headers=self.headers)
193+
response.raise_for_status()
194+
except Exception: # pragma: no cover - audit transport is best-effort
195+
pass
128196

129-
return self._attach_headers(response, recalled=recalled, written=written)
197+
@staticmethod
198+
def _extract_usage(response: Any) -> Optional[Dict[str, Any]]:
199+
usage = getattr(response, "usage", None)
200+
if usage is None and isinstance(response, dict):
201+
usage = response.get("usage")
202+
if usage is None:
203+
return None
204+
if not isinstance(usage, dict):
205+
usage = {
206+
"prompt_tokens": getattr(usage, "prompt_tokens", None),
207+
"completion_tokens": getattr(usage, "completion_tokens", None),
208+
"total_tokens": getattr(usage, "total_tokens", None),
209+
}
210+
return {k: v for k, v in usage.items() if v is not None} or None
130211

131-
async def async_log_failure_event(self, kwargs: dict, response_obj: Any, start_time: Any, end_time: Any) -> None:
212+
@staticmethod
213+
def _extract_cost(response: Any) -> Optional[float]:
214+
hidden = getattr(response, "_hidden_params", None)
215+
if isinstance(hidden, dict):
216+
cost = hidden.get("response_cost")
217+
if cost is not None:
218+
try:
219+
return float(cost)
220+
except (TypeError, ValueError):
221+
return None
132222
return None
133223

134224
@staticmethod
@@ -209,10 +299,12 @@ def _build_interaction_record(user_text: str, assistant_text: str) -> str:
209299
return "\n".join(parts)
210300

211301
@staticmethod
212-
def _attach_headers(response: Any, recalled: int, written: int) -> Any:
302+
def _attach_headers(response: Any, recalled: int, written: int, audit_id: Optional[str] = None) -> Any:
213303
additional_headers = getattr(response, "_hidden_params", {}).get("additional_headers", {}) or {}
214304
additional_headers["x-memory-mesh-recalled"] = str(recalled)
215305
additional_headers["x-memory-mesh-written"] = str(written)
306+
if audit_id:
307+
additional_headers["x-memory-mesh-audit-id"] = audit_id
216308
if not hasattr(response, "_hidden_params"):
217309
response._hidden_params = {}
218310
response._hidden_params["additional_headers"] = additional_headers

0 commit comments

Comments
 (0)