devtorch-core 3.0.1__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.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.gateway.metrics_webhook
|
|
3
|
+
=======================================
|
|
4
|
+
Push aggregate gateway metrics to an external webhook endpoint.
|
|
5
|
+
|
|
6
|
+
PRIVACY GUARANTEE:
|
|
7
|
+
The payload NEVER includes prompt text, code, reasoning blocks, or any
|
|
8
|
+
developer-authored content. Only counts and aggregate scores are sent.
|
|
9
|
+
|
|
10
|
+
Payload schema:
|
|
11
|
+
{
|
|
12
|
+
"ts": ISO-8601 timestamp (UTC),
|
|
13
|
+
"gateway_version": str,
|
|
14
|
+
"token_counts": {
|
|
15
|
+
"total": int, # sum across all sessions
|
|
16
|
+
"by_session": {user_id: int} # per-user totals (user IDs only)
|
|
17
|
+
},
|
|
18
|
+
"mcs_score": float | null,
|
|
19
|
+
"dhs_score": float | null,
|
|
20
|
+
"collision_count": int,
|
|
21
|
+
"session_count": int
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
Design decisions:
|
|
25
|
+
- Uses stdlib urllib.request so no new dependencies are introduced.
|
|
26
|
+
- All errors are caught silently and push() returns False — the gateway
|
|
27
|
+
must never crash because a webhook is unreachable.
|
|
28
|
+
- enabled=False is the safe default so metrics are opt-in.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
import urllib.request
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
from typing import Optional
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("devtorch.gateway.metrics_webhook")
|
|
41
|
+
|
|
42
|
+
GATEWAY_VERSION = "0.7.0"
|
|
43
|
+
|
|
44
|
+
DEFAULT_METRICS_INTERVAL_SECONDS = 300
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class MetricsWebhookConfig:
|
|
49
|
+
"""
|
|
50
|
+
Environment-driven configuration for the metrics webhook.
|
|
51
|
+
|
|
52
|
+
All fields are optional and default to safe/opt-in-off values.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
url: str = ""
|
|
56
|
+
enabled: bool = False
|
|
57
|
+
api_key: str = ""
|
|
58
|
+
interval_seconds: int = DEFAULT_METRICS_INTERVAL_SECONDS
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Keys that must NEVER appear in the webhook payload (privacy guard)
|
|
62
|
+
_FORBIDDEN_KEYS = frozenset(
|
|
63
|
+
{"prompt", "prompts", "content", "messages", "reasoning", "text", "code"}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_metrics_webhook_config() -> MetricsWebhookConfig:
|
|
68
|
+
"""
|
|
69
|
+
Load metrics webhook configuration from environment variables.
|
|
70
|
+
|
|
71
|
+
Variables:
|
|
72
|
+
- DEVTORCH_METRICS_WEBHOOK_URL
|
|
73
|
+
- DEVTORCH_METRICS_WEBHOOK_ENABLED
|
|
74
|
+
- DEVTORCH_METRICS_WEBHOOK_API_KEY
|
|
75
|
+
- DEVTORCH_METRICS_WEBHOOK_INTERVAL_SECONDS
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
MetricsWebhookConfig with safe defaults (disabled by default).
|
|
80
|
+
"""
|
|
81
|
+
url = os.environ.get("DEVTORCH_METRICS_WEBHOOK_URL", "").strip()
|
|
82
|
+
enabled = _parse_bool_env(os.environ.get("DEVTORCH_METRICS_WEBHOOK_ENABLED", ""))
|
|
83
|
+
api_key = os.environ.get("DEVTORCH_METRICS_WEBHOOK_API_KEY", "").strip()
|
|
84
|
+
|
|
85
|
+
interval = DEFAULT_METRICS_INTERVAL_SECONDS
|
|
86
|
+
interval_raw = os.environ.get("DEVTORCH_METRICS_WEBHOOK_INTERVAL_SECONDS", "")
|
|
87
|
+
if interval_raw:
|
|
88
|
+
try:
|
|
89
|
+
interval = max(1, int(interval_raw))
|
|
90
|
+
except ValueError:
|
|
91
|
+
interval = DEFAULT_METRICS_INTERVAL_SECONDS
|
|
92
|
+
|
|
93
|
+
return MetricsWebhookConfig(
|
|
94
|
+
url=url,
|
|
95
|
+
enabled=enabled,
|
|
96
|
+
api_key=api_key,
|
|
97
|
+
interval_seconds=interval,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_bool_env(value: str) -> bool:
|
|
102
|
+
"""Return True if *value* looks like an affirmative env string."""
|
|
103
|
+
if not value:
|
|
104
|
+
return False
|
|
105
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MetricsWebhook:
|
|
109
|
+
"""
|
|
110
|
+
Push aggregate metrics to an external HTTP webhook.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
webhook_url:
|
|
115
|
+
Full URL to POST metrics to (e.g. "https://analytics.example.com/devtorch").
|
|
116
|
+
enabled:
|
|
117
|
+
Set True to activate. Defaults to False so metrics are opt-in.
|
|
118
|
+
api_key:
|
|
119
|
+
Optional bearer token sent as `Authorization: Bearer <api_key>`.
|
|
120
|
+
interval_seconds:
|
|
121
|
+
How often (seconds) metrics are pushed when scheduled by the gateway.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
webhook_url: str = "",
|
|
127
|
+
enabled: bool = False,
|
|
128
|
+
api_key: str = "",
|
|
129
|
+
interval_seconds: int = DEFAULT_METRICS_INTERVAL_SECONDS,
|
|
130
|
+
) -> None:
|
|
131
|
+
self._url = webhook_url.strip()
|
|
132
|
+
self._enabled = enabled
|
|
133
|
+
self._api_key = api_key.strip()
|
|
134
|
+
self._interval_seconds = max(1, interval_seconds)
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# Public API
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def push(self, payload: dict) -> bool:
|
|
141
|
+
"""
|
|
142
|
+
POST *payload* as JSON to the configured webhook URL.
|
|
143
|
+
|
|
144
|
+
Returns True on HTTP 200, False on any error or if disabled.
|
|
145
|
+
NEVER raises — all exceptions are caught silently.
|
|
146
|
+
"""
|
|
147
|
+
if not self._enabled or not self._url:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# Privacy guard: strip any forbidden keys recursively.
|
|
151
|
+
safe_payload = _strip_forbidden(payload)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
body = json.dumps(safe_payload, default=str).encode("utf-8")
|
|
155
|
+
headers = {
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
"User-Agent": f"DevTorch-Gateway/{GATEWAY_VERSION}",
|
|
158
|
+
}
|
|
159
|
+
if self._api_key:
|
|
160
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
161
|
+
|
|
162
|
+
req = urllib.request.Request(
|
|
163
|
+
self._url,
|
|
164
|
+
data=body,
|
|
165
|
+
method="POST",
|
|
166
|
+
headers=headers,
|
|
167
|
+
)
|
|
168
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
169
|
+
return resp.status == 200
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
logger.debug("MetricsWebhook.push failed: %s", exc)
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def build_payload(self, sessions: list[dict]) -> dict:
|
|
175
|
+
"""
|
|
176
|
+
Build a privacy-safe webhook payload from session data.
|
|
177
|
+
|
|
178
|
+
Parameters
|
|
179
|
+
----------
|
|
180
|
+
sessions:
|
|
181
|
+
List of session dicts. Each dict may contain:
|
|
182
|
+
- user_id: str
|
|
183
|
+
- tokens_used: int
|
|
184
|
+
- mcs_score: float | None
|
|
185
|
+
- dhs_score: float | None
|
|
186
|
+
- collision_count: int
|
|
187
|
+
|
|
188
|
+
Returns a dict matching the payload schema. No prompt/content fields
|
|
189
|
+
will ever appear.
|
|
190
|
+
"""
|
|
191
|
+
total_tokens = 0
|
|
192
|
+
by_session: dict[str, int] = {}
|
|
193
|
+
mcs_scores: list[float] = []
|
|
194
|
+
dhs_scores: list[float] = []
|
|
195
|
+
total_collisions = 0
|
|
196
|
+
|
|
197
|
+
for sess in sessions:
|
|
198
|
+
uid = str(sess.get("user_id", "unknown"))
|
|
199
|
+
tokens = int(sess.get("tokens_used", 0))
|
|
200
|
+
total_tokens += tokens
|
|
201
|
+
by_session[uid] = by_session.get(uid, 0) + tokens
|
|
202
|
+
|
|
203
|
+
mcs = sess.get("mcs_score")
|
|
204
|
+
if mcs is not None:
|
|
205
|
+
try:
|
|
206
|
+
mcs_scores.append(float(mcs))
|
|
207
|
+
except (TypeError, ValueError):
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
dhs = sess.get("dhs_score")
|
|
211
|
+
if dhs is not None:
|
|
212
|
+
try:
|
|
213
|
+
dhs_scores.append(float(dhs))
|
|
214
|
+
except (TypeError, ValueError):
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
total_collisions += int(sess.get("collision_count", 0))
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
"ts": datetime.now(tz=timezone.utc).isoformat(),
|
|
221
|
+
"gateway_version": GATEWAY_VERSION,
|
|
222
|
+
"token_counts": {
|
|
223
|
+
"total": total_tokens,
|
|
224
|
+
"by_session": by_session,
|
|
225
|
+
},
|
|
226
|
+
"mcs_score": (sum(mcs_scores) / len(mcs_scores)) if mcs_scores else None,
|
|
227
|
+
"dhs_score": (sum(dhs_scores) / len(dhs_scores)) if dhs_scores else None,
|
|
228
|
+
"collision_count": total_collisions,
|
|
229
|
+
"session_count": len(sessions),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Internal helpers
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def _strip_forbidden(obj: object) -> object:
|
|
238
|
+
"""
|
|
239
|
+
Recursively remove any dict keys in _FORBIDDEN_KEYS.
|
|
240
|
+
|
|
241
|
+
This is the privacy firewall between internal session data and the
|
|
242
|
+
outgoing webhook payload.
|
|
243
|
+
"""
|
|
244
|
+
if isinstance(obj, dict):
|
|
245
|
+
return {
|
|
246
|
+
k: _strip_forbidden(v)
|
|
247
|
+
for k, v in obj.items()
|
|
248
|
+
if k.lower() not in _FORBIDDEN_KEYS
|
|
249
|
+
}
|
|
250
|
+
if isinstance(obj, list):
|
|
251
|
+
return [_strip_forbidden(item) for item in obj]
|
|
252
|
+
return obj
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.gateway.policy
|
|
3
|
+
=============================
|
|
4
|
+
Governance policy dataclass and enforcement engine for the enterprise gateway.
|
|
5
|
+
|
|
6
|
+
GovernancePolicy defines what is allowed for an organisation.
|
|
7
|
+
PolicyEngine enforces it on every request and response.
|
|
8
|
+
|
|
9
|
+
Design decisions:
|
|
10
|
+
- check_request returns (False, reason) to let the caller decide HTTP status.
|
|
11
|
+
- check_response only *warns* on missing RACP blocks — it does not block
|
|
12
|
+
responses so that developer flow is never interrupted by compliance issues.
|
|
13
|
+
- load_from_file accepts a JSON file whose top-level keys map 1:1 to the
|
|
14
|
+
dataclass fields, making policy files human-editable without a schema editor.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import threading
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import ClassVar
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("devtorch.gateway.policy")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Dataclass
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class GovernancePolicy:
|
|
33
|
+
"""
|
|
34
|
+
Org-level governance policy.
|
|
35
|
+
|
|
36
|
+
All fields have permissive defaults so that a gateway started with
|
|
37
|
+
``GovernancePolicy.default()`` never blocks any legitimate request.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Which LLM models are permitted. Empty list = all permitted.
|
|
41
|
+
allowed_models: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
# Maximum cumulative tokens consumed by one (user, date) session.
|
|
44
|
+
max_tokens_per_session: int = 1_000_000
|
|
45
|
+
|
|
46
|
+
# Whether RACP compliance is mandatory. If True, a missing RACP block
|
|
47
|
+
# triggers a WARNING log (but does NOT block the response).
|
|
48
|
+
racp_compliance_required: bool = False
|
|
49
|
+
|
|
50
|
+
# Minimum ratio (0.0–1.0) of responses that must contain RACP blocks.
|
|
51
|
+
# Informational for now; used by the metrics webhook payload.
|
|
52
|
+
racp_compliance_threshold: float = 0.0
|
|
53
|
+
|
|
54
|
+
# If True, a warning is emitted when the agent skips devtorch_commit.
|
|
55
|
+
require_devtorch_commit: bool = False
|
|
56
|
+
|
|
57
|
+
# Concepts that trigger an alert when found in a response (informational).
|
|
58
|
+
blocked_concepts: list[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Class methods
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def default(cls) -> "GovernancePolicy":
|
|
66
|
+
"""Return a maximally permissive policy suitable for development."""
|
|
67
|
+
return cls(
|
|
68
|
+
allowed_models=[], # empty = all models allowed
|
|
69
|
+
max_tokens_per_session=1_000_000,
|
|
70
|
+
racp_compliance_required=False,
|
|
71
|
+
racp_compliance_threshold=0.0,
|
|
72
|
+
require_devtorch_commit=False,
|
|
73
|
+
blocked_concepts=[],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def load_from_file(cls, path: str) -> "GovernancePolicy":
|
|
78
|
+
"""
|
|
79
|
+
Load a GovernancePolicy from a JSON file.
|
|
80
|
+
|
|
81
|
+
The JSON must have top-level keys matching the dataclass field names.
|
|
82
|
+
Unknown keys are ignored. Missing keys fall back to the default.
|
|
83
|
+
"""
|
|
84
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
85
|
+
raw = json.load(fh)
|
|
86
|
+
|
|
87
|
+
defaults = cls.default()
|
|
88
|
+
return cls(
|
|
89
|
+
allowed_models=raw.get("allowed_models", defaults.allowed_models),
|
|
90
|
+
max_tokens_per_session=int(
|
|
91
|
+
raw.get("max_tokens_per_session", defaults.max_tokens_per_session)
|
|
92
|
+
),
|
|
93
|
+
racp_compliance_required=bool(
|
|
94
|
+
raw.get("racp_compliance_required", defaults.racp_compliance_required)
|
|
95
|
+
),
|
|
96
|
+
racp_compliance_threshold=float(
|
|
97
|
+
raw.get("racp_compliance_threshold", defaults.racp_compliance_threshold)
|
|
98
|
+
),
|
|
99
|
+
require_devtorch_commit=bool(
|
|
100
|
+
raw.get("require_devtorch_commit", defaults.require_devtorch_commit)
|
|
101
|
+
),
|
|
102
|
+
blocked_concepts=raw.get("blocked_concepts", defaults.blocked_concepts),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def to_summary(self) -> dict:
|
|
106
|
+
"""Return a JSON-safe summary for the /health endpoint (no secrets)."""
|
|
107
|
+
return {
|
|
108
|
+
"allowed_models": self.allowed_models or ["*"],
|
|
109
|
+
"max_tokens_per_session": self.max_tokens_per_session,
|
|
110
|
+
"racp_compliance_required": self.racp_compliance_required,
|
|
111
|
+
"racp_compliance_threshold": self.racp_compliance_threshold,
|
|
112
|
+
"require_devtorch_commit": self.require_devtorch_commit,
|
|
113
|
+
"blocked_concepts_count": len(self.blocked_concepts),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Engine
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
class PolicyEngine:
|
|
122
|
+
"""
|
|
123
|
+
Enforce a GovernancePolicy on individual requests and responses.
|
|
124
|
+
|
|
125
|
+
Thread-safe: the mutable RACP compliance window is protected by a lock.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
# Default size of the rolling RACP compliance window.
|
|
129
|
+
_DEFAULT_RACP_WINDOW_SIZE: ClassVar[int] = 20
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
policy: GovernancePolicy,
|
|
134
|
+
racp_window_size: int = _DEFAULT_RACP_WINDOW_SIZE,
|
|
135
|
+
) -> None:
|
|
136
|
+
self._policy = policy
|
|
137
|
+
self._racp_window_size = max(1, racp_window_size)
|
|
138
|
+
self._racp_window: list[bool] = []
|
|
139
|
+
self._lock = threading.Lock()
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# Public API
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def check_request(
|
|
146
|
+
self,
|
|
147
|
+
model: str,
|
|
148
|
+
messages: list,
|
|
149
|
+
session_tokens_used: int,
|
|
150
|
+
) -> tuple[bool, str]:
|
|
151
|
+
"""
|
|
152
|
+
Validate an outgoing request against the current policy.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
(True, "") — request is permitted
|
|
156
|
+
(False, reason_str) — request should be blocked with 403
|
|
157
|
+
"""
|
|
158
|
+
p = self._policy
|
|
159
|
+
|
|
160
|
+
# 1. Model allowlist (empty list = all models permitted)
|
|
161
|
+
if p.allowed_models and model not in p.allowed_models:
|
|
162
|
+
reason = (
|
|
163
|
+
f"Model '{model}' is not in the org allowed_models list. "
|
|
164
|
+
f"Permitted: {p.allowed_models}"
|
|
165
|
+
)
|
|
166
|
+
logger.warning("Gateway BLOCKED request: %s", reason)
|
|
167
|
+
return False, reason
|
|
168
|
+
|
|
169
|
+
# 2. Token quota
|
|
170
|
+
if session_tokens_used >= p.max_tokens_per_session:
|
|
171
|
+
reason = (
|
|
172
|
+
f"Session token limit reached: {session_tokens_used} >= "
|
|
173
|
+
f"{p.max_tokens_per_session}. Start a new session."
|
|
174
|
+
)
|
|
175
|
+
logger.warning("Gateway BLOCKED request: %s", reason)
|
|
176
|
+
return False, reason
|
|
177
|
+
|
|
178
|
+
return True, ""
|
|
179
|
+
|
|
180
|
+
def check_response(
|
|
181
|
+
self,
|
|
182
|
+
response_body: dict,
|
|
183
|
+
has_racp_blocks: bool,
|
|
184
|
+
) -> tuple[bool, str]:
|
|
185
|
+
"""
|
|
186
|
+
Validate a response from the upstream LLM.
|
|
187
|
+
|
|
188
|
+
This method only *warns* — it never blocks a response — so the return
|
|
189
|
+
value is always (True, warning_or_empty).
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
(True, "") — response is clean
|
|
193
|
+
(True, warning_str) — response has compliance issue but is allowed
|
|
194
|
+
"""
|
|
195
|
+
p = self._policy
|
|
196
|
+
|
|
197
|
+
# Track RACP presence in a rolling window for threshold enforcement.
|
|
198
|
+
self._record_racp_presence(has_racp_blocks)
|
|
199
|
+
|
|
200
|
+
if p.racp_compliance_required and not has_racp_blocks:
|
|
201
|
+
warning = (
|
|
202
|
+
"RACP compliance required but this response contains no RACP blocks. "
|
|
203
|
+
"Ensure the agent is using devtorch_commit / RACP annotations."
|
|
204
|
+
)
|
|
205
|
+
logger.warning("Gateway compliance warning: %s", warning)
|
|
206
|
+
return True, warning
|
|
207
|
+
|
|
208
|
+
# Enforce the rolling RACP compliance threshold (informational only).
|
|
209
|
+
threshold_warning = self._check_racp_threshold()
|
|
210
|
+
if threshold_warning:
|
|
211
|
+
logger.warning("Gateway compliance warning: %s", threshold_warning)
|
|
212
|
+
return True, threshold_warning
|
|
213
|
+
|
|
214
|
+
return True, ""
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def policy(self) -> GovernancePolicy:
|
|
218
|
+
return self._policy
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def racp_ratio(self) -> float:
|
|
222
|
+
"""Return the current ratio of responses in the window that contain RACP blocks."""
|
|
223
|
+
with self._lock:
|
|
224
|
+
if not self._racp_window:
|
|
225
|
+
return 0.0
|
|
226
|
+
return sum(self._racp_window) / len(self._racp_window)
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def racp_window_count(self) -> int:
|
|
230
|
+
"""Return the number of responses currently in the RACP compliance window."""
|
|
231
|
+
with self._lock:
|
|
232
|
+
return len(self._racp_window)
|
|
233
|
+
|
|
234
|
+
# ------------------------------------------------------------------
|
|
235
|
+
# Internal helpers
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def _record_racp_presence(self, has_racp_blocks: bool) -> None:
|
|
239
|
+
"""Append *has_racp_blocks* to the rolling window, trimming to size."""
|
|
240
|
+
with self._lock:
|
|
241
|
+
self._racp_window.append(bool(has_racp_blocks))
|
|
242
|
+
if len(self._racp_window) > self._racp_window_size:
|
|
243
|
+
self._racp_window.pop(0)
|
|
244
|
+
|
|
245
|
+
def _check_racp_threshold(self) -> str:
|
|
246
|
+
"""
|
|
247
|
+
Return a warning string if the rolling RACP ratio is below the policy
|
|
248
|
+
threshold. Returns an empty string when the threshold is disabled or
|
|
249
|
+
when the ratio is satisfactory.
|
|
250
|
+
"""
|
|
251
|
+
p = self._policy
|
|
252
|
+
if p.racp_compliance_threshold <= 0:
|
|
253
|
+
return ""
|
|
254
|
+
|
|
255
|
+
ratio = self.racp_ratio
|
|
256
|
+
if ratio < p.racp_compliance_threshold:
|
|
257
|
+
return (
|
|
258
|
+
f"Rolling RACP compliance ratio {ratio:.2f} is below the org "
|
|
259
|
+
f"threshold {p.racp_compliance_threshold:.2f}. "
|
|
260
|
+
f"Ensure agents are using devtorch_commit / RACP annotations."
|
|
261
|
+
)
|
|
262
|
+
return ""
|