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.
Files changed (193) hide show
  1. devtorch_core/__init__.py +158 -0
  2. devtorch_core/aggphi_textual.py +275 -0
  3. devtorch_core/alerts/__init__.py +23 -0
  4. devtorch_core/alerts/base.py +46 -0
  5. devtorch_core/alerts/config.py +60 -0
  6. devtorch_core/alerts/dispatcher.py +110 -0
  7. devtorch_core/alerts/jira.py +96 -0
  8. devtorch_core/alerts/linear.py +72 -0
  9. devtorch_core/alerts/pagerduty.py +66 -0
  10. devtorch_core/alerts/slack.py +81 -0
  11. devtorch_core/alerts/teams.py +70 -0
  12. devtorch_core/audit/__init__.py +43 -0
  13. devtorch_core/audit/exporter.py +297 -0
  14. devtorch_core/audit/privacy.py +101 -0
  15. devtorch_core/audit/scrubber.py +149 -0
  16. devtorch_core/audit/service.py +67 -0
  17. devtorch_core/audit/signing.py +127 -0
  18. devtorch_core/broadcast/__init__.py +4 -0
  19. devtorch_core/broadcast/broadcaster.py +100 -0
  20. devtorch_core/broadcast/watcher.py +71 -0
  21. devtorch_core/capability.py +639 -0
  22. devtorch_core/cloud/__init__.py +1 -0
  23. devtorch_core/cloud/client_config.py +472 -0
  24. devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
  25. devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
  26. devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
  27. devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
  28. devtorch_core/cloud/client_configs/.opencode.json +15 -0
  29. devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
  30. devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
  31. devtorch_core/cloud/mcp_client.py +229 -0
  32. devtorch_core/cloud/setup.py +144 -0
  33. devtorch_core/cloud/sync.py +143 -0
  34. devtorch_core/cloud/sync_bundle.py +603 -0
  35. devtorch_core/cloud/sync_conflicts.py +159 -0
  36. devtorch_core/cloud/sync_state.py +159 -0
  37. devtorch_core/cloud/team_sync.py +283 -0
  38. devtorch_core/codex/__init__.py +9 -0
  39. devtorch_core/codex/__main__.py +97 -0
  40. devtorch_core/codex/capture.py +208 -0
  41. devtorch_core/codex/proxy.py +412 -0
  42. devtorch_core/concept_catalog.py +209 -0
  43. devtorch_core/consolidation/__init__.py +3 -0
  44. devtorch_core/consolidation/synthesizer.py +87 -0
  45. devtorch_core/consolidation/workflow.py +175 -0
  46. devtorch_core/daemon/__init__.py +27 -0
  47. devtorch_core/daemon/supervisor.py +293 -0
  48. devtorch_core/daemon/watcher.py +244 -0
  49. devtorch_core/dashboard_api.py +2012 -0
  50. devtorch_core/deltaf.py +97 -0
  51. devtorch_core/disclosure.py +50 -0
  52. devtorch_core/divergence/__init__.py +3 -0
  53. devtorch_core/divergence/detector.py +166 -0
  54. devtorch_core/gateway/__init__.py +32 -0
  55. devtorch_core/gateway/key_manager.py +124 -0
  56. devtorch_core/gateway/metrics_webhook.py +252 -0
  57. devtorch_core/gateway/policy.py +262 -0
  58. devtorch_core/gateway/server.py +727 -0
  59. devtorch_core/gateway/sso.py +233 -0
  60. devtorch_core/gcc.py +1246 -0
  61. devtorch_core/github/__init__.py +35 -0
  62. devtorch_core/github/app.py +240 -0
  63. devtorch_core/github/comment_builder.py +113 -0
  64. devtorch_core/github/pat.py +76 -0
  65. devtorch_core/github/pr_parser.py +82 -0
  66. devtorch_core/github/pr_reporter.py +555 -0
  67. devtorch_core/gitlab/__init__.py +177 -0
  68. devtorch_core/hitl/__init__.py +4 -0
  69. devtorch_core/hitl/channels.py +129 -0
  70. devtorch_core/hitl/orchestrator.py +95 -0
  71. devtorch_core/hooks/__init__.py +17 -0
  72. devtorch_core/hooks/claude_code.py +228 -0
  73. devtorch_core/hooks/git_capture.py +341 -0
  74. devtorch_core/hooks/git_commit.py +182 -0
  75. devtorch_core/hooks/installer.py +733 -0
  76. devtorch_core/hooks/pre_commit.py +157 -0
  77. devtorch_core/hooks/runner.py +344 -0
  78. devtorch_core/identity/__init__.py +4 -0
  79. devtorch_core/identity/agent.py +86 -0
  80. devtorch_core/identity/providers.py +85 -0
  81. devtorch_core/invariants.py +182 -0
  82. devtorch_core/mcp/__init__.py +10 -0
  83. devtorch_core/mcp/auth.py +177 -0
  84. devtorch_core/mcp/server.py +1049 -0
  85. devtorch_core/metrics/__init__.py +35 -0
  86. devtorch_core/metrics/aggregate.py +215 -0
  87. devtorch_core/metrics/calibrate.py +198 -0
  88. devtorch_core/metrics/calibration.py +125 -0
  89. devtorch_core/metrics/credibility.py +288 -0
  90. devtorch_core/metrics/delivery_time.py +70 -0
  91. devtorch_core/metrics/dhs.py +126 -0
  92. devtorch_core/metrics/mcs.py +96 -0
  93. devtorch_core/metrics/roi.py +88 -0
  94. devtorch_core/metrics/session_writer.py +81 -0
  95. devtorch_core/metrics/shadow_ai.py +117 -0
  96. devtorch_core/metrics/sprint_writer.py +243 -0
  97. devtorch_core/observability/__init__.py +78 -0
  98. devtorch_core/observability/datadog.py +157 -0
  99. devtorch_core/observability/formatter.py +119 -0
  100. devtorch_core/observability/report.py +264 -0
  101. devtorch_core/observability/servicenow.py +147 -0
  102. devtorch_core/observability/splunk.py +218 -0
  103. devtorch_core/observability/webhook.py +227 -0
  104. devtorch_core/parser/__init__.py +30 -0
  105. devtorch_core/parser/blocks.py +216 -0
  106. devtorch_core/parser/inference.py +159 -0
  107. devtorch_core/parser/thinking.py +112 -0
  108. devtorch_core/projects.py +169 -0
  109. devtorch_core/prompt_artifact.py +76 -0
  110. devtorch_core/proxy/__init__.py +9 -0
  111. devtorch_core/proxy/routes/__init__.py +1 -0
  112. devtorch_core/proxy/routes/anthropic.py +264 -0
  113. devtorch_core/proxy/routes/azure_openai.py +336 -0
  114. devtorch_core/proxy/routes/gemini.py +331 -0
  115. devtorch_core/proxy/routes/groq.py +284 -0
  116. devtorch_core/proxy/routes/ollama.py +279 -0
  117. devtorch_core/proxy/routes/openai.py +287 -0
  118. devtorch_core/proxy/server.py +356 -0
  119. devtorch_core/query/__init__.py +15 -0
  120. devtorch_core/query/grep.py +181 -0
  121. devtorch_core/query/hybrid.py +86 -0
  122. devtorch_core/query/semantic.py +157 -0
  123. devtorch_core/rdp.py +105 -0
  124. devtorch_core/reasoning/__init__.py +4 -0
  125. devtorch_core/reasoning/entry.py +31 -0
  126. devtorch_core/reasoning/store.py +122 -0
  127. devtorch_core/reasoning_plus/__init__.py +70 -0
  128. devtorch_core/reasoning_plus/augmenter.py +326 -0
  129. devtorch_core/reasoning_plus/capture.py +51 -0
  130. devtorch_core/reasoning_plus/config.py +256 -0
  131. devtorch_core/reasoning_plus/context.py +262 -0
  132. devtorch_core/reasoning_plus/learning/__init__.py +72 -0
  133. devtorch_core/reasoning_plus/learning/analytics.py +141 -0
  134. devtorch_core/reasoning_plus/learning/api.py +313 -0
  135. devtorch_core/reasoning_plus/learning/chain.py +285 -0
  136. devtorch_core/reasoning_plus/learning/composer.py +74 -0
  137. devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
  138. devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
  139. devtorch_core/reasoning_plus/learning/extractor.py +207 -0
  140. devtorch_core/reasoning_plus/learning/models.py +116 -0
  141. devtorch_core/reasoning_plus/learning/provenance.py +126 -0
  142. devtorch_core/reasoning_plus/learning/recorder.py +81 -0
  143. devtorch_core/reasoning_plus/learning/relevance.py +122 -0
  144. devtorch_core/reasoning_plus/learning/state.py +86 -0
  145. devtorch_core/reasoning_plus/learning/store.py +160 -0
  146. devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
  147. devtorch_core/reasoning_plus/prompt.py +90 -0
  148. devtorch_core/rep.py +134 -0
  149. devtorch_core/rep_network/__init__.py +25 -0
  150. devtorch_core/rep_network/merge.py +70 -0
  151. devtorch_core/rep_network/node.py +137 -0
  152. devtorch_core/rep_network/server.py +140 -0
  153. devtorch_core/rep_network/sync.py +207 -0
  154. devtorch_core/sensitivity.py +182 -0
  155. devtorch_core/serve.py +258 -0
  156. devtorch_core/session/__init__.py +39 -0
  157. devtorch_core/session/disagreement.py +188 -0
  158. devtorch_core/session/models.py +114 -0
  159. devtorch_core/session/orchestrator.py +182 -0
  160. devtorch_core/session/planner.py +169 -0
  161. devtorch_core/session/simulator.py +132 -0
  162. devtorch_core/signing.py +290 -0
  163. devtorch_core/sis.py +197 -0
  164. devtorch_core/storage.py +308 -0
  165. devtorch_core/templates/__init__.py +6 -0
  166. devtorch_core/templates/engine.py +122 -0
  167. devtorch_core/templates/go.py +18 -0
  168. devtorch_core/templates/infra.py +19 -0
  169. devtorch_core/templates/library/__init__.py +18 -0
  170. devtorch_core/templates/library/api_design.md +27 -0
  171. devtorch_core/templates/library/bug_fix.md +27 -0
  172. devtorch_core/templates/library/decision_record.md +27 -0
  173. devtorch_core/templates/library/engine.py +228 -0
  174. devtorch_core/templates/library/security_review.md +30 -0
  175. devtorch_core/templates/python.py +19 -0
  176. devtorch_core/templates/react.py +18 -0
  177. devtorch_core/templates/typescript.py +18 -0
  178. devtorch_core/theta.py +221 -0
  179. devtorch_core/theta_synthesis.py +268 -0
  180. devtorch_core/topics.py +320 -0
  181. devtorch_core/variance.py +219 -0
  182. devtorch_core/wrapper/__init__.py +52 -0
  183. devtorch_core/wrapper/anthropic.py +487 -0
  184. devtorch_core/wrapper/base.py +562 -0
  185. devtorch_core/wrapper/bedrock.py +342 -0
  186. devtorch_core/wrapper/gemini.py +422 -0
  187. devtorch_core/wrapper/ollama.py +527 -0
  188. devtorch_core/wrapper/openai.py +461 -0
  189. devtorch_core-3.0.1.dist-info/METADATA +867 -0
  190. devtorch_core-3.0.1.dist-info/RECORD +193 -0
  191. devtorch_core-3.0.1.dist-info/WHEEL +5 -0
  192. devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
  193. devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,727 @@
1
+ """
2
+ devtorch_core.gateway.server
3
+ ==============================
4
+ Enterprise multi-tenant org proxy — the cloud-hosted counterpart to the
5
+ local proxy (devtorch_core/proxy/server.py).
6
+
7
+ Architecture overview:
8
+ Developer IDE → GatewayServer (port 8080, inside org VPC)
9
+
10
+ SSO validation
11
+ Policy enforcement
12
+ RACP prefix injection
13
+
14
+ Anthropic / OpenAI API
15
+
16
+ Key differences from the local proxy:
17
+ - Handles MANY developers simultaneously (ThreadingHTTPServer).
18
+ - Validates developer identity via SSO JWT rather than trusting localhost.
19
+ - Substitutes the ORG-managed API key for any developer-supplied key.
20
+ - Enforces GovernancePolicy (model allowlist, token quota).
21
+ - Tracks per-developer token usage across the working day.
22
+ - Pushes aggregate (never prompt-level) metrics via MetricsWebhook.
23
+
24
+ Privacy guarantee:
25
+ Prompt text, code, and reasoning tokens are NEVER logged, stored, or
26
+ forwarded outside the org's own cloud. Only token counts and scores
27
+ reach the metrics webhook.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import threading
34
+ import time
35
+ import urllib.request
36
+ import urllib.error
37
+ import uuid
38
+ from datetime import date
39
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
40
+ from pathlib import Path
41
+ from typing import Optional
42
+
43
+ from devtorch_core.metrics.session_writer import write_session_metrics
44
+
45
+ from .key_manager import KeyManager
46
+ from .policy import GovernancePolicy, PolicyEngine
47
+ from .sso import SSOValidator
48
+ from .metrics_webhook import MetricsWebhook, MetricsWebhookConfig, load_metrics_webhook_config
49
+
50
+ logger = logging.getLogger("devtorch.gateway.server")
51
+
52
+ GATEWAY_VERSION = "0.7.0"
53
+
54
+ # How often (seconds) accumulated metrics are pushed to the webhook.
55
+ METRICS_PUSH_INTERVAL_SECS = 300 # 5 minutes
56
+
57
+ # Paths that are routed to Anthropic vs OpenAI.
58
+ _ANTHROPIC_PATH_PREFIXES = ("/v1/messages", "/v1/complete")
59
+ _OPENAI_PATH_PREFIXES = ("/v1/chat", "/v1/completions", "/v1/embeddings")
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Session tracking
64
+ # ---------------------------------------------------------------------------
65
+
66
+ class _SessionStore:
67
+ """
68
+ Thread-safe store for per-developer session data.
69
+
70
+ Keys are (user_id: str, day: date).
71
+ """
72
+
73
+ def __init__(self) -> None:
74
+ self._lock = threading.Lock()
75
+ self._sessions: dict[tuple, dict] = {}
76
+
77
+ def get_or_create(self, user_id: str) -> dict:
78
+ key = (user_id, date.today())
79
+ with self._lock:
80
+ if key not in self._sessions:
81
+ self._sessions[key] = {
82
+ "user_id": user_id,
83
+ "tokens_used": 0,
84
+ "request_count": 0,
85
+ "start_ts": time.time(),
86
+ "mcs_score": None,
87
+ "dhs_score": None,
88
+ "collision_count": 0,
89
+ }
90
+ return self._sessions[key]
91
+
92
+ def add_tokens(self, user_id: str, tokens: int) -> None:
93
+ sess = self.get_or_create(user_id)
94
+ with self._lock:
95
+ sess["tokens_used"] += tokens
96
+ sess["request_count"] += 1
97
+
98
+ def tokens_used(self, user_id: str) -> int:
99
+ key = (user_id, date.today())
100
+ with self._lock:
101
+ return self._sessions.get(key, {}).get("tokens_used", 0)
102
+
103
+ def session_count(self) -> int:
104
+ with self._lock:
105
+ return len(self._sessions)
106
+
107
+ def all_sessions(self) -> list[dict]:
108
+ with self._lock:
109
+ import copy
110
+ return [copy.copy(s) for s in self._sessions.values()]
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Request handler
115
+ # ---------------------------------------------------------------------------
116
+
117
+ class _GatewayHandler(BaseHTTPRequestHandler):
118
+ """
119
+ HTTP request handler for the org gateway.
120
+
121
+ server.gateway_context is set by GatewayServer before serving starts.
122
+ """
123
+
124
+ # Silence default request logging — we log selectively.
125
+ def log_message(self, fmt: str, *args: object) -> None: # type: ignore[override]
126
+ pass
127
+
128
+ # ------------------------------------------------------------------
129
+ # Routing
130
+ # ------------------------------------------------------------------
131
+
132
+ def do_GET(self) -> None:
133
+ if self.path == "/health" or self.path.startswith("/health?"):
134
+ self._handle_health()
135
+ elif self.path == "/metrics" or self.path.startswith("/metrics?"):
136
+ self._handle_metrics()
137
+ elif self.path == "/policy" or self.path.startswith("/policy?"):
138
+ self._handle_policy()
139
+ else:
140
+ self._send_json(404, {"error": "not found"})
141
+
142
+ def do_POST(self) -> None:
143
+ self._handle_llm_request()
144
+
145
+ def do_PUT(self) -> None:
146
+ self._handle_llm_request()
147
+
148
+ def do_PATCH(self) -> None:
149
+ self._handle_llm_request()
150
+
151
+ def do_DELETE(self) -> None:
152
+ self._handle_llm_request()
153
+
154
+ # ------------------------------------------------------------------
155
+ # Health / metrics
156
+ # ------------------------------------------------------------------
157
+
158
+ def _handle_health(self) -> None:
159
+ ctx = self.server.gateway_context # type: ignore[attr-defined]
160
+ body = {
161
+ "status": "ok",
162
+ "gateway_version": GATEWAY_VERSION,
163
+ "sessions": ctx["sessions"].session_count(),
164
+ "policy": ctx["policy_engine"].policy.to_summary(),
165
+ }
166
+ self._send_json(200, body)
167
+
168
+ def _require_auth(self) -> Optional[dict]:
169
+ """Validate the Authorization header and return claims, or send 401."""
170
+ ctx = self.server.gateway_context # type: ignore[attr-defined]
171
+ sso: SSOValidator = ctx["sso"]
172
+ auth_header = self.headers.get("Authorization", "")
173
+ token = auth_header.removeprefix("Bearer ").strip() if auth_header else ""
174
+ claims = sso.validate_token(token)
175
+ if claims is None:
176
+ self._send_json(401, {"error": "invalid or missing SSO token"})
177
+ return None
178
+ return claims
179
+
180
+ def _handle_metrics(self) -> None:
181
+ """
182
+ Return the current aggregate metrics payload without transmitting it.
183
+
184
+ Requires the same SSO auth as the proxy path. Health endpoint stays
185
+ public for load balancers.
186
+ """
187
+ if self._require_auth() is None:
188
+ return
189
+ ctx = self.server.gateway_context # type: ignore[attr-defined]
190
+ metrics_webhook: MetricsWebhook = ctx["metrics_webhook"]
191
+ sessions = ctx["sessions"].all_sessions()
192
+ payload = metrics_webhook.build_payload(sessions)
193
+ self._send_json(200, payload)
194
+
195
+ def _handle_policy(self) -> None:
196
+ """Return the current governance policy summary. Requires SSO auth."""
197
+ if self._require_auth() is None:
198
+ return
199
+ ctx = self.server.gateway_context # type: ignore[attr-defined]
200
+ policy_engine: PolicyEngine = ctx["policy_engine"]
201
+ body = policy_engine.policy.to_summary()
202
+ self._send_json(200, body)
203
+
204
+ # ------------------------------------------------------------------
205
+ # LLM proxy
206
+ # ------------------------------------------------------------------
207
+
208
+ def _handle_llm_request(self) -> None:
209
+ ctx = self.server.gateway_context # type: ignore[attr-defined]
210
+
211
+ # 1. Read request body.
212
+ try:
213
+ content_length = int(self.headers.get("Content-Length", 0))
214
+ raw_body = self.rfile.read(content_length) if content_length else b""
215
+ except Exception:
216
+ self._send_json(400, {"error": "could not read request body"})
217
+ return
218
+
219
+ # 2. Parse JSON body (best-effort).
220
+ try:
221
+ request_body: dict = json.loads(raw_body) if raw_body else {}
222
+ except Exception:
223
+ request_body = {}
224
+
225
+ # 3. SSO — extract and validate identity.
226
+ auth_header = self.headers.get("Authorization", "")
227
+ token = auth_header.removeprefix("Bearer ").strip() if auth_header else ""
228
+
229
+ sso: SSOValidator = ctx["sso"]
230
+ claims = sso.validate_token(token)
231
+ if claims is None:
232
+ self._send_json(401, {"error": "invalid or missing SSO token"})
233
+ return
234
+ identity = sso.extract_identity(claims)
235
+ user_id = identity["user_id"]
236
+
237
+ # 3b. Per-request session attribution.
238
+ session_id = f"{user_id}-{date.today().isoformat()}-{uuid.uuid4().hex[:8]}"
239
+
240
+ # 4. Policy — check request.
241
+ model = request_body.get("model", "")
242
+ sessions: _SessionStore = ctx["sessions"]
243
+ tokens_used = sessions.tokens_used(user_id)
244
+ policy_engine: PolicyEngine = ctx["policy_engine"]
245
+
246
+ allowed, reason = policy_engine.check_request(
247
+ model=model,
248
+ messages=request_body.get("messages", []),
249
+ session_tokens_used=tokens_used,
250
+ )
251
+ if not allowed:
252
+ self._send_json(403, {"error": reason})
253
+ return
254
+
255
+ # 5. Substitute org API key.
256
+ forwarded_headers = self._build_forwarded_headers(ctx)
257
+
258
+ # 6. RACP prefix injection (best-effort — never fail the request).
259
+ try:
260
+ from devtorch_core.wrapper.base import RACPInjector
261
+ gcc_repo = ctx.get("gcc_repo")
262
+ if gcc_repo is not None:
263
+ injector = RACPInjector(gcc_repo)
264
+ prefix = injector.build_system_prefix()
265
+ if prefix:
266
+ request_body = _inject_racp_prefix(request_body, prefix)
267
+ raw_body = json.dumps(request_body).encode("utf-8")
268
+ except Exception as exc:
269
+ logger.debug("RACP injection skipped: %s", exc)
270
+
271
+ # 7. Forward to upstream.
272
+ upstream_url = self._resolve_upstream(ctx)
273
+ if not upstream_url:
274
+ self._send_json(502, {"error": "could not determine upstream URL"})
275
+ return
276
+
277
+ target_url = upstream_url.rstrip("/") + self.path
278
+
279
+ try:
280
+ upstream_req = urllib.request.Request(
281
+ target_url,
282
+ data=raw_body if raw_body else None,
283
+ method=self.command,
284
+ headers=forwarded_headers,
285
+ )
286
+ with urllib.request.urlopen(upstream_req, timeout=120) as upstream_resp:
287
+ resp_body = upstream_resp.read()
288
+ resp_status = upstream_resp.status
289
+ resp_headers = dict(upstream_resp.headers)
290
+ except urllib.error.HTTPError as exc:
291
+ resp_body = exc.read()
292
+ resp_status = exc.code
293
+ resp_headers = {}
294
+ except Exception as exc:
295
+ logger.warning("Gateway upstream error: %s", exc)
296
+ self._send_json(502, {"error": f"upstream error: {exc}"})
297
+ return
298
+
299
+ # 8. Parse upstream response.
300
+ try:
301
+ response_body: dict = json.loads(resp_body) if resp_body else {}
302
+ except Exception:
303
+ response_body = {}
304
+
305
+ # 9. Policy — check response (warning only, never blocks).
306
+ has_racp_blocks = _detect_racp_blocks(resp_body)
307
+ _, compliance_warning = policy_engine.check_response(
308
+ response_body=response_body,
309
+ has_racp_blocks=has_racp_blocks,
310
+ )
311
+ if compliance_warning:
312
+ logger.warning(
313
+ "RACP compliance warning for user %s: %s", user_id, compliance_warning
314
+ )
315
+
316
+ # 10. Token accounting (from usage field in response).
317
+ tokens_in_response = _extract_token_count(response_body)
318
+ if tokens_in_response:
319
+ sessions.add_tokens(user_id, tokens_in_response)
320
+ else:
321
+ # Fallback: count tokens in request body as a rough estimate.
322
+ sessions.add_tokens(user_id, _estimate_tokens(request_body))
323
+
324
+ # 11. Capture orchestration (best-effort).
325
+ try:
326
+ from devtorch_core.wrapper.base import CaptureOrchestrator
327
+ gcc_repo = ctx.get("gcc_repo")
328
+ if gcc_repo is not None:
329
+ orchestrator = CaptureOrchestrator(gcc_repo)
330
+ response_text = _extract_response_text(response_body)
331
+ if response_text:
332
+ orchestrator.capture(
333
+ response_text=response_text,
334
+ thinking_blocks=[],
335
+ session_id=session_id,
336
+ )
337
+ except Exception as exc:
338
+ logger.debug("Capture skipped: %s", exc)
339
+
340
+ # 12. Session metrics write (best-effort).
341
+ try:
342
+ gcc_repo_path = ctx.get("gcc_repo_path")
343
+ if gcc_repo_path:
344
+ write_session_metrics(
345
+ gcc_dir=Path(gcc_repo_path),
346
+ session_id=session_id,
347
+ data={
348
+ "developer_id": user_id,
349
+ "tokens_used": _extract_token_count(response_body)
350
+ or _estimate_tokens(request_body),
351
+ "request_count": 1,
352
+ "bundle_tokens": 0,
353
+ "full_history_tokens": 0,
354
+ "latency_speedup_factor": 0.0,
355
+ "coverage": 0.0,
356
+ "confidence_distribution": {},
357
+ "cold_start_ms": 0.0,
358
+ },
359
+ )
360
+ except Exception as exc:
361
+ logger.debug("Session metrics write skipped: %s", exc)
362
+
363
+ # 13. Return response to the developer.
364
+ self._send_raw(resp_status, resp_body, resp_headers)
365
+
366
+ # ------------------------------------------------------------------
367
+ # Helpers
368
+ # ------------------------------------------------------------------
369
+
370
+ def _build_forwarded_headers(self, ctx: dict) -> dict:
371
+ """
372
+ Build headers for the upstream request.
373
+
374
+ Substitutes the org API key for any developer-supplied key.
375
+ """
376
+ headers: dict = {}
377
+
378
+ # Copy Content-Type
379
+ ct = self.headers.get("Content-Type", "application/json")
380
+ headers["Content-Type"] = ct
381
+
382
+ # Determine which org key to use based on path.
383
+ path = self.path or ""
384
+ key_manager: KeyManager = ctx.get("key_manager")
385
+
386
+ is_anthropic = any(path.startswith(p) for p in _ANTHROPIC_PATH_PREFIXES)
387
+ org_anthropic_key = key_manager.get_key("anthropic") if key_manager else ""
388
+ org_openai_key = key_manager.get_key("openai") if key_manager else ""
389
+
390
+ # Fallback to legacy ctx keys for backward compatibility.
391
+ if not org_anthropic_key:
392
+ org_anthropic_key = ctx.get("anthropic_api_key", "")
393
+ if not org_openai_key:
394
+ org_openai_key = ctx.get("openai_api_key", "")
395
+
396
+ if is_anthropic and org_anthropic_key:
397
+ headers["x-api-key"] = org_anthropic_key
398
+ headers["anthropic-version"] = self.headers.get(
399
+ "anthropic-version", "2023-06-01"
400
+ )
401
+ elif not is_anthropic and org_openai_key:
402
+ headers["Authorization"] = f"Bearer {org_openai_key}"
403
+ else:
404
+ # Pass through whatever the developer sent (masked).
405
+ auth = self.headers.get("Authorization", "")
406
+ if auth:
407
+ headers["Authorization"] = auth
408
+ x_api = self.headers.get("x-api-key", "")
409
+ if x_api:
410
+ headers["x-api-key"] = x_api
411
+
412
+ # Forward anthropic-specific headers
413
+ for hdr in ("anthropic-beta", "anthropic-version"):
414
+ val = self.headers.get(hdr)
415
+ if val:
416
+ headers[hdr] = val
417
+
418
+ return headers
419
+
420
+ def _resolve_upstream(self, ctx: dict) -> str:
421
+ """
422
+ Pick the upstream base URL based on the request path.
423
+ """
424
+ path = self.path or ""
425
+ if any(path.startswith(p) for p in _ANTHROPIC_PATH_PREFIXES):
426
+ return ctx.get("upstream_anthropic", "https://api.anthropic.com")
427
+ return ctx.get("upstream_openai", "https://api.openai.com")
428
+
429
+ def _send_json(self, status: int, body: dict) -> None:
430
+ data = json.dumps(body).encode("utf-8")
431
+ self._send_raw(status, data, {"Content-Type": "application/json"})
432
+
433
+ def _send_raw(self, status: int, body: bytes, headers: dict) -> None:
434
+ self.send_response(status)
435
+ ct = headers.get("Content-Type", "application/json")
436
+ self.send_header("Content-Type", ct)
437
+ self.send_header("Content-Length", str(len(body)))
438
+ self.send_header("X-DevTorch-Gateway", GATEWAY_VERSION)
439
+ self.end_headers()
440
+ self.wfile.write(body)
441
+
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # GatewayServer
445
+ # ---------------------------------------------------------------------------
446
+
447
+ class GatewayServer:
448
+ """
449
+ Multi-tenant org proxy server.
450
+
451
+ Runs a ThreadingHTTPServer so that concurrent IDE requests from many
452
+ developers are handled without blocking each other.
453
+
454
+ Parameters
455
+ ----------
456
+ port:
457
+ TCP port to listen on. Default 8080.
458
+ upstream_anthropic:
459
+ Base URL of the Anthropic API.
460
+ upstream_openai:
461
+ Base URL of the OpenAI API.
462
+ anthropic_api_key:
463
+ Central org Anthropic key. Overrides any developer-supplied key.
464
+ openai_api_key:
465
+ Central org OpenAI key. Overrides any developer-supplied key.
466
+ policy:
467
+ GovernancePolicy instance. Defaults to GovernancePolicy.default().
468
+ sso:
469
+ SSOValidator instance. Defaults to provider="none" (anonymous).
470
+ metrics_webhook:
471
+ MetricsWebhook instance. Disabled by default.
472
+ metrics_webhook_config:
473
+ MetricsWebhookConfig instance. If not provided, configuration is
474
+ loaded from environment variables via load_metrics_webhook_config().
475
+ gcc_repo_path:
476
+ Path to the shared org .GCC/ directory. Optional.
477
+ """
478
+
479
+ def __init__(
480
+ self,
481
+ port: int = 8080,
482
+ host: str = "127.0.0.1",
483
+ upstream_anthropic: str = "https://api.anthropic.com",
484
+ upstream_openai: str = "https://api.openai.com",
485
+ anthropic_api_key: str = "",
486
+ openai_api_key: str = "",
487
+ policy: Optional[GovernancePolicy] = None,
488
+ sso: Optional[SSOValidator] = None,
489
+ metrics_webhook: Optional[MetricsWebhook] = None,
490
+ metrics_webhook_config: Optional[MetricsWebhookConfig] = None,
491
+ gcc_repo_path: str = "",
492
+ ) -> None:
493
+ self._port = port
494
+ self._host = host
495
+ self._upstream_anthropic = upstream_anthropic
496
+ self._upstream_openai = upstream_openai
497
+ self._anthropic_api_key = anthropic_api_key
498
+ self._openai_api_key = openai_api_key
499
+ self._policy = policy or GovernancePolicy.default()
500
+ self._sso = sso or SSOValidator(provider="none")
501
+ self._metrics_webhook_config = metrics_webhook_config or load_metrics_webhook_config()
502
+ self._metrics_webhook = metrics_webhook or MetricsWebhook(
503
+ webhook_url=self._metrics_webhook_config.url,
504
+ enabled=self._metrics_webhook_config.enabled,
505
+ api_key=self._metrics_webhook_config.api_key,
506
+ interval_seconds=self._metrics_webhook_config.interval_seconds,
507
+ )
508
+ self._gcc_repo_path = gcc_repo_path
509
+
510
+ # Centralised org API key management. Constructor-supplied keys take
511
+ # precedence over environment variables.
512
+ self._key_manager = KeyManager(
513
+ keys={
514
+ "anthropic": anthropic_api_key,
515
+ "openai": openai_api_key,
516
+ }
517
+ )
518
+
519
+ self._sessions = _SessionStore()
520
+ self._server: Optional[ThreadingHTTPServer] = None
521
+ self._metrics_timer: Optional[threading.Timer] = None
522
+
523
+ # Try to load gcc_repo if path provided.
524
+ self._gcc_repo = self._load_gcc_repo(gcc_repo_path)
525
+
526
+ # ------------------------------------------------------------------
527
+ # Lifecycle
528
+ # ------------------------------------------------------------------
529
+
530
+ def start(self, blocking: bool = True) -> None:
531
+ """
532
+ Start the gateway server.
533
+
534
+ Parameters
535
+ ----------
536
+ blocking:
537
+ If True (default), blocks until Ctrl+C / stop() is called.
538
+ If False, starts in a background thread and returns immediately.
539
+ """
540
+ context = {
541
+ "upstream_anthropic": self._upstream_anthropic,
542
+ "upstream_openai": self._upstream_openai,
543
+ "anthropic_api_key": self._anthropic_api_key,
544
+ "openai_api_key": self._openai_api_key,
545
+ "key_manager": self._key_manager,
546
+ "policy_engine": PolicyEngine(self._policy),
547
+ "sso": self._sso,
548
+ "sessions": self._sessions,
549
+ "metrics_webhook": self._metrics_webhook,
550
+ "gcc_repo": self._gcc_repo,
551
+ "gcc_repo_path": self._gcc_repo_path,
552
+ }
553
+
554
+ self._server = ThreadingHTTPServer((self._host, self._port), _GatewayHandler)
555
+ self._server.gateway_context = context # type: ignore[attr-defined]
556
+
557
+ logger.info("DevTorch org gateway listening on port %d", self._port)
558
+
559
+ self._schedule_metrics_push()
560
+
561
+ if blocking:
562
+ try:
563
+ self._server.serve_forever()
564
+ except KeyboardInterrupt:
565
+ pass
566
+ finally:
567
+ self.stop()
568
+ else:
569
+ t = threading.Thread(
570
+ target=self._server.serve_forever,
571
+ daemon=True,
572
+ name="devtorch-gateway",
573
+ )
574
+ t.start()
575
+
576
+ def stop(self) -> None:
577
+ """Shut down the gateway server gracefully."""
578
+ if self._metrics_timer is not None:
579
+ self._metrics_timer.cancel()
580
+ self._metrics_timer = None
581
+
582
+ if self._server is not None:
583
+ self._server.shutdown()
584
+ self._server = None
585
+
586
+ logger.info("DevTorch org gateway stopped")
587
+
588
+ # ------------------------------------------------------------------
589
+ # Metrics
590
+ # ------------------------------------------------------------------
591
+
592
+ def _schedule_metrics_push(self) -> None:
593
+ """Schedule a recurring metrics push via threading.Timer."""
594
+ if not self._metrics_webhook._enabled:
595
+ return
596
+
597
+ interval = getattr(
598
+ self._metrics_webhook, "_interval_seconds", METRICS_PUSH_INTERVAL_SECS
599
+ )
600
+
601
+ def _push_and_reschedule() -> None:
602
+ self._push_metrics()
603
+ self._schedule_metrics_push()
604
+
605
+ self._metrics_timer = threading.Timer(interval, _push_and_reschedule)
606
+ self._metrics_timer.daemon = True
607
+ self._metrics_timer.start()
608
+
609
+ def _push_metrics(self) -> None:
610
+ sessions = self._sessions.all_sessions()
611
+ payload = self._metrics_webhook.build_payload(sessions)
612
+ ok = self._metrics_webhook.push(payload)
613
+ logger.debug("Metrics push: %s (%d sessions)", "ok" if ok else "failed", len(sessions))
614
+
615
+ # ------------------------------------------------------------------
616
+ # Helpers
617
+ # ------------------------------------------------------------------
618
+
619
+ @staticmethod
620
+ def _load_gcc_repo(path: str) -> Optional[object]:
621
+ if not path:
622
+ return None
623
+ try:
624
+ from devtorch_core import GCCRepository
625
+ repo = GCCRepository.at(path)
626
+ if repo.is_initialized():
627
+ return repo
628
+ except Exception as exc:
629
+ logger.debug("GCC repo load skipped (%s): %s", path, exc)
630
+ return None
631
+
632
+
633
+ # ---------------------------------------------------------------------------
634
+ # Body helpers (pure functions — never log content)
635
+ # ---------------------------------------------------------------------------
636
+
637
+ def _inject_racp_prefix(request_body: dict, prefix: str) -> dict:
638
+ """
639
+ Prepend *prefix* to the system prompt in *request_body*.
640
+
641
+ Supports both Anthropic ({system: str}) and OpenAI ({messages: [...]}) formats.
642
+ Returns a shallow copy — does not mutate the original.
643
+ """
644
+ body = dict(request_body)
645
+
646
+ # Anthropic style: top-level "system" key
647
+ if "system" in body:
648
+ existing = body["system"] or ""
649
+ body["system"] = f"{prefix}\n\n{existing}".strip()
650
+ return body
651
+
652
+ # OpenAI style: messages array with role="system"
653
+ messages = list(body.get("messages", []))
654
+ if messages and messages[0].get("role") == "system":
655
+ msg = dict(messages[0])
656
+ msg["content"] = f"{prefix}\n\n{msg.get('content', '')}".strip()
657
+ messages[0] = msg
658
+ else:
659
+ messages.insert(0, {"role": "system", "content": prefix})
660
+ body["messages"] = messages
661
+ return body
662
+
663
+
664
+ def _detect_racp_blocks(resp_body: bytes) -> bool:
665
+ """Return True if the response body contains RACP block markers."""
666
+ if not resp_body:
667
+ return False
668
+ try:
669
+ text = resp_body.decode("utf-8", errors="ignore")
670
+ return "<racp>" in text.lower() or "devtorch_commit" in text.lower()
671
+ except Exception:
672
+ return False
673
+
674
+
675
+ def _extract_token_count(response_body: dict) -> int:
676
+ """
677
+ Extract total token usage from an LLM response body.
678
+
679
+ Handles Anthropic (usage.input_tokens + output_tokens) and
680
+ OpenAI (usage.total_tokens) formats.
681
+ """
682
+ usage = response_body.get("usage", {})
683
+ if not isinstance(usage, dict):
684
+ return 0
685
+
686
+ # OpenAI
687
+ if "total_tokens" in usage:
688
+ return int(usage["total_tokens"])
689
+
690
+ # Anthropic
691
+ return int(usage.get("input_tokens", 0)) + int(usage.get("output_tokens", 0))
692
+
693
+
694
+ def _estimate_tokens(request_body: dict) -> int:
695
+ """
696
+ Rough token estimate from request body (fallback when response has no usage).
697
+ Uses 4 chars ≈ 1 token heuristic. Never logs content.
698
+ """
699
+ try:
700
+ raw = json.dumps(request_body)
701
+ return max(1, len(raw) // 4)
702
+ except Exception:
703
+ return 1
704
+
705
+
706
+ def _extract_response_text(response_body: dict) -> str:
707
+ """
708
+ Extract the text of the first assistant turn from a response body.
709
+
710
+ Returns empty string on any error — never raises.
711
+ """
712
+ try:
713
+ # Anthropic
714
+ content = response_body.get("content", [])
715
+ if isinstance(content, list):
716
+ for block in content:
717
+ if isinstance(block, dict) and block.get("type") == "text":
718
+ return str(block.get("text", ""))
719
+
720
+ # OpenAI
721
+ choices = response_body.get("choices", [])
722
+ if isinstance(choices, list) and choices:
723
+ msg = choices[0].get("message", {})
724
+ return str(msg.get("content", ""))
725
+ except Exception:
726
+ pass
727
+ return ""