main
py 654 lines 25.7 KB
Raw
1 from __future__ import annotations
2
3 import asyncio
4 import hashlib
5 import re
6 import uuid
7 from functools import lru_cache
8 from pathlib import Path
9 from typing import Any
10 from urllib.parse import urlparse
11
12 from helpers import chat_media, media_artifacts
13
14 try:
15 from helpers.ws import NAMESPACE
16 except Exception:
17 NAMESPACE = "/ws"
18
19 try:
20 from helpers.ws_manager import ConnectionNotFoundError, get_shared_ws_manager
21 except Exception:
22 class ConnectionNotFoundError(RuntimeError):
23 pass
24
25 def get_shared_ws_manager():
26 raise ConnectionNotFoundError("WebSocket manager is unavailable")
27
28 from plugins._a0_connector.helpers.ws_runtime import (
29 clear_pending_browser_op,
30 host_browser_metadata_for_context,
31 host_browser_metadata_for_sid,
32 select_host_browser_candidate_sid,
33 select_host_browser_target_sid,
34 store_pending_browser_op,
35 )
36 from plugins._browser.helpers import config as browser_config
37 from plugins._browser.helpers.url import normalize_url
38
39
40 BROWSER_OP_EVENT = "connector_browser_op"
41 BROWSER_OP_TIMEOUT = 120.0
42 DOM_HELPER_PATH = Path(__file__).resolve().parents[1] / "assets" / "browser-dom-helper.js"
43 CONTENT_HELPER_PATH = Path(__file__).resolve().parents[1] / "assets" / "browser-page-content.js"
44 MAX_ARTIFACT_SIZE_BYTES = 25 * 1024 * 1024
45 HOST_BROWSER_PRIVACY_POLICY_KEY = getattr(
46 browser_config,
47 "HOST_BROWSER_PRIVACY_POLICY_KEY",
48 "host_browser_privacy_policy",
49 )
50 DEFAULT_HOST_BROWSER_PRIVACY_POLICY = getattr(
51 browser_config,
52 "DEFAULT_HOST_BROWSER_PRIVACY_POLICY",
53 "allow",
54 )
55 HOST_BROWSER_PROFILE_MODE_KEY = getattr(
56 browser_config,
57 "HOST_BROWSER_PROFILE_MODE_KEY",
58 "host_browser_profile_mode",
59 )
60 HOST_BROWSER_SELECTION_KEY = getattr(
61 browser_config,
62 "HOST_BROWSER_SELECTION_KEY",
63 "host_browser_selection",
64 )
65 get_browser_config = browser_config.get_browser_config
66 _LOCAL_PROVIDERS = {"ollama", "lm_studio", "llama_cpp", "omlx", "vllm"}
67 _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
68 _SENSITIVE_ACTIONS = {"content", "detail", "evaluate", "screenshot", "screenshot_file"}
69 _KEY_ALIASES = {
70 "cmd": "Meta",
71 "command": "Meta",
72 "control": "Control",
73 "ctrl": "Control",
74 "escape": "Escape",
75 "esc": "Escape",
76 "meta": "Meta",
77 "option": "Alt",
78 "return": "Enter",
79 "space": "Space",
80 }
81 _REQUIRED_API_NAMES_RE = re.compile(
82 r"const\s+REQUIRED_API_NAMES\s*=\s*Object\.freeze\(\[(?P<body>.*?)\]\);",
83 re.S,
84 )
85 _HOST_BROWSER_REMOTE_DEBUGGING_HELP = (
86 "For an already-open Chromium-family browser, open its inspect page, such as "
87 "`chrome://inspect/#remote-debugging` or `opera://inspect/#remote-debugging`, "
88 'enable "Allow remote debugging for this browser instance", run `/browser host on`, '
89 "and retry."
90 )
91 _DOCKER_BROWSER_RECOVERY_HELP = (
92 "To use Agent Zero's internal Docker browser instead, open Browser settings and set "
93 "Browser location to Internal Docker browser, or run `/browser container` from A0 CLI."
94 )
95 _REMOTE_DEBUGGING_ERROR_TOKENS = (
96 "remote debugging",
97 "remote-debugging",
98 "devtoolsactiveport",
99 "devtools endpoint",
100 "cdp endpoint",
101 "cannot connect to the host browser",
102 "127.0.0.1:9222",
103 "localhost:9222",
104 "blocks playwright remote debugging",
105 )
106
107
108 def _stable_host_browser_selection(selection: Any, metadata: Any) -> str:
109 selected = str(selection or "").strip()
110 if not selected or not isinstance(metadata, dict):
111 return selected
112 candidates = list(metadata.get("available_browsers") or [])
113 candidates.append(metadata)
114 for candidate in candidates:
115 if not isinstance(candidate, dict):
116 continue
117 endpoint = str(candidate.get("cdp_endpoint") or "").strip()
118 browser_id = str(candidate.get("id") or candidate.get("browser_id") or "").strip()
119 if endpoint == selected and browser_id:
120 return browser_id
121 return selected
122
123
124 class ConnectorBrowserRuntime:
125 def __init__(self, context_id: str, agent: Any):
126 self.context_id = str(context_id or "").strip()
127 self.agent = agent
128
129 async def call(self, method: str, *args: Any, **kwargs: Any) -> Any:
130 payload = self._payload_for_call(method, *args, **kwargs)
131 warning = self._privacy_warning(payload)
132 result = await self._dispatch(payload)
133 result = self._materialize_artifact(result)
134 if warning:
135 if isinstance(result, dict):
136 result.setdefault("privacy_warning", warning)
137 else:
138 result = {"result": result, "privacy_warning": warning}
139 return result
140
141 def _payload_for_call(self, method: str, *args: Any, **kwargs: Any) -> dict[str, Any]:
142 action = str(method or "").strip().lower().replace("-", "_")
143 payload: dict[str, Any] = {
144 "op_id": str(uuid.uuid4()),
145 "context_id": self.context_id,
146 "action": action,
147 "profile_mode": self._host_browser_profile_mode(),
148 "browser_selection": self._host_browser_selection(),
149 }
150
151 if action == "open":
152 payload["url"] = self._normalize_open_url(args[0] if args else "")
153 elif action in {"state", "set_active", "back", "forward", "reload"}:
154 payload["browser_id"] = args[0] if args else None
155 elif action == "navigate":
156 payload["browser_id"] = args[0] if args else None
157 payload["url"] = normalize_url(args[1] if len(args) > 1 else "")
158 elif action == "screenshot_file":
159 payload["action"] = "screenshot"
160 payload["browser_id"] = args[0] if args else None
161 payload["quality"] = kwargs.get("quality", 80)
162 payload["full_page"] = kwargs.get("full_page", False)
163 payload["path"] = kwargs.get("path", "")
164 elif action == "list":
165 payload["include_content"] = kwargs.get("include_content", False)
166 elif action == "content":
167 payload["browser_id"] = args[0] if args else None
168 payload["payload"] = args[1] if len(args) > 1 and isinstance(args[1], dict) else None
169 elif action == "detail":
170 payload["browser_id"] = args[0] if args else None
171 payload["ref"] = args[1] if len(args) > 1 else None
172 elif action == "evaluate":
173 payload["browser_id"] = args[0] if args else None
174 payload["script"] = args[1] if len(args) > 1 else ""
175 elif action == "click":
176 payload["browser_id"] = args[0] if args else None
177 payload["ref"] = args[1] if len(args) > 1 else None
178 payload["modifiers"] = kwargs.get("modifiers")
179 payload["focus_popup"] = kwargs.get("focus_popup")
180 elif action in {"type", "submit", "type_submit", "scroll"}:
181 payload["browser_id"] = args[0] if args else None
182 payload["ref"] = args[1] if len(args) > 1 else None
183 if action in {"type", "type_submit"}:
184 payload["text"] = args[2] if len(args) > 2 else ""
185 elif action in {"hover", "double_click", "right_click", "drag"}:
186 payload["browser_id"] = args[0] if args else None
187 payload.update(kwargs)
188 elif action == "wheel":
189 payload["browser_id"] = args[0] if args else None
190 payload["x"] = args[1] if len(args) > 1 else 0
191 payload["y"] = args[2] if len(args) > 2 else 0
192 payload["delta_x"] = args[3] if len(args) > 3 else 0
193 payload["delta_y"] = args[4] if len(args) > 4 else 0
194 elif action == "mouse":
195 payload["browser_id"] = args[0] if args else None
196 payload["event_type"] = args[1] if len(args) > 1 else "click"
197 payload["x"] = args[2] if len(args) > 2 else 0
198 payload["y"] = args[3] if len(args) > 3 else 0
199 payload["button"] = kwargs.get("button", args[4] if len(args) > 4 else "left")
200 payload["modifiers"] = kwargs.get("modifiers")
201 elif action == "keyboard":
202 payload["browser_id"] = args[0] if args else None
203 payload["key"] = kwargs.get("key", "")
204 payload["text"] = kwargs.get("text", "")
205 elif action == "key_chord":
206 payload["browser_id"] = args[0] if args else None
207 payload["keys"] = self._normalize_keys(args[1] if len(args) > 1 else [])
208 elif action == "clipboard":
209 payload["browser_id"] = args[0] if args else None
210 payload["clipboard_action"] = kwargs.get("action", "")
211 payload["text"] = kwargs.get("text", "")
212 elif action == "set_viewport":
213 payload["browser_id"] = args[0] if args else None
214 payload["width"] = args[1] if len(args) > 1 else 0
215 payload["height"] = args[2] if len(args) > 2 else 0
216 elif action in {"select_option", "set_checked", "upload_file"}:
217 payload["browser_id"] = args[0] if args else None
218 payload["ref"] = args[1] if len(args) > 1 else None
219 payload.update(kwargs)
220 elif action == "multi":
221 payload["calls"] = self._normalize_multi_calls(args[0] if args else [])
222 elif action == "close_browser":
223 payload["action"] = "close"
224 payload["browser_id"] = args[0] if args else None
225 elif action == "close_all_browsers":
226 payload["action"] = "close_all"
227 else:
228 payload.update(kwargs)
229
230 return payload
231
232 @staticmethod
233 def _normalize_open_url(value: Any) -> str:
234 raw = str(value or "").strip()
235 return normalize_url(raw) if raw else ""
236
237 @classmethod
238 def _normalize_multi_calls(cls, calls: Any) -> Any:
239 if not isinstance(calls, list):
240 return calls
241 normalized_calls: list[Any] = []
242 for call in calls:
243 if not isinstance(call, dict):
244 normalized_calls.append(call)
245 continue
246 normalized = dict(call)
247 action = str(normalized.get("action") or "").strip().lower().replace("-", "_")
248 if action == "open":
249 normalized["url"] = cls._normalize_open_url(normalized.get("url"))
250 elif action == "navigate":
251 normalized["url"] = normalize_url(normalized.get("url", ""))
252 elif action == "click" and not normalized.get("ref") and (
253 normalized.get("x") or normalized.get("y")
254 ):
255 normalized["action"] = "mouse"
256 normalized.setdefault("event_type", "click")
257 normalized.setdefault("button", "left")
258 elif action == "type" and not normalized.get("ref"):
259 normalized["action"] = "keyboard"
260 normalized.setdefault("key", "")
261 elif action in {"key_chord", "keychord"}:
262 normalized["keys"] = cls._normalize_keys(normalized.get("keys"))
263 elif action == "multi" or isinstance(normalized.get("calls"), list):
264 normalized["calls"] = cls._normalize_multi_calls(normalized.get("calls", []))
265 normalized_calls.append(normalized)
266 return normalized_calls
267
268 @staticmethod
269 def _normalize_keys(keys: Any) -> list[str]:
270 if keys is None:
271 return []
272 if isinstance(keys, str):
273 raw = re.split(r"\s*\+\s*|\s*,\s*", keys.strip())
274 elif isinstance(keys, list):
275 raw = keys
276 else:
277 raw = [str(keys)]
278 normalized: list[str] = []
279 for key in raw:
280 value = str(key or "").strip()
281 if not value:
282 continue
283 normalized.append(
284 _KEY_ALIASES.get(
285 value.lower(),
286 value.upper() if len(value) == 1 and value.isalpha() else value,
287 )
288 )
289 return normalized
290
291 async def _dispatch(self, payload: dict[str, Any]) -> Any:
292 payload.setdefault("profile_mode", self._host_browser_profile_mode())
293 self._enforce_privacy(payload)
294 sid = self._select_sid()
295 if not sid:
296 statuses = host_browser_metadata_for_context(self.context_id)
297 raise RuntimeError(self._host_browser_unavailable_message(statuses))
298 payload["browser_selection"] = self._host_browser_selection(sid)
299
300 if self._needs_prepare(sid, payload):
301 await self._send_browser_op(
302 sid,
303 self._with_content_helper(
304 sid,
305 {
306 "op_id": str(uuid.uuid4()),
307 "context_id": self.context_id,
308 "action": "ensure",
309 "profile_mode": self._host_browser_profile_mode(),
310 "browser_selection": self._host_browser_selection(sid),
311 },
312 ),
313 )
314 sid = self._select_sid() or sid
315
316 return await self._send_browser_op(sid, self._with_browser_helpers(sid, payload))
317
318 def _host_browser_profile_mode(self) -> str:
319 config = get_browser_config(self.agent)
320 mode = str(config.get(HOST_BROWSER_PROFILE_MODE_KEY) or "existing").strip().lower()
321 return "agent" if mode == "agent" else "existing"
322
323 def _host_browser_selection(self, sid: str = "") -> str:
324 config = get_browser_config(self.agent)
325 selection = str(config.get(HOST_BROWSER_SELECTION_KEY) or "").strip()
326 if sid:
327 return _stable_host_browser_selection(
328 selection,
329 host_browser_metadata_for_sid(sid),
330 )
331 for metadata in host_browser_metadata_for_context(self.context_id):
332 stable = _stable_host_browser_selection(selection, metadata)
333 if stable != selection:
334 return stable
335 return selection
336
337 def _with_content_helper(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]:
338 return self._with_browser_helpers(sid, payload)
339
340 def _with_browser_helpers(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]:
341 metadata = host_browser_metadata_for_sid(sid) or {}
342 content_helper_current = (
343 str(metadata.get("content_helper_sha256") or "").strip().lower()
344 == _content_helper_sha256()
345 )
346 dom_helper_current = (
347 str(metadata.get("dom_helper_sha256") or "").strip().lower()
348 == _dom_helper_sha256()
349 )
350 if content_helper_current and dom_helper_current:
351 return payload
352 payload = dict(payload)
353 if not dom_helper_current:
354 payload["dom_helper"] = _dom_helper_payload()
355 if not content_helper_current:
356 payload["content_helper"] = _content_helper_payload()
357 return payload
358
359 async def _send_browser_op(self, sid: str, payload: dict[str, Any]) -> Any:
360 op_id = str(payload["op_id"])
361 loop = asyncio.get_running_loop()
362 future: asyncio.Future[dict[str, Any]] = loop.create_future()
363 store_pending_browser_op(
364 op_id,
365 sid=sid,
366 future=future,
367 loop=loop,
368 context_id=self.context_id,
369 )
370 try:
371 await get_shared_ws_manager().emit_to(
372 NAMESPACE,
373 sid,
374 BROWSER_OP_EVENT,
375 payload,
376 handler_id=f"{self.__class__.__module__}.{self.__class__.__name__}",
377 )
378 response = await asyncio.wait_for(future, timeout=BROWSER_OP_TIMEOUT)
379 except ConnectionNotFoundError as exc:
380 raise RuntimeError(
381 "The selected A0 CLI disconnected before the host browser request could be delivered."
382 ) from exc
383 except asyncio.TimeoutError as exc:
384 raise RuntimeError(
385 f"Timed out waiting for A0 CLI host browser action={payload.get('action')!r}."
386 ) from exc
387 finally:
388 clear_pending_browser_op(op_id)
389
390 if not isinstance(response, dict):
391 raise RuntimeError(f"Unexpected host browser response: {response!r}")
392 if not response.get("ok"):
393 raise RuntimeError(
394 self._host_browser_error_message(
395 response.get("error") or "Host browser operation failed"
396 )
397 )
398 return response.get("result")
399
400 def _select_sid(self) -> str | None:
401 return (
402 select_host_browser_target_sid(self.context_id)
403 or select_host_browser_candidate_sid(self.context_id)
404 )
405
406 def _needs_prepare(self, sid: str, payload: dict[str, Any]) -> bool:
407 action = str(payload.get("action") or "").strip().lower().replace("-", "_")
408 if action in {"status", "ensure"}:
409 return False
410 metadata = host_browser_metadata_for_sid(sid) or {}
411 return not (
412 metadata.get("enabled")
413 and str(metadata.get("status") or "").strip() in {"ready", "active"}
414 )
415
416 def _enforce_privacy(self, payload: dict[str, Any]) -> None:
417 policy = str(
418 get_browser_config(agent=self.agent).get(HOST_BROWSER_PRIVACY_POLICY_KEY)
419 or DEFAULT_HOST_BROWSER_PRIVACY_POLICY
420 ).strip()
421 if not self._payload_is_sensitive(payload) or policy != "enforce_local":
422 return
423 if _agent_uses_local_chat_model(self.agent):
424 return
425 raise RuntimeError(
426 "Host-browser content is blocked by Browser privacy policy. "
427 "Switch this project to a local chat model, or change Browser settings from "
428 "enforce_local to warn/allow."
429 )
430
431 def _privacy_warning(self, payload: dict[str, Any]) -> str:
432 policy = str(
433 get_browser_config(agent=self.agent).get(HOST_BROWSER_PRIVACY_POLICY_KEY)
434 or DEFAULT_HOST_BROWSER_PRIVACY_POLICY
435 ).strip()
436 if policy != "warn" or not self._payload_is_sensitive(payload):
437 return ""
438 if _agent_uses_local_chat_model(self.agent):
439 return ""
440 return (
441 "Browser privacy policy is warn: host-browser content was returned while "
442 "the active chat model does not appear local."
443 )
444
445 def _payload_is_sensitive(self, payload: dict[str, Any]) -> bool:
446 action = str(payload.get("action") or "").strip().lower().replace("-", "_")
447 if action in _SENSITIVE_ACTIONS:
448 return True
449 if action == "list" and bool(payload.get("include_content")):
450 return True
451 if action == "multi":
452 calls = payload.get("calls")
453 if isinstance(calls, list):
454 return any(
455 self._payload_is_sensitive(call)
456 for call in calls
457 if isinstance(call, dict)
458 )
459 return False
460
461 def _materialize_artifact(self, result: Any) -> Any:
462 if isinstance(result, list):
463 materialized_list = []
464 for item in result:
465 if isinstance(item, dict) and isinstance(item.get("result"), dict):
466 next_item = dict(item)
467 next_item["result"] = self._materialize_artifact(next_item["result"])
468 materialized_list.append(next_item)
469 else:
470 materialized_list.append(item)
471 return materialized_list
472 if not isinstance(result, dict):
473 return result
474 artifact = result.get("artifact")
475 if not isinstance(artifact, dict):
476 return result
477 if str(artifact.get("encoding", "")).lower() != "base64":
478 return result
479 data = str(artifact.get("data") or "")
480 if not data:
481 return result
482 estimated_size = media_artifacts.estimated_base64_decoded_size(data)
483 if estimated_size > MAX_ARTIFACT_SIZE_BYTES:
484 raise RuntimeError(
485 "Host browser artifact is too large to attach safely "
486 f"({estimated_size} bytes, limit {MAX_ARTIFACT_SIZE_BYTES} bytes)."
487 )
488 filename = media_artifacts.safe_filename(
489 str(artifact.get("filename") or "host-browser.jpg"),
490 default=f"host-browser-{uuid.uuid4().hex}.jpg",
491 default_extension=".jpg",
492 )
493 mime = str(artifact.get("mime") or result.get("mime") or "image/jpeg")
494 try:
495 saved = chat_media.save_image_base64(
496 context_id=self.context_id,
497 data=data,
498 mime_type=mime,
499 category="screenshots",
500 source="browser",
501 preferred_name=filename,
502 max_bytes=MAX_ARTIFACT_SIZE_BYTES,
503 )
504 except Exception as exc:
505 raise RuntimeError("Host browser artifact could not be decoded.") from exc
506 materialized = dict(result)
507 materialized.pop("artifact", None)
508 materialized.pop("path", None)
509 materialized.pop("a0_path", None)
510 materialized.pop("host_path", None)
511 materialized.setdefault("context_id", self.context_id)
512 materialized["path"] = saved.path
513 materialized["a0_path"] = saved.a0_path
514 materialized["mime"] = saved.mime
515 materialized["ephemeral"] = False
516 materialized["chat_scoped"] = True
517 materialized["vision_load"] = {
518 "tool_name": "vision_load",
519 "tool_args": {"paths": [saved.a0_path]},
520 }
521 return materialized
522
523 @staticmethod
524 def _format_statuses(statuses: list[dict[str, Any]]) -> str:
525 parts = []
526 for status in statuses:
527 parts.append(
528 f"sid={status.get('sid')} status={status.get('status')} "
529 f"supported={status.get('supported')} can_prepare={status.get('can_prepare')} "
530 f"enabled={status.get('enabled')} "
531 f"reason={status.get('support_reason') or 'none'}"
532 )
533 return "; ".join(parts)
534
535 @classmethod
536 def _host_browser_unavailable_message(cls, statuses: list[dict[str, Any]]) -> str:
537 detail = cls._format_statuses(statuses)
538 message = (
539 "Host browser is required but no subscribed A0 CLI advertises host-browser support"
540 + (f": {detail}" if detail else ".")
541 )
542 return cls._host_browser_error_message(message)
543
544 @staticmethod
545 def _host_browser_error_message(error: Any) -> str:
546 message = str(error or "Host browser operation failed").strip()
547 if not message:
548 message = "Host browser operation failed"
549 normalized = message.lower()
550 if (
551 "chrome://inspect/#remote-debugging" in normalized
552 or "opera://inspect/#remote-debugging" in normalized
553 ):
554 return _append_docker_browser_recovery(message)
555 if any(token in normalized for token in _REMOTE_DEBUGGING_ERROR_TOKENS):
556 return _append_docker_browser_recovery(
557 f"{message}\n\n{_HOST_BROWSER_REMOTE_DEBUGGING_HELP}"
558 )
559 return _append_docker_browser_recovery(message)
560
561
562 def _append_docker_browser_recovery(message: str) -> str:
563 normalized = str(message or "").lower()
564 if "internal docker browser" in normalized or "/browser container" in normalized:
565 return message
566 return f"{message}\n\n{_DOCKER_BROWSER_RECOVERY_HELP}"
567
568
569 @lru_cache(maxsize=1)
570 def _content_helper_payload() -> dict[str, Any]:
571 try:
572 source = CONTENT_HELPER_PATH.read_text(encoding="utf-8")
573 except OSError as exc:
574 raise RuntimeError(
575 f"Host-browser content helper could not be read from {CONTENT_HELPER_PATH}: {exc}"
576 ) from exc
577 return {
578 "required_apis": _content_helper_required_apis(source),
579 "source": source,
580 "sha256": hashlib.sha256(source.encode("utf-8")).hexdigest(),
581 }
582
583
584 def _content_helper_sha256() -> str:
585 return str(_content_helper_payload()["sha256"])
586
587
588 @lru_cache(maxsize=1)
589 def _dom_helper_payload() -> dict[str, Any]:
590 try:
591 source = DOM_HELPER_PATH.read_text(encoding="utf-8")
592 except OSError as exc:
593 raise RuntimeError(
594 f"Host-browser DOM helper could not be read from {DOM_HELPER_PATH}: {exc}"
595 ) from exc
596 return {
597 "required_apis": [
598 "captureDocument",
599 "clickNode",
600 "detailNode",
601 "scrollNode",
602 "submitNode",
603 "typeNode",
604 "typeSubmitNode",
605 ],
606 "source": source,
607 "sha256": hashlib.sha256(source.encode("utf-8")).hexdigest(),
608 }
609
610
611 def _dom_helper_sha256() -> str:
612 return str(_dom_helper_payload()["sha256"])
613
614
615 def _content_helper_required_apis(source: str) -> list[str]:
616 match = _REQUIRED_API_NAMES_RE.search(source)
617 if not match:
618 raise RuntimeError(
619 f"Host-browser content helper from {CONTENT_HELPER_PATH} does not declare REQUIRED_API_NAMES."
620 )
621 names = re.findall(r'"([^"]+)"', match.group("body"))
622 if not names:
623 raise RuntimeError(
624 f"Host-browser content helper from {CONTENT_HELPER_PATH} declares no required API names."
625 )
626 return names
627
628
629 def _agent_uses_local_chat_model(agent: Any) -> bool:
630 try:
631 from plugins._model_config.helpers import model_config
632
633 cfg = model_config.get_chat_model_config(agent)
634 except Exception:
635 cfg = {}
636 if not isinstance(cfg, dict):
637 return False
638 provider = str(cfg.get("provider", "") or "").strip().lower()
639 if provider in _LOCAL_PROVIDERS:
640 return True
641 api_base = str(cfg.get("api_base", "") or cfg.get("base_url", "") or "").strip()
642 if not api_base:
643 kwargs = cfg.get("kwargs")
644 if isinstance(kwargs, dict):
645 api_base = str(kwargs.get("api_base", "") or kwargs.get("base_url", "") or "").strip()
646 return _api_base_is_local(api_base)
647
648
649 def _api_base_is_local(api_base: str) -> bool:
650 if not api_base:
651 return False
652 parsed = urlparse(api_base if "://" in api_base else f"http://{api_base}")
653 hostname = (parsed.hostname or "").strip().lower()
654 return hostname in _LOCAL_HOSTS