redberry-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. redberry_sdk/__init__.py +20 -0
  2. redberry_sdk/_call_role.py +78 -0
  3. redberry_sdk/_config.py +181 -0
  4. redberry_sdk/_coverage.py +70 -0
  5. redberry_sdk/_emit.py +541 -0
  6. redberry_sdk/_emit_aws.py +254 -0
  7. redberry_sdk/_emit_azure.py +214 -0
  8. redberry_sdk/_env.py +20 -0
  9. redberry_sdk/_sdk.py +364 -0
  10. redberry_sdk/_span_event_logger.py +96 -0
  11. redberry_sdk/_version.py +3 -0
  12. redberry_sdk/hasher/__init__.py +115 -0
  13. redberry_sdk/hasher/_agents.py +62 -0
  14. redberry_sdk/hasher/_azure.py +396 -0
  15. redberry_sdk/hasher/_canonical.py +156 -0
  16. redberry_sdk/hasher/_config.py +165 -0
  17. redberry_sdk/hasher/_crypto.py +88 -0
  18. redberry_sdk/hasher/_envelope.py +84 -0
  19. redberry_sdk/hasher/_http.py +134 -0
  20. redberry_sdk/hasher/_processor.py +381 -0
  21. redberry_sdk/hasher/_report.py +143 -0
  22. redberry_sdk/hasher/_sigv4.py +132 -0
  23. redberry_sdk/hasher/_span.py +101 -0
  24. redberry_sdk/hasher/stores.py +586 -0
  25. redberry_sdk/instrumentors/__init__.py +236 -0
  26. redberry_sdk/instrumentors/_base.py +77 -0
  27. redberry_sdk/instrumentors/_expected.py +202 -0
  28. redberry_sdk/instrumentors/_native.py +97 -0
  29. redberry_sdk/instrumentors/_request_snapshot.py +449 -0
  30. redberry_sdk/instrumentors/_stream_capture.py +363 -0
  31. redberry_sdk/instrumentors/_tool_definitions.py +403 -0
  32. redberry_sdk/instrumentors/_wrapt_fallback.py +155 -0
  33. redberry_sdk/integrations/__init__.py +1 -0
  34. redberry_sdk/integrations/openai_agents.py +450 -0
  35. redberry_sdk/valp.py +536 -0
  36. redberry_sdk-0.1.0.dist-info/METADATA +157 -0
  37. redberry_sdk-0.1.0.dist-info/RECORD +41 -0
  38. redberry_sdk-0.1.0.dist-info/WHEEL +5 -0
  39. redberry_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
  40. redberry_sdk-0.1.0.dist-info/licenses/NOTICE +4 -0
  41. redberry_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,20 @@
1
+ """Redberry SDK: records an AI agent's model and tool calls and sends them to the customer's Redberry install.
2
+
3
+ Usage::
4
+
5
+ import redberry_sdk
6
+ redberry_sdk.init(agent="my-agent")
7
+
8
+ After init(), model calls made through supported libraries (OpenAI, Anthropic, LangChain and others) are
9
+ recorded with OpenTelemetry and sent to the ingest service of the Redberry install in the customer's own cloud
10
+ (Google Cloud, AWS or Azure), or to an explicit endpoint.
11
+ """
12
+
13
+ from ._sdk import ContentCaptureDisabledError, MissingInstrumentationError, flush, flush_after, init, get_tracer, shutdown
14
+ from ._call_role import internal_call
15
+ from ._emit import dropped_spans
16
+ from ._config import SDKConfig, EvaluationTopology
17
+ from ._coverage import CoverageReport
18
+ from ._version import __version__
19
+
20
+ __all__ = ["__version__", "init", "dropped_spans", "flush", "flush_after", "get_tracer", "shutdown", "internal_call", "MissingInstrumentationError", "ContentCaptureDisabledError", "SDKConfig", "EvaluationTopology", "CoverageReport"]
@@ -0,0 +1,78 @@
1
+ """``internal_call()``: mark the model calls an agent makes for itself.
2
+
3
+ A control's verifier, a judge, a planner or a summariser is a model call whose output stays inside
4
+ the agent. Recorded like any other call, its text became a message to the user: RAP's G3 verifier
5
+ put 11 ``ACCEPT`` / ``REJECT`` "replies" into one airline trace. Wrap such a call::
6
+
7
+ with redberry_sdk.internal_call("verifier"):
8
+ resp = client.chat.completions.create(...)
9
+
10
+ and every span started inside the block carries ``redberry.call_role = "internal"`` and
11
+ ``redberry.call_name = "verifier"``. The normaliser then records the call under the trace's
12
+ ``metadata.internal_calls`` and not as an event (``redberry_normaliser._call_role``).
13
+
14
+ Mark only calls whose output the agent consumes itself. A call whose tool calls the agent
15
+ dispatches is the agent acting, and must not be marked.
16
+
17
+ The marker is a ``contextvars`` value, so it follows the call into ``await`` and stays out of
18
+ other threads and tasks. Without ``redberry_sdk.init()`` it does nothing, and costs nothing.
19
+
20
+ ``redberry.*``, not ``valp.*``: ``valp.*`` is VALP's frozen evidence vocabulary
21
+ (``redberry_sdk.valp``), and a new key there is a VALP specification change.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import contextvars
26
+ from contextlib import contextmanager
27
+ from typing import Iterator
28
+
29
+ from opentelemetry.context import Context
30
+ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
31
+
32
+ #: Read by ``redberry_normaliser._call_role``; kept in step with it.
33
+ CALL_ROLE_ATTR = "redberry.call_role"
34
+ CALL_NAME_ATTR = "redberry.call_name"
35
+ INTERNAL = "internal"
36
+
37
+ _role: contextvars.ContextVar[tuple[str, str | None] | None] = contextvars.ContextVar(
38
+ "redberry_call_role", default=None)
39
+
40
+
41
+ @contextmanager
42
+ def internal_call(name: str | None = None) -> Iterator[None]:
43
+ """Mark every span started inside the block as an internal call named ``name``."""
44
+ if name is not None and not isinstance(name, str):
45
+ raise TypeError(f"internal_call name must be a string, got {type(name).__name__}")
46
+ token = _role.set((INTERNAL, name))
47
+ try:
48
+ yield
49
+ finally:
50
+ _role.reset(token)
51
+
52
+
53
+ def current_call_role() -> tuple[str, str | None] | None:
54
+ """``(role, name)`` inside ``internal_call()``, else None."""
55
+ return _role.get()
56
+
57
+
58
+ class CallRoleSpanProcessor(SpanProcessor):
59
+ """Stamps ``redberry.call_role`` / ``redberry.call_name`` at span start inside ``internal_call()``."""
60
+
61
+ def on_start(self, span: Span, parent_context: Context | None = None) -> None:
62
+ role = _role.get()
63
+ if role is None:
64
+ return
65
+ attrs = span.attributes or {}
66
+ if CALL_ROLE_ATTR not in attrs:
67
+ span.set_attribute(CALL_ROLE_ATTR, role[0])
68
+ if role[1] and CALL_NAME_ATTR not in attrs:
69
+ span.set_attribute(CALL_NAME_ATTR, role[1])
70
+
71
+ def on_end(self, span: ReadableSpan) -> None:
72
+ pass
73
+
74
+ def shutdown(self) -> None:
75
+ pass
76
+
77
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
78
+ return True
@@ -0,0 +1,181 @@
1
+ """SDK configuration — loaded from redberry.yaml or passed directly to init()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from . import _env
13
+
14
+
15
+ class EvaluationTopology(str, Enum):
16
+ REDBERRY_HOSTED = "redberry_hosted"
17
+ CLIENT_HOSTED = "client_hosted"
18
+
19
+
20
+ #: Where redberry-extract writes the manifest (redberry_extractor.cli).
21
+ DEFAULT_MANIFEST = ".redberry/manifest.json"
22
+
23
+ @dataclass
24
+ class SDKConfig:
25
+ # Identity
26
+ agent_id: str
27
+ task_class: str | None = None
28
+
29
+ # RAP run identifier (per scenario invocation). Set by the RAP outer loop
30
+ # via REDBERRY_RUN_ID, or passed explicitly. Reported as the redberry.run_id
31
+ # resource attribute; optional.
32
+ run_id: str | None = None
33
+
34
+ # OTel collector endpoint (defaults to local Docker stack)
35
+ otel_endpoint: str = "http://localhost:4318" # OTLP HTTP
36
+
37
+ # Whether to capture message content (prompts/completions).
38
+ # Setting False records metadata_only tier — content is not sent to collector.
39
+ capture_content: bool = True
40
+
41
+ # Topology controls what the Results Export service ships out of the stack.
42
+ topology: EvaluationTopology = EvaluationTopology.REDBERRY_HOSTED
43
+
44
+ # Evaluator endpoint (Topology 1 only).
45
+ evaluator_endpoint: str | None = None
46
+
47
+ # Declared annual interaction volume — feeds Contract 4.
48
+ declared_annual_volume: int | None = None
49
+
50
+ # Extra attributes added to every span as OTel resource attributes.
51
+ extra_resource_attributes: dict[str, Any] = field(default_factory=dict)
52
+
53
+ # VALP: the tool catalogue the insured confirmed at binding (tool_catalogue.confirmed.json).
54
+ # When set, execute_tool spans get valp.is_external / valp.is_mutating from it; when unset,
55
+ # they get neither (redberry_sdk.valp: omit rather than guess).
56
+ valp_tool_catalogue: str | None = None
57
+
58
+ # VALP: how long a run paused for a person's approval may wait before its interaction is closed
59
+ # as failed (redberry_sdk.integrations.openai_agents, "pending-approval TTL"). Default 7 days.
60
+ valp_pending_approval_ttl_s: float | None = None
61
+
62
+ # An agent whose provider library is installed without its instrumentation package would run
63
+ # unobserved (see instrumentors/_expected.py), so init() refuses to start. Set this (or
64
+ # REDBERRY_ALLOW_MISSING_INSTRUMENTATION=1) only where a subset is installed deliberately; init()
65
+ # then warns and logs at ERROR instead.
66
+ allow_missing_instrumentation: bool = False
67
+
68
+ # Content capture cannot be switched off silently (RAP docs/valp_single_chain_plan.md, A3): the
69
+ # evidence and pricing both need content. With it off (capture_content=False, or an inherited
70
+ # OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT other than "true") init() refuses to start,
71
+ # unless this is set (or REDBERRY_ALLOW_CONTENT_CAPTURE_OFF=1): a metadata-only run, chosen.
72
+ allow_content_capture_off: bool = False
73
+
74
+ # The agent's code version and its manifest, reported on every span as resource attributes
75
+ # (redberry.agent_code_version, redberry.manifest_hash: the SHA-256 of the manifest file's
76
+ # bytes, as VALP's genesis computes it) for VALP spec v2's observed fingerprint. Env:
77
+ # REDBERRY_AGENT_CODE_VERSION, REDBERRY_MANIFEST. The manifest defaults to
78
+ # .redberry/manifest.json when that file exists.
79
+ agent_code_version: str | None = None
80
+ manifest_path: str | None = None
81
+
82
+ def fingerprint_attributes(self) -> dict[str, str]:
83
+ """``redberry.agent_code_version`` and ``redberry.manifest_hash``, where known."""
84
+ import hashlib
85
+
86
+ attrs: dict[str, str] = {}
87
+ if self.agent_code_version:
88
+ attrs["redberry.agent_code_version"] = self.agent_code_version
89
+ manifest = self.manifest_path or (DEFAULT_MANIFEST if Path(DEFAULT_MANIFEST).is_file() else None)
90
+ if manifest:
91
+ try:
92
+ attrs["redberry.manifest_hash"] = hashlib.sha256(Path(manifest).read_bytes()).hexdigest()
93
+ except OSError as e:
94
+ import logging
95
+ logging.getLogger("redberry.sdk").warning("cannot read the manifest %s: %s", manifest, e)
96
+ return attrs
97
+
98
+ @classmethod
99
+ def from_yaml(cls, path: str | Path) -> "SDKConfig":
100
+ p = Path(path)
101
+ if not p.exists():
102
+ raise FileNotFoundError(f"Config file not found: {p}")
103
+ with p.open() as fh:
104
+ raw = yaml.safe_load(fh) or {}
105
+ sdk_cfg = raw.get("sdk", {})
106
+ return cls(
107
+ agent_id=sdk_cfg["agent_id"],
108
+ task_class=sdk_cfg.get("task_class"),
109
+ run_id=sdk_cfg.get("run_id"),
110
+ otel_endpoint=sdk_cfg.get("otel_endpoint", "http://localhost:4318"),
111
+ capture_content=sdk_cfg.get("capture_content", True),
112
+ topology=EvaluationTopology(
113
+ sdk_cfg.get("topology", EvaluationTopology.REDBERRY_HOSTED.value)
114
+ ),
115
+ evaluator_endpoint=sdk_cfg.get("evaluator_endpoint"),
116
+ declared_annual_volume=sdk_cfg.get("declared_annual_volume"),
117
+ extra_resource_attributes=sdk_cfg.get("extra_resource_attributes", {}),
118
+ valp_tool_catalogue=sdk_cfg.get("valp_tool_catalogue"),
119
+ valp_pending_approval_ttl_s=sdk_cfg.get("valp_pending_approval_ttl_s"),
120
+ allow_missing_instrumentation=bool(sdk_cfg.get("allow_missing_instrumentation", False)),
121
+ allow_content_capture_off=bool(sdk_cfg.get("allow_content_capture_off", False)),
122
+ agent_code_version=sdk_cfg.get("agent_code_version"),
123
+ manifest_path=sdk_cfg.get("manifest_path"),
124
+ )
125
+
126
+ def apply_env_overrides(self) -> "SDKConfig":
127
+ """Fill any missing fields from the REDBERRY_* (or the earlier OTELLINE_*) environment variables.
128
+
129
+ Resolution order is: explicit kwargs / YAML > env vars > defaults.
130
+ Called from `init()` after the config is otherwise resolved, so callers
131
+ can opt out by passing values explicitly.
132
+
133
+ Env vars:
134
+ REDBERRY_AGENT_ID — agent_id
135
+ REDBERRY_RUN_ID — run_id
136
+ REDBERRY_TASK_CLASS — task_class
137
+ REDBERRY_OTEL_ENDPOINT — otel_endpoint
138
+ VALP_TOOL_CATALOGUE — valp_tool_catalogue
139
+ VALP_PENDING_APPROVAL_TTL_S — valp_pending_approval_ttl_s
140
+ REDBERRY_ALLOW_MISSING_INSTRUMENTATION=1 — allow_missing_instrumentation
141
+ REDBERRY_ALLOW_CONTENT_CAPTURE_OFF=1 — allow_content_capture_off
142
+ REDBERRY_AGENT_CODE_VERSION — agent_code_version
143
+ REDBERRY_MANIFEST — manifest_path
144
+ """
145
+
146
+ def _get_stripped_env(name: str) -> str | None:
147
+ value = _env.get(name)
148
+ if value is None:
149
+ return None
150
+ value = value.strip()
151
+ return value or None
152
+
153
+ if not self.agent_id:
154
+ env_id = _get_stripped_env("REDBERRY_AGENT_ID")
155
+ if env_id is not None:
156
+ self.agent_id = env_id
157
+ if self.run_id is None:
158
+ self.run_id = _get_stripped_env("REDBERRY_RUN_ID")
159
+ if self.task_class is None:
160
+ self.task_class = _get_stripped_env("REDBERRY_TASK_CLASS")
161
+ env_endpoint = _get_stripped_env("REDBERRY_OTEL_ENDPOINT")
162
+ if env_endpoint is not None and self.otel_endpoint == "http://localhost:4318":
163
+ self.otel_endpoint = env_endpoint
164
+ if self.valp_tool_catalogue is None:
165
+ self.valp_tool_catalogue = _get_stripped_env("VALP_TOOL_CATALOGUE")
166
+ if self.valp_pending_approval_ttl_s is None and (ttl := _get_stripped_env("VALP_PENDING_APPROVAL_TTL_S")):
167
+ try:
168
+ self.valp_pending_approval_ttl_s = float(ttl)
169
+ except ValueError:
170
+ raise ValueError(f"VALP_PENDING_APPROVAL_TTL_S must be a number of seconds, not {ttl!r}") from None
171
+ if not self.allow_missing_instrumentation and _get_stripped_env(
172
+ "REDBERRY_ALLOW_MISSING_INSTRUMENTATION") in ("1", "true", "yes"):
173
+ self.allow_missing_instrumentation = True
174
+ if not self.allow_content_capture_off and _get_stripped_env(
175
+ "REDBERRY_ALLOW_CONTENT_CAPTURE_OFF") in ("1", "true", "yes"):
176
+ self.allow_content_capture_off = True
177
+ if self.agent_code_version is None:
178
+ self.agent_code_version = _get_stripped_env("REDBERRY_AGENT_CODE_VERSION")
179
+ if self.manifest_path is None:
180
+ self.manifest_path = _get_stripped_env("REDBERRY_MANIFEST")
181
+ return self
@@ -0,0 +1,70 @@
1
+ """Coverage report — emitted at init() time and persisted to .redberry/coverage.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import tempfile
9
+ from dataclasses import asdict, dataclass, field
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+
13
+ from .instrumentors import InstrumentorStatus
14
+
15
+ _log = logging.getLogger("redberry.sdk")
16
+
17
+
18
+ @dataclass
19
+ class CoverageReport:
20
+ agent_id: str
21
+ generated_at: str
22
+ instrumentors: list[dict]
23
+ tool_gaps: list[str] = field(default_factory=list) # tools in manifest not wrapped
24
+ notes: list[str] = field(default_factory=list)
25
+
26
+ @classmethod
27
+ def build(
28
+ cls,
29
+ agent_id: str,
30
+ statuses: list[InstrumentorStatus],
31
+ tool_gaps: list[str] | None = None,
32
+ ) -> CoverageReport:
33
+ return cls(
34
+ agent_id=agent_id,
35
+ generated_at=datetime.now(UTC).isoformat(),
36
+ instrumentors=[asdict(s) for s in statuses],
37
+ tool_gaps=tool_gaps or [],
38
+ )
39
+
40
+ def print_summary(self) -> None:
41
+ enabled = [i for i in self.instrumentors if i["enabled"]]
42
+ disabled = [i for i in self.instrumentors if not i["enabled"]]
43
+ print(f"\n[redberry] Coverage report for agent '{self.agent_id}'")
44
+ print(f" Enabled ({len(enabled)}): " + ", ".join(i["name"] for i in enabled))
45
+ if disabled:
46
+ print(f" Skipped ({len(disabled)}): " + ", ".join(
47
+ f"{i['name']} ({i['reason']})" for i in disabled
48
+ ))
49
+ if self.tool_gaps:
50
+ print(f" Tool gaps ({len(self.tool_gaps)}): " + ", ".join(self.tool_gaps))
51
+ print(" → Run the manifest extractor to generate wrappers for these tools.")
52
+ print()
53
+
54
+ def persist(self, base_dir: Path | None = None) -> Path | None:
55
+ """Write the report to ``<cwd>/.redberry/coverage.json``, else to the temp directory (a read-only
56
+ working directory: AWS Lambda's ``/var/task``). Never raises: the report is a convenience, and
57
+ ``init()`` must not fail the agent over it. Returns where it was written, or None."""
58
+ text = json.dumps(asdict(self), indent=2)
59
+ candidates = [base_dir] if base_dir else [Path(os.getcwd()) / ".redberry",
60
+ Path(tempfile.gettempdir()) / ".redberry"]
61
+ for target_dir in candidates:
62
+ try:
63
+ target_dir.mkdir(parents=True, exist_ok=True)
64
+ out = target_dir / "coverage.json"
65
+ out.write_text(text)
66
+ return out
67
+ except OSError:
68
+ continue
69
+ _log.info("redberry_sdk: coverage report not written (no writable directory)")
70
+ return None