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,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 ""
|