agentrust-py 0.0.3__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.
@@ -0,0 +1,204 @@
1
+ """
2
+ queue_replay.py — Replay-on-reconnect helper for AgentTrust SDK.
3
+
4
+ After each successful validate() call, maybe_drain_on_success() checks
5
+ whether there are pending records in the local queue DB and, if so,
6
+ drains up to 50 of them in a daemon background thread.
7
+
8
+ A module-level cooldown (_DRAIN_COOLDOWN = 60 s) prevents spawning a
9
+ drain thread more than once per minute regardless of how many successful
10
+ validate() calls arrive in quick succession.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import sqlite3
16
+ import threading
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Cooldown state
25
+ # ---------------------------------------------------------------------------
26
+
27
+ _last_drain_attempt: float = 0.0
28
+ _DRAIN_COOLDOWN: float = 60.0
29
+ _drain_lock = threading.Lock()
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Public API
33
+ # ---------------------------------------------------------------------------
34
+
35
+ _MAX_DRAIN_ITEMS = 50
36
+
37
+
38
+ def maybe_drain_on_success(
39
+ gateway_url: str,
40
+ headers: dict,
41
+ queue_db: Path,
42
+ ) -> None:
43
+ """Call this after each successful validate() response.
44
+
45
+ If the queue DB has pending rows *and* the cooldown has elapsed, a
46
+ daemon thread is spawned to drain up to _MAX_DRAIN_ITEMS records.
47
+ Returns immediately (non-blocking).
48
+
49
+ Args:
50
+ gateway_url: Full base URL of the AgentTrust gateway
51
+ (e.g. ``"https://gateway.agentrust.ai"``).
52
+ headers: HTTP headers to use for replay requests
53
+ (should include ``X-AgentTrust-Token`` etc.).
54
+ queue_db: :class:`pathlib.Path` to the SQLite queue database.
55
+ """
56
+ global _last_drain_attempt
57
+
58
+ # Fast path — no DB file at all.
59
+ if not queue_db.exists():
60
+ return
61
+
62
+ now = time.monotonic()
63
+
64
+ with _drain_lock:
65
+ if now - _last_drain_attempt < _DRAIN_COOLDOWN:
66
+ return # Still within cooldown window; skip.
67
+
68
+ # Check whether the DB actually has pending rows before committing
69
+ # to spawning a thread.
70
+ pending = _pending_count(queue_db)
71
+ if pending == 0:
72
+ return
73
+
74
+ _last_drain_attempt = now # Claim the slot inside the lock.
75
+
76
+ logger.debug(
77
+ "[AgentTrust] replay-on-reconnect: %d pending record(s) found; "
78
+ "spawning background drain (max %d).",
79
+ pending,
80
+ _MAX_DRAIN_ITEMS,
81
+ )
82
+
83
+ t = threading.Thread(
84
+ target=_drain_worker,
85
+ args=(gateway_url, headers, queue_db),
86
+ daemon=True,
87
+ name="agentrust-queue-drain",
88
+ )
89
+ t.start()
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Internal helpers
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def _pending_count(queue_db: Path) -> int:
97
+ """Return the number of rows in the queue table, or 0 on any error."""
98
+ try:
99
+ con = sqlite3.connect(str(queue_db))
100
+ try:
101
+ row = con.execute("SELECT COUNT(*) FROM queue").fetchone()
102
+ return int(row[0]) if row else 0
103
+ finally:
104
+ con.close()
105
+ except Exception as exc:
106
+ logger.debug("[AgentTrust] queue_replay: could not count queue rows: %s", exc)
107
+ return 0
108
+
109
+
110
+ def _drain_worker(
111
+ gateway_url: str,
112
+ headers: dict,
113
+ queue_db: Path,
114
+ ) -> None:
115
+ """Background thread: drain up to _MAX_DRAIN_ITEMS from the queue.
116
+
117
+ Imports drain_queue() from client at call-time to avoid circular
118
+ imports at module load. All exceptions are caught and logged.
119
+ """
120
+ try:
121
+ from agentrust_sdk.client import drain_queue # local import avoids circular dep
122
+
123
+ sent, failed = _drain_limited(
124
+ gateway_url=gateway_url,
125
+ headers=headers,
126
+ queue_db=queue_db,
127
+ limit=_MAX_DRAIN_ITEMS,
128
+ )
129
+ logger.info(
130
+ "[AgentTrust] replay-on-reconnect complete: sent=%d failed=%d",
131
+ sent,
132
+ failed,
133
+ )
134
+ except Exception as exc:
135
+ logger.error(
136
+ "[AgentTrust] replay-on-reconnect background thread raised: %s",
137
+ exc,
138
+ exc_info=True,
139
+ )
140
+
141
+
142
+ def _drain_limited(
143
+ gateway_url: str,
144
+ headers: dict,
145
+ queue_db: Path,
146
+ limit: int,
147
+ ) -> tuple[int, int]:
148
+ """Replay up to *limit* records from queue_db against gateway_url.
149
+
150
+ This is a self-contained implementation that mirrors the logic in
151
+ ``drain_queue()`` but accepts explicit gateway_url / headers and
152
+ caps the number of rows processed so that a very large backlog does
153
+ not block the daemon thread indefinitely.
154
+ """
155
+ import json
156
+
157
+ import httpx
158
+
159
+ from .config import SDK_CONFIG # for _VALIDATE_PATH equivalent
160
+
161
+ _VALIDATE_PATH = "/v1/runtime/validate"
162
+ _url = gateway_url.rstrip("/")
163
+
164
+ if not queue_db.exists():
165
+ return 0, 0
166
+
167
+ try:
168
+ con = sqlite3.connect(str(queue_db))
169
+ except Exception as exc:
170
+ logger.error("[AgentTrust] replay-on-reconnect: cannot open queue DB: %s", exc)
171
+ return 0, 0
172
+
173
+ try:
174
+ rows = con.execute(
175
+ "SELECT id, payload FROM queue ORDER BY id LIMIT ?", (limit,)
176
+ ).fetchall()
177
+ except Exception as exc:
178
+ logger.error("[AgentTrust] replay-on-reconnect: cannot read queue DB: %s", exc)
179
+ con.close()
180
+ return 0, 0
181
+
182
+ sent = 0
183
+ failed = 0
184
+ timeout = SDK_CONFIG.timeout_sec
185
+
186
+ for row_id, raw in rows:
187
+ try:
188
+ payload = json.loads(raw)
189
+ with httpx.Client(base_url=_url, headers=headers, timeout=timeout) as http:
190
+ resp = http.post(_VALIDATE_PATH, json=payload)
191
+ resp.raise_for_status()
192
+ con.execute("DELETE FROM queue WHERE id = ?", (row_id,))
193
+ con.commit()
194
+ sent += 1
195
+ except Exception as exc:
196
+ logger.warning(
197
+ "[AgentTrust] replay-on-reconnect: record %d failed: %s",
198
+ row_id,
199
+ exc,
200
+ )
201
+ failed += 1
202
+
203
+ con.close()
204
+ return sent, failed
agentrust_sdk/tiers.py ADDED
@@ -0,0 +1,180 @@
1
+ """
2
+ Tier and capability definitions for AgentTrust SDK.
3
+
4
+ Every capability maps to a minimum tier. The SDK reads the tier from the
5
+ API key JWT and silently skips (never blocks) capabilities above the tier.
6
+
7
+ Tier order (ascending): oss → free → developer → team → enterprise
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from enum import Enum
12
+
13
+
14
+ class Tier(str, Enum):
15
+ OSS = "oss"
16
+ FREE = "free"
17
+ DEVELOPER = "developer"
18
+ TEAM = "team"
19
+ ENTERPRISE = "enterprise"
20
+
21
+ def __ge__(self, other: "Tier") -> bool:
22
+ return _TIER_ORDER[self] >= _TIER_ORDER[other]
23
+
24
+ def __gt__(self, other: "Tier") -> bool:
25
+ return _TIER_ORDER[self] > _TIER_ORDER[other]
26
+
27
+ def __le__(self, other: "Tier") -> bool:
28
+ return _TIER_ORDER[self] <= _TIER_ORDER[other]
29
+
30
+ def __lt__(self, other: "Tier") -> bool:
31
+ return _TIER_ORDER[self] < _TIER_ORDER[other]
32
+
33
+
34
+ _TIER_ORDER: dict[Tier, int] = {
35
+ Tier.OSS: 0,
36
+ Tier.FREE: 1,
37
+ Tier.DEVELOPER: 2,
38
+ Tier.TEAM: 3,
39
+ Tier.ENTERPRISE: 4,
40
+ }
41
+
42
+
43
+ class Capability(str, Enum):
44
+ # ── Validation ──────────────────────────────────────────────────────────
45
+ SCHEMA_VALIDATION = "schema_validation" # OSS
46
+ EVIDENCE_VALIDATION = "evidence_validation" # Free
47
+ TOOL_TRUST_CHECK = "tool_trust_check" # Free
48
+ CONTRADICTION_DETECTION = "contradiction_detection" # Free
49
+ CONSISTENCY_CHECK = "consistency_check" # Free
50
+
51
+ # ── Confidence & Risk ───────────────────────────────────────────────────
52
+ CONFIDENCE_ENGINE = "confidence_engine" # Developer
53
+ RISK_SCORING = "risk_scoring" # Developer
54
+ HISTORICAL_RELIABILITY = "historical_reliability" # Developer
55
+ LLM_JUDGE_OLLAMA = "llm_judge_ollama" # Enterprise
56
+ LLM_JUDGE_CLAUDE = "llm_judge_claude" # Enterprise
57
+
58
+ # ── Decision & Policy ───────────────────────────────────────────────────
59
+ AUTO_DECISION = "auto_decision" # Free
60
+ BUILTIN_POLICY_PACKS = "builtin_policy_packs" # Developer
61
+ CUSTOM_POLICIES = "custom_policies" # Team
62
+ POLICY_VERSIONING = "policy_versioning" # Team
63
+ POLICY_SYNC = "policy_sync" # Team
64
+
65
+ # ── Audit & Observability ───────────────────────────────────────────────
66
+ LOCAL_AUDIT = "local_audit" # Free
67
+ CENTRAL_AUDIT = "central_audit" # Team
68
+ HASH_CHAIN_AUDIT = "hash_chain_audit" # Enterprise
69
+ ANALYTICS = "analytics" # Team
70
+ SOC2_EXPORT = "soc2_export" # Enterprise
71
+
72
+ # ── Operations & Collaboration ──────────────────────────────────────────
73
+ REVIEW_QUEUE = "review_queue" # Team
74
+ ALERT_ENGINE = "alert_engine" # Team
75
+ WEBHOOKS = "webhooks" # Team
76
+ TEAM_MANAGEMENT = "team_management" # Team
77
+ SSO_SAML = "sso_saml" # Enterprise
78
+
79
+ # ── Multi-Agent & Trust ─────────────────────────────────────────────────
80
+ TRUST_CHAIN = "trust_chain" # Enterprise
81
+ RATE_LIMITER = "rate_limiter" # Enterprise
82
+
83
+ # ── SDK & Integrations ──────────────────────────────────────────────────
84
+ LANGGRAPH_ADAPTER = "langgraph_adapter" # Team
85
+ CREWAI_ADAPTER = "crewai_adapter" # Team
86
+ MCP_ADAPTER = "mcp_adapter" # Developer
87
+ AUTOGEN_ADAPTER = "autogen_adapter" # Team
88
+ CLAUDE_AGENTS_ADAPTER = "claude_agents_adapter" # Team
89
+ OPENAI_AGENTS_ADAPTER = "openai_agents_adapter" # Team
90
+ CLI_FULL = "cli_full" # Developer
91
+ SELF_HOSTED = "self_hosted" # Enterprise
92
+
93
+
94
+ # Maps each capability to the minimum tier required
95
+ CAPABILITY_MIN_TIER: dict[Capability, Tier] = {
96
+ # Validation
97
+ Capability.SCHEMA_VALIDATION: Tier.OSS,
98
+ Capability.EVIDENCE_VALIDATION: Tier.FREE,
99
+ Capability.TOOL_TRUST_CHECK: Tier.FREE,
100
+ Capability.CONTRADICTION_DETECTION: Tier.FREE,
101
+ Capability.CONSISTENCY_CHECK: Tier.FREE,
102
+
103
+ # Confidence & Risk
104
+ Capability.CONFIDENCE_ENGINE: Tier.DEVELOPER,
105
+ Capability.RISK_SCORING: Tier.DEVELOPER,
106
+ Capability.HISTORICAL_RELIABILITY: Tier.DEVELOPER,
107
+ Capability.LLM_JUDGE_OLLAMA: Tier.ENTERPRISE,
108
+ Capability.LLM_JUDGE_CLAUDE: Tier.ENTERPRISE,
109
+
110
+ # Decision & Policy
111
+ Capability.AUTO_DECISION: Tier.FREE,
112
+ Capability.BUILTIN_POLICY_PACKS: Tier.DEVELOPER,
113
+ Capability.CUSTOM_POLICIES: Tier.TEAM,
114
+ Capability.POLICY_VERSIONING: Tier.TEAM,
115
+ Capability.POLICY_SYNC: Tier.TEAM,
116
+
117
+ # Audit
118
+ Capability.LOCAL_AUDIT: Tier.FREE,
119
+ Capability.CENTRAL_AUDIT: Tier.TEAM,
120
+ Capability.HASH_CHAIN_AUDIT: Tier.ENTERPRISE,
121
+ Capability.ANALYTICS: Tier.TEAM,
122
+ Capability.SOC2_EXPORT: Tier.ENTERPRISE,
123
+
124
+ # Operations
125
+ Capability.REVIEW_QUEUE: Tier.TEAM,
126
+ Capability.ALERT_ENGINE: Tier.TEAM,
127
+ Capability.WEBHOOKS: Tier.TEAM,
128
+ Capability.TEAM_MANAGEMENT: Tier.TEAM,
129
+ Capability.SSO_SAML: Tier.ENTERPRISE,
130
+
131
+ # Multi-agent
132
+ Capability.TRUST_CHAIN: Tier.ENTERPRISE,
133
+ Capability.RATE_LIMITER: Tier.ENTERPRISE,
134
+
135
+ # SDK
136
+ Capability.LANGGRAPH_ADAPTER: Tier.TEAM,
137
+ Capability.CREWAI_ADAPTER: Tier.TEAM,
138
+ Capability.MCP_ADAPTER: Tier.DEVELOPER,
139
+ Capability.AUTOGEN_ADAPTER: Tier.TEAM,
140
+ Capability.CLAUDE_AGENTS_ADAPTER: Tier.TEAM,
141
+ Capability.OPENAI_AGENTS_ADAPTER: Tier.TEAM,
142
+ Capability.CLI_FULL: Tier.DEVELOPER,
143
+ Capability.SELF_HOSTED: Tier.ENTERPRISE,
144
+ }
145
+
146
+
147
+ def is_allowed(capability: Capability, tier: Tier) -> bool:
148
+ """Return True if the given tier can use this capability."""
149
+ min_tier = CAPABILITY_MIN_TIER.get(capability, Tier.ENTERPRISE)
150
+ return tier >= min_tier
151
+
152
+
153
+ def allowed_capabilities(tier: Tier) -> list[Capability]:
154
+ """Return all capabilities available on a given tier."""
155
+ return [cap for cap, min_t in CAPABILITY_MIN_TIER.items() if tier >= min_t]
156
+
157
+
158
+ # Human-readable upgrade messages
159
+ UPGRADE_MESSAGES: dict[Capability, str] = {
160
+ Capability.CONFIDENCE_ENGINE: "Confidence scoring requires Developer tier ($29/mo). Upgrade: agentrust upgrade",
161
+ Capability.RISK_SCORING: "Risk scoring requires Developer tier ($29/mo). Upgrade: agentrust upgrade",
162
+ Capability.BUILTIN_POLICY_PACKS: "Policy packs require Developer tier ($29/mo). Upgrade: agentrust upgrade",
163
+ Capability.CUSTOM_POLICIES: "Custom policies require Team tier ($149/mo). Upgrade: agentrust upgrade",
164
+ Capability.POLICY_SYNC: "Policy sync requires Team tier ($149/mo). Upgrade: agentrust upgrade",
165
+ Capability.CENTRAL_AUDIT: "Central audit requires Team tier ($149/mo). Upgrade: agentrust upgrade",
166
+ Capability.ANALYTICS: "Analytics require Team tier ($149/mo). Upgrade: agentrust upgrade",
167
+ Capability.REVIEW_QUEUE: "Review queue requires Team tier ($149/mo). Upgrade: agentrust upgrade",
168
+ Capability.ALERT_ENGINE: "Alert engine requires Team tier ($149/mo). Upgrade: agentrust upgrade",
169
+ Capability.LANGGRAPH_ADAPTER: "LangGraph adapter requires Team tier ($149/mo). Upgrade: agentrust upgrade",
170
+ Capability.CREWAI_ADAPTER: "CrewAI adapter requires Team tier ($149/mo). Upgrade: agentrust upgrade",
171
+ Capability.MCP_ADAPTER: "MCP adapter requires Developer tier ($29/mo). Upgrade: agentrust upgrade",
172
+ Capability.AUTOGEN_ADAPTER: "AutoGen adapter requires Team tier ($149/mo). Upgrade: agentrust upgrade",
173
+ Capability.CLAUDE_AGENTS_ADAPTER: "Claude Agents adapter requires Team tier ($149/mo). Upgrade: agentrust upgrade",
174
+ Capability.OPENAI_AGENTS_ADAPTER: "OpenAI Agents adapter requires Team tier ($149/mo). Upgrade: agentrust upgrade",
175
+ Capability.TRUST_CHAIN: "Trust chain requires Enterprise tier. Contact: sales@agentrust.io",
176
+ Capability.LLM_JUDGE_CLAUDE: "LLM Judge requires Enterprise tier. Contact: sales@agentrust.io",
177
+ Capability.SSO_SAML: "SSO/SAML requires Enterprise tier. Contact: sales@agentrust.io",
178
+ Capability.SELF_HOSTED: "Self-hosted control plane requires Enterprise tier. Contact: sales@agentrust.io",
179
+ Capability.SOC2_EXPORT: "SOC2 audit export requires Enterprise tier. Contact: sales@agentrust.io",
180
+ }
@@ -0,0 +1,290 @@
1
+ """
2
+ AgentTrust SDK — version negotiation.
3
+
4
+ Handles SDK/gateway version compatibility checks and header-based negotiation.
5
+
6
+ Supported gateway versions for forward-compatibility checks:
7
+ SUPPORTED_GATEWAY_VERSIONS
8
+
9
+ Typical usage
10
+ -------------
11
+ from .version_negotiation import check_response_version
12
+ check_response_version(response.headers, SDK_CONFIG.sdk_version)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import Optional
18
+
19
+ from .config import SDK_CONFIG
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Module-level constants
23
+ # ---------------------------------------------------------------------------
24
+
25
+ SUPPORTED_GATEWAY_VERSIONS: list[str] = ["0.0.1a1", "0.0.2", "0.1.0"]
26
+
27
+ _HEADER_MIN_SDK = "X-AgentTrust-Min-SDK-Version"
28
+ _HEADER_GATEWAY = "X-AgentTrust-Gateway-Version"
29
+
30
+ # Matches "1.2.3", "0.0.1a1", "1.2.3b4", "0.1.0rc2", etc.
31
+ _SEMVER_RE = re.compile(
32
+ r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:[a-zA-Z].*)?$"
33
+ )
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Exception
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ class VersionNegotiationError(Exception):
42
+ """Raised when SDK and gateway versions are incompatible.
43
+
44
+ Attributes
45
+ ----------
46
+ sdk_ver : SDK version string reported by this library.
47
+ gateway_ver : Gateway version string extracted from the response headers
48
+ (may be None if the gateway did not advertise one).
49
+ min_required : Minimum SDK version the gateway requires.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ message: str,
55
+ sdk_ver: str,
56
+ gateway_ver: Optional[str],
57
+ min_required: str,
58
+ ) -> None:
59
+ super().__init__(message)
60
+ self.sdk_ver = sdk_ver
61
+ self.gateway_ver = gateway_ver
62
+ self.min_required = min_required
63
+
64
+ def __repr__(self) -> str: # pragma: no cover
65
+ return (
66
+ f"VersionNegotiationError({str(self)!r}, "
67
+ f"sdk_ver={self.sdk_ver!r}, "
68
+ f"gateway_ver={self.gateway_ver!r}, "
69
+ f"min_required={self.min_required!r})"
70
+ )
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Core helpers
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ def parse_semver(v: str) -> tuple[int, int, int]:
79
+ """Parse a version string into a (major, minor, patch) integer tuple.
80
+
81
+ Pre-release labels (``a1``, ``b2``, ``rc3``, …) and build metadata
82
+ attached to the patch segment are stripped before comparison so that
83
+ ``"0.0.1a1"`` and ``"0.0.1"`` compare as equal by numeric precedence.
84
+
85
+ Parameters
86
+ ----------
87
+ v:
88
+ A version string such as ``"1.2.3"``, ``"0.0.1a1"``, or ``"1.0.0rc2"``.
89
+
90
+ Returns
91
+ -------
92
+ tuple[int, int, int]
93
+ ``(major, minor, patch)`` as integers.
94
+
95
+ Raises
96
+ ------
97
+ ValueError
98
+ If *v* cannot be parsed as a semantic version.
99
+ """
100
+ v = v.strip()
101
+ m = _SEMVER_RE.match(v)
102
+ if not m:
103
+ raise ValueError(
104
+ f"Cannot parse {v!r} as a semantic version — "
105
+ "expected MAJOR.MINOR.PATCH[pre-release]"
106
+ )
107
+ return (int(m.group("major")), int(m.group("minor")), int(m.group("patch")))
108
+
109
+
110
+ def is_compatible(
111
+ sdk_version: str,
112
+ gateway_version: str,
113
+ min_gateway: str,
114
+ ) -> tuple[bool, str]:
115
+ """Check whether *sdk_version* satisfies the gateway's minimum requirement.
116
+
117
+ Compatibility rule (semver precedence, pre-release labels ignored):
118
+ sdk_version >= min_gateway
119
+
120
+ Parameters
121
+ ----------
122
+ sdk_version:
123
+ The version of this SDK (e.g. ``SDK_CONFIG.sdk_version``).
124
+ gateway_version:
125
+ The version the gateway reported (used only for diagnostic messages).
126
+ min_gateway:
127
+ The minimum SDK version the gateway will accept.
128
+
129
+ Returns
130
+ -------
131
+ tuple[bool, str]
132
+ ``(True, "")`` when compatible; ``(False, reason)`` otherwise.
133
+ """
134
+ try:
135
+ sdk_tuple = parse_semver(sdk_version)
136
+ except ValueError as exc:
137
+ return False, f"Invalid SDK version {sdk_version!r}: {exc}"
138
+
139
+ try:
140
+ min_tuple = parse_semver(min_gateway)
141
+ except ValueError as exc:
142
+ return False, f"Invalid minimum gateway version {min_gateway!r}: {exc}"
143
+
144
+ try:
145
+ parse_semver(gateway_version)
146
+ except ValueError:
147
+ # Gateway version is informational only; don't fail on bad value.
148
+ pass
149
+
150
+ if sdk_tuple >= min_tuple:
151
+ return True, ""
152
+
153
+ return False, (
154
+ f"SDK version {sdk_version} is below the minimum required "
155
+ f"{min_gateway} (gateway {gateway_version}). "
156
+ "Please upgrade the agentrust-sdk package."
157
+ )
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Header extraction
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ def negotiate_header(response_headers: dict) -> Optional[str]:
166
+ """Extract ``X-AgentTrust-Min-SDK-Version`` from *response_headers*.
167
+
168
+ Header lookup is case-insensitive to accommodate both raw ``http.client``
169
+ responses (which lowercase headers) and ``requests``/``httpx`` mappings
170
+ (which preserve original casing but expose case-insensitive access only
171
+ via their own classes — here we normalise manually).
172
+
173
+ Parameters
174
+ ----------
175
+ response_headers:
176
+ A plain dict or any mapping of response header names → values.
177
+
178
+ Returns
179
+ -------
180
+ str or None
181
+ The header value if present, ``None`` otherwise.
182
+ """
183
+ target_lower = _HEADER_MIN_SDK.lower()
184
+ for key, value in response_headers.items():
185
+ if key.lower() == target_lower:
186
+ return str(value).strip()
187
+ return None
188
+
189
+
190
+ def _extract_gateway_version(response_headers: dict) -> Optional[str]:
191
+ """Extract ``X-AgentTrust-Gateway-Version`` for diagnostic use."""
192
+ target_lower = _HEADER_GATEWAY.lower()
193
+ for key, value in response_headers.items():
194
+ if key.lower() == target_lower:
195
+ return str(value).strip()
196
+ return None
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Primary public entry point
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ def check_response_version(
205
+ resp_headers: dict,
206
+ sdk_version: str,
207
+ ) -> None:
208
+ """Validate that the current SDK satisfies the gateway's version requirement.
209
+
210
+ Reads ``X-AgentTrust-Min-SDK-Version`` from *resp_headers*. If the header
211
+ is absent no check is performed (the gateway is treated as compatible).
212
+
213
+ Parameters
214
+ ----------
215
+ resp_headers:
216
+ Response headers from the gateway (plain dict or mapping).
217
+ sdk_version:
218
+ The version of this SDK to validate against the requirement.
219
+
220
+ Raises
221
+ ------
222
+ VersionNegotiationError
223
+ When the SDK version is below the gateway's stated minimum.
224
+ """
225
+ min_required = negotiate_header(resp_headers)
226
+ if min_required is None:
227
+ # Gateway did not advertise a minimum; assume compatible.
228
+ return
229
+
230
+ gateway_ver = _extract_gateway_version(resp_headers)
231
+
232
+ ok, reason = is_compatible(
233
+ sdk_version=sdk_version,
234
+ gateway_version=gateway_ver or "unknown",
235
+ min_gateway=min_required,
236
+ )
237
+
238
+ if not ok:
239
+ raise VersionNegotiationError(
240
+ reason,
241
+ sdk_ver=sdk_version,
242
+ gateway_ver=gateway_ver,
243
+ min_required=min_required,
244
+ )
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Metadata helper
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ def get_version_info() -> dict:
253
+ """Return a dict with full version metadata for this SDK installation.
254
+
255
+ Keys
256
+ ----
257
+ sdk_version Current SDK version string.
258
+ min_gateway_version Minimum gateway version this SDK requires.
259
+ supported_gateway_versions
260
+ List of gateway versions explicitly tested for
261
+ forward-compatibility.
262
+ sdk_semver ``(major, minor, patch)`` tuple for the SDK version.
263
+ min_gateway_semver ``(major, minor, patch)`` tuple for the minimum
264
+ gateway version.
265
+ negotiation_header The HTTP header name used for version negotiation.
266
+ gateway_version_header The HTTP header name used by the gateway to
267
+ advertise its own version.
268
+ """
269
+ sdk_ver = SDK_CONFIG.sdk_version
270
+ min_gw = SDK_CONFIG.min_gateway_version
271
+
272
+ try:
273
+ sdk_semver = parse_semver(sdk_ver)
274
+ except ValueError:
275
+ sdk_semver = None # type: ignore[assignment]
276
+
277
+ try:
278
+ min_gw_semver = parse_semver(min_gw)
279
+ except ValueError:
280
+ min_gw_semver = None # type: ignore[assignment]
281
+
282
+ return {
283
+ "sdk_version": sdk_ver,
284
+ "min_gateway_version": min_gw,
285
+ "supported_gateway_versions": list(SUPPORTED_GATEWAY_VERSIONS),
286
+ "sdk_semver": sdk_semver,
287
+ "min_gateway_semver": min_gw_semver,
288
+ "negotiation_header": _HEADER_MIN_SDK,
289
+ "gateway_version_header": _HEADER_GATEWAY,
290
+ }