preppergpt 0.1.0

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 (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/bin/preppergpt.js +8 -0
  4. package/compose/preppergpt.yaml +232 -0
  5. package/docs/hardware.md +15 -0
  6. package/docs/model-sources.md +12 -0
  7. package/docs/preppergpt-local-parity-map.md +16 -0
  8. package/docs/publishing.md +24 -0
  9. package/installer/cli.mjs +225 -0
  10. package/installer/install.sh +18 -0
  11. package/installer/lib/detect.mjs +128 -0
  12. package/installer/lib/paths.mjs +26 -0
  13. package/installer/lib/planner.mjs +175 -0
  14. package/installer/lib/render.mjs +76 -0
  15. package/installer/lib/util.mjs +84 -0
  16. package/package.json +48 -0
  17. package/profiles/models.json +277 -0
  18. package/services/comfyui/flux-kontext-edit-openwebui-nodes.json +46 -0
  19. package/services/comfyui/flux-kontext-edit-openwebui-workflow.json +245 -0
  20. package/services/comfyui/flux-kontext-mask-edit-openwebui-nodes.json +51 -0
  21. package/services/comfyui/flux-kontext-mask-edit-openwebui-workflow.json +322 -0
  22. package/services/comfyui/flux2-klein-9b-openwebui-nodes.json +58 -0
  23. package/services/comfyui/flux2-klein-9b-openwebui-workflow.json +141 -0
  24. package/services/comfyui/image-invert-edit-openwebui-nodes.json +23 -0
  25. package/services/comfyui/image-invert-edit-openwebui-workflow.json +52 -0
  26. package/services/deep-research/Dockerfile +7 -0
  27. package/services/deep-research/app.py +1913 -0
  28. package/services/local-agent/Dockerfile +17 -0
  29. package/services/local-agent/app.py +2311 -0
  30. package/services/local-scheduler/Dockerfile +8 -0
  31. package/services/local-scheduler/app.py +15774 -0
  32. package/services/local-vision/Dockerfile +11 -0
  33. package/services/local-vision/app.py +888 -0
  34. package/services/searxng/settings.yml +16 -0
  35. package/themes/preppergpt/custom.css +15 -0
  36. package/themes/preppergpt/static/favicon.svg +5 -0
  37. package/themes/preppergpt/static/logo.svg +6 -0
@@ -0,0 +1,2311 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ import time
9
+ import uuid
10
+ from dataclasses import asdict, dataclass
11
+ from html import escape
12
+ from html.parser import HTMLParser
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from pathlib import Path
15
+ from threading import Lock
16
+ from urllib import parse, request
17
+
18
+
19
+ MODEL_ID = os.environ.get("LOCAL_AGENT_MODEL_ID", "local-agent-glm52")
20
+ GLM_MODEL = os.environ.get("LOCAL_AGENT_GLM_MODEL", "glm52-q4-local")
21
+ GLM_BASE_URL = os.environ.get("LOCAL_AGENT_GLM_BASE_URL", "http://127.0.0.1:11441/v1")
22
+ AUTO_ROUTER_MODEL_ID = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_MODEL_ID", "local-auto-router")
23
+ AUTO_ROUTER_FAST_MODEL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_FAST_MODEL", "gemma4:12b-256k-gpu")
24
+ AUTO_ROUTER_FAST_BASE_URL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_FAST_BASE_URL", "http://127.0.0.1:11434/v1")
25
+ AUTO_ROUTER_CODE_MODEL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_CODE_MODEL", "qwen3.6-35b-a3b:slopcode-cpu-64k")
26
+ AUTO_ROUTER_CODE_BASE_URL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_CODE_BASE_URL", "http://127.0.0.1:11438/v1")
27
+ AUTO_ROUTER_RESEARCH_MODEL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_RESEARCH_MODEL", "deep-research-glm52")
28
+ AUTO_ROUTER_RESEARCH_BASE_URL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_RESEARCH_BASE_URL", "http://127.0.0.1:18041/v1")
29
+ AUTO_ROUTER_AGENT_MODEL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_AGENT_MODEL", MODEL_ID)
30
+ AUTO_ROUTER_AGENT_BASE_URL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_AGENT_BASE_URL", "http://127.0.0.1:18043/v1")
31
+ SEARXNG_URL = os.environ.get("LOCAL_AGENT_SEARXNG_URL", "http://127.0.0.1:18080/search")
32
+ TIKA_URL = os.environ.get("LOCAL_AGENT_TIKA_URL", "http://127.0.0.1:9998/tika")
33
+ SCHEDULER_URL = os.environ.get("LOCAL_AGENT_SCHEDULER_URL", "http://127.0.0.1:18042")
34
+ PUBLIC_BASE_URL = os.environ.get("LOCAL_AGENT_PUBLIC_BASE_URL", "http://127.0.0.1:18043")
35
+ PLAYWRIGHT_WS_URL = os.environ.get("LOCAL_AGENT_PLAYWRIGHT_WS_URL", "ws://127.0.0.1:18045")
36
+ HOST = os.environ.get("LOCAL_AGENT_HOST", "127.0.0.1")
37
+ PORT = int(os.environ.get("LOCAL_AGENT_PORT", "18043"))
38
+ STORAGE = Path(os.environ.get("LOCAL_AGENT_STORAGE", "/data"))
39
+ GLM_TIMEOUT = int(os.environ.get("LOCAL_AGENT_GLM_TIMEOUT_SECONDS", "21600"))
40
+ AUTO_ROUTER_FAST_TIMEOUT = int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_FAST_TIMEOUT_SECONDS", "180"))
41
+ AUTO_ROUTER_CODE_TIMEOUT = int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_CODE_TIMEOUT_SECONDS", "240"))
42
+ AUTO_ROUTER_RESEARCH_TIMEOUT = int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_RESEARCH_TIMEOUT_SECONDS", "300"))
43
+ AUTO_ROUTER_AGENT_TIMEOUT = int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_AGENT_TIMEOUT_SECONDS", "120"))
44
+ AUTO_ROUTER_GLM_COLD_FALLBACK = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_GLM_COLD_FALLBACK", "1").lower() not in {
45
+ "0",
46
+ "false",
47
+ "no",
48
+ }
49
+ AUTO_ROUTER_GLM_WARM_MIN_DECODED = int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_GLM_WARM_MIN_DECODED", "1"))
50
+ AUTO_ROUTER_GLM_HEALTH_TIMEOUT = float(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_GLM_HEALTH_TIMEOUT_SECONDS", "2"))
51
+ FETCH_TIMEOUT = int(os.environ.get("LOCAL_AGENT_FETCH_TIMEOUT_SECONDS", "20"))
52
+ MAX_FETCH_BYTES = int(os.environ.get("LOCAL_AGENT_MAX_FETCH_BYTES", str(2 * 1024 * 1024)))
53
+ PYTHON_TIMEOUT = int(os.environ.get("LOCAL_AGENT_PYTHON_TIMEOUT_SECONDS", "20"))
54
+ APPROVAL_WAIT_SECONDS = int(os.environ.get("LOCAL_AGENT_APPROVAL_WAIT_SECONDS", "120"))
55
+ PLAYWRIGHT_TIMEOUT_MS = int(os.environ.get("LOCAL_AGENT_PLAYWRIGHT_TIMEOUT_MS", "15000"))
56
+ DESKTOP_ENABLED = os.environ.get("LOCAL_AGENT_DESKTOP_ENABLED", "1").lower() not in {"0", "false", "no"}
57
+ DESKTOP_TIMEOUT = int(os.environ.get("LOCAL_AGENT_DESKTOP_TIMEOUT_SECONDS", "15"))
58
+ DESKTOP_COMMAND_MAX_OUTPUT = int(os.environ.get("LOCAL_AGENT_DESKTOP_COMMAND_MAX_OUTPUT", "6000"))
59
+ AUTO_ROUTER_TELEMETRY_LIMIT = max(10, int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_TELEMETRY_LIMIT", "200")))
60
+
61
+ LLM_LOCK = Lock()
62
+ APPROVAL_LOCK = Lock()
63
+ AUTO_ROUTER_TELEMETRY_LOCK = Lock()
64
+ AUTO_ROUTER_EVENTS: list[dict] = []
65
+ STORAGE.mkdir(parents=True, exist_ok=True)
66
+ APPROVALS_DIR = STORAGE / "approvals"
67
+ APPROVALS_DIR.mkdir(parents=True, exist_ok=True)
68
+ SCREENSHOTS_DIR = STORAGE / "screenshots"
69
+ SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
70
+ DESKTOP_DIR = STORAGE / "desktop"
71
+ DESKTOP_DIR.mkdir(parents=True, exist_ok=True)
72
+
73
+
74
+ @dataclass
75
+ class Action:
76
+ kind: str
77
+ title: str
78
+ input: str
79
+ output: str
80
+ status: str = "completed"
81
+
82
+
83
+ class TextExtractor(HTMLParser):
84
+ def __init__(self):
85
+ super().__init__()
86
+ self.skip = 0
87
+ self.parts = []
88
+
89
+ def handle_starttag(self, tag, attrs):
90
+ if tag in {"script", "style", "noscript", "svg"}:
91
+ self.skip += 1
92
+
93
+ def handle_endtag(self, tag):
94
+ if tag in {"script", "style", "noscript", "svg"} and self.skip:
95
+ self.skip -= 1
96
+
97
+ def handle_data(self, data):
98
+ if not self.skip:
99
+ text = " ".join(data.split())
100
+ if text:
101
+ self.parts.append(text)
102
+
103
+ def text(self) -> str:
104
+ return "\n".join(self.parts)
105
+
106
+
107
+ class BrowserSnapshotParser(HTMLParser):
108
+ def __init__(self):
109
+ super().__init__()
110
+ self.skip = 0
111
+ self.title_parts = []
112
+ self.text_parts = []
113
+ self.links = []
114
+ self.current_link = None
115
+ self.in_title = False
116
+
117
+ def handle_starttag(self, tag, attrs):
118
+ attrs_dict = dict(attrs)
119
+ if tag in {"script", "style", "noscript", "svg"}:
120
+ self.skip += 1
121
+ if tag == "title":
122
+ self.in_title = True
123
+ if tag == "a":
124
+ self.current_link = {"href": attrs_dict.get("href", ""), "text": []}
125
+ if tag in {"button", "input", "textarea", "select"}:
126
+ label = attrs_dict.get("aria-label") or attrs_dict.get("name") or attrs_dict.get("value") or tag
127
+ self.text_parts.append(f"[control:{tag}] {label}")
128
+
129
+ def handle_endtag(self, tag):
130
+ if tag in {"script", "style", "noscript", "svg"} and self.skip:
131
+ self.skip -= 1
132
+ if tag == "title":
133
+ self.in_title = False
134
+ if tag == "a" and self.current_link:
135
+ text = clean_text(" ".join(self.current_link["text"])) or self.current_link["href"]
136
+ self.links.append({"text": text[:160], "href": self.current_link["href"]})
137
+ self.current_link = None
138
+
139
+ def handle_data(self, data):
140
+ if self.skip:
141
+ return
142
+ text = " ".join(data.split())
143
+ if not text:
144
+ return
145
+ if self.in_title:
146
+ self.title_parts.append(text)
147
+ if self.current_link is not None:
148
+ self.current_link["text"].append(text)
149
+ self.text_parts.append(text)
150
+
151
+ def snapshot(self, url: str) -> dict:
152
+ text = clean_text("\n".join(self.text_parts))
153
+ return {
154
+ "url": url,
155
+ "title": clean_text(" ".join(self.title_parts))[:200],
156
+ "text": text[:3000],
157
+ "links": self.links[:25],
158
+ }
159
+
160
+
161
+ def now() -> int:
162
+ return int(time.time())
163
+
164
+
165
+ def clean_text(text: str) -> str:
166
+ text = re.sub(r"\r", "\n", text)
167
+ text = re.sub(r"[ \t]+", " ", text)
168
+ text = re.sub(r"\n{3,}", "\n\n", text)
169
+ return text.strip()
170
+
171
+
172
+ def read_json(handler: BaseHTTPRequestHandler) -> dict:
173
+ length = int(handler.headers.get("Content-Length", "0") or "0")
174
+ raw = handler.rfile.read(length) if length else b"{}"
175
+ return json.loads(raw.decode("utf-8") or "{}")
176
+
177
+
178
+ def send_json(handler: BaseHTTPRequestHandler, status: int, payload: dict):
179
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
180
+ handler.send_response(status)
181
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
182
+ handler.send_header("Content-Length", str(len(body)))
183
+ handler.send_header("Access-Control-Allow-Origin", "*")
184
+ handler.end_headers()
185
+ handler.wfile.write(body)
186
+
187
+
188
+ def http_json(url: str, payload: dict | None = None, timeout: int = 60, headers: dict | None = None) -> dict:
189
+ data = None
190
+ req_headers = {"User-Agent": "openwebui-local-agent/0.1"}
191
+ if headers:
192
+ req_headers.update(headers)
193
+ if payload is not None:
194
+ data = json.dumps(payload).encode("utf-8")
195
+ req_headers["Content-Type"] = "application/json"
196
+ req = request.Request(url, data=data, headers=req_headers)
197
+ with request.urlopen(req, timeout=timeout) as resp:
198
+ return json.loads(resp.read().decode("utf-8") or "{}")
199
+
200
+
201
+ def http_bytes(url: str, timeout: int = FETCH_TIMEOUT) -> tuple[bytes, str]:
202
+ req = request.Request(url, headers={"User-Agent": "Mozilla/5.0 openwebui-local-agent/0.1"})
203
+ with request.urlopen(req, timeout=timeout) as resp:
204
+ content_type = resp.headers.get("Content-Type", "application/octet-stream").split(";")[0]
205
+ chunks = []
206
+ total = 0
207
+ while True:
208
+ chunk = resp.read(65536)
209
+ if not chunk:
210
+ break
211
+ total += len(chunk)
212
+ if total > MAX_FETCH_BYTES:
213
+ break
214
+ chunks.append(chunk)
215
+ return b"".join(chunks), content_type
216
+
217
+
218
+ def html_to_text(raw: bytes) -> str:
219
+ parser = TextExtractor()
220
+ parser.feed(raw.decode("utf-8", errors="replace"))
221
+ return clean_text(parser.text())
222
+
223
+
224
+ def html_to_snapshot(url: str, raw: bytes) -> dict:
225
+ parser = BrowserSnapshotParser()
226
+ parser.feed(raw.decode("utf-8", errors="replace"))
227
+ return parser.snapshot(url)
228
+
229
+
230
+ def tika_extract(raw: bytes, content_type: str) -> str:
231
+ req = request.Request(TIKA_URL, data=raw, method="PUT", headers={"Content-Type": content_type})
232
+ with request.urlopen(req, timeout=max(60, FETCH_TIMEOUT)) as resp:
233
+ return clean_text(resp.read().decode("utf-8", errors="replace"))
234
+
235
+
236
+ def fetch_url(url: str) -> str:
237
+ raw, content_type = http_bytes(url)
238
+ if content_type in {"text/html", "application/xhtml+xml"}:
239
+ return html_to_text(raw)
240
+ if content_type.startswith("text/"):
241
+ return clean_text(raw.decode("utf-8", errors="replace"))
242
+ return tika_extract(raw, content_type)
243
+
244
+
245
+ def browser_fixture(path: str) -> bytes | None:
246
+ if path == "/fixtures/browser-start.html":
247
+ return b"""<!doctype html>
248
+ <html>
249
+ <head><title>Local Agent Browser Fixture</title></head>
250
+ <body>
251
+ <main>
252
+ <h1>Browser Control Fixture</h1>
253
+ <p>START_MARKER browser control page.</p>
254
+ <a href="/fixtures/browser-done.html">Continue</a>
255
+ </main>
256
+ </body>
257
+ </html>
258
+ """
259
+ if path == "/fixtures/browser-done.html":
260
+ return b"""<!doctype html>
261
+ <html>
262
+ <head><title>Browser Fixture Complete</title></head>
263
+ <body>
264
+ <main>
265
+ <h1>DONE_MARKER browser click completed</h1>
266
+ <p>The local agent followed the approved link.</p>
267
+ </main>
268
+ </body>
269
+ </html>
270
+ """
271
+ return None
272
+
273
+
274
+ def send_html(handler: BaseHTTPRequestHandler, body: bytes):
275
+ handler.send_response(200)
276
+ handler.send_header("Content-Type", "text/html; charset=utf-8")
277
+ handler.send_header("Content-Length", str(len(body)))
278
+ handler.send_header("Access-Control-Allow-Origin", "*")
279
+ handler.end_headers()
280
+ handler.wfile.write(body)
281
+
282
+
283
+ def send_binary(handler: BaseHTTPRequestHandler, body: bytes, content_type: str):
284
+ handler.send_response(200)
285
+ handler.send_header("Content-Type", content_type)
286
+ handler.send_header("Content-Length", str(len(body)))
287
+ handler.send_header("Access-Control-Allow-Origin", "*")
288
+ handler.end_headers()
289
+ handler.wfile.write(body)
290
+
291
+
292
+ def send_redirect(handler: BaseHTTPRequestHandler, location: str):
293
+ handler.send_response(303)
294
+ handler.send_header("Location", location)
295
+ handler.send_header("Access-Control-Allow-Origin", "*")
296
+ handler.end_headers()
297
+
298
+
299
+ def approval_path(approval_id: str) -> Path:
300
+ if not re.fullmatch(r"[a-f0-9-]{36}", approval_id):
301
+ raise ValueError("invalid approval id")
302
+ return APPROVALS_DIR / f"{approval_id}.json"
303
+
304
+
305
+ def write_json_atomic(path: Path, payload: dict):
306
+ tmp = path.with_suffix(path.suffix + ".tmp")
307
+ tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
308
+ os.replace(tmp, path)
309
+
310
+
311
+ def get_approval(approval_id: str) -> dict | None:
312
+ try:
313
+ path = approval_path(approval_id)
314
+ except ValueError:
315
+ return None
316
+ if not path.exists():
317
+ return None
318
+ try:
319
+ return json.loads(path.read_text(encoding="utf-8"))
320
+ except Exception:
321
+ return None
322
+
323
+
324
+ def list_approvals(status: str | None = None, limit: int = 50) -> list[dict]:
325
+ approvals: list[dict] = []
326
+ for path in APPROVALS_DIR.glob("*.json"):
327
+ try:
328
+ item = json.loads(path.read_text(encoding="utf-8"))
329
+ except Exception:
330
+ continue
331
+ if status and item.get("status") != status:
332
+ continue
333
+ approvals.append(item)
334
+ approvals.sort(key=lambda item: int(item.get("updated_at") or item.get("created_at") or 0), reverse=True)
335
+ return approvals[:limit]
336
+
337
+
338
+ def update_approval_status(approval_id: str, status: str) -> dict:
339
+ if status not in {"approved", "denied"}:
340
+ raise ValueError("unsupported approval status")
341
+ with APPROVAL_LOCK:
342
+ approval = get_approval(approval_id)
343
+ if not approval:
344
+ raise FileNotFoundError("approval not found")
345
+ if approval.get("status") == "pending":
346
+ approval["status"] = status
347
+ approval["updated_at"] = now()
348
+ approval["decision"] = status
349
+ write_json_atomic(approval_path(approval_id), approval)
350
+ return approval
351
+
352
+
353
+ def approval_public_url(approval_id: str) -> str:
354
+ return f"{PUBLIC_BASE_URL.rstrip('/')}/approvals/{approval_id}"
355
+
356
+
357
+ def create_browser_approval(url: str, overrides: dict, reason: str, action: str, click_text: str | None = None) -> dict:
358
+ parsed = parse.urlparse(url)
359
+ host = parsed.netloc.lower()
360
+ approval = {
361
+ "id": str(uuid.uuid4()),
362
+ "type": "browser",
363
+ "status": "pending",
364
+ "created_at": now(),
365
+ "updated_at": now(),
366
+ "url": url,
367
+ "host": host,
368
+ "approved_hosts": [host],
369
+ "action": action,
370
+ "click_text": click_text or "",
371
+ "reason": reason,
372
+ "requested_by": "local-agent-glm52",
373
+ }
374
+ with APPROVAL_LOCK:
375
+ write_json_atomic(approval_path(approval["id"]), approval)
376
+ return approval
377
+
378
+
379
+ def create_desktop_approval(action: str, command: list[str] | None, reason: str) -> dict:
380
+ approval = {
381
+ "id": str(uuid.uuid4()),
382
+ "type": "desktop",
383
+ "status": "pending",
384
+ "created_at": now(),
385
+ "updated_at": now(),
386
+ "action": action,
387
+ "command": command or [],
388
+ "reason": reason,
389
+ "requested_by": "local-agent-glm52",
390
+ }
391
+ with APPROVAL_LOCK:
392
+ write_json_atomic(approval_path(approval["id"]), approval)
393
+ return approval
394
+
395
+
396
+ def approval_allows_url(approval: dict, url: str) -> tuple[bool, str]:
397
+ if approval.get("status") == "denied":
398
+ return False, "approval_denied"
399
+ if approval.get("status") != "approved":
400
+ return False, f"approval_pending: {approval_public_url(approval.get('id', ''))}"
401
+ parsed = parse.urlparse(url)
402
+ host = parsed.netloc.lower()
403
+ approved_hosts = {str(host).lower() for host in approval.get("approved_hosts", [])}
404
+ if host not in approved_hosts and "*" not in approved_hosts:
405
+ return False, f"approval_required: host {host!r} was not approved"
406
+ return True, "approved_by_interactive_review"
407
+
408
+
409
+ def approval_allows_desktop(approval: dict, action: str, command: list[str] | None = None) -> tuple[bool, str]:
410
+ if approval.get("status") == "denied":
411
+ return False, "approval_denied"
412
+ if approval.get("status") != "approved":
413
+ return False, f"approval_pending: {approval_public_url(approval.get('id', ''))}"
414
+ if approval.get("type") not in {"desktop", None}:
415
+ return False, "approval_type_mismatch"
416
+ if str(approval.get("action") or "") != action:
417
+ return False, "approval_action_mismatch"
418
+ approved_command = approval.get("command") or []
419
+ if action == "command" and list(command or []) != list(approved_command):
420
+ return False, "approval_command_mismatch"
421
+ if command is not None and approved_command and list(command) != approved_command:
422
+ return False, "approval_command_mismatch"
423
+ return True, "approved_by_interactive_review"
424
+
425
+
426
+ def wait_for_approval(approval_id: str, timeout_seconds: int) -> dict | None:
427
+ deadline = time.time() + max(1, timeout_seconds)
428
+ while time.time() < deadline:
429
+ approval = get_approval(approval_id)
430
+ if approval and approval.get("status") in {"approved", "denied"}:
431
+ return approval
432
+ time.sleep(0.5)
433
+ return get_approval(approval_id)
434
+
435
+
436
+ def approval_page_html(approval: dict | None = None) -> bytes:
437
+ if approval is None:
438
+ rows = []
439
+ for item in list_approvals(limit=100):
440
+ status = escape(str(item.get("status", "")))
441
+ item_type = escape(str(item.get("type", "browser")))
442
+ label = escape(str(item.get("action", item_type)))
443
+ target = item.get("url") or " ".join(str(part) for part in item.get("command", []))
444
+ target = escape(str(target))
445
+ rows.append(
446
+ f"<tr><td>{status}</td><td>{item_type}</td><td>{label}</td><td><a href=\"/approvals/{item['id']}\">{escape(item['id'])}</a></td><td>{target}</td></tr>"
447
+ )
448
+ body = """
449
+ <h1>Local Agent Approvals</h1>
450
+ <table>
451
+ <thead><tr><th>Status</th><th>Type</th><th>Action</th><th>ID</th><th>Target</th></tr></thead>
452
+ <tbody>{rows}</tbody>
453
+ </table>
454
+ """.format(rows="\n".join(rows) or "<tr><td colspan=\"5\">No approvals yet.</td></tr>")
455
+ else:
456
+ approval_id = escape(str(approval.get("id", "")))
457
+ approval_type = str(approval.get("type", "browser"))
458
+ status = escape(str(approval.get("status", "")))
459
+ action = escape(str(approval.get("action", "")))
460
+ url = escape(str(approval.get("url", "")))
461
+ host = escape(str(approval.get("host", "")))
462
+ click_text = escape(str(approval.get("click_text", "")))
463
+ reason = escape(str(approval.get("reason", "")))
464
+ command = escape(" ".join(str(part) for part in approval.get("command", [])))
465
+ controls = ""
466
+ if approval.get("status") == "pending":
467
+ controls = f"""
468
+ <form method="post" action="/approvals/{approval_id}/approve"><button class="approve" type="submit">Approve</button></form>
469
+ <form method="post" action="/approvals/{approval_id}/deny"><button class="deny" type="submit">Deny</button></form>
470
+ """
471
+ title = "Desktop Action Approval" if approval_type == "desktop" else "Browser Action Approval"
472
+ target_rows = ""
473
+ if approval_type == "desktop":
474
+ target_rows = f"""
475
+ <dt>Command</dt><dd><code>{command}</code></dd>
476
+ """
477
+ else:
478
+ target_rows = f"""
479
+ <dt>URL</dt><dd><code>{url}</code></dd>
480
+ <dt>Host</dt><dd>{host}</dd>
481
+ <dt>Click text</dt><dd>{click_text}</dd>
482
+ """
483
+ body = f"""
484
+ <p><a href="/approvals">All approvals</a></p>
485
+ <h1>{title}</h1>
486
+ <dl>
487
+ <dt>Status</dt><dd>{status}</dd>
488
+ <dt>Type</dt><dd>{escape(approval_type)}</dd>
489
+ <dt>Action</dt><dd>{action}</dd>
490
+ {target_rows}
491
+ <dt>Reason</dt><dd>{reason}</dd>
492
+ <dt>Resume ID</dt><dd><code>{approval_id}</code></dd>
493
+ </dl>
494
+ <div class="actions">{controls}</div>
495
+ """
496
+
497
+ html = f"""<!doctype html>
498
+ <html>
499
+ <head>
500
+ <meta charset="utf-8">
501
+ <meta name="viewport" content="width=device-width, initial-scale=1">
502
+ <title>Local Agent Approvals</title>
503
+ <style>
504
+ body {{ font: 15px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 32px; max-width: 1100px; color: #111827; }}
505
+ table {{ border-collapse: collapse; width: 100%; }}
506
+ th, td {{ border-bottom: 1px solid #d1d5db; padding: 10px 8px; text-align: left; vertical-align: top; }}
507
+ dt {{ font-weight: 700; margin-top: 16px; }}
508
+ dd {{ margin: 4px 0 0; }}
509
+ code {{ background: #f3f4f6; padding: 2px 4px; border-radius: 4px; overflow-wrap: anywhere; }}
510
+ .actions {{ display: flex; gap: 10px; margin-top: 24px; }}
511
+ button {{ border: 1px solid #9ca3af; border-radius: 6px; padding: 8px 14px; background: white; cursor: pointer; }}
512
+ .approve {{ background: #14532d; color: white; border-color: #14532d; }}
513
+ .deny {{ background: #7f1d1d; color: white; border-color: #7f1d1d; }}
514
+ </style>
515
+ </head>
516
+ <body>{body}</body>
517
+ </html>
518
+ """
519
+ return html.encode("utf-8")
520
+
521
+
522
+ def screenshot_public_url(screenshot_id: str) -> str:
523
+ return f"{PUBLIC_BASE_URL.rstrip('/')}/screenshots/{screenshot_id}.png"
524
+
525
+
526
+ def save_screenshot(body: bytes) -> str:
527
+ screenshot_id = str(uuid.uuid4())
528
+ path = SCREENSHOTS_DIR / f"{screenshot_id}.png"
529
+ path.write_bytes(body)
530
+ return screenshot_id
531
+
532
+
533
+ def search_web(query: str, count: int) -> list[dict]:
534
+ params = parse.urlencode({"q": query, "format": "json", "language": "all"})
535
+ data = http_json(f"{SEARXNG_URL}?{params}", timeout=45)
536
+ results = []
537
+ for item in data.get("results", [])[:count]:
538
+ url = item.get("url")
539
+ if isinstance(url, str) and url.startswith(("http://", "https://")):
540
+ results.append(
541
+ {
542
+ "title": clean_text(item.get("title") or url)[:220],
543
+ "url": url,
544
+ "snippet": clean_text(item.get("content") or item.get("snippet") or "")[:800],
545
+ }
546
+ )
547
+ return results
548
+
549
+
550
+ def python_code_for(question: str) -> str | None:
551
+ lower = question.lower()
552
+ if "python" not in lower and not any(word in lower for word in ["calculate", "compute", "sum", "average", "mean"]):
553
+ return None
554
+ expr_match = re.search(r"([-+*/(). 0-9]{3,})", question)
555
+ if expr_match and re.search(r"\d", expr_match.group(1)):
556
+ expr = expr_match.group(1).strip()
557
+ if re.fullmatch(r"[-+*/(). 0-9]+", expr):
558
+ return f"print({expr})"
559
+ nums = [float(x) for x in re.findall(r"-?\d+(?:\.\d+)?", question)]
560
+ if nums and ("average" in lower or "mean" in lower):
561
+ return "values = " + repr(nums) + "\nprint(sum(values) / len(values))"
562
+ if nums and "sum" in lower:
563
+ return "values = " + repr(nums) + "\nprint(sum(values))"
564
+ return None
565
+
566
+
567
+ def run_python(code: str) -> str:
568
+ with tempfile.TemporaryDirectory(prefix="openwebui-local-agent-") as tmpdir:
569
+ result = subprocess.run(
570
+ ["python3", "-I", "-c", code],
571
+ cwd=tmpdir,
572
+ text=True,
573
+ capture_output=True,
574
+ timeout=PYTHON_TIMEOUT,
575
+ )
576
+ output = []
577
+ if result.stdout:
578
+ output.append("stdout:\n" + result.stdout.strip())
579
+ if result.stderr:
580
+ output.append("stderr:\n" + result.stderr.strip())
581
+ if result.returncode != 0:
582
+ output.append(f"exit_code: {result.returncode}")
583
+ return "\n\n".join(output).strip() or "(no output)"
584
+
585
+
586
+ def glm_chat(messages: list[dict], max_tokens: int = 512, temperature: float = 0.2) -> str:
587
+ payload = {
588
+ "model": GLM_MODEL,
589
+ "messages": messages,
590
+ "max_tokens": max_tokens,
591
+ "temperature": temperature,
592
+ "stream": False,
593
+ }
594
+ with LLM_LOCK:
595
+ data = http_json(f"{GLM_BASE_URL}/chat/completions", payload=payload, timeout=GLM_TIMEOUT)
596
+ return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
597
+
598
+
599
+ def content_text(content) -> str:
600
+ if isinstance(content, str):
601
+ return content
602
+ if isinstance(content, list):
603
+ parts = []
604
+ for part in content:
605
+ if not isinstance(part, dict):
606
+ continue
607
+ if part.get("type") in {"text", "input_text"}:
608
+ parts.append(str(part.get("text", "")))
609
+ elif "text" in part:
610
+ parts.append(str(part.get("text", "")))
611
+ return "\n".join(parts)
612
+ if content is None:
613
+ return ""
614
+ return json.dumps(content, ensure_ascii=False)
615
+
616
+
617
+ def user_intent_text(payload: dict) -> str:
618
+ messages = payload.get("messages") or []
619
+ parts = [
620
+ content_text(message.get("content", ""))
621
+ for message in messages
622
+ if isinstance(message, dict) and message.get("role") == "user"
623
+ ]
624
+ return "\n".join(clean_text(part) for part in parts if clean_text(part))
625
+
626
+
627
+ def visible_content_from_reasoning(reasoning_content: str) -> str:
628
+ answer_labels = (
629
+ "Answer:",
630
+ "Summary:",
631
+ "Math:",
632
+ "Code:",
633
+ "Translate:",
634
+ "Translation:",
635
+ "Question:",
636
+ "Questions:",
637
+ "Hint:",
638
+ "Check:",
639
+ "Criteria:",
640
+ "Tradeoffs:",
641
+ "Verify:",
642
+ "Total:",
643
+ "Total Revenue:",
644
+ "Profit:",
645
+ "Total Profit:",
646
+ "Best:",
647
+ "Best Item:",
648
+ "Caveat:",
649
+ "Title:",
650
+ "Draft:",
651
+ "Edit:",
652
+ "Source note:",
653
+ "Recommendation:",
654
+ "Plan:",
655
+ "Result:",
656
+ "Final:",
657
+ )
658
+ preamble_labels = (
659
+ "Role:",
660
+ "Task:",
661
+ "Constraint",
662
+ "Context:",
663
+ "Goal:",
664
+ "Input:",
665
+ "Input text:",
666
+ "Input data:",
667
+ "Keywords to include:",
668
+ "Labels must be:",
669
+ "Reply with",
670
+ "No quotes",
671
+ "No labels",
672
+ "Summary must include",
673
+ "Content requirements",
674
+ "Exactly ",
675
+ "Output format:",
676
+ "Required output",
677
+ )
678
+ answer_blocks = []
679
+ current_answer_block = []
680
+ current_answer_labels = set()
681
+ answer_candidates = []
682
+ visible_lines = []
683
+ for raw_line in reasoning_content.splitlines():
684
+ line = re.sub(r"^\s*(?:[-*]|\d+[.)])\s*", "", raw_line).strip()
685
+ line = re.sub(r"^\*\*([^*]+?:)\*\*\s*", r"\1 ", line)
686
+ line = re.sub(r"^\*([^*]+?:)\*\s*", r"\1 ", line)
687
+ line = re.sub(r"^Line\s+\d+:\s*", "", line, flags=re.IGNORECASE)
688
+ line = re.sub(r"^Line\s+\d+\s*\(([^)]+)\):\s*", r"\1: ", line, flags=re.IGNORECASE)
689
+ if not line:
690
+ continue
691
+ matched_label = next((label for label in answer_labels if line.startswith(label)), None)
692
+ if matched_label:
693
+ if matched_label in current_answer_labels and current_answer_block:
694
+ answer_blocks.append(current_answer_block)
695
+ current_answer_block = []
696
+ current_answer_labels = set()
697
+ current_answer_block.append(line)
698
+ current_answer_labels.add(matched_label)
699
+ continue
700
+ if current_answer_block:
701
+ answer_blocks.append(current_answer_block)
702
+ current_answer_block = []
703
+ current_answer_labels = set()
704
+ if any(line.startswith(label) for label in preamble_labels):
705
+ continue
706
+ candidate = re.sub(
707
+ r"^(?:Option\s+[A-Z]|Candidate\s+\d+|Final answer)\s*:\s*",
708
+ "",
709
+ line,
710
+ flags=re.IGNORECASE,
711
+ ).strip()
712
+ candidate = re.sub(
713
+ r"\s*\((?:good|better|best|simple|concise|preferred)\)\s*$",
714
+ "",
715
+ candidate,
716
+ flags=re.IGNORECASE,
717
+ ).strip()
718
+ candidate = candidate.strip("\"'")
719
+ if candidate and candidate != line:
720
+ answer_candidates.append(candidate)
721
+ continue
722
+ quoted = re.match(r'^"([^"]{12,})"', line)
723
+ if quoted:
724
+ answer_candidates.append(quoted.group(1).strip())
725
+ continue
726
+ visible_lines.append(line)
727
+ if current_answer_block:
728
+ answer_blocks.append(current_answer_block)
729
+ complete_answer_blocks = [block for block in answer_blocks if len(block) >= 2]
730
+ if complete_answer_blocks:
731
+ return "\n".join(complete_answer_blocks[-1])
732
+ if visible_lines and answer_candidates:
733
+ return "\n".join([*visible_lines, *answer_candidates]).strip()
734
+ if visible_lines:
735
+ return "\n".join(visible_lines).strip()
736
+ if answer_candidates:
737
+ return max(answer_candidates, key=len).strip()
738
+ if any(label in reasoning_content for label in ("Role:", "Constraint", "Task:")):
739
+ return ""
740
+ return reasoning_content.strip()
741
+
742
+
743
+ def ensure_visible_chat_content(data: dict) -> dict:
744
+ choices = data.get("choices") if isinstance(data, dict) else None
745
+ if not isinstance(choices, list) or not choices:
746
+ return data
747
+ message = choices[0].get("message") if isinstance(choices[0], dict) else None
748
+ if not isinstance(message, dict) or message.get("content"):
749
+ return data
750
+ reasoning_content = message.get("reasoning_content") or message.get("reasoning")
751
+ if not isinstance(reasoning_content, str) or not reasoning_content.strip():
752
+ return data
753
+ visible_content = visible_content_from_reasoning(reasoning_content)
754
+ if visible_content:
755
+ message["content"] = visible_content
756
+ data["local_agent_content_fallback"] = "reasoning_content" if message.get("reasoning_content") else "reasoning"
757
+ return data
758
+
759
+
760
+ def auto_router_overrides(payload: dict) -> dict:
761
+ metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
762
+ direct = payload.get("local_auto_router") if isinstance(payload.get("local_auto_router"), dict) else {}
763
+ nested = metadata.get("local_auto_router") if isinstance(metadata.get("local_auto_router"), dict) else {}
764
+ return {**nested, **direct}
765
+
766
+
767
+ def truthy(value) -> bool:
768
+ if isinstance(value, bool):
769
+ return value
770
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
771
+
772
+
773
+ def glm_warm_status(overrides: dict | None = None) -> dict:
774
+ overrides = overrides or {}
775
+ if truthy(overrides.get("simulate_glm_cold")):
776
+ return {
777
+ "available": True,
778
+ "warm": False,
779
+ "cached_or_decoded_tokens": 0,
780
+ "reason": "simulated cold GLM slot",
781
+ }
782
+ try:
783
+ base = GLM_BASE_URL.rstrip("/")
784
+ root = base[:-3] if base.endswith("/v1") else base
785
+ slots = http_json(f"{root}/slots", timeout=AUTO_ROUTER_GLM_HEALTH_TIMEOUT)
786
+ if not isinstance(slots, list) or not slots:
787
+ return {"available": True, "warm": False, "cached_or_decoded_tokens": 0, "reason": "no GLM slots reported"}
788
+ decoded_values = []
789
+ for slot in slots:
790
+ if not isinstance(slot, dict):
791
+ continue
792
+ next_tokens = slot.get("next_token") or []
793
+ if isinstance(next_tokens, list):
794
+ for item in next_tokens:
795
+ if isinstance(item, dict):
796
+ try:
797
+ decoded_values.append(int(item.get("n_decoded") or 0))
798
+ except (TypeError, ValueError):
799
+ pass
800
+ decoded = max(decoded_values or [0])
801
+ return {
802
+ "available": True,
803
+ "warm": decoded >= AUTO_ROUTER_GLM_WARM_MIN_DECODED,
804
+ "cached_or_decoded_tokens": decoded,
805
+ "reason": "GLM slot has decoded tokens" if decoded else "GLM slot has no decoded tokens yet",
806
+ }
807
+ except Exception as exc:
808
+ return {
809
+ "available": False,
810
+ "warm": False,
811
+ "cached_or_decoded_tokens": 0,
812
+ "reason": f"GLM slot probe failed: {exc}",
813
+ }
814
+
815
+
816
+ def maybe_apply_glm_cold_fallback(route: dict, overrides: dict) -> dict:
817
+ if route.get("route") != "glm" or route.get("forced"):
818
+ return route
819
+ if not AUTO_ROUTER_GLM_COLD_FALLBACK or truthy(overrides.get("allow_cold_glm")):
820
+ return route
821
+ status = glm_warm_status(overrides)
822
+ if status.get("warm"):
823
+ return {**route, "glm_warm_status": status}
824
+ return {
825
+ "route": "fast",
826
+ "target_model": AUTO_ROUTER_FAST_MODEL,
827
+ "target_base_url": AUTO_ROUTER_FAST_BASE_URL,
828
+ "reason": f"GLM cold fallback from {route.get('reason', 'glm route')}",
829
+ "fallback_from_route": route.get("route"),
830
+ "fallback_from_model": route.get("target_model"),
831
+ "glm_warm_status": status,
832
+ }
833
+
834
+
835
+ def select_auto_route(payload: dict, question: str, overrides: dict) -> dict:
836
+ force_route = str(overrides.get("force_route") or "").strip().lower()
837
+ if force_route in {"fast", "instant", "gemma", "gemma4"}:
838
+ return {
839
+ "route": "fast",
840
+ "target_model": AUTO_ROUTER_FAST_MODEL,
841
+ "target_base_url": AUTO_ROUTER_FAST_BASE_URL,
842
+ "reason": "forced fast route",
843
+ "forced": True,
844
+ }
845
+ if force_route in {"code", "coding", "slopcode", "qwen"}:
846
+ return {
847
+ "route": "code",
848
+ "target_model": AUTO_ROUTER_CODE_MODEL,
849
+ "target_base_url": AUTO_ROUTER_CODE_BASE_URL,
850
+ "reason": "forced coding route",
851
+ "forced": True,
852
+ }
853
+ if force_route in {"research", "deep-research", "deep_research", "sources"}:
854
+ return {
855
+ "route": "research",
856
+ "target_model": AUTO_ROUTER_RESEARCH_MODEL,
857
+ "target_base_url": AUTO_ROUTER_RESEARCH_BASE_URL,
858
+ "reason": "forced research route",
859
+ "forced": True,
860
+ }
861
+ if force_route in {"agent", "tool", "tools", "browser", "desktop"}:
862
+ return {
863
+ "route": "agent",
864
+ "target_model": AUTO_ROUTER_AGENT_MODEL,
865
+ "target_base_url": AUTO_ROUTER_AGENT_BASE_URL,
866
+ "reason": "forced tool-agent route",
867
+ "forced": True,
868
+ }
869
+ if force_route in {"glm", "deep", "reasoning", "quality"}:
870
+ return {
871
+ "route": "glm",
872
+ "target_model": GLM_MODEL,
873
+ "target_base_url": GLM_BASE_URL,
874
+ "reason": "forced GLM route",
875
+ "forced": True,
876
+ }
877
+
878
+ messages = payload.get("messages") or []
879
+ text = user_intent_text(payload)
880
+ lower = f"{question}\n{text}".lower()
881
+ has_tool_context = any(message.get("role") == "tool" for message in messages if isinstance(message, dict))
882
+ coding_terms = {
883
+ "api",
884
+ "bug",
885
+ "code",
886
+ "code review",
887
+ "coding",
888
+ "csv",
889
+ "dataframe",
890
+ "debug",
891
+ "error",
892
+ "function",
893
+ "jest",
894
+ "javascript",
895
+ "notebook",
896
+ "pandas",
897
+ "patch",
898
+ "python",
899
+ "query",
900
+ "refactor",
901
+ "regex",
902
+ "script",
903
+ "stack trace",
904
+ "sql",
905
+ "spreadsheet",
906
+ "test",
907
+ "typescript",
908
+ "unit test",
909
+ }
910
+ research_terms = {
911
+ "buyer guide",
912
+ "cite",
913
+ "cited",
914
+ "citation",
915
+ "compare products",
916
+ "deep research",
917
+ "due diligence",
918
+ "find sources",
919
+ "latest",
920
+ "literature",
921
+ "literature review",
922
+ "look up",
923
+ "market",
924
+ "market research",
925
+ "news",
926
+ "price",
927
+ "prices",
928
+ "product comparison",
929
+ "reviews",
930
+ "research",
931
+ "source-backed",
932
+ "sources",
933
+ "web search",
934
+ "web research",
935
+ }
936
+ no_search_terms = {
937
+ "do not browse",
938
+ "do not look up",
939
+ "do not search",
940
+ "don't browse",
941
+ "don't look up",
942
+ "don't search",
943
+ "from general knowledge",
944
+ "no browsing",
945
+ "no search",
946
+ "no sources needed",
947
+ "no web search",
948
+ "offline only",
949
+ "without browsing",
950
+ "without looking anything up",
951
+ "without search",
952
+ "without sources",
953
+ "without web search",
954
+ }
955
+ agent_terms = {
956
+ "browser",
957
+ "click",
958
+ "desktop",
959
+ "download",
960
+ "navigate",
961
+ "open page",
962
+ "playwright",
963
+ "run command",
964
+ "screenshot",
965
+ "take a screenshot",
966
+ "tool call",
967
+ "tool-use",
968
+ "upload",
969
+ "use a tool",
970
+ "use the browser",
971
+ "web page",
972
+ }
973
+ no_agent_terms = {
974
+ "do not use browser",
975
+ "do not use desktop",
976
+ "do not use the browser",
977
+ "do not use tools",
978
+ "don't use browser",
979
+ "don't use desktop",
980
+ "don't use the browser",
981
+ "don't use tools",
982
+ "no browser",
983
+ "no browser tools",
984
+ "no desktop",
985
+ "no tool calls",
986
+ "no tools",
987
+ "without browser",
988
+ "without desktop",
989
+ "without tool calls",
990
+ "without tools",
991
+ }
992
+ complex_terms = {
993
+ "analyze",
994
+ "architecture",
995
+ "decision",
996
+ "deep",
997
+ "design",
998
+ "derive",
999
+ "explain why",
1000
+ "implement",
1001
+ "multi-step",
1002
+ "multi step",
1003
+ "plan",
1004
+ "prove",
1005
+ "reason",
1006
+ "root cause",
1007
+ "research",
1008
+ "strategy",
1009
+ "tradeoff",
1010
+ "tradeoffs",
1011
+ "why",
1012
+ }
1013
+ short_reasoning_terms = {
1014
+ "analyze",
1015
+ "explain",
1016
+ "explain why",
1017
+ "reason",
1018
+ "why",
1019
+ }
1020
+ latency_sensitive_terms = {
1021
+ "brief",
1022
+ "briefly",
1023
+ "eli5",
1024
+ "in one sentence",
1025
+ "in simple terms",
1026
+ "one sentence",
1027
+ "plain language",
1028
+ "quick",
1029
+ "quickly",
1030
+ "short",
1031
+ "simple explanation",
1032
+ "two sentences",
1033
+ }
1034
+ heavyweight_reasoning_terms = {
1035
+ "architecture",
1036
+ "decision criteria",
1037
+ "deep",
1038
+ "derive",
1039
+ "due diligence",
1040
+ "multi-step",
1041
+ "multi step",
1042
+ "phased plan",
1043
+ "private",
1044
+ "prove",
1045
+ "root cause",
1046
+ "sensitive",
1047
+ "strategy",
1048
+ "tradeoff",
1049
+ "tradeoffs",
1050
+ }
1051
+ no_tools_requested = any(term in lower for term in no_agent_terms)
1052
+ no_search_requested = no_tools_requested or any(term in lower for term in no_search_terms)
1053
+
1054
+ if not no_tools_requested and any(term in lower for term in agent_terms):
1055
+ return {
1056
+ "route": "agent",
1057
+ "target_model": AUTO_ROUTER_AGENT_MODEL,
1058
+ "target_base_url": AUTO_ROUTER_AGENT_BASE_URL,
1059
+ "reason": "tool-agent keyword",
1060
+ }
1061
+ if not no_search_requested and any(term in lower for term in research_terms):
1062
+ return {
1063
+ "route": "research",
1064
+ "target_model": AUTO_ROUTER_RESEARCH_MODEL,
1065
+ "target_base_url": AUTO_ROUTER_RESEARCH_BASE_URL,
1066
+ "reason": "research keyword",
1067
+ }
1068
+ if any(term in lower for term in coding_terms):
1069
+ return {
1070
+ "route": "code",
1071
+ "target_model": AUTO_ROUTER_CODE_MODEL,
1072
+ "target_base_url": AUTO_ROUTER_CODE_BASE_URL,
1073
+ "reason": "coding keyword",
1074
+ }
1075
+ if (
1076
+ len(text) <= int(overrides.get("short_reasoning_chars") or 420)
1077
+ and any(term in lower for term in short_reasoning_terms)
1078
+ and any(term in lower for term in latency_sensitive_terms)
1079
+ and not any(term in lower for term in heavyweight_reasoning_terms)
1080
+ and not has_tool_context
1081
+ ):
1082
+ return {
1083
+ "route": "fast",
1084
+ "target_model": AUTO_ROUTER_FAST_MODEL,
1085
+ "target_base_url": AUTO_ROUTER_FAST_BASE_URL,
1086
+ "reason": "latency-sensitive short reasoning",
1087
+ }
1088
+ if len(text) > int(overrides.get("long_prompt_chars") or 1200):
1089
+ reason = "long prompt"
1090
+ elif any(term in lower for term in complex_terms):
1091
+ reason = "reasoning keyword"
1092
+ elif has_tool_context:
1093
+ reason = "tool context"
1094
+ else:
1095
+ return {
1096
+ "route": "fast",
1097
+ "target_model": AUTO_ROUTER_FAST_MODEL,
1098
+ "target_base_url": AUTO_ROUTER_FAST_BASE_URL,
1099
+ "reason": "short everyday prompt",
1100
+ }
1101
+
1102
+ return {
1103
+ "route": "glm",
1104
+ "target_model": GLM_MODEL,
1105
+ "target_base_url": GLM_BASE_URL,
1106
+ "reason": reason,
1107
+ }
1108
+
1109
+
1110
+ def routed_payload(payload: dict, model: str) -> dict:
1111
+ allowed_keys = {
1112
+ "messages",
1113
+ "max_tokens",
1114
+ "temperature",
1115
+ "top_p",
1116
+ "stop",
1117
+ "presence_penalty",
1118
+ "frequency_penalty",
1119
+ "seed",
1120
+ "response_format",
1121
+ }
1122
+ forwarded = {key: payload[key] for key in allowed_keys if key in payload}
1123
+ forwarded["model"] = model
1124
+ forwarded["stream"] = False
1125
+ return forwarded
1126
+
1127
+
1128
+ def completion_response(model: str, content: str, usage: dict | None = None, extra: dict | None = None) -> dict:
1129
+ response = {
1130
+ "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
1131
+ "object": "chat.completion",
1132
+ "created": now(),
1133
+ "model": model,
1134
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
1135
+ "usage": usage or {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
1136
+ }
1137
+ if extra:
1138
+ response.update(extra)
1139
+ return response
1140
+
1141
+
1142
+ def auto_router_request_shape(payload: dict) -> dict:
1143
+ messages = [message for message in payload.get("messages") or [] if isinstance(message, dict)]
1144
+ prompt_text = "\n".join(content_text(message.get("content", "")) for message in messages)
1145
+ return {
1146
+ "message_count": len(messages),
1147
+ "prompt_chars": len(prompt_text),
1148
+ "has_tool_context": any(message.get("role") == "tool" for message in messages),
1149
+ "stream": bool(payload.get("stream")),
1150
+ }
1151
+
1152
+
1153
+ def record_auto_router_event(
1154
+ route: dict,
1155
+ payload: dict,
1156
+ overrides: dict,
1157
+ *,
1158
+ status: str,
1159
+ elapsed_ms: float,
1160
+ usage: dict | None = None,
1161
+ response_model: str | None = None,
1162
+ error: str | None = None,
1163
+ ) -> dict:
1164
+ event = {
1165
+ "id": str(uuid.uuid4()),
1166
+ "created": now(),
1167
+ "trace_id": str(overrides.get("trace_id") or ""),
1168
+ "route": route.get("route"),
1169
+ "target_model": route.get("target_model"),
1170
+ "target_base_url": route.get("target_base_url"),
1171
+ "reason": route.get("reason"),
1172
+ "fallback_from_route": route.get("fallback_from_route", ""),
1173
+ "fallback_from_model": route.get("fallback_from_model", ""),
1174
+ "glm_warm_status": route.get("glm_warm_status") or {},
1175
+ "status": status,
1176
+ "elapsed_ms": round(float(elapsed_ms), 2),
1177
+ "response_model": response_model or "",
1178
+ "usage": usage or {},
1179
+ "echo_route": bool(overrides.get("echo_route")),
1180
+ **auto_router_request_shape(payload),
1181
+ }
1182
+ if error:
1183
+ event["error"] = str(error)[:500]
1184
+ with AUTO_ROUTER_TELEMETRY_LOCK:
1185
+ AUTO_ROUTER_EVENTS.append(event)
1186
+ if len(AUTO_ROUTER_EVENTS) > AUTO_ROUTER_TELEMETRY_LIMIT:
1187
+ del AUTO_ROUTER_EVENTS[: len(AUTO_ROUTER_EVENTS) - AUTO_ROUTER_TELEMETRY_LIMIT]
1188
+ return event
1189
+
1190
+
1191
+ def list_auto_router_events(query: dict) -> dict:
1192
+ try:
1193
+ limit = int((query.get("limit") or ["50"])[0])
1194
+ except Exception:
1195
+ limit = 50
1196
+ limit = max(1, min(limit, AUTO_ROUTER_TELEMETRY_LIMIT))
1197
+ route_filter = (query.get("route") or [""])[0]
1198
+ trace_filter = (query.get("trace_id") or [""])[0]
1199
+ with AUTO_ROUTER_TELEMETRY_LOCK:
1200
+ events = list(AUTO_ROUTER_EVENTS)
1201
+ if route_filter:
1202
+ events = [event for event in events if event.get("route") == route_filter]
1203
+ if trace_filter:
1204
+ events = [event for event in events if event.get("trace_id") == trace_filter]
1205
+ events = events[-limit:]
1206
+ route_counts: dict[str, int] = {}
1207
+ status_counts: dict[str, int] = {}
1208
+ for event in events:
1209
+ route_counts[event.get("route", "")] = route_counts.get(event.get("route", ""), 0) + 1
1210
+ status_counts[event.get("status", "")] = status_counts.get(event.get("status", ""), 0) + 1
1211
+ return {
1212
+ "events": events,
1213
+ "count": len(events),
1214
+ "limit": AUTO_ROUTER_TELEMETRY_LIMIT,
1215
+ "summary": {
1216
+ "routes": route_counts,
1217
+ "statuses": status_counts,
1218
+ "latest_created": max((event.get("created", 0) for event in events), default=0),
1219
+ },
1220
+ }
1221
+
1222
+
1223
+ def auto_router_chat(payload: dict, question: str) -> dict:
1224
+ overrides = auto_router_overrides(payload)
1225
+ route = maybe_apply_glm_cold_fallback(select_auto_route(payload, question, overrides), overrides)
1226
+ if overrides.get("echo_route"):
1227
+ answer = (
1228
+ f"route={route['route']}; target_model={route['target_model']}; "
1229
+ f"reason={route['reason']}"
1230
+ )
1231
+ event = record_auto_router_event(
1232
+ route,
1233
+ payload,
1234
+ overrides,
1235
+ status="echo",
1236
+ elapsed_ms=0,
1237
+ usage={"prompt_tokens": 0, "completion_tokens": len(answer.split()), "total_tokens": len(answer.split())},
1238
+ response_model=AUTO_ROUTER_MODEL_ID,
1239
+ )
1240
+ route = {**route, "telemetry_id": event["id"], "trace_id": event.get("trace_id", "")}
1241
+ return completion_response(
1242
+ AUTO_ROUTER_MODEL_ID,
1243
+ answer,
1244
+ usage={"prompt_tokens": 0, "completion_tokens": len(answer.split()), "total_tokens": len(answer.split())},
1245
+ extra={"local_auto_router": route},
1246
+ )
1247
+
1248
+ target_payload = routed_payload(payload, route["target_model"])
1249
+ timeout_by_route = {
1250
+ "agent": AUTO_ROUTER_AGENT_TIMEOUT,
1251
+ "code": AUTO_ROUTER_CODE_TIMEOUT,
1252
+ "fast": AUTO_ROUTER_FAST_TIMEOUT,
1253
+ "glm": GLM_TIMEOUT,
1254
+ "research": AUTO_ROUTER_RESEARCH_TIMEOUT,
1255
+ }
1256
+ timeout = timeout_by_route.get(route["route"], GLM_TIMEOUT)
1257
+ lock = LLM_LOCK if route["route"] == "glm" else None
1258
+ started = time.time()
1259
+ try:
1260
+ if lock:
1261
+ with lock:
1262
+ data = http_json(f"{route['target_base_url']}/chat/completions", payload=target_payload, timeout=timeout)
1263
+ else:
1264
+ data = http_json(f"{route['target_base_url']}/chat/completions", payload=target_payload, timeout=timeout)
1265
+ except Exception as exc:
1266
+ record_auto_router_event(
1267
+ route,
1268
+ payload,
1269
+ overrides,
1270
+ status="error",
1271
+ elapsed_ms=(time.time() - started) * 1000,
1272
+ error=str(exc),
1273
+ )
1274
+ raise
1275
+ event = record_auto_router_event(
1276
+ route,
1277
+ payload,
1278
+ overrides,
1279
+ status="ok",
1280
+ elapsed_ms=(time.time() - started) * 1000,
1281
+ usage=data.get("usage") if isinstance(data.get("usage"), dict) else None,
1282
+ response_model=str(data.get("model") or ""),
1283
+ )
1284
+ route = {**route, "telemetry_id": event["id"], "trace_id": event.get("trace_id", "")}
1285
+ data = ensure_visible_chat_content(data)
1286
+ data["model"] = AUTO_ROUTER_MODEL_ID
1287
+ data["local_auto_router"] = route
1288
+ return data
1289
+
1290
+
1291
+ def last_user_message(payload: dict) -> str:
1292
+ for message in reversed(payload.get("messages") or []):
1293
+ if message.get("role") == "user":
1294
+ content = message.get("content", "")
1295
+ if isinstance(content, str):
1296
+ return content
1297
+ if isinstance(content, list):
1298
+ return "\n".join(part.get("text", "") for part in content if isinstance(part, dict))
1299
+ return ""
1300
+
1301
+
1302
+ def system_messages(payload: dict) -> list[str]:
1303
+ messages = []
1304
+ for message in payload.get("messages") or []:
1305
+ if message.get("role") != "system":
1306
+ continue
1307
+ content = message.get("content", "")
1308
+ if isinstance(content, str):
1309
+ messages.append(content)
1310
+ elif isinstance(content, list):
1311
+ messages.append("\n".join(part.get("text", "") for part in content if isinstance(part, dict)))
1312
+ return [clean_text(message) for message in messages if clean_text(message)]
1313
+
1314
+
1315
+ def message_summaries(payload: dict) -> list[dict]:
1316
+ messages = []
1317
+ for message in payload.get("messages") or []:
1318
+ content = message.get("content", "")
1319
+ if isinstance(content, str):
1320
+ text = content
1321
+ elif isinstance(content, list):
1322
+ text = "\n".join(part.get("text", "") for part in content if isinstance(part, dict))
1323
+ else:
1324
+ text = json.dumps(content, ensure_ascii=False)
1325
+ messages.append({"role": message.get("role", ""), "content": clean_text(text)})
1326
+ return messages
1327
+
1328
+
1329
+ def should_search(question: str, overrides: dict) -> bool:
1330
+ if "web_search" in overrides:
1331
+ return bool(overrides.get("web_search"))
1332
+ lower = question.lower()
1333
+ return any(word in lower for word in ["search", "web", "latest", "current", "find sources", "look up"])
1334
+
1335
+
1336
+ def should_check_scheduler(question: str, overrides: dict) -> bool:
1337
+ if "check_scheduler" in overrides:
1338
+ return bool(overrides.get("check_scheduler"))
1339
+ return "schedule" in question.lower() or "task" in question.lower()
1340
+
1341
+
1342
+ def should_use_browser(question: str, overrides: dict) -> bool:
1343
+ if overrides.get("browser_url"):
1344
+ return True
1345
+ lower = question.lower()
1346
+ return "browser" in lower or "click" in lower or "open this page" in lower
1347
+
1348
+
1349
+ def requested_browser_url(question: str, overrides: dict) -> str | None:
1350
+ url = overrides.get("browser_url")
1351
+ if isinstance(url, str) and url.strip():
1352
+ return url.strip()
1353
+ match = re.search(r"https?://[^\s)>\"]+", question)
1354
+ return match.group(0).rstrip(".,") if match else None
1355
+
1356
+
1357
+ def allowed_hosts(overrides: dict) -> set[str]:
1358
+ hosts = overrides.get("allowed_hosts") or overrides.get("browser_allowed_hosts") or []
1359
+ if isinstance(hosts, str):
1360
+ hosts = [host.strip() for host in hosts.split(",")]
1361
+ return {str(host).strip().lower() for host in hosts if str(host).strip()}
1362
+
1363
+
1364
+ def browser_approval(url: str, overrides: dict, action: str = "snapshot", click_text: str | None = None) -> tuple[bool, str, str | None]:
1365
+ parsed = parse.urlparse(url)
1366
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
1367
+ return False, "browser tool only supports absolute http(s) URLs", None
1368
+
1369
+ approval_id = str(overrides.get("approval_id") or overrides.get("browser_approval_id") or "").strip()
1370
+ if approval_id:
1371
+ approval = get_approval(approval_id)
1372
+ if not approval:
1373
+ return False, f"approval_not_found: {approval_id}", None
1374
+ approved, reason = approval_allows_url(approval, url)
1375
+ if approved:
1376
+ return True, reason, approval_id
1377
+ return False, reason, approval_id
1378
+
1379
+ if not overrides.get("allow_browser"):
1380
+ reason = "approval_required: browser action needs review"
1381
+ approval = create_browser_approval(url, overrides, reason, action, click_text)
1382
+ return False, f"{reason}: {approval_public_url(approval['id'])}", approval["id"]
1383
+ hosts = allowed_hosts(overrides)
1384
+ host = parsed.netloc.lower()
1385
+ if "*" not in hosts and host not in hosts:
1386
+ reason = f"approval_required: host {host!r} is not in local_agent.allowed_hosts"
1387
+ approval = create_browser_approval(url, overrides, reason, action, click_text)
1388
+ return False, f"{reason}: {approval_public_url(approval['id'])}", approval["id"]
1389
+ return True, "approved", None
1390
+
1391
+
1392
+ def maybe_wait_for_browser_approval(
1393
+ url: str,
1394
+ overrides: dict,
1395
+ reason: str,
1396
+ approval_id: str | None,
1397
+ action: str,
1398
+ click_text: str | None = None,
1399
+ progress=None,
1400
+ ) -> tuple[bool, str, dict]:
1401
+ if not approval_id or not overrides.get("interactive_approval"):
1402
+ return False, reason, overrides
1403
+
1404
+ wait_seconds = int(overrides.get("approval_timeout_seconds") or APPROVAL_WAIT_SECONDS)
1405
+ approval_url = approval_public_url(approval_id)
1406
+ if progress:
1407
+ progress(f"Approval required: {approval_url}\n\n")
1408
+ approval = wait_for_approval(approval_id, wait_seconds)
1409
+ if not approval:
1410
+ return False, f"approval_timeout: {approval_url}", overrides
1411
+ approved, approval_reason = approval_allows_url(approval, url)
1412
+ if not approved:
1413
+ return False, approval_reason, overrides
1414
+ approved_overrides = dict(overrides)
1415
+ approved_overrides["approval_id"] = approval_id
1416
+ approved_overrides["allow_browser"] = True
1417
+ approved_overrides["allowed_hosts"] = sorted(set(allowed_hosts(overrides)) | set(approval.get("approved_hosts", [])))
1418
+ return True, "approved_by_interactive_review", approved_overrides
1419
+
1420
+
1421
+ def browser_snapshot(url: str) -> dict:
1422
+ raw, content_type = http_bytes(url)
1423
+ if content_type not in {"text/html", "application/xhtml+xml"}:
1424
+ text = clean_text(raw.decode("utf-8", errors="replace")) if content_type.startswith("text/") else f"{len(raw)} bytes of {content_type}"
1425
+ return {"url": url, "title": "", "text": text[:3000], "links": []}
1426
+ return html_to_snapshot(url, raw)
1427
+
1428
+
1429
+ def format_browser_snapshot(snapshot: dict) -> str:
1430
+ lines = [
1431
+ f"URL: {snapshot.get('url', '')}",
1432
+ f"Title: {snapshot.get('title', '')}",
1433
+ "",
1434
+ "Visible text:",
1435
+ snapshot.get("text", ""),
1436
+ "",
1437
+ "Links:",
1438
+ ]
1439
+ for link in snapshot.get("links", []):
1440
+ lines.append(f"- {link.get('text', '')}: {link.get('href', '')}")
1441
+ return "\n".join(lines).strip()
1442
+
1443
+
1444
+ def wait_for_page_idle(page):
1445
+ for state, timeout in [("domcontentloaded", PLAYWRIGHT_TIMEOUT_MS), ("networkidle", 5000)]:
1446
+ try:
1447
+ page.wait_for_load_state(state, timeout=timeout)
1448
+ except Exception:
1449
+ pass
1450
+
1451
+
1452
+ def playwright_extract_links(page) -> list[dict]:
1453
+ try:
1454
+ return page.locator("a").evaluate_all(
1455
+ """links => links.slice(0, 25).map(link => ({
1456
+ text: (link.innerText || link.textContent || link.href || '').trim().slice(0, 160),
1457
+ href: link.href || link.getAttribute('href') || ''
1458
+ }))"""
1459
+ )
1460
+ except Exception:
1461
+ return []
1462
+
1463
+
1464
+ def playwright_find_link_url(page, click_text: str) -> str | None:
1465
+ text = click_text.strip().lower()
1466
+ if not text:
1467
+ return None
1468
+ try:
1469
+ return page.locator("a").evaluate_all(
1470
+ """(links, wanted) => {
1471
+ for (const link of links) {
1472
+ const label = (link.innerText || link.textContent || '').trim().toLowerCase();
1473
+ if (label.includes(wanted)) {
1474
+ return link.href || link.getAttribute('href') || null;
1475
+ }
1476
+ }
1477
+ return null;
1478
+ }""",
1479
+ text,
1480
+ )
1481
+ except Exception:
1482
+ return None
1483
+
1484
+
1485
+ def playwright_snapshot(url: str, click_text: str | None = None) -> dict:
1486
+ from playwright.sync_api import sync_playwright
1487
+
1488
+ with sync_playwright() as p:
1489
+ browser = p.chromium.connect(PLAYWRIGHT_WS_URL, timeout=PLAYWRIGHT_TIMEOUT_MS)
1490
+ page = browser.new_page(viewport={"width": 1280, "height": 720}, device_scale_factor=1)
1491
+ try:
1492
+ page.goto(url, wait_until="domcontentloaded", timeout=PLAYWRIGHT_TIMEOUT_MS)
1493
+ wait_for_page_idle(page)
1494
+ clicked = False
1495
+ if click_text:
1496
+ page.get_by_text(click_text, exact=False).first.click(timeout=PLAYWRIGHT_TIMEOUT_MS)
1497
+ clicked = True
1498
+ wait_for_page_idle(page)
1499
+ try:
1500
+ text = page.locator("body").inner_text(timeout=PLAYWRIGHT_TIMEOUT_MS)
1501
+ except Exception:
1502
+ text = page.content()
1503
+ screenshot_id = save_screenshot(page.screenshot(type="png", full_page=False, timeout=PLAYWRIGHT_TIMEOUT_MS))
1504
+ return {
1505
+ "url": page.url,
1506
+ "title": page.title()[:200],
1507
+ "text": clean_text(text)[:4000],
1508
+ "links": playwright_extract_links(page),
1509
+ "screenshot_id": screenshot_id,
1510
+ "screenshot_url": screenshot_public_url(screenshot_id),
1511
+ "clicked": clicked,
1512
+ }
1513
+ finally:
1514
+ page.close()
1515
+ browser.close()
1516
+
1517
+
1518
+ def format_playwright_snapshot(snapshot: dict) -> str:
1519
+ lines = [
1520
+ f"URL: {snapshot.get('url', '')}",
1521
+ f"Title: {snapshot.get('title', '')}",
1522
+ f"Screenshot: {snapshot.get('screenshot_url', '')}",
1523
+ "",
1524
+ "Rendered text:",
1525
+ snapshot.get("text", ""),
1526
+ "",
1527
+ "Links:",
1528
+ ]
1529
+ for link in snapshot.get("links", []):
1530
+ lines.append(f"- {link.get('text', '')}: {link.get('href', '')}")
1531
+ return "\n".join(lines).strip()
1532
+
1533
+
1534
+ def run_playwright_browser_tool(url: str, action: str, click_text: str, overrides: dict) -> Action:
1535
+ if action == "snapshot":
1536
+ rendered = playwright_snapshot(url)
1537
+ return Action(kind="browser", title="Graphical Browser Snapshot", input=url, output=format_playwright_snapshot(rendered))
1538
+
1539
+ if action == "click_text":
1540
+ if not click_text:
1541
+ return Action(kind="browser", title="Graphical Browser Click", input=url, output="click_text is required", status="failed")
1542
+ target = None
1543
+ try:
1544
+ initial = playwright_snapshot(url)
1545
+ target = None
1546
+ # Re-open for the actual click after approval checks; this keeps target probing side-effect free.
1547
+ from playwright.sync_api import sync_playwright
1548
+
1549
+ with sync_playwright() as p:
1550
+ browser = p.chromium.connect(PLAYWRIGHT_WS_URL, timeout=PLAYWRIGHT_TIMEOUT_MS)
1551
+ page = browser.new_page(viewport={"width": 1280, "height": 720}, device_scale_factor=1)
1552
+ try:
1553
+ page.goto(url, wait_until="domcontentloaded", timeout=PLAYWRIGHT_TIMEOUT_MS)
1554
+ wait_for_page_idle(page)
1555
+ target = playwright_find_link_url(page, click_text)
1556
+ finally:
1557
+ page.close()
1558
+ browser.close()
1559
+ except Exception:
1560
+ initial = None
1561
+
1562
+ if target:
1563
+ approved, reason, approval_id = browser_approval(target, overrides, action, click_text)
1564
+ if not approved:
1565
+ output = reason
1566
+ if approval_id:
1567
+ output += f"\napproval_id: {approval_id}\napproval_url: {approval_public_url(approval_id)}"
1568
+ return Action(kind="browser", title="Graphical Browser Click Approval Required", input=target, output=output, status="approval_required")
1569
+
1570
+ rendered = playwright_snapshot(url, click_text=click_text)
1571
+ output = (
1572
+ f"Clicked text: {click_text}\n"
1573
+ f"Start URL: {url}\n"
1574
+ f"Final URL: {rendered.get('url', '')}\n"
1575
+ f"Screenshot: {rendered.get('screenshot_url', '')}\n\n"
1576
+ f"Final rendered snapshot:\n{format_playwright_snapshot(rendered)}"
1577
+ )
1578
+ if initial:
1579
+ output += f"\n\nInitial screenshot: {initial.get('screenshot_url', '')}"
1580
+ return Action(kind="browser", title="Graphical Browser Click", input=f"{url}\nclick_text={click_text}", output=output)
1581
+
1582
+ return Action(kind="browser", title="Graphical Browser Tool", input=url, output=f"Unsupported browser_action: {action}", status="failed")
1583
+
1584
+
1585
+ def run_browser_tool(question: str, overrides: dict, progress=None) -> Action:
1586
+ url = requested_browser_url(question, overrides)
1587
+ if not url:
1588
+ return Action(kind="browser", title="Browser Tool", input="", output="No browser_url or URL found.", status="failed")
1589
+
1590
+ action = str(overrides.get("browser_action") or "snapshot")
1591
+ click_text = str(overrides.get("click_text") or "").strip()
1592
+ approved, reason, approval_id = browser_approval(url, overrides, action, click_text)
1593
+ if not approved:
1594
+ approved, reason, overrides = maybe_wait_for_browser_approval(
1595
+ url, overrides, reason, approval_id, action, click_text, progress=progress
1596
+ )
1597
+ if not approved:
1598
+ output = reason
1599
+ if approval_id:
1600
+ output += f"\napproval_id: {approval_id}\napproval_url: {approval_public_url(approval_id)}"
1601
+ return Action(kind="browser", title="Browser Approval Required", input=url, output=output, status="approval_required")
1602
+
1603
+ if str(overrides.get("browser_backend") or "").lower() == "playwright":
1604
+ try:
1605
+ return run_playwright_browser_tool(url, action, click_text, overrides)
1606
+ except Exception as exc:
1607
+ return Action(kind="browser", title="Graphical Browser Tool", input=url, output=str(exc), status="failed")
1608
+
1609
+ snapshot = browser_snapshot(url)
1610
+ if action == "snapshot":
1611
+ return Action(kind="browser", title="Browser Snapshot", input=url, output=format_browser_snapshot(snapshot))
1612
+
1613
+ if action == "click_text":
1614
+ normalized_click_text = click_text.lower()
1615
+ if not normalized_click_text:
1616
+ return Action(kind="browser", title="Browser Click", input=url, output="click_text is required", status="failed")
1617
+ target = None
1618
+ for link in snapshot.get("links", []):
1619
+ if normalized_click_text in str(link.get("text", "")).lower():
1620
+ target = parse.urljoin(url, link.get("href", ""))
1621
+ break
1622
+ if not target:
1623
+ return Action(
1624
+ kind="browser",
1625
+ title="Browser Click",
1626
+ input=f"{url}\nclick_text={overrides.get('click_text')}",
1627
+ output="No matching link found.\n\n" + format_browser_snapshot(snapshot),
1628
+ status="failed",
1629
+ )
1630
+ approved, reason, target_approval_id = browser_approval(target, overrides, action, click_text)
1631
+ if not approved:
1632
+ approved, reason, overrides = maybe_wait_for_browser_approval(
1633
+ target, overrides, reason, target_approval_id, action, click_text, progress=progress
1634
+ )
1635
+ if not approved:
1636
+ output = reason
1637
+ if target_approval_id:
1638
+ output += f"\napproval_id: {target_approval_id}\napproval_url: {approval_public_url(target_approval_id)}"
1639
+ return Action(kind="browser", title="Browser Click Approval Required", input=target, output=output, status="approval_required")
1640
+ clicked = browser_snapshot(target)
1641
+ output = (
1642
+ f"Clicked link text: {overrides.get('click_text')}\n"
1643
+ f"Start URL: {url}\n"
1644
+ f"Final URL: {target}\n\n"
1645
+ f"Final snapshot:\n{format_browser_snapshot(clicked)}"
1646
+ )
1647
+ return Action(kind="browser", title="Browser Click", input=f"{url}\nclick_text={overrides.get('click_text')}", output=output)
1648
+
1649
+ return Action(kind="browser", title="Browser Tool", input=url, output=f"Unsupported browser_action: {action}", status="failed")
1650
+
1651
+
1652
+ def command_exists(name: str) -> bool:
1653
+ return shutil.which(name) is not None
1654
+
1655
+
1656
+ def display_probe(display: str, wayland_display: str, xdg_runtime_dir: str) -> tuple[bool, str]:
1657
+ if display and command_exists("xdpyinfo"):
1658
+ try:
1659
+ result = subprocess.run(
1660
+ ["xdpyinfo"],
1661
+ text=True,
1662
+ capture_output=True,
1663
+ timeout=3,
1664
+ env=os.environ.copy(),
1665
+ )
1666
+ if result.returncode == 0:
1667
+ first_line = (result.stdout or "").splitlines()[0] if result.stdout else "xdpyinfo ok"
1668
+ return True, first_line[:200]
1669
+ return False, (result.stderr or result.stdout or f"xdpyinfo exit {result.returncode}").strip()[:300]
1670
+ except Exception as exc:
1671
+ return False, str(exc)[:300]
1672
+
1673
+ if wayland_display and xdg_runtime_dir:
1674
+ wayland_socket = Path(xdg_runtime_dir) / wayland_display
1675
+ if wayland_socket.exists():
1676
+ return True, f"Wayland socket exists: {wayland_socket}"
1677
+ return False, f"Wayland socket not found: {wayland_socket}"
1678
+
1679
+ return False, "DISPLAY and WAYLAND_DISPLAY are unset"
1680
+
1681
+
1682
+ def desktop_capabilities() -> dict:
1683
+ display = os.environ.get("DISPLAY", "")
1684
+ wayland_display = os.environ.get("WAYLAND_DISPLAY", "")
1685
+ xdg_runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "")
1686
+ xauthority = os.environ.get("XAUTHORITY", "")
1687
+ tools = {
1688
+ "xdg-open": command_exists("xdg-open"),
1689
+ "xdotool": command_exists("xdotool"),
1690
+ "wmctrl": command_exists("wmctrl"),
1691
+ "xprop": command_exists("xprop"),
1692
+ "xdpyinfo": command_exists("xdpyinfo"),
1693
+ "xev": command_exists("xev"),
1694
+ "xterm": command_exists("xterm"),
1695
+ "gnome-screenshot": command_exists("gnome-screenshot"),
1696
+ "grim": command_exists("grim"),
1697
+ "scrot": command_exists("scrot"),
1698
+ "import": command_exists("import"),
1699
+ }
1700
+ gui_available, probe = display_probe(display, wayland_display, xdg_runtime_dir)
1701
+ return {
1702
+ "enabled": DESKTOP_ENABLED,
1703
+ "gui_available": gui_available,
1704
+ "display_probe": probe,
1705
+ "display": display,
1706
+ "wayland_display": wayland_display,
1707
+ "xdg_runtime_dir": xdg_runtime_dir,
1708
+ "xauthority": xauthority,
1709
+ "tools": tools,
1710
+ }
1711
+
1712
+
1713
+ def format_desktop_status(capabilities: dict) -> str:
1714
+ tool_lines = ", ".join(f"{name}={available}" for name, available in capabilities.get("tools", {}).items())
1715
+ lines = [
1716
+ "Desktop tool status:",
1717
+ f"enabled={capabilities.get('enabled')}",
1718
+ f"gui_available={capabilities.get('gui_available')}",
1719
+ f"DISPLAY={capabilities.get('display') or '(unset)'}",
1720
+ f"WAYLAND_DISPLAY={capabilities.get('wayland_display') or '(unset)'}",
1721
+ f"XDG_RUNTIME_DIR={capabilities.get('xdg_runtime_dir') or '(unset)'}",
1722
+ f"XAUTHORITY={capabilities.get('xauthority') or '(unset)'}",
1723
+ f"display_probe={capabilities.get('display_probe') or '(none)'}",
1724
+ f"tools: {tool_lines}",
1725
+ ]
1726
+ if not capabilities.get("gui_available"):
1727
+ lines.append("GUI display is not available to the local-agent container; command/status actions still work.")
1728
+ return "\n".join(lines)
1729
+
1730
+
1731
+ def should_use_desktop(question: str, overrides: dict) -> bool:
1732
+ if overrides.get("desktop_action") or overrides.get("desktop_command"):
1733
+ return True
1734
+ lower = question.lower()
1735
+ return any(phrase in lower for phrase in ["desktop", "local app", "os control", "screen control", "take a screenshot"])
1736
+
1737
+
1738
+ def desktop_command_from_overrides(overrides: dict) -> list[str] | None:
1739
+ command = overrides.get("desktop_command")
1740
+ if command is None:
1741
+ return None
1742
+ if isinstance(command, list):
1743
+ parts = [str(part) for part in command if str(part)]
1744
+ elif isinstance(command, str):
1745
+ parts = [part for part in command.strip().split() if part]
1746
+ else:
1747
+ return None
1748
+ return parts or None
1749
+
1750
+
1751
+ def allowed_desktop_actions(overrides: dict) -> set[str]:
1752
+ actions = overrides.get("allowed_desktop_actions") or []
1753
+ if isinstance(actions, str):
1754
+ actions = [part.strip() for part in actions.split(",")]
1755
+ return {str(action).strip().lower() for action in actions if str(action).strip()}
1756
+
1757
+
1758
+ def desktop_approval(action: str, command: list[str] | None, overrides: dict) -> tuple[bool, str, str | None]:
1759
+ approval_id = str(overrides.get("desktop_approval_id") or overrides.get("approval_id") or "").strip()
1760
+ if approval_id:
1761
+ approval = get_approval(approval_id)
1762
+ if not approval:
1763
+ return False, f"approval_not_found: {approval_id}", None
1764
+ approved, reason = approval_allows_desktop(approval, action, command)
1765
+ if approved:
1766
+ return True, reason, approval_id
1767
+ return False, reason, approval_id
1768
+
1769
+ read_only = action in {"status", "list_windows"}
1770
+ if read_only:
1771
+ return True, "read_only", None
1772
+
1773
+ if overrides.get("allow_desktop"):
1774
+ allowed = allowed_desktop_actions(overrides)
1775
+ if "*" in allowed or action.lower() in allowed:
1776
+ return True, "approved", None
1777
+
1778
+ reason = "approval_required: desktop action needs review"
1779
+ approval = create_desktop_approval(action, command, reason)
1780
+ return False, f"{reason}: {approval_public_url(approval['id'])}", approval["id"]
1781
+
1782
+
1783
+ def maybe_wait_for_desktop_approval(
1784
+ action: str,
1785
+ command: list[str] | None,
1786
+ overrides: dict,
1787
+ reason: str,
1788
+ approval_id: str | None,
1789
+ progress=None,
1790
+ ) -> tuple[bool, str, dict]:
1791
+ if not approval_id or not overrides.get("interactive_approval"):
1792
+ return False, reason, overrides
1793
+
1794
+ wait_seconds = int(overrides.get("approval_timeout_seconds") or APPROVAL_WAIT_SECONDS)
1795
+ approval_url = approval_public_url(approval_id)
1796
+ if progress:
1797
+ progress(f"Desktop approval required: {approval_url}\n\n")
1798
+ approval = wait_for_approval(approval_id, wait_seconds)
1799
+ if not approval:
1800
+ return False, f"approval_timeout: {approval_url}", overrides
1801
+ approved, approval_reason = approval_allows_desktop(approval, action, command)
1802
+ if not approved:
1803
+ return False, approval_reason, overrides
1804
+ approved_overrides = dict(overrides)
1805
+ approved_overrides["desktop_approval_id"] = approval_id
1806
+ approved_overrides["allow_desktop"] = True
1807
+ approved_overrides["allowed_desktop_actions"] = sorted(set(allowed_desktop_actions(overrides)) | {action})
1808
+ return True, "approved_by_interactive_review", approved_overrides
1809
+
1810
+
1811
+ def run_desktop_subprocess(command: list[str], timeout: int = DESKTOP_TIMEOUT) -> subprocess.CompletedProcess:
1812
+ return subprocess.run(
1813
+ command,
1814
+ text=True,
1815
+ capture_output=True,
1816
+ timeout=timeout,
1817
+ cwd=str(DESKTOP_DIR),
1818
+ env=os.environ.copy(),
1819
+ )
1820
+
1821
+
1822
+ def run_desktop_command(command: list[str]) -> str:
1823
+ result = run_desktop_subprocess(command)
1824
+ output = []
1825
+ if result.stdout:
1826
+ output.append("stdout:\n" + result.stdout.strip()[:DESKTOP_COMMAND_MAX_OUTPUT])
1827
+ if result.stderr:
1828
+ output.append("stderr:\n" + result.stderr.strip()[:DESKTOP_COMMAND_MAX_OUTPUT])
1829
+ if result.returncode != 0:
1830
+ output.append(f"exit_code: {result.returncode}")
1831
+ return "\n\n".join(output).strip() or "(no output)"
1832
+
1833
+
1834
+ def list_desktop_windows() -> str:
1835
+ if not desktop_capabilities().get("gui_available"):
1836
+ return format_desktop_status(desktop_capabilities())
1837
+ for command in (["wmctrl", "-l"], ["xprop", "-root", "_NET_CLIENT_LIST_STACKING"]):
1838
+ if command_exists(command[0]):
1839
+ try:
1840
+ return run_desktop_command(command)
1841
+ except Exception as exc:
1842
+ return str(exc)
1843
+ return "No supported window-listing tool is installed."
1844
+
1845
+
1846
+ def desktop_screenshot() -> str:
1847
+ capabilities = desktop_capabilities()
1848
+ if not capabilities.get("gui_available"):
1849
+ return format_desktop_status(capabilities)
1850
+
1851
+ screenshot_id = str(uuid.uuid4())
1852
+ path = SCREENSHOTS_DIR / f"{screenshot_id}.png"
1853
+ candidates = []
1854
+ if command_exists("gnome-screenshot"):
1855
+ candidates.append(["gnome-screenshot", "-f", str(path)])
1856
+ if command_exists("grim"):
1857
+ candidates.append(["grim", str(path)])
1858
+ if command_exists("scrot"):
1859
+ candidates.append(["scrot", str(path)])
1860
+ if command_exists("import"):
1861
+ candidates.append(["import", "-window", "root", str(path)])
1862
+
1863
+ errors = []
1864
+ for command in candidates:
1865
+ try:
1866
+ result = run_desktop_subprocess(command)
1867
+ if result.returncode == 0 and path.exists() and path.stat().st_size > 8:
1868
+ return f"Desktop screenshot: {screenshot_public_url(screenshot_id)}"
1869
+ errors.append(f"{' '.join(command)}: exit={result.returncode}; stderr={result.stderr.strip()[:500]}")
1870
+ except Exception as exc:
1871
+ errors.append(f"{' '.join(command)}: {exc}")
1872
+ return "Desktop screenshot failed.\n" + ("\n".join(errors) if errors else "No supported screenshot tool is installed.")
1873
+
1874
+
1875
+ def run_desktop_tool(question: str, overrides: dict, progress=None) -> Action:
1876
+ if not DESKTOP_ENABLED:
1877
+ return Action(kind="desktop", title="Desktop Tool", input="", output="Desktop tool is disabled.", status="failed")
1878
+
1879
+ action = str(overrides.get("desktop_action") or "status").strip().lower()
1880
+ command = desktop_command_from_overrides(overrides)
1881
+ if action == "command" and not command:
1882
+ return Action(kind="desktop", title="Desktop Command", input="", output="desktop_command is required.", status="failed")
1883
+
1884
+ approved, reason, approval_id = desktop_approval(action, command, overrides)
1885
+ if not approved:
1886
+ approved, reason, overrides = maybe_wait_for_desktop_approval(action, command, overrides, reason, approval_id, progress=progress)
1887
+ if not approved:
1888
+ output = reason
1889
+ if approval_id:
1890
+ output += f"\napproval_id: {approval_id}\napproval_url: {approval_public_url(approval_id)}"
1891
+ return Action(kind="desktop", title="Desktop Approval Required", input=" ".join(command or [action]), output=output, status="approval_required")
1892
+
1893
+ try:
1894
+ if action == "status":
1895
+ return Action(kind="desktop", title="Desktop Status", input=question, output=format_desktop_status(desktop_capabilities()))
1896
+ if action == "list_windows":
1897
+ return Action(kind="desktop", title="Desktop Windows", input=question, output=list_desktop_windows())
1898
+ if action == "screenshot":
1899
+ return Action(kind="desktop", title="Desktop Screenshot", input=question, output=desktop_screenshot())
1900
+ if action == "command":
1901
+ return Action(kind="desktop", title="Desktop Command", input=" ".join(command or []), output=run_desktop_command(command or []))
1902
+ return Action(kind="desktop", title="Desktop Tool", input=action, output=f"Unsupported desktop_action: {action}", status="failed")
1903
+ except Exception as exc:
1904
+ return Action(kind="desktop", title="Desktop Tool", input=" ".join(command or [action]), output=str(exc), status="failed")
1905
+
1906
+
1907
+ def source_pack(run_dir: Path, run_id: str, question: str, actions: list[Action], answer: str):
1908
+ run_dir.mkdir(parents=True, exist_ok=True)
1909
+ payload = {
1910
+ "run_id": run_id,
1911
+ "question": question,
1912
+ "created_at": now(),
1913
+ "actions": [asdict(action) for action in actions],
1914
+ "answer": answer,
1915
+ }
1916
+ (run_dir / "agent-run.json").write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
1917
+ lines = [f"# Agent Run: {run_id}", "", f"Question: {question}", "", "## Actions", ""]
1918
+ for index, action in enumerate(actions, start=1):
1919
+ lines.extend(
1920
+ [
1921
+ f"### A{index}. {action.title}",
1922
+ f"- Kind: {action.kind}",
1923
+ f"- Status: {action.status}",
1924
+ "",
1925
+ "Input:",
1926
+ "",
1927
+ f"```text\n{action.input}\n```",
1928
+ "",
1929
+ "Output:",
1930
+ "",
1931
+ f"```text\n{action.output}\n```",
1932
+ "",
1933
+ ]
1934
+ )
1935
+ lines.extend(["## Final Answer", "", answer])
1936
+ (run_dir / "agent-run.md").write_text("\n".join(lines), encoding="utf-8")
1937
+
1938
+
1939
+ def synthesize(question: str, actions: list[Action], overrides: dict) -> str:
1940
+ if not actions:
1941
+ return "I did not need to call tools for this request."
1942
+ if overrides.get("synthesize") is False:
1943
+ lines = ["Agent actions completed:"]
1944
+ for index, action in enumerate(actions, start=1):
1945
+ lines.append(f"- A{index} {action.kind}: {action.output[:500]}")
1946
+ return "\n".join(lines)
1947
+
1948
+ evidence = []
1949
+ for index, action in enumerate(actions, start=1):
1950
+ evidence.append(f"A{index} {action.kind} ({action.title})\nInput: {action.input}\nOutput: {action.output[:1800]}")
1951
+ prompt = (
1952
+ "You are a local agent. Answer the user using only the tool results below. "
1953
+ "Call out any tool failures or uncertainty. Keep the answer concise.\n\n"
1954
+ f"User request:\n{question}\n\nTool results:\n" + "\n\n".join(evidence)
1955
+ )
1956
+ try:
1957
+ return glm_chat(
1958
+ [
1959
+ {"role": "system", "content": "You are a careful local tool-using agent."},
1960
+ {"role": "user", "content": prompt},
1961
+ ],
1962
+ max_tokens=int(overrides.get("max_tokens", 512)),
1963
+ temperature=0.2,
1964
+ )
1965
+ except Exception as exc:
1966
+ fallback = "\n".join(f"- {action.kind}: {action.output[:500]}" for action in actions)
1967
+ return f"Agent tools completed, but GLM synthesis failed: {exc}\n\n{fallback}"
1968
+
1969
+
1970
+ def run_agent(question: str, overrides: dict | None = None, progress=None) -> str:
1971
+ overrides = overrides or {}
1972
+ run_id = str(uuid.uuid4())
1973
+ run_dir = STORAGE / "runs" / run_id
1974
+ actions: list[Action] = []
1975
+
1976
+ def say(message: str):
1977
+ if progress:
1978
+ progress(message)
1979
+
1980
+ say(f"Agent run `{run_id}` started.\n\n")
1981
+
1982
+ code = overrides.get("python_code") or python_code_for(question)
1983
+ if code:
1984
+ say("Running local Python tool.\n\n")
1985
+ try:
1986
+ output = run_python(str(code))
1987
+ actions.append(Action(kind="python", title="Run Python", input=str(code), output=output))
1988
+ except Exception as exc:
1989
+ actions.append(Action(kind="python", title="Run Python", input=str(code), output=str(exc), status="failed"))
1990
+
1991
+ if should_search(question, overrides):
1992
+ query = str(overrides.get("query") or question)
1993
+ count = int(overrides.get("search_results", 5))
1994
+ say(f"Searching web for `{query}`.\n\n")
1995
+ try:
1996
+ results = search_web(query, count)
1997
+ output = "\n".join(f"- {item['title']}: {item['url']}\n {item['snippet']}" for item in results) or "No results"
1998
+ actions.append(Action(kind="web_search", title="Search Web", input=query, output=output))
1999
+ if overrides.get("fetch_first") and results:
2000
+ first = results[0]["url"]
2001
+ say(f"Reading first search result: {first}\n\n")
2002
+ text = fetch_url(first)[:3000]
2003
+ actions.append(Action(kind="fetch_url", title="Fetch URL", input=first, output=text or "No text extracted"))
2004
+ except Exception as exc:
2005
+ actions.append(Action(kind="web_search", title="Search Web", input=query, output=str(exc), status="failed"))
2006
+
2007
+ if should_check_scheduler(question, overrides):
2008
+ say("Checking local scheduler state.\n\n")
2009
+ try:
2010
+ state = http_json(f"{SCHEDULER_URL}/health", timeout=20)
2011
+ actions.append(Action(kind="scheduler", title="Check Scheduler", input=SCHEDULER_URL, output=json.dumps(state)))
2012
+ except Exception as exc:
2013
+ actions.append(Action(kind="scheduler", title="Check Scheduler", input=SCHEDULER_URL, output=str(exc), status="failed"))
2014
+
2015
+ if should_use_browser(question, overrides):
2016
+ say("Using local browser-control tool.\n\n")
2017
+ try:
2018
+ actions.append(run_browser_tool(question, overrides, progress=say))
2019
+ except Exception as exc:
2020
+ actions.append(Action(kind="browser", title="Browser Tool", input=str(overrides.get("browser_url", "")), output=str(exc), status="failed"))
2021
+
2022
+ if should_use_desktop(question, overrides):
2023
+ say("Using local desktop-control tool.\n\n")
2024
+ try:
2025
+ actions.append(run_desktop_tool(question, overrides, progress=say))
2026
+ except Exception as exc:
2027
+ actions.append(Action(kind="desktop", title="Desktop Tool", input=str(overrides.get("desktop_action", "")), output=str(exc), status="failed"))
2028
+
2029
+ answer = synthesize(question, actions, overrides)
2030
+ source_pack(run_dir, run_id, question, actions, answer)
2031
+ md_url = f"{PUBLIC_BASE_URL}/runs/{run_id}/agent-run.md"
2032
+ json_url = f"{PUBLIC_BASE_URL}/runs/{run_id}/agent-run.json"
2033
+ return answer + f"\n\nAgent run: [Markdown]({md_url}) | [JSON]({json_url})"
2034
+
2035
+
2036
+ def chunk_payload(content: str, finish_reason=None, model: str = MODEL_ID) -> dict:
2037
+ return {
2038
+ "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
2039
+ "object": "chat.completion.chunk",
2040
+ "created": now(),
2041
+ "model": model,
2042
+ "choices": [{"index": 0, "delta": {"content": content} if content else {}, "finish_reason": finish_reason}],
2043
+ }
2044
+
2045
+
2046
+ def model_cards() -> list[dict]:
2047
+ local_agent_card = {
2048
+ "id": MODEL_ID,
2049
+ "name": MODEL_ID,
2050
+ "object": "model",
2051
+ "created": now(),
2052
+ "owned_by": "local",
2053
+ "connection_type": "local",
2054
+ "info": {
2055
+ "id": MODEL_ID,
2056
+ "name": "Local Agent - GLM 5.2",
2057
+ "meta": {
2058
+ "description": "Local GLM-backed agent with Python, search, scheduler, browser, Playwright, and approval-gated desktop/system tools.",
2059
+ "capabilities": {
2060
+ "web_search": True,
2061
+ "code_interpreter": True,
2062
+ "browser": True,
2063
+ "desktop_control": DESKTOP_ENABLED,
2064
+ "requires_approval": True,
2065
+ },
2066
+ },
2067
+ },
2068
+ }
2069
+ auto_router_card = {
2070
+ "id": AUTO_ROUTER_MODEL_ID,
2071
+ "name": AUTO_ROUTER_MODEL_ID,
2072
+ "object": "model",
2073
+ "created": now(),
2074
+ "owned_by": "local",
2075
+ "connection_type": "local",
2076
+ "info": {
2077
+ "id": AUTO_ROUTER_MODEL_ID,
2078
+ "name": "Local Auto Router",
2079
+ "meta": {
2080
+ "description": "Local additive auto-router for fast chat, coding, research, tool-agent, and GLM 5.2 reasoning routes.",
2081
+ "capabilities": {
2082
+ "auto_routing": True,
2083
+ "fast_model": AUTO_ROUTER_FAST_MODEL,
2084
+ "coding_model": AUTO_ROUTER_CODE_MODEL,
2085
+ "research_model": AUTO_ROUTER_RESEARCH_MODEL,
2086
+ "agent_model": AUTO_ROUTER_AGENT_MODEL,
2087
+ "reasoning_model": GLM_MODEL,
2088
+ "telemetry_endpoint": "/api/auto-router/events",
2089
+ "glm_cold_fallback": AUTO_ROUTER_GLM_COLD_FALLBACK,
2090
+ "glm_warm_min_decoded_tokens": AUTO_ROUTER_GLM_WARM_MIN_DECODED,
2091
+ "local_only": True,
2092
+ },
2093
+ },
2094
+ },
2095
+ }
2096
+ return [local_agent_card, auto_router_card]
2097
+
2098
+
2099
+ class Handler(BaseHTTPRequestHandler):
2100
+ server_version = "openwebui-local-agent/0.2"
2101
+
2102
+ def log_message(self, fmt, *args):
2103
+ print("%s - - [%s] %s" % (self.client_address[0], self.log_date_time_string(), fmt % args), flush=True)
2104
+
2105
+ def do_OPTIONS(self):
2106
+ self.send_response(204)
2107
+ self.send_header("Access-Control-Allow-Origin", "*")
2108
+ self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
2109
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
2110
+ self.end_headers()
2111
+
2112
+ def do_GET(self):
2113
+ parsed_path = parse.urlparse(self.path)
2114
+ path = parsed_path.path
2115
+ query = parse.parse_qs(parsed_path.query)
2116
+ if path == "/health":
2117
+ return send_json(
2118
+ self,
2119
+ 200,
2120
+ {
2121
+ "status": "ok",
2122
+ "model": MODEL_ID,
2123
+ "desktop": desktop_capabilities(),
2124
+ },
2125
+ )
2126
+ if path == "/approvals":
2127
+ return send_html(self, approval_page_html())
2128
+ if path == "/api/approvals":
2129
+ status = (query.get("status") or [None])[0]
2130
+ return send_json(self, 200, {"approvals": list_approvals(status=status)})
2131
+ if path == "/api/auto-router/events":
2132
+ return send_json(self, 200, list_auto_router_events(query))
2133
+ screenshot_match = re.match(r"^/screenshots/([a-f0-9-]{36})\.png$", path)
2134
+ if screenshot_match:
2135
+ screenshot_path = SCREENSHOTS_DIR / f"{screenshot_match.group(1)}.png"
2136
+ if not screenshot_path.exists():
2137
+ return send_json(self, 404, {"error": "not found"})
2138
+ return send_binary(self, screenshot_path.read_bytes(), "image/png")
2139
+ approval_match = re.match(r"^/approvals/([a-f0-9-]{36})$", path)
2140
+ if approval_match:
2141
+ approval = get_approval(approval_match.group(1))
2142
+ if not approval:
2143
+ return send_json(self, 404, {"error": "not found"})
2144
+ return send_html(self, approval_page_html(approval))
2145
+ approval_api_match = re.match(r"^/api/approvals/([a-f0-9-]{36})$", path)
2146
+ if approval_api_match:
2147
+ approval = get_approval(approval_api_match.group(1))
2148
+ if not approval:
2149
+ return send_json(self, 404, {"error": "not found"})
2150
+ return send_json(self, 200, {"approval": approval})
2151
+ fixture = browser_fixture(path)
2152
+ if fixture is not None:
2153
+ return send_html(self, fixture)
2154
+ if path in {"/v1/models", "/models"}:
2155
+ cards = model_cards()
2156
+ return send_json(
2157
+ self,
2158
+ 200,
2159
+ {
2160
+ "object": "list",
2161
+ "data": cards,
2162
+ "models": [
2163
+ {"name": card["id"], "model": card["id"], "type": "model", "info": card["info"]}
2164
+ for card in cards
2165
+ ],
2166
+ },
2167
+ )
2168
+ match = re.match(r"^/runs/([^/]+)/(agent-run\.(?:md|json))$", path)
2169
+ if match:
2170
+ run_id, filename = match.groups()
2171
+ file_path = STORAGE / "runs" / run_id / filename
2172
+ if not file_path.exists():
2173
+ return send_json(self, 404, {"error": "not found"})
2174
+ body = file_path.read_bytes()
2175
+ content_type = "application/json" if filename.endswith(".json") else "text/markdown; charset=utf-8"
2176
+ self.send_response(200)
2177
+ self.send_header("Content-Type", content_type)
2178
+ self.send_header("Content-Length", str(len(body)))
2179
+ self.send_header("Access-Control-Allow-Origin", "*")
2180
+ self.end_headers()
2181
+ self.wfile.write(body)
2182
+ return
2183
+ return send_json(self, 404, {"error": "not found"})
2184
+
2185
+ def do_POST(self):
2186
+ path = parse.urlparse(self.path).path
2187
+ approval_action_match = re.match(r"^/(api/)?approvals/([a-f0-9-]{36})/(approve|deny)$", path)
2188
+ if approval_action_match:
2189
+ is_api = bool(approval_action_match.group(1))
2190
+ approval_id = approval_action_match.group(2)
2191
+ decision = "approved" if approval_action_match.group(3) == "approve" else "denied"
2192
+ try:
2193
+ approval = update_approval_status(approval_id, decision)
2194
+ except FileNotFoundError:
2195
+ return send_json(self, 404, {"error": "not found"})
2196
+ except Exception as exc:
2197
+ return send_json(self, 400, {"error": str(exc)})
2198
+ if is_api:
2199
+ return send_json(self, 200, {"approval": approval})
2200
+ return send_redirect(self, f"/approvals/{approval_id}")
2201
+
2202
+ if path not in {"/v1/chat/completions", "/chat/completions"}:
2203
+ return send_json(self, 404, {"error": "not found"})
2204
+ try:
2205
+ payload = read_json(self)
2206
+ question = last_user_message(payload).strip()
2207
+ if not question:
2208
+ return send_json(self, 400, {"error": {"message": "No user message found"}})
2209
+ requested_model = payload.get("model") or MODEL_ID
2210
+ if requested_model == AUTO_ROUTER_MODEL_ID:
2211
+ data = auto_router_chat(payload, question)
2212
+ if payload.get("stream"):
2213
+ self.send_response(200)
2214
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
2215
+ self.send_header("Cache-Control", "no-cache")
2216
+ self.send_header("Connection", "keep-alive")
2217
+ self.send_header("Access-Control-Allow-Origin", "*")
2218
+ self.end_headers()
2219
+ content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
2220
+ event = "data: " + json.dumps(chunk_payload(content, model=AUTO_ROUTER_MODEL_ID), ensure_ascii=False) + "\n\n"
2221
+ self.wfile.write(event.encode("utf-8"))
2222
+ done = (
2223
+ "data: "
2224
+ + json.dumps(chunk_payload("", "stop", model=AUTO_ROUTER_MODEL_ID), ensure_ascii=False)
2225
+ + "\n\n"
2226
+ + "data: [DONE]\n\n"
2227
+ )
2228
+ self.wfile.write(done.encode("utf-8"))
2229
+ self.wfile.flush()
2230
+ return
2231
+ return send_json(self, 200, data)
2232
+ overrides = payload.get("local_agent") or payload.get("metadata", {}).get("local_agent") or {}
2233
+ if overrides.get("echo_system_messages"):
2234
+ answer = "Received system messages:\n" + json.dumps(system_messages(payload), ensure_ascii=False)
2235
+ return send_json(
2236
+ self,
2237
+ 200,
2238
+ {
2239
+ "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
2240
+ "object": "chat.completion",
2241
+ "created": now(),
2242
+ "model": MODEL_ID,
2243
+ "choices": [
2244
+ {"index": 0, "message": {"role": "assistant", "content": answer}, "finish_reason": "stop"}
2245
+ ],
2246
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
2247
+ },
2248
+ )
2249
+ if overrides.get("echo_messages"):
2250
+ answer = "Received messages:\n" + json.dumps(message_summaries(payload), ensure_ascii=False)
2251
+ return send_json(
2252
+ self,
2253
+ 200,
2254
+ {
2255
+ "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
2256
+ "object": "chat.completion",
2257
+ "created": now(),
2258
+ "model": MODEL_ID,
2259
+ "choices": [
2260
+ {"index": 0, "message": {"role": "assistant", "content": answer}, "finish_reason": "stop"}
2261
+ ],
2262
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
2263
+ },
2264
+ )
2265
+ if payload.get("stream"):
2266
+ self.send_response(200)
2267
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
2268
+ self.send_header("Cache-Control", "no-cache")
2269
+ self.send_header("Connection", "keep-alive")
2270
+ self.send_header("Access-Control-Allow-Origin", "*")
2271
+ self.end_headers()
2272
+
2273
+ def emit(text: str):
2274
+ event = "data: " + json.dumps(chunk_payload(text), ensure_ascii=False) + "\n\n"
2275
+ self.wfile.write(event.encode("utf-8"))
2276
+ self.wfile.flush()
2277
+
2278
+ answer = run_agent(question, overrides=overrides, progress=emit)
2279
+ emit(answer)
2280
+ done = "data: " + json.dumps(chunk_payload("", "stop")) + "\n\n" + "data: [DONE]\n\n"
2281
+ self.wfile.write(done.encode("utf-8"))
2282
+ self.wfile.flush()
2283
+ return
2284
+
2285
+ answer = run_agent(question, overrides=overrides)
2286
+ return send_json(
2287
+ self,
2288
+ 200,
2289
+ {
2290
+ "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
2291
+ "object": "chat.completion",
2292
+ "created": now(),
2293
+ "model": MODEL_ID,
2294
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": answer}, "finish_reason": "stop"}],
2295
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
2296
+ },
2297
+ )
2298
+ except BrokenPipeError:
2299
+ return
2300
+ except Exception as exc:
2301
+ return send_json(self, 500, {"error": {"message": str(exc), "type": "server_error"}})
2302
+
2303
+
2304
+ def main():
2305
+ server = ThreadingHTTPServer((HOST, PORT), Handler)
2306
+ print(f"local agent listening on http://{HOST}:{PORT}", flush=True)
2307
+ server.serve_forever()
2308
+
2309
+
2310
+ if __name__ == "__main__":
2311
+ main()