openwright-core 0.6.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.
- openwright/__init__.py +20 -0
- openwright/_ed25519_pure.py +98 -0
- openwright/adapters/__init__.py +37 -0
- openwright/adapters/a2a.py +71 -0
- openwright/adapters/base.py +24 -0
- openwright/adapters/langfuse.py +56 -0
- openwright/adapters/otel_genai.py +180 -0
- openwright/adapters/policy.py +63 -0
- openwright/adapters/receipt.py +198 -0
- openwright/adapters/sarif_in.py +68 -0
- openwright/anchor.py +134 -0
- openwright/authz.py +68 -0
- openwright/browser_verifier.py +197 -0
- openwright/canonical.py +119 -0
- openwright/checkpoint_store.py +140 -0
- openwright/cli.py +445 -0
- openwright/connectors/__init__.py +236 -0
- openwright/connectors/builtin.py +88 -0
- openwright/crosswalk.py +372 -0
- openwright/crosswalk_loader.py +53 -0
- openwright/crosswalks/CHANGELOG.md +47 -0
- openwright/crosswalks/__init__.py +7 -0
- openwright/crosswalks/eu_ai_act.yaml +294 -0
- openwright/crosswalks/eu_ai_act_v1.yaml +107 -0
- openwright/crosswalks/gdpr.yaml +304 -0
- openwright/crosswalks/iso_42001.yaml +209 -0
- openwright/crosswalks/nist_ai_rmf.yaml +204 -0
- openwright/crosswalks/soc2.yaml +246 -0
- openwright/dashboard.py +100 -0
- openwright/demo.py +270 -0
- openwright/events.py +210 -0
- openwright/identity.py +61 -0
- openwright/ingest/__init__.py +13 -0
- openwright/ingest/durable.py +144 -0
- openwright/ingest/fanout.py +38 -0
- openwright/ingest/grpc_server.py +44 -0
- openwright/ingest/http_server.py +163 -0
- openwright/ingest/otlp_common.py +69 -0
- openwright/ingest/pipeline.py +307 -0
- openwright/ledger.py +479 -0
- openwright/merkle.py +262 -0
- openwright/report.py +388 -0
- openwright/sbom.py +42 -0
- openwright/scheduler.py +97 -0
- openwright/sdk.py +297 -0
- openwright/signing.py +343 -0
- openwright/spec.py +109 -0
- openwright/vault.py +55 -0
- openwright/verify.py +392 -0
- openwright/web_demo.py +275 -0
- openwright/witness.py +90 -0
- openwright/witness_service.py +136 -0
- openwright_core-0.6.0.dist-info/METADATA +174 -0
- openwright_core-0.6.0.dist-info/RECORD +57 -0
- openwright_core-0.6.0.dist-info/WHEEL +4 -0
- openwright_core-0.6.0.dist-info/entry_points.txt +3 -0
- openwright_core-0.6.0.dist-info/licenses/LICENSE +52 -0
openwright/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""OpenWright — the Agent Evidence Layer.
|
|
2
|
+
|
|
3
|
+
OpenWright turns the runtime behavior of AI agents into signed, tamper-evident,
|
|
4
|
+
control-mapped audit evidence: telemetry/SDK signals → a canonical
|
|
5
|
+
``ComplianceEvent`` → declarative regulatory crosswalks → an append-only Merkle
|
|
6
|
+
log → signed reports → independent verification.
|
|
7
|
+
|
|
8
|
+
Boundary (non-negotiable, see FR-RPT-07 / NFR-COMP-01): OpenWright produces
|
|
9
|
+
*evidence that controls were exercised*. It does NOT assert or imply legal
|
|
10
|
+
compliance, certification, or fitness. Those are judgments reserved for
|
|
11
|
+
qualified auditors and counsel.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.6.0"
|
|
15
|
+
|
|
16
|
+
# Schema version for the canonical event model. Bumped independently of the
|
|
17
|
+
# package version; readers MUST tolerate unknown future fields (DR-04).
|
|
18
|
+
COMPLIANCE_EVENT_SCHEMA_VERSION = "1.0.0"
|
|
19
|
+
|
|
20
|
+
__all__ = ["__version__", "COMPLIANCE_EVENT_SCHEMA_VERSION"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Pure-Python Ed25519 signature *verification* (RFC 8032).
|
|
2
|
+
|
|
3
|
+
Used as a fallback by the verifier when the ``cryptography`` package is not
|
|
4
|
+
available — e.g. when running the verifier inside a stock WASM Python (Pyodide)
|
|
5
|
+
in the browser (FR-VER-04 [P2]). With this fallback the verifier needs **zero
|
|
6
|
+
third-party dependencies**, only the standard library.
|
|
7
|
+
|
|
8
|
+
This implements verification only (never signing); it follows the cofactorless
|
|
9
|
+
group-equation check ``[S]B == R + [k]A`` from RFC 8032 §5.1.7, which accepts all
|
|
10
|
+
signatures produced by a compliant signer (e.g. ``cryptography``). It is not
|
|
11
|
+
constant-time, which is fine — verification uses only public data.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
|
|
18
|
+
_P = 2**255 - 19
|
|
19
|
+
_L = 2**252 + 27742317777372353535851937790883648493
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _inv(x: int) -> int:
|
|
23
|
+
return pow(x, _P - 2, _P)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_D = (-121665 * _inv(121666)) % _P
|
|
27
|
+
_I = pow(2, (_P - 1) // 4, _P)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _xrecover(y: int) -> int:
|
|
31
|
+
xx = (y * y - 1) * _inv(_D * y * y + 1)
|
|
32
|
+
x = pow(xx, (_P + 3) // 8, _P)
|
|
33
|
+
if (x * x - xx) % _P != 0:
|
|
34
|
+
x = (x * _I) % _P
|
|
35
|
+
if x % 2 != 0:
|
|
36
|
+
x = _P - x
|
|
37
|
+
return x
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_BY = (4 * _inv(5)) % _P
|
|
41
|
+
_BX = _xrecover(_BY) % _P
|
|
42
|
+
_B = (_BX, _BY)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _edwards_add(p1, p2):
|
|
46
|
+
x1, y1 = p1
|
|
47
|
+
x2, y2 = p2
|
|
48
|
+
denom = _inv(1 + _D * x1 * x2 * y1 * y2)
|
|
49
|
+
x3 = (x1 * y2 + x2 * y1) * denom % _P
|
|
50
|
+
denom2 = _inv(1 - _D * x1 * x2 * y1 * y2)
|
|
51
|
+
y3 = (y1 * y2 + x1 * x2) * denom2 % _P
|
|
52
|
+
return (x3, y3)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _scalarmult(point, e: int):
|
|
56
|
+
result = (0, 1) # neutral element
|
|
57
|
+
addend = point
|
|
58
|
+
while e > 0:
|
|
59
|
+
if e & 1:
|
|
60
|
+
result = _edwards_add(result, addend)
|
|
61
|
+
addend = _edwards_add(addend, addend)
|
|
62
|
+
e >>= 1
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_on_curve(point) -> bool:
|
|
67
|
+
x, y = point
|
|
68
|
+
return (-x * x + y * y - 1 - _D * x * x * y * y) % _P == 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _decodepoint(s: bytes):
|
|
72
|
+
val = int.from_bytes(s, "little")
|
|
73
|
+
y = val & ((1 << 255) - 1)
|
|
74
|
+
x = _xrecover(y)
|
|
75
|
+
if (x & 1) != ((val >> 255) & 1):
|
|
76
|
+
x = _P - x
|
|
77
|
+
point = (x, y)
|
|
78
|
+
if not _is_on_curve(point):
|
|
79
|
+
raise ValueError("point not on curve")
|
|
80
|
+
return point
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def verify(public_key: bytes, signature: bytes, message: bytes) -> bool:
|
|
84
|
+
"""Return True iff ``signature`` is a valid Ed25519 signature of ``message``."""
|
|
85
|
+
if len(signature) != 64 or len(public_key) != 32:
|
|
86
|
+
return False
|
|
87
|
+
try:
|
|
88
|
+
r_point = _decodepoint(signature[:32])
|
|
89
|
+
a_point = _decodepoint(public_key)
|
|
90
|
+
except (ValueError, Exception): # noqa: BLE001
|
|
91
|
+
return False
|
|
92
|
+
s = int.from_bytes(signature[32:], "little")
|
|
93
|
+
if s >= _L:
|
|
94
|
+
return False
|
|
95
|
+
h = int.from_bytes(hashlib.sha512(signature[:32] + public_key + message).digest(), "little") % _L
|
|
96
|
+
sb = _scalarmult(_B, s)
|
|
97
|
+
ha = _scalarmult(a_point, h)
|
|
98
|
+
return sb == _edwards_add(r_point, ha)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Source-format adapters (FR-NRM-03).
|
|
2
|
+
|
|
3
|
+
Each adapter isolates one upstream convention so that a change there (e.g. the
|
|
4
|
+
OTel GenAI conventions leaving experimental status — C-01) requires editing only
|
|
5
|
+
that adapter, never the core or the canonical event schema. Adapters are
|
|
6
|
+
deliberately proto-agnostic: they consume plain Python dicts, so the ingest
|
|
7
|
+
layer owns OTLP/protobuf decoding.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .base import SpanData
|
|
11
|
+
from .otel_genai import span_to_event
|
|
12
|
+
from .a2a import reconstruct_provenance
|
|
13
|
+
from .langfuse import apply_langfuse_precedence, has_langfuse_attributes
|
|
14
|
+
from .sarif_in import sarif_to_events
|
|
15
|
+
from .policy import policy_decisions_to_events
|
|
16
|
+
from .receipt import (
|
|
17
|
+
SignedActionReceiptSource,
|
|
18
|
+
ReceiptSource,
|
|
19
|
+
ReceiptVerificationError,
|
|
20
|
+
receipt_to_event,
|
|
21
|
+
sign_receipt,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SpanData",
|
|
26
|
+
"span_to_event",
|
|
27
|
+
"reconstruct_provenance",
|
|
28
|
+
"apply_langfuse_precedence",
|
|
29
|
+
"has_langfuse_attributes",
|
|
30
|
+
"sarif_to_events",
|
|
31
|
+
"policy_decisions_to_events",
|
|
32
|
+
"SignedActionReceiptSource",
|
|
33
|
+
"ReceiptSource",
|
|
34
|
+
"ReceiptVerificationError",
|
|
35
|
+
"receipt_to_event",
|
|
36
|
+
"sign_receipt",
|
|
37
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""A2A task-provenance reconstruction (FR-ING-05).
|
|
2
|
+
|
|
3
|
+
Verified against the A2A spec (v0.3.0): a ``Task`` has ``id`` and ``contextId``
|
|
4
|
+
but NO parent-task field; cross-task lineage is expressed softly via
|
|
5
|
+
``Message.referenceTaskIds`` and grouping via ``contextId``. OpenWright therefore
|
|
6
|
+
*reconstructs* a parent/root chain from those signals — the ``parent_task_id``
|
|
7
|
+
and ``root_task_id`` on a :class:`Provenance` are OpenWright-derived, not native
|
|
8
|
+
A2A fields. Absent A2A, provenance degrades to single-event records (A-02).
|
|
9
|
+
|
|
10
|
+
All A2A-origin data is treated as untrusted input (FR-ING-10): IDs are used only
|
|
11
|
+
as opaque correlation keys, never interpreted or executed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from ..events import Provenance
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def reconstruct_provenance(
|
|
22
|
+
task_id: Optional[str],
|
|
23
|
+
context_id: Optional[str] = None,
|
|
24
|
+
reference_task_ids: Optional[List[str]] = None,
|
|
25
|
+
known: Optional[Dict[str, Provenance]] = None,
|
|
26
|
+
) -> Provenance:
|
|
27
|
+
"""Reconstruct parent/root for ``task_id`` from A2A signals.
|
|
28
|
+
|
|
29
|
+
``known`` maps already-seen task IDs to their reconstructed provenance, used
|
|
30
|
+
to walk the root chain. ``reference_task_ids`` (from the triggering Message)
|
|
31
|
+
yields the parent; the root is the parent's root, or this task if it has none.
|
|
32
|
+
"""
|
|
33
|
+
known = known or {}
|
|
34
|
+
parent_task_id = reference_task_ids[0] if reference_task_ids else None
|
|
35
|
+
|
|
36
|
+
if parent_task_id and parent_task_id in known and known[parent_task_id].root_task_id:
|
|
37
|
+
root_task_id = known[parent_task_id].root_task_id
|
|
38
|
+
elif parent_task_id:
|
|
39
|
+
root_task_id = parent_task_id # parent unseen; best-effort root = parent
|
|
40
|
+
else:
|
|
41
|
+
root_task_id = task_id # no parent → this task is its own root
|
|
42
|
+
|
|
43
|
+
return Provenance(
|
|
44
|
+
task_id=task_id,
|
|
45
|
+
context_id=context_id,
|
|
46
|
+
parent_task_id=parent_task_id,
|
|
47
|
+
root_task_id=root_task_id,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Attribute keys some instrumentations use to carry A2A identifiers on spans.
|
|
52
|
+
A2A_TASK_ID = "a2a.task.id"
|
|
53
|
+
A2A_CONTEXT_ID = "a2a.context.id"
|
|
54
|
+
A2A_REFERENCE_TASK_IDS = "a2a.reference_task_ids"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def provenance_from_attributes(
|
|
58
|
+
attrs: Dict[str, object], known: Optional[Dict[str, Provenance]] = None
|
|
59
|
+
) -> Optional[Provenance]:
|
|
60
|
+
"""Reconstruct provenance from A2A identifiers carried as span attributes."""
|
|
61
|
+
task_id = attrs.get(A2A_TASK_ID)
|
|
62
|
+
if not task_id:
|
|
63
|
+
return None
|
|
64
|
+
refs = attrs.get(A2A_REFERENCE_TASK_IDS)
|
|
65
|
+
ref_list = list(refs) if isinstance(refs, (list, tuple)) else ([refs] if refs else None)
|
|
66
|
+
return reconstruct_provenance(
|
|
67
|
+
str(task_id),
|
|
68
|
+
context_id=str(attrs[A2A_CONTEXT_ID]) if attrs.get(A2A_CONTEXT_ID) else None,
|
|
69
|
+
reference_task_ids=[str(r) for r in ref_list] if ref_list else None,
|
|
70
|
+
known=known,
|
|
71
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Shared adapter input types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SpanData:
|
|
11
|
+
"""A telemetry span reduced to plain Python (no protobuf types).
|
|
12
|
+
|
|
13
|
+
The ingest layer fills this from OTLP; adapters consume it. Keeping adapters
|
|
14
|
+
free of protobuf imports is what makes them swappable (FR-NRM-03).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
attributes: Dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
resource: Dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
trace_id: Optional[str] = None
|
|
21
|
+
span_id: Optional[str] = None
|
|
22
|
+
parent_span_id: Optional[str] = None
|
|
23
|
+
start_time_unix_nano: Optional[int] = None
|
|
24
|
+
status: Optional[str] = None
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Langfuse ``langfuse.*`` attribute precedence (FR-ING-06).
|
|
2
|
+
|
|
3
|
+
Langfuse documents that its ``langfuse.*`` namespace "always take[s] precedence
|
|
4
|
+
over the generic OpenTelemetry conventions" (langfuse.com/integrations/native/
|
|
5
|
+
opentelemetry, verified 2026-05). This adapter overlays those values onto an
|
|
6
|
+
event already normalized from generic ``gen_ai.*`` attributes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
from ..canonical import hash_payload
|
|
14
|
+
from ..events import ComplianceEvent, IORef, ModelInfo
|
|
15
|
+
|
|
16
|
+
LF_SESSION_ID = "langfuse.session.id"
|
|
17
|
+
LF_USER_ID = "langfuse.user.id"
|
|
18
|
+
LF_ENVIRONMENT = "langfuse.environment"
|
|
19
|
+
LF_OBS_MODEL = "langfuse.observation.model.name"
|
|
20
|
+
LF_OBS_INPUT = "langfuse.observation.input"
|
|
21
|
+
LF_OBS_OUTPUT = "langfuse.observation.output"
|
|
22
|
+
LF_OBS_LEVEL = "langfuse.observation.level"
|
|
23
|
+
LF_OBS_TYPE = "langfuse.observation.type"
|
|
24
|
+
LF_TRACE_NAME = "langfuse.trace.name"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def has_langfuse_attributes(attrs: Dict[str, Any]) -> bool:
|
|
28
|
+
return any(k.startswith("langfuse.") for k in attrs)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def apply_langfuse_precedence(event: ComplianceEvent, attrs: Dict[str, Any]) -> ComplianceEvent:
|
|
32
|
+
"""Mutate ``event`` so langfuse.* values win over generic OTel values."""
|
|
33
|
+
if not has_langfuse_attributes(attrs):
|
|
34
|
+
return event
|
|
35
|
+
|
|
36
|
+
# Session id groups related observations → use as context if present.
|
|
37
|
+
if attrs.get(LF_SESSION_ID):
|
|
38
|
+
event.provenance.context_id = str(attrs[LF_SESSION_ID])
|
|
39
|
+
|
|
40
|
+
if attrs.get(LF_OBS_MODEL):
|
|
41
|
+
event.model = event.model or ModelInfo()
|
|
42
|
+
event.model.request_model = str(attrs[LF_OBS_MODEL])
|
|
43
|
+
|
|
44
|
+
if attrs.get(LF_OBS_INPUT) is not None or attrs.get(LF_OBS_OUTPUT) is not None:
|
|
45
|
+
event.io = event.io or IORef()
|
|
46
|
+
if attrs.get(LF_OBS_INPUT) is not None:
|
|
47
|
+
event.io.input_ref = hash_payload(attrs[LF_OBS_INPUT])
|
|
48
|
+
if attrs.get(LF_OBS_OUTPUT) is not None:
|
|
49
|
+
event.io.output_ref = hash_payload(attrs[LF_OBS_OUTPUT])
|
|
50
|
+
|
|
51
|
+
for key, label in ((LF_USER_ID, "user_id"), (LF_ENVIRONMENT, "environment"), (LF_OBS_LEVEL, "level"), (LF_OBS_TYPE, "observation_type"), (LF_TRACE_NAME, "trace_name")):
|
|
52
|
+
if attrs.get(key) is not None:
|
|
53
|
+
event.attributes[f"langfuse.{label}"] = str(attrs[key])
|
|
54
|
+
|
|
55
|
+
event.attributes["langfuse_precedence_applied"] = True
|
|
56
|
+
return event
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""OpenTelemetry GenAI semantic-convention adapter (FR-ING-03, FR-NRM-01).
|
|
2
|
+
|
|
3
|
+
All attribute names live in this module so a future rename is a one-line change
|
|
4
|
+
(FR-NRM-03; the conventions are still Experimental/"Development" — C-01). Both
|
|
5
|
+
the current names and their deprecated predecessors are accepted, since
|
|
6
|
+
instrumentations that have not opted into ``gen_ai_latest_experimental`` still
|
|
7
|
+
emit the old names (verified against opentelemetry.io, 2026-05). When both are
|
|
8
|
+
present the current name wins.
|
|
9
|
+
|
|
10
|
+
There is NO ``gen_ai.usage.cost`` attribute in the conventions; cost is derived
|
|
11
|
+
downstream from token counts and an operator-supplied pricing table, or omitted.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from ..canonical import hash_payload
|
|
19
|
+
from ..events import ComplianceEvent, EventKind, IORef, ModelInfo, ToolInfo
|
|
20
|
+
from .base import SpanData
|
|
21
|
+
|
|
22
|
+
# -- attribute names (single source of truth) ---------------------------------
|
|
23
|
+
A_OPERATION = "gen_ai.operation.name"
|
|
24
|
+
A_PROVIDER = "gen_ai.provider.name"
|
|
25
|
+
A_PROVIDER_LEGACY = "gen_ai.system"
|
|
26
|
+
A_REQUEST_MODEL = "gen_ai.request.model"
|
|
27
|
+
A_RESPONSE_MODEL = "gen_ai.response.model"
|
|
28
|
+
A_INPUT_TOKENS = "gen_ai.usage.input_tokens"
|
|
29
|
+
A_INPUT_TOKENS_LEGACY = "gen_ai.usage.prompt_tokens"
|
|
30
|
+
A_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
|
|
31
|
+
A_OUTPUT_TOKENS_LEGACY = "gen_ai.usage.completion_tokens"
|
|
32
|
+
A_FINISH_REASONS = "gen_ai.response.finish_reasons"
|
|
33
|
+
A_TOOL_NAME = "gen_ai.tool.name"
|
|
34
|
+
A_TOOL_CALL_ID = "gen_ai.tool.call.id"
|
|
35
|
+
A_TOOL_TYPE = "gen_ai.tool.type"
|
|
36
|
+
A_CONVERSATION_ID = "gen_ai.conversation.id"
|
|
37
|
+
A_AGENT_NAME = "gen_ai.agent.name"
|
|
38
|
+
# Deprecated/removed content attributes — hashed if present, never stored raw.
|
|
39
|
+
A_PROMPT_LEGACY = "gen_ai.prompt"
|
|
40
|
+
A_COMPLETION_LEGACY = "gen_ai.completion"
|
|
41
|
+
|
|
42
|
+
_OPERATION_TO_KIND = {
|
|
43
|
+
"chat": EventKind.LLM_CALL,
|
|
44
|
+
"generate_content": EventKind.LLM_CALL,
|
|
45
|
+
"text_completion": EventKind.LLM_CALL,
|
|
46
|
+
"embeddings": EventKind.LLM_CALL,
|
|
47
|
+
"execute_tool": EventKind.TOOL_CALL,
|
|
48
|
+
"invoke_agent": EventKind.AGENT_DECISION,
|
|
49
|
+
"create_agent": EventKind.AGENT_DECISION,
|
|
50
|
+
"invoke_workflow": EventKind.AGENT_DECISION,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _first(attrs: Dict[str, Any], *keys: str) -> Optional[Any]:
|
|
55
|
+
for k in keys:
|
|
56
|
+
if k in attrs and attrs[k] is not None:
|
|
57
|
+
return attrs[k]
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _coerce_int(value: Any) -> Optional[int]:
|
|
62
|
+
if value is None:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
return int(value)
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _coerce_str_list(value: Any) -> Optional[List[str]]:
|
|
71
|
+
if value is None:
|
|
72
|
+
return None
|
|
73
|
+
if isinstance(value, (list, tuple)):
|
|
74
|
+
return [str(v) for v in value]
|
|
75
|
+
return [str(value)] # tolerate a scalar emitted instead of a 1-element array
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def detected_semconv(attrs: Dict[str, Any]) -> str:
|
|
79
|
+
"""Best-effort tag of which convention generation a span follows."""
|
|
80
|
+
if A_PROVIDER in attrs or A_INPUT_TOKENS in attrs:
|
|
81
|
+
return "gen_ai/latest"
|
|
82
|
+
if A_PROVIDER_LEGACY in attrs or A_INPUT_TOKENS_LEGACY in attrs:
|
|
83
|
+
return "gen_ai/legacy"
|
|
84
|
+
return "gen_ai/unknown"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def span_to_event(
|
|
88
|
+
span: SpanData,
|
|
89
|
+
*,
|
|
90
|
+
timestamp: str,
|
|
91
|
+
agent_id: Optional[str] = None,
|
|
92
|
+
pricing: Optional[Dict[str, Any]] = None,
|
|
93
|
+
) -> ComplianceEvent:
|
|
94
|
+
"""Normalize one GenAI span into a canonical :class:`ComplianceEvent`."""
|
|
95
|
+
attrs = span.attributes or {}
|
|
96
|
+
operation = attrs.get(A_OPERATION)
|
|
97
|
+
kind = _OPERATION_TO_KIND.get(operation, EventKind.GENERIC)
|
|
98
|
+
|
|
99
|
+
resolved_agent = (
|
|
100
|
+
agent_id
|
|
101
|
+
or attrs.get(A_AGENT_NAME)
|
|
102
|
+
or span.resource.get("service.name")
|
|
103
|
+
or "unknown-agent"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
input_tokens = _coerce_int(_first(attrs, A_INPUT_TOKENS, A_INPUT_TOKENS_LEGACY))
|
|
107
|
+
output_tokens = _coerce_int(_first(attrs, A_OUTPUT_TOKENS, A_OUTPUT_TOKENS_LEGACY))
|
|
108
|
+
total_tokens = (input_tokens or 0) + (output_tokens or 0) if (input_tokens or output_tokens) else None
|
|
109
|
+
|
|
110
|
+
# Legacy raw content, if present, is hashed — never stored inline (FR-LED-06).
|
|
111
|
+
prompt = attrs.get(A_PROMPT_LEGACY)
|
|
112
|
+
completion = attrs.get(A_COMPLETION_LEGACY)
|
|
113
|
+
|
|
114
|
+
cost = None
|
|
115
|
+
if pricing and (input_tokens is not None or output_tokens is not None):
|
|
116
|
+
cost = _compute_cost(pricing, _first(attrs, A_REQUEST_MODEL, A_RESPONSE_MODEL), input_tokens, output_tokens)
|
|
117
|
+
|
|
118
|
+
io = IORef(
|
|
119
|
+
input_ref=hash_payload(prompt) if prompt is not None else None,
|
|
120
|
+
output_ref=hash_payload(completion) if completion is not None else None,
|
|
121
|
+
input_tokens=input_tokens,
|
|
122
|
+
output_tokens=output_tokens,
|
|
123
|
+
total_tokens=total_tokens,
|
|
124
|
+
cost=cost,
|
|
125
|
+
cost_currency="USD" if cost else None,
|
|
126
|
+
)
|
|
127
|
+
model = ModelInfo(
|
|
128
|
+
provider=_first(attrs, A_PROVIDER, A_PROVIDER_LEGACY),
|
|
129
|
+
request_model=attrs.get(A_REQUEST_MODEL),
|
|
130
|
+
response_model=attrs.get(A_RESPONSE_MODEL),
|
|
131
|
+
)
|
|
132
|
+
tool = None
|
|
133
|
+
if attrs.get(A_TOOL_NAME) or attrs.get(A_TOOL_CALL_ID):
|
|
134
|
+
tool = ToolInfo(name=attrs.get(A_TOOL_NAME), call_id=attrs.get(A_TOOL_CALL_ID))
|
|
135
|
+
|
|
136
|
+
extra: Dict[str, Any] = {}
|
|
137
|
+
if operation:
|
|
138
|
+
extra["operation"] = operation
|
|
139
|
+
finish = _coerce_str_list(attrs.get(A_FINISH_REASONS))
|
|
140
|
+
if finish:
|
|
141
|
+
extra["finish_reasons"] = finish
|
|
142
|
+
if attrs.get(A_TOOL_TYPE):
|
|
143
|
+
extra["tool_type"] = attrs[A_TOOL_TYPE]
|
|
144
|
+
extra["semconv"] = detected_semconv(attrs)
|
|
145
|
+
|
|
146
|
+
conversation_id = attrs.get(A_CONVERSATION_ID)
|
|
147
|
+
|
|
148
|
+
return ComplianceEvent(
|
|
149
|
+
timestamp=timestamp,
|
|
150
|
+
kind=kind,
|
|
151
|
+
actor={"agent_id": resolved_agent},
|
|
152
|
+
provenance={"context_id": conversation_id} if conversation_id else {},
|
|
153
|
+
io=io if any(v is not None for v in io.model_dump().values()) else None,
|
|
154
|
+
model=model if any(model.model_dump().values()) else None,
|
|
155
|
+
tool=tool,
|
|
156
|
+
attributes=extra,
|
|
157
|
+
source={
|
|
158
|
+
"format": "otel-genai",
|
|
159
|
+
"span_id": span.span_id,
|
|
160
|
+
"trace_id": span.trace_id,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _compute_cost(
|
|
166
|
+
pricing: Dict[str, Any], model: Optional[str], in_tok: Optional[int], out_tok: Optional[int]
|
|
167
|
+
) -> Optional[str]:
|
|
168
|
+
"""Derive a cost as a decimal string (no floats in the event — FR-NRM-04)."""
|
|
169
|
+
from decimal import Decimal
|
|
170
|
+
|
|
171
|
+
rates = pricing.get(model) if model else None
|
|
172
|
+
rates = rates or pricing.get("default")
|
|
173
|
+
if not rates:
|
|
174
|
+
return None
|
|
175
|
+
cost = Decimal(0)
|
|
176
|
+
if in_tok:
|
|
177
|
+
cost += Decimal(str(rates.get("input_per_1k", 0))) * Decimal(in_tok) / Decimal(1000)
|
|
178
|
+
if out_tok:
|
|
179
|
+
cost += Decimal(str(rates.get("output_per_1k", 0))) * Decimal(out_tok) / Decimal(1000)
|
|
180
|
+
return f"{cost:.6f}"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Policy-engine decision ingest (FR-ING-09): OPA and Cedar → policy.decision events.
|
|
2
|
+
|
|
3
|
+
Consumes decision logs from Open Policy Agent (OPA) and Amazon Cedar and records
|
|
4
|
+
them as ``policy_decision`` events. We consume the engines' output; we do not
|
|
5
|
+
re-implement policy evaluation. All input is treated as untrusted (FR-ING-10):
|
|
6
|
+
parsing is defensive and malformed entries are skipped, not fatal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from ..events import ComplianceEvent, EventKind
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _opa_entry(entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
17
|
+
result = entry.get("result")
|
|
18
|
+
allowed = result.get("allow") if isinstance(result, dict) else result
|
|
19
|
+
return {
|
|
20
|
+
"engine": "opa",
|
|
21
|
+
"decision_id": entry.get("decision_id"),
|
|
22
|
+
"policy": entry.get("path"),
|
|
23
|
+
"decision": "allow" if allowed else "deny",
|
|
24
|
+
"result": result if not isinstance(result, dict) else None,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cedar_entry(entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
29
|
+
decision = entry.get("decision")
|
|
30
|
+
diagnostics = entry.get("diagnostics", {}) if isinstance(entry.get("diagnostics"), dict) else {}
|
|
31
|
+
return {
|
|
32
|
+
"engine": "cedar",
|
|
33
|
+
"decision": str(decision).lower() if decision else None,
|
|
34
|
+
"determining_policies": diagnostics.get("reason"),
|
|
35
|
+
"errors": diagnostics.get("errors"),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def policy_decisions_to_events(
|
|
40
|
+
entries: List[Dict[str, Any]],
|
|
41
|
+
*,
|
|
42
|
+
agent_id: str,
|
|
43
|
+
timestamp: str,
|
|
44
|
+
engine: str = "opa",
|
|
45
|
+
task_id: Optional[str] = None,
|
|
46
|
+
) -> List[ComplianceEvent]:
|
|
47
|
+
parse = _opa_entry if engine == "opa" else _cedar_entry
|
|
48
|
+
events: List[ComplianceEvent] = []
|
|
49
|
+
for entry in entries:
|
|
50
|
+
if not isinstance(entry, dict):
|
|
51
|
+
continue
|
|
52
|
+
attrs = {k: v for k, v in parse(entry).items() if v is not None}
|
|
53
|
+
events.append(
|
|
54
|
+
ComplianceEvent(
|
|
55
|
+
timestamp=entry.get("timestamp") or timestamp,
|
|
56
|
+
kind=EventKind.POLICY_DECISION,
|
|
57
|
+
actor={"agent_id": agent_id},
|
|
58
|
+
provenance={"task_id": task_id} if task_id else {},
|
|
59
|
+
attributes=attrs,
|
|
60
|
+
source={"format": f"policy-{engine}"},
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return events
|