fleet-python 0.2.66b2__py3-none-any.whl → 0.2.105__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 (70) hide show
  1. examples/export_tasks.py +16 -5
  2. examples/export_tasks_filtered.py +245 -0
  3. examples/fetch_tasks.py +230 -0
  4. examples/import_tasks.py +140 -8
  5. examples/iterate_verifiers.py +725 -0
  6. fleet/__init__.py +128 -5
  7. fleet/_async/__init__.py +27 -3
  8. fleet/_async/base.py +24 -9
  9. fleet/_async/client.py +938 -41
  10. fleet/_async/env/client.py +60 -3
  11. fleet/_async/instance/client.py +52 -7
  12. fleet/_async/models.py +15 -0
  13. fleet/_async/resources/api.py +200 -0
  14. fleet/_async/resources/sqlite.py +1801 -46
  15. fleet/_async/tasks.py +122 -25
  16. fleet/_async/verifiers/bundler.py +22 -21
  17. fleet/_async/verifiers/verifier.py +25 -19
  18. fleet/agent/__init__.py +32 -0
  19. fleet/agent/gemini_cua/Dockerfile +45 -0
  20. fleet/agent/gemini_cua/__init__.py +10 -0
  21. fleet/agent/gemini_cua/agent.py +759 -0
  22. fleet/agent/gemini_cua/mcp/main.py +108 -0
  23. fleet/agent/gemini_cua/mcp_server/__init__.py +5 -0
  24. fleet/agent/gemini_cua/mcp_server/main.py +105 -0
  25. fleet/agent/gemini_cua/mcp_server/tools.py +178 -0
  26. fleet/agent/gemini_cua/requirements.txt +5 -0
  27. fleet/agent/gemini_cua/start.sh +30 -0
  28. fleet/agent/orchestrator.py +854 -0
  29. fleet/agent/types.py +49 -0
  30. fleet/agent/utils.py +34 -0
  31. fleet/base.py +34 -9
  32. fleet/cli.py +1061 -0
  33. fleet/client.py +1060 -48
  34. fleet/config.py +1 -1
  35. fleet/env/__init__.py +16 -0
  36. fleet/env/client.py +60 -3
  37. fleet/eval/__init__.py +15 -0
  38. fleet/eval/uploader.py +231 -0
  39. fleet/exceptions.py +8 -0
  40. fleet/instance/client.py +53 -8
  41. fleet/instance/models.py +1 -0
  42. fleet/models.py +303 -0
  43. fleet/proxy/__init__.py +25 -0
  44. fleet/proxy/proxy.py +453 -0
  45. fleet/proxy/whitelist.py +244 -0
  46. fleet/resources/api.py +200 -0
  47. fleet/resources/sqlite.py +1845 -46
  48. fleet/tasks.py +113 -20
  49. fleet/utils/__init__.py +7 -0
  50. fleet/utils/http_logging.py +178 -0
  51. fleet/utils/logging.py +13 -0
  52. fleet/utils/playwright.py +440 -0
  53. fleet/verifiers/bundler.py +22 -21
  54. fleet/verifiers/db.py +985 -1
  55. fleet/verifiers/decorator.py +1 -1
  56. fleet/verifiers/verifier.py +25 -19
  57. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/METADATA +28 -1
  58. fleet_python-0.2.105.dist-info/RECORD +115 -0
  59. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/WHEEL +1 -1
  60. fleet_python-0.2.105.dist-info/entry_points.txt +2 -0
  61. tests/test_app_method.py +85 -0
  62. tests/test_expect_exactly.py +4148 -0
  63. tests/test_expect_only.py +2593 -0
  64. tests/test_instance_dispatch.py +607 -0
  65. tests/test_sqlite_resource_dual_mode.py +263 -0
  66. tests/test_sqlite_shared_memory_behavior.py +117 -0
  67. fleet_python-0.2.66b2.dist-info/RECORD +0 -81
  68. tests/test_verifier_security.py +0 -427
  69. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/licenses/LICENSE +0 -0
  70. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/top_level.txt +0 -0
fleet/proxy/proxy.py ADDED
@@ -0,0 +1,453 @@
1
+ """Simple HTTP proxy for capturing traffic during eval runs.
2
+
3
+ Captures requests/responses to whitelisted endpoints and writes them to a JSONL file.
4
+ Uses aiohttp as a simple HTTP proxy (no SSL interception, just tunneling for HTTPS).
5
+
6
+ Whitelist tiers:
7
+ 1. Static: Known public LLM endpoints (googleapis.com, openai.com, etc.)
8
+ 2. Runtime: Dynamically detected from SDK client initialization
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import os
15
+ import sys
16
+ import time
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Optional, Set
20
+ from urllib.parse import urlparse
21
+ import ssl
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Output directory
26
+ DEFAULT_LOG_DIR = Path.home() / ".fleet" / "proxy_logs"
27
+
28
+ # Import whitelist utilities
29
+ try:
30
+ from .whitelist import is_whitelisted, get_full_whitelist, STATIC_WHITELIST
31
+ except ImportError:
32
+ # Fallback for subprocess execution
33
+ from fleet.proxy.whitelist import is_whitelisted, get_full_whitelist, STATIC_WHITELIST
34
+
35
+ # Headers to redact (case-insensitive)
36
+ SENSITIVE_HEADERS = {
37
+ "authorization",
38
+ "x-api-key",
39
+ "api-key",
40
+ "x-goog-api-key",
41
+ "x-gemini-api-key",
42
+ "x-openai-api-key",
43
+ "x-anthropic-api-key",
44
+ "cookie",
45
+ "set-cookie",
46
+ "x-auth-token",
47
+ "x-access-token",
48
+ "x-refresh-token",
49
+ "proxy-authorization",
50
+ }
51
+
52
+
53
+ def redact_headers(headers: dict) -> dict:
54
+ """Redact sensitive headers from a headers dict."""
55
+ redacted = {}
56
+ for k, v in headers.items():
57
+ if k.lower() in SENSITIVE_HEADERS:
58
+ # Keep first 8 chars for debugging, redact rest
59
+ if len(v) > 12:
60
+ redacted[k] = v[:8] + "...[REDACTED]"
61
+ else:
62
+ redacted[k] = "[REDACTED]"
63
+ else:
64
+ redacted[k] = v
65
+ return redacted
66
+
67
+
68
+ def extract_host(url: str) -> str:
69
+ """Extract host from URL."""
70
+ if "://" not in url:
71
+ url = "http://" + url
72
+ parsed = urlparse(url)
73
+ return parsed.netloc.split(":")[0] # Remove port if present
74
+
75
+
76
+ class TrafficLogger:
77
+ """Logs HTTP traffic to a JSONL file with whitelist filtering."""
78
+
79
+ def __init__(self, log_file: Path, use_whitelist: bool = True):
80
+ self.log_file = log_file
81
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
82
+ self._file = open(log_file, "a")
83
+ self._lock = asyncio.Lock()
84
+ self._use_whitelist = use_whitelist
85
+ self._logged_count = 0
86
+ self._skipped_count = 0
87
+
88
+ # Log active whitelist at startup
89
+ whitelist = get_full_whitelist()
90
+ logger.info(f"Traffic logging to: {log_file}")
91
+ logger.info(f"Whitelist active: {sorted(whitelist)}")
92
+ print(f"Traffic logging to: {log_file}")
93
+ print(f"Whitelist ({len(whitelist)} endpoints): {', '.join(sorted(whitelist)[:5])}{'...' if len(whitelist) > 5 else ''}")
94
+
95
+ def should_log(self, host: str) -> bool:
96
+ """Check if traffic to this host should be logged."""
97
+ if not self._use_whitelist:
98
+ return True
99
+ return is_whitelisted(host)
100
+
101
+ async def log(self, entry: dict, host: Optional[str] = None):
102
+ """Log a traffic entry if it passes whitelist filter."""
103
+ # Check whitelist
104
+ if host and not self.should_log(host):
105
+ self._skipped_count += 1
106
+ return
107
+
108
+ entry["logged_at"] = datetime.now().isoformat()
109
+ async with self._lock:
110
+ self._file.write(json.dumps(entry) + "\n")
111
+ self._file.flush()
112
+ self._logged_count += 1
113
+
114
+ def close(self):
115
+ logger.info(f"Traffic logger closed. Logged: {self._logged_count}, Skipped: {self._skipped_count}")
116
+ self._file.close()
117
+
118
+ @property
119
+ def logged_count(self) -> int:
120
+ return self._logged_count
121
+
122
+ @property
123
+ def skipped_count(self) -> int:
124
+ return self._skipped_count
125
+
126
+
127
+ async def run_proxy_server(
128
+ host: str = "127.0.0.1",
129
+ port: int = 8888,
130
+ log_file: Optional[Path] = None,
131
+ use_whitelist: bool = True,
132
+ ):
133
+ """Run a simple HTTP proxy server with whitelist filtering.
134
+
135
+ This proxy:
136
+ - Logs requests/responses to whitelisted endpoints (LLM APIs)
137
+ - Tunnels HTTPS via CONNECT (logs URL but not content)
138
+ - Filters out non-whitelisted traffic (reduces noise)
139
+ """
140
+ import aiohttp
141
+ from aiohttp import web
142
+
143
+ log_file = log_file or (DEFAULT_LOG_DIR / f"traffic_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl")
144
+ traffic_logger = TrafficLogger(log_file, use_whitelist=use_whitelist)
145
+
146
+ async def handle_connect(request: web.Request):
147
+ """Handle CONNECT for HTTPS tunneling."""
148
+ # Parse host:port from request.path
149
+ target = request.path_qs
150
+ if ":" in target:
151
+ target_host, target_port = target.rsplit(":", 1)
152
+ target_port = int(target_port)
153
+ else:
154
+ target_host = target
155
+ target_port = 443
156
+
157
+ # Log the CONNECT request (we can see the host but not the content)
158
+ # Only log if host is whitelisted
159
+ await traffic_logger.log({
160
+ "type": "https_connect",
161
+ "timestamp": datetime.now().isoformat(),
162
+ "method": "CONNECT",
163
+ "host": target_host,
164
+ "port": target_port,
165
+ "path": request.path_qs,
166
+ }, host=target_host)
167
+
168
+ # Connect to target
169
+ try:
170
+ reader, writer = await asyncio.open_connection(target_host, target_port)
171
+ except Exception as e:
172
+ await traffic_logger.log({
173
+ "type": "https_connect_error",
174
+ "timestamp": datetime.now().isoformat(),
175
+ "host": target_host,
176
+ "port": target_port,
177
+ "error": str(e),
178
+ }, host=target_host)
179
+ return web.Response(status=502, text=f"Failed to connect: {e}")
180
+
181
+ # Send 200 Connection Established
182
+ response = web.StreamResponse(status=200, reason="Connection Established")
183
+ await response.prepare(request)
184
+
185
+ # Get the underlying transport
186
+ # This is a hack to get raw socket access in aiohttp
187
+ transport = request.transport
188
+
189
+ async def pipe(reader_from, writer_to):
190
+ try:
191
+ while True:
192
+ data = await reader_from.read(65536)
193
+ if not data:
194
+ break
195
+ writer_to.write(data)
196
+ await writer_to.drain()
197
+ except:
198
+ pass
199
+ finally:
200
+ try:
201
+ writer_to.close()
202
+ except:
203
+ pass
204
+
205
+ # Pipe data in both directions
206
+ client_reader = request.content
207
+
208
+ # For CONNECT tunneling, we need to hijack the connection
209
+ # This is complex with aiohttp, so we'll just close and note it
210
+ writer.close()
211
+ await traffic_logger.log({
212
+ "type": "https_tunnel_started",
213
+ "timestamp": datetime.now().isoformat(),
214
+ "host": target_host,
215
+ "port": target_port,
216
+ }, host=target_host)
217
+
218
+ return response
219
+
220
+ async def handle_request(request: web.Request):
221
+ """Handle HTTP requests (non-CONNECT)."""
222
+ start_time = time.time()
223
+
224
+ # Build target URL
225
+ url = str(request.url)
226
+ if not url.startswith("http"):
227
+ # Relative URL - reconstruct from Host header
228
+ host_header = request.headers.get("Host", "")
229
+ url = f"http://{host_header}{request.path_qs}"
230
+
231
+ # Extract host for whitelist filtering
232
+ target_host = extract_host(url)
233
+
234
+ # Log request (redact sensitive headers)
235
+ request_entry = {
236
+ "method": request.method,
237
+ "url": url,
238
+ "headers": redact_headers(dict(request.headers)),
239
+ }
240
+
241
+ # Read request body
242
+ try:
243
+ body = await request.read()
244
+ if body:
245
+ request_entry["body_length"] = len(body)
246
+ # Include body for JSON
247
+ content_type = request.headers.get("Content-Type", "")
248
+ if "json" in content_type:
249
+ try:
250
+ body_str = body.decode("utf-8", errors="replace")
251
+ if len(body_str) > 10000:
252
+ body_str = body_str[:10000] + "...[truncated]"
253
+ request_entry["body"] = body_str
254
+ except:
255
+ pass
256
+ except:
257
+ body = None
258
+
259
+ # Forward request
260
+ try:
261
+ async with aiohttp.ClientSession() as session:
262
+ async with session.request(
263
+ method=request.method,
264
+ url=url,
265
+ headers={k: v for k, v in request.headers.items() if k.lower() not in ("host", "connection")},
266
+ data=body,
267
+ ssl=False, # Don't verify SSL
268
+ ) as resp:
269
+ duration_ms = int((time.time() - start_time) * 1000)
270
+
271
+ # Read response body
272
+ resp_body = await resp.read()
273
+
274
+ # Build response entry (redact sensitive headers)
275
+ response_entry = {
276
+ "status_code": resp.status,
277
+ "headers": redact_headers(dict(resp.headers)),
278
+ "body_length": len(resp_body) if resp_body else 0,
279
+ }
280
+
281
+ # Include body for JSON and SSE responses (MCP uses text/event-stream)
282
+ content_type = resp.headers.get("Content-Type", "")
283
+ if resp_body and ("json" in content_type or "event-stream" in content_type or "text" in content_type):
284
+ try:
285
+ body_str = resp_body.decode("utf-8", errors="replace")
286
+ if len(body_str) > 50000:
287
+ body_str = body_str[:50000] + "...[truncated]"
288
+ response_entry["body"] = body_str
289
+ except:
290
+ pass
291
+
292
+ # Log complete entry (only if host is whitelisted)
293
+ await traffic_logger.log({
294
+ "type": "http",
295
+ "timestamp": datetime.now().isoformat(),
296
+ "duration_ms": duration_ms,
297
+ "host": target_host,
298
+ "request": request_entry,
299
+ "response": response_entry,
300
+ }, host=target_host)
301
+
302
+ # Return response to client
303
+ return web.Response(
304
+ status=resp.status,
305
+ headers={k: v for k, v in resp.headers.items() if k.lower() not in ("transfer-encoding", "content-encoding", "connection")},
306
+ body=resp_body,
307
+ )
308
+
309
+ except Exception as e:
310
+ await traffic_logger.log({
311
+ "type": "http_error",
312
+ "timestamp": datetime.now().isoformat(),
313
+ "host": target_host,
314
+ "request": request_entry,
315
+ "error": str(e),
316
+ }, host=target_host)
317
+ return web.Response(status=502, text=f"Proxy error: {e}")
318
+
319
+ async def handler(request: web.Request):
320
+ """Main request handler."""
321
+ if request.method == "CONNECT":
322
+ return await handle_connect(request)
323
+ else:
324
+ return await handle_request(request)
325
+
326
+ # Create app
327
+ app = web.Application()
328
+ app.router.add_route("*", "/{path:.*}", handler)
329
+
330
+ runner = web.AppRunner(app)
331
+ await runner.setup()
332
+
333
+ site = web.TCPSite(runner, host, port)
334
+ await site.start()
335
+
336
+ print(f"Proxy listening on {host}:{port}")
337
+ print(f"Traffic logging to: {log_file}")
338
+ print("Press Ctrl+C to stop")
339
+
340
+ # Keep running
341
+ try:
342
+ while True:
343
+ await asyncio.sleep(3600)
344
+ except asyncio.CancelledError:
345
+ pass
346
+ finally:
347
+ traffic_logger.close()
348
+ await runner.cleanup()
349
+
350
+
351
+ class ProxyManager:
352
+ """Manages the lifecycle of the proxy server."""
353
+
354
+ def __init__(self, port: int = 8888, log_file: Optional[Path] = None):
355
+ self.port = port
356
+ self.log_file = log_file or (DEFAULT_LOG_DIR / f"traffic_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl")
357
+ self._process: Optional[asyncio.subprocess.Process] = None
358
+ self._started = False
359
+
360
+ async def start(self) -> dict:
361
+ """Start the proxy in a subprocess.
362
+
363
+ Returns:
364
+ dict with proxy env vars to set
365
+ """
366
+ if self._started:
367
+ return self.get_env_vars()
368
+
369
+ # Ensure log dir exists
370
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
371
+
372
+ # Start proxy as subprocess
373
+ self._process = await asyncio.create_subprocess_exec(
374
+ sys.executable, "-m", "fleet.proxy.proxy",
375
+ "--port", str(self.port),
376
+ "--log-file", str(self.log_file),
377
+ stdout=asyncio.subprocess.PIPE,
378
+ stderr=asyncio.subprocess.PIPE,
379
+ )
380
+
381
+ # Wait for proxy to be ready
382
+ await asyncio.sleep(2)
383
+
384
+ if self._process.returncode is not None:
385
+ stdout, stderr = await self._process.communicate()
386
+ raise RuntimeError(f"Proxy failed to start: {stderr.decode()}")
387
+
388
+ self._started = True
389
+ logger.info(f"Proxy started on port {self.port}, logging to {self.log_file}")
390
+
391
+ return self.get_env_vars()
392
+
393
+ def get_env_vars(self) -> dict:
394
+ """Get environment variables for using this proxy.
395
+
396
+ Note: We only proxy HTTP, not HTTPS, to avoid SSL issues with LLM APIs.
397
+ HTTPS traffic goes direct but we can't inspect the content.
398
+ """
399
+ proxy_url = f"http://127.0.0.1:{self.port}"
400
+ return {
401
+ "HTTP_PROXY": proxy_url,
402
+ "http_proxy": proxy_url,
403
+ # Don't proxy HTTPS - our simple proxy doesn't handle SSL interception
404
+ # This means we can only log HTTP traffic, not HTTPS API calls
405
+ # "HTTPS_PROXY": proxy_url,
406
+ # "https_proxy": proxy_url,
407
+ }
408
+
409
+ async def stop(self):
410
+ """Stop the proxy."""
411
+ if self._process and self._process.returncode is None:
412
+ self._process.terminate()
413
+ try:
414
+ await asyncio.wait_for(self._process.wait(), timeout=5)
415
+ except asyncio.TimeoutError:
416
+ self._process.kill()
417
+ await self._process.wait()
418
+ self._started = False
419
+ logger.info("Proxy stopped")
420
+
421
+ @property
422
+ def log_path(self) -> Path:
423
+ return self.log_file
424
+
425
+
426
+ def main():
427
+ """CLI entry point."""
428
+ import argparse
429
+
430
+ parser = argparse.ArgumentParser(description="Fleet HTTP Proxy with LLM endpoint whitelist")
431
+ parser.add_argument("--host", default="127.0.0.1", help="Host to bind to")
432
+ parser.add_argument("--port", type=int, default=8888, help="Port to listen on")
433
+ parser.add_argument("--log-file", type=Path, default=None, help="Log file path")
434
+ parser.add_argument("--no-whitelist", action="store_true",
435
+ help="Disable whitelist filtering (log ALL traffic)")
436
+ args = parser.parse_args()
437
+
438
+ logging.basicConfig(level=logging.INFO)
439
+
440
+ try:
441
+ asyncio.run(run_proxy_server(
442
+ host=args.host,
443
+ port=args.port,
444
+ log_file=args.log_file,
445
+ use_whitelist=not args.no_whitelist,
446
+ ))
447
+ except KeyboardInterrupt:
448
+ print("\nProxy stopped")
449
+
450
+
451
+ if __name__ == "__main__":
452
+ main()
453
+
@@ -0,0 +1,244 @@
1
+ """
2
+ Endpoint whitelist management for proxy capture.
3
+
4
+ Two-tier whitelist:
5
+ 1. Static: Known public LLM endpoints (in code)
6
+ 2. Runtime: Dynamically detected from SDK client initialization
7
+ """
8
+
9
+ import os
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Set
13
+ from urllib.parse import urlparse
14
+ import threading
15
+
16
+ # =============================================================================
17
+ # Static Whitelist - Known public LLM endpoints
18
+ # =============================================================================
19
+
20
+ STATIC_WHITELIST: Set[str] = {
21
+ # Google / Gemini
22
+ "generativelanguage.googleapis.com",
23
+ "aiplatform.googleapis.com",
24
+
25
+ # OpenAI
26
+ "api.openai.com",
27
+
28
+ # Anthropic
29
+ "api.anthropic.com",
30
+
31
+ # Azure OpenAI
32
+ "openai.azure.com",
33
+
34
+ # Cohere
35
+ "api.cohere.ai",
36
+
37
+ # Together AI
38
+ "api.together.xyz",
39
+
40
+ # Groq
41
+ "api.groq.com",
42
+
43
+ # Fireworks
44
+ "api.fireworks.ai",
45
+
46
+ # Replicate
47
+ "api.replicate.com",
48
+
49
+ # Mistral
50
+ "api.mistral.ai",
51
+ }
52
+
53
+ # =============================================================================
54
+ # Runtime Whitelist - Dynamically detected endpoints
55
+ # =============================================================================
56
+
57
+ _runtime_whitelist: Set[str] = set()
58
+ _whitelist_lock = threading.Lock()
59
+
60
+ # File for persisting runtime whitelist (shared with proxy subprocess)
61
+ _RUNTIME_WHITELIST_FILE = Path.home() / ".fleet" / "runtime_whitelist.json"
62
+
63
+
64
+ def _extract_host(url: str) -> str:
65
+ """Extract host from URL."""
66
+ if not url:
67
+ return ""
68
+ # Handle URLs without scheme
69
+ if "://" not in url:
70
+ url = "https://" + url
71
+ parsed = urlparse(url)
72
+ return parsed.netloc or parsed.path.split("/")[0]
73
+
74
+
75
+ def register_endpoint(endpoint: str) -> None:
76
+ """
77
+ Register an endpoint to the runtime whitelist.
78
+
79
+ Args:
80
+ endpoint: URL or hostname of the endpoint
81
+ """
82
+ host = _extract_host(endpoint)
83
+ if not host:
84
+ return
85
+
86
+ with _whitelist_lock:
87
+ _runtime_whitelist.add(host)
88
+ _save_runtime_whitelist()
89
+
90
+
91
+ def get_runtime_whitelist() -> Set[str]:
92
+ """Get the current runtime whitelist."""
93
+ with _whitelist_lock:
94
+ return _runtime_whitelist.copy()
95
+
96
+
97
+ def get_full_whitelist() -> Set[str]:
98
+ """Get combined static + runtime whitelist."""
99
+ _load_runtime_whitelist() # Refresh from file
100
+ with _whitelist_lock:
101
+ return STATIC_WHITELIST | _runtime_whitelist
102
+
103
+
104
+ def _save_runtime_whitelist() -> None:
105
+ """Persist runtime whitelist to file."""
106
+ try:
107
+ _RUNTIME_WHITELIST_FILE.parent.mkdir(parents=True, exist_ok=True)
108
+ with open(_RUNTIME_WHITELIST_FILE, "w") as f:
109
+ json.dump(list(_runtime_whitelist), f)
110
+ except Exception:
111
+ pass # Best effort
112
+
113
+
114
+ def _load_runtime_whitelist() -> None:
115
+ """Load runtime whitelist from file."""
116
+ global _runtime_whitelist
117
+ try:
118
+ if _RUNTIME_WHITELIST_FILE.exists():
119
+ with open(_RUNTIME_WHITELIST_FILE, "r") as f:
120
+ data = json.load(f)
121
+ with _whitelist_lock:
122
+ _runtime_whitelist = set(data)
123
+ except Exception:
124
+ pass # Best effort
125
+
126
+
127
+ def clear_runtime_whitelist() -> None:
128
+ """Clear the runtime whitelist."""
129
+ global _runtime_whitelist
130
+ with _whitelist_lock:
131
+ _runtime_whitelist.clear()
132
+ try:
133
+ if _RUNTIME_WHITELIST_FILE.exists():
134
+ _RUNTIME_WHITELIST_FILE.unlink()
135
+ except Exception:
136
+ pass
137
+
138
+
139
+ def is_whitelisted(host: str) -> bool:
140
+ """Check if a host is in the whitelist."""
141
+ host = host.lower()
142
+ whitelist = get_full_whitelist()
143
+
144
+ # Exact match
145
+ if host in whitelist:
146
+ return True
147
+
148
+ # Subdomain match (e.g., us-central1-aiplatform.googleapis.com)
149
+ for pattern in whitelist:
150
+ if host.endswith("." + pattern) or host.endswith(pattern):
151
+ return True
152
+
153
+ return False
154
+
155
+
156
+ # =============================================================================
157
+ # SDK Hooks - Auto-detect endpoints from client initialization
158
+ # =============================================================================
159
+
160
+ _original_genai_client_init = None
161
+
162
+
163
+ def _hook_genai_client():
164
+ """
165
+ Monkey-patch google.genai.Client to capture endpoint configuration.
166
+ Call this early in your application.
167
+ """
168
+ global _original_genai_client_init
169
+
170
+ try:
171
+ from google import genai
172
+ from google.genai import Client
173
+
174
+ if _original_genai_client_init is not None:
175
+ return # Already hooked
176
+
177
+ _original_genai_client_init = Client.__init__
178
+
179
+ def _patched_init(self, *args, **kwargs):
180
+ _original_genai_client_init(self, *args, **kwargs)
181
+
182
+ # Extract base_url from http_options
183
+ http_options = kwargs.get("http_options")
184
+ if http_options:
185
+ base_url = getattr(http_options, "base_url", None)
186
+ if base_url:
187
+ register_endpoint(base_url)
188
+ return
189
+
190
+ # Default Google endpoint
191
+ register_endpoint("generativelanguage.googleapis.com")
192
+
193
+ Client.__init__ = _patched_init
194
+
195
+ except ImportError:
196
+ pass # google-genai not installed
197
+
198
+
199
+ def _hook_openai_client():
200
+ """Monkey-patch openai.OpenAI to capture endpoint configuration."""
201
+ try:
202
+ import openai
203
+
204
+ original_init = openai.OpenAI.__init__
205
+
206
+ def _patched_init(self, *args, **kwargs):
207
+ original_init(self, *args, **kwargs)
208
+ base_url = kwargs.get("base_url") or os.getenv("OPENAI_BASE_URL", "api.openai.com")
209
+ register_endpoint(base_url)
210
+
211
+ openai.OpenAI.__init__ = _patched_init
212
+
213
+ except (ImportError, AttributeError):
214
+ pass
215
+
216
+
217
+ def _hook_anthropic_client():
218
+ """Monkey-patch anthropic.Anthropic to capture endpoint configuration."""
219
+ try:
220
+ import anthropic
221
+
222
+ original_init = anthropic.Anthropic.__init__
223
+
224
+ def _patched_init(self, *args, **kwargs):
225
+ original_init(self, *args, **kwargs)
226
+ base_url = kwargs.get("base_url") or os.getenv("ANTHROPIC_BASE_URL", "api.anthropic.com")
227
+ register_endpoint(base_url)
228
+
229
+ anthropic.Anthropic.__init__ = _patched_init
230
+
231
+ except (ImportError, AttributeError):
232
+ pass
233
+
234
+
235
+ def install_hooks():
236
+ """Install all SDK hooks. Call this early in your application."""
237
+ _hook_genai_client()
238
+ _hook_openai_client()
239
+ _hook_anthropic_client()
240
+
241
+
242
+ # Auto-load runtime whitelist on import
243
+ _load_runtime_whitelist()
244
+