@capagents/forgeqe 2.2.2 → 2.2.3
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.
- package/HOW_TO_USE.md +102 -37
- package/README.md +2 -2
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/dist/forgeqe.js +1005 -898
- package/docs/ARCHITECTURE.md +6 -2
- package/docs/CONTAINERS.md +80 -4
- package/docs/user-guide.html +1383 -140
- package/forgeqe.example.yaml +3 -1
- package/llms.example.json +2 -4
- package/package.json +4 -2
- package/py/forgeqe_engine/browser.py +106 -1
- package/py/forgeqe_engine/cli.py +13 -0
- package/py/forgeqe_engine/copilot_gateway.py +369 -0
- package/py/forgeqe_engine/service.py +29 -0
package/forgeqe.example.yaml
CHANGED
|
@@ -86,9 +86,11 @@ browser:
|
|
|
86
86
|
max_steps: 40 # a checkpoint: a run still making progress carries on, up to 3x
|
|
87
87
|
timeout_seconds: 600 # whole run
|
|
88
88
|
vision_detail: low # low | high
|
|
89
|
-
viewport:
|
|
89
|
+
viewport: # headless page size; a visible window too with window: viewport
|
|
90
90
|
width: 1280
|
|
91
91
|
height: 800
|
|
92
|
+
window: maximized # visible browser: maximized | viewport
|
|
93
|
+
video: true # video.mp4 of each run, shown in its report
|
|
92
94
|
allowed_hosts: []
|
|
93
95
|
judge: false # true: a second model call checks each finished run against the goal and can fail it
|
|
94
96
|
overlay: true # visible browser: ForgeQE start page, edge glow, a bar with the current step, your mouse and keyboard locked while the agent works
|
package/llms.example.json
CHANGED
|
@@ -50,10 +50,8 @@
|
|
|
50
50
|
},
|
|
51
51
|
"copilot": {
|
|
52
52
|
"provider": "github_copilot",
|
|
53
|
-
"model": "
|
|
54
|
-
"
|
|
55
|
-
"github_org": "",
|
|
56
|
-
"description": "GitHub Copilot seat for ForgeQE's agent and Scribe (forgeqe auth login copilot); the browser engine uses GitHub Models with your GitHub sign-in"
|
|
53
|
+
"model": "gpt-4.1",
|
|
54
|
+
"description": "GitHub Copilot seat for the browser engine, through GitHub's Copilot SDK (forgeqe auth login copilot: a fine-grained token with Copilot Requests, or the GitHub CLI's sign-in)"
|
|
57
55
|
}
|
|
58
56
|
}
|
|
59
57
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capagents/forgeqe",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -40,11 +40,13 @@
|
|
|
40
40
|
"forgeqe": "bun src/index.ts",
|
|
41
41
|
"start": "bun src/index.ts start",
|
|
42
42
|
"test": "bun test",
|
|
43
|
+
"test:release": "bun scripts/release-tests.ts",
|
|
43
44
|
"typecheck": "tsc --noEmit",
|
|
44
45
|
"build:report": "cd report-ui && npm install --no-audit --no-fund && npm run build && cp dist/index.html ../src/report/template.html",
|
|
45
46
|
"build": "bun scripts/build.ts",
|
|
46
47
|
"notices": "bun scripts/gen-notices.ts",
|
|
47
|
-
"prepack": "bun scripts/build.ts && bun scripts/gen-notices.ts"
|
|
48
|
+
"prepack": "bun scripts/build.ts && bun scripts/gen-notices.ts",
|
|
49
|
+
"prepublishOnly": "bun run typecheck && bun scripts/release-tests.ts"
|
|
48
50
|
},
|
|
49
51
|
"keywords": [
|
|
50
52
|
"forgeqe",
|
|
@@ -192,7 +192,20 @@ def _testmu_caps(remote: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
|
|
195
|
+
# browser.provider docker: ForgeQE starts the browser container and passes its gateway's endpoint (with its token) here.
|
|
196
|
+
DOCKER_CDP_ENV = "FORGEQE_BROWSER_CDP_URL"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def docker_browser_launch_kwargs() -> Dict[str, Any]:
|
|
200
|
+
url = (os.getenv(DOCKER_CDP_ENV) or "").strip()
|
|
201
|
+
if not url.startswith("ws://127.0.0.1:"):
|
|
202
|
+
raise RuntimeError(f"docker: {DOCKER_CDP_ENV} is not set to the browser container's local endpoint; ForgeQE starts that container itself.")
|
|
203
|
+
return {"cdp_url": url}
|
|
204
|
+
|
|
205
|
+
|
|
195
206
|
def remote_browser_launch_kwargs(provider: str, remote: Dict[str, Any]) -> Dict[str, Any]:
|
|
207
|
+
if provider == "docker":
|
|
208
|
+
return docker_browser_launch_kwargs()
|
|
196
209
|
endpoint = str(remote.get("endpoint") or "").strip().rstrip("/")
|
|
197
210
|
if not endpoint:
|
|
198
211
|
raise RuntimeError(f"{provider}: a remote endpoint is required.")
|
|
@@ -212,6 +225,8 @@ def browser_launch_kwargs(*, provider: str, headless: bool, remote: Optional[Dic
|
|
|
212
225
|
def browser_connection_label(provider: str, launch: Dict[str, Any], remote: Optional[Dict[str, Any]] = None) -> str:
|
|
213
226
|
if provider == "local":
|
|
214
227
|
return str(launch.get("channel") or "chrome")
|
|
228
|
+
if provider == "docker":
|
|
229
|
+
return "Chromium in a container"
|
|
215
230
|
browser = str((remote or {}).get("browser") or "")
|
|
216
231
|
target = str((remote or {}).get("platform") or "").strip() or " ".join(
|
|
217
232
|
x for x in [str((remote or {}).get("os") or "").strip(), str((remote or {}).get("os_version") or (remote or {}).get("osVersion") or "").strip()] if x
|
|
@@ -296,6 +311,93 @@ async def reopen_in_fresh_tab(browser: Any, url: str) -> None:
|
|
|
296
311
|
await close.event_result(raise_if_any=False, raise_if_none=False)
|
|
297
312
|
|
|
298
313
|
|
|
314
|
+
VIDEO_FILE = "video.mp4"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _size(value: Any) -> Optional[Dict[str, int]]:
|
|
318
|
+
"""browser.viewport as [width, height], or None when it is not two sizes a screen can have."""
|
|
319
|
+
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
|
320
|
+
return None
|
|
321
|
+
try:
|
|
322
|
+
w, h = int(value[0]), int(value[1])
|
|
323
|
+
except (TypeError, ValueError):
|
|
324
|
+
return None
|
|
325
|
+
return {"width": w, "height": h} if 200 <= w <= 10000 and 200 <= h <= 10000 else None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def window_launch_kwargs(browser_cfg: Dict[str, Any], *, headless: bool) -> Dict[str, Any]:
|
|
329
|
+
"""
|
|
330
|
+
The size of a local browser. Headless: the page is browser.viewport. Visible: the window is that size with
|
|
331
|
+
browser.window: viewport, else the library's screen-sized window, which `maximize_window` then maximises.
|
|
332
|
+
"""
|
|
333
|
+
if str(browser_cfg.get("provider") or "local") != "local":
|
|
334
|
+
return {}
|
|
335
|
+
size = _size(browser_cfg.get("viewport"))
|
|
336
|
+
if not size:
|
|
337
|
+
return {}
|
|
338
|
+
if headless:
|
|
339
|
+
return {"viewport": size}
|
|
340
|
+
return {"window_size": size} if browser_cfg.get("window") == "viewport" else {}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def wants_maximized(browser_cfg: Dict[str, Any], *, headless: bool) -> bool:
|
|
344
|
+
return not headless and str(browser_cfg.get("provider") or "local") == "local" and browser_cfg.get("window") != "viewport"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def maximize_window(browser: Any) -> bool:
|
|
348
|
+
"""Maximise the visible browser's window (the library only sizes it to the screen, under the menu bar and Dock)."""
|
|
349
|
+
try:
|
|
350
|
+
session = await browser.get_or_create_cdp_session()
|
|
351
|
+
send = browser.cdp_client.send.Browser
|
|
352
|
+
found = await send.getWindowForTarget(params={"targetId": session.target_id})
|
|
353
|
+
window_id = found["windowId"]
|
|
354
|
+
await send.setWindowBounds(params={"windowId": window_id, "bounds": {"windowState": "maximized"}})
|
|
355
|
+
# The page takes the new size a moment later; the video is sized from it.
|
|
356
|
+
for _ in range(10):
|
|
357
|
+
bounds = (await send.getWindowBounds(params={"windowId": window_id})).get("bounds") or {}
|
|
358
|
+
if bounds.get("windowState") == "maximized":
|
|
359
|
+
break
|
|
360
|
+
await asyncio.sleep(0.1)
|
|
361
|
+
await asyncio.sleep(0.3)
|
|
362
|
+
return True
|
|
363
|
+
except Exception as ex:
|
|
364
|
+
logger.debug("maximise window failed: %s", ex)
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def video_available() -> bool:
|
|
369
|
+
try:
|
|
370
|
+
from browser_use.browser.video_recorder import IMAGEIO_AVAILABLE
|
|
371
|
+
except Exception:
|
|
372
|
+
return False
|
|
373
|
+
return bool(IMAGEIO_AVAILABLE)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def start_video(browser: Any, out_dir: Path) -> Optional[Path]:
|
|
377
|
+
"""Record the agent's tab to <out_dir>/video.mp4 until `stop_video`; None when it could not start."""
|
|
378
|
+
watchdog = getattr(browser, "_recording_watchdog", None)
|
|
379
|
+
if watchdog is None or not video_available():
|
|
380
|
+
return None
|
|
381
|
+
try:
|
|
382
|
+
return Path(await watchdog.start_recording(out_dir / VIDEO_FILE))
|
|
383
|
+
except Exception as ex:
|
|
384
|
+
logger.debug("video recording did not start: %s", ex)
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def stop_video(browser: Any) -> Optional[Path]:
|
|
389
|
+
"""Finish the video file; None when nothing was recorded."""
|
|
390
|
+
watchdog = getattr(browser, "_recording_watchdog", None)
|
|
391
|
+
if watchdog is None or not getattr(watchdog, "is_recording", False):
|
|
392
|
+
return None
|
|
393
|
+
try:
|
|
394
|
+
path = await asyncio.wait_for(watchdog.stop_recording(), timeout=120.0)
|
|
395
|
+
except Exception as ex:
|
|
396
|
+
logger.debug("video recording did not finish: %s", ex)
|
|
397
|
+
return None
|
|
398
|
+
return Path(path) if path and Path(path).is_file() and Path(path).stat().st_size else None
|
|
399
|
+
|
|
400
|
+
|
|
299
401
|
async def _shutdown_browser_instance(browser: Any) -> None:
|
|
300
402
|
"""Stop the library gracefully, falling back to kill()."""
|
|
301
403
|
if browser is None:
|
|
@@ -348,7 +450,10 @@ async def browser_profile(browser: Any, launch: Dict[str, Any], *, provider: str
|
|
|
348
450
|
"extra_http_headers": {},
|
|
349
451
|
"provider": provider,
|
|
350
452
|
}
|
|
351
|
-
if provider
|
|
453
|
+
if provider == "docker":
|
|
454
|
+
# Not a browser on another machine: the coding agent's replay and live browser use the same kind of container.
|
|
455
|
+
profile["container"] = True
|
|
456
|
+
elif provider != "local":
|
|
352
457
|
host = urlparse(str(launch.get("cdp_url") or "")).netloc
|
|
353
458
|
profile["remote_browser"] = f"{'BrowserStack' if provider == 'browserstack' else 'TestMu AI'} via {host}"
|
|
354
459
|
if remote:
|
package/py/forgeqe_engine/cli.py
CHANGED
|
@@ -13,6 +13,7 @@ Engine process protocol, used by the Bun CLI:
|
|
|
13
13
|
python -m forgeqe_engine doctor
|
|
14
14
|
python -m forgeqe_engine token (stdin: an llms.json profile with auth: entra; prints {token, expires_on})
|
|
15
15
|
python -m forgeqe_engine probe (stdin: an llms.json profile; prints {ok, ms, structured, vision, error?})
|
|
16
|
+
python -m forgeqe_engine copilot-gateway (GitHub Copilot as an OpenAI-style endpoint on 127.0.0.1; see copilot_gateway.py)
|
|
16
17
|
"""
|
|
17
18
|
from __future__ import annotations
|
|
18
19
|
|
|
@@ -73,6 +74,14 @@ def _doctor(out: TextIO) -> int:
|
|
|
73
74
|
info["azure_identity"] = version("azure-identity")
|
|
74
75
|
except Exception:
|
|
75
76
|
pass
|
|
77
|
+
try:
|
|
78
|
+
info["copilot_sdk"] = version("github-copilot-sdk")
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
try:
|
|
82
|
+
info["video"] = version("imageio")
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
76
85
|
try:
|
|
77
86
|
from .service import local_browser_launch_kwargs
|
|
78
87
|
|
|
@@ -252,5 +261,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
252
261
|
return _token(out)
|
|
253
262
|
if cmd == "probe":
|
|
254
263
|
return _probe(out)
|
|
264
|
+
if cmd == "copilot-gateway":
|
|
265
|
+
from .copilot_gateway import main as copilot_gateway
|
|
266
|
+
|
|
267
|
+
return copilot_gateway(out)
|
|
255
268
|
out.write(json.dumps({"type": "done", "result": {"success": False, "error": f"Unknown command {cmd}"}}) + "\n")
|
|
256
269
|
return 2
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub Copilot as an OpenAI-style chat endpoint on this machine, through GitHub's Copilot SDK.
|
|
3
|
+
|
|
4
|
+
Copilot has no model API for other tools: the SDK runs Copilot "sessions" on the user's seat. The browser engine and
|
|
5
|
+
the desktop agent speak OpenAI chat completions, so this gateway turns each request into one SDK session with no
|
|
6
|
+
tools of its own (the system prompt replaced, screenshots attached, a JSON schema passed on) and answers in the
|
|
7
|
+
OpenAI shape.
|
|
8
|
+
|
|
9
|
+
python -m forgeqe_engine copilot-gateway
|
|
10
|
+
env COPILOT_GITHUB_TOKEN the GitHub token (fine-grained with Copilot Requests, or the GitHub CLI's)
|
|
11
|
+
FORGEQE_COPILOT_GATEWAY_KEY the one-time key every request must carry as "Bearer <key>"
|
|
12
|
+
stdout one line: {"port": N, "login": "<account>"} once it listens, or {"error": "..."}
|
|
13
|
+
stdin end of input stops it, so it never outlives ForgeQE
|
|
14
|
+
|
|
15
|
+
It listens on 127.0.0.1 only, compares the key in constant time and refuses requests a web page sends.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import hmac
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import time
|
|
26
|
+
import uuid
|
|
27
|
+
from typing import Any, Dict, List, Optional, TextIO, Tuple
|
|
28
|
+
|
|
29
|
+
TOKEN_ENV = "COPILOT_GITHUB_TOKEN"
|
|
30
|
+
KEY_ENV = "FORGEQE_COPILOT_GATEWAY_KEY"
|
|
31
|
+
|
|
32
|
+
MAX_BODY = 32 * 1024 * 1024
|
|
33
|
+
# A screenshot step can take minutes on a slow model; past this the request fails rather than hangs.
|
|
34
|
+
REQUEST_TIMEOUT_S = 300.0
|
|
35
|
+
LOCAL_ORIGINS = ("http://127.0.0.1", "http://localhost", "https://127.0.0.1", "https://localhost")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GatewayError(Exception):
|
|
39
|
+
def __init__(self, status: int, message: str):
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.status = status
|
|
42
|
+
self.message = message
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
46
|
+
# OpenAI chat completions → one Copilot session
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parts(content: Any) -> List[Dict[str, Any]]:
|
|
50
|
+
if isinstance(content, str):
|
|
51
|
+
return [{"type": "text", "text": content}]
|
|
52
|
+
if isinstance(content, list):
|
|
53
|
+
return [p for p in content if isinstance(p, dict)]
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _data_url(url: str) -> Optional[Tuple[str, str]]:
|
|
58
|
+
"""data:image/png;base64,AAAA → ("image/png", "AAAA"); None for anything else (remote images are not fetched)."""
|
|
59
|
+
if not url.startswith("data:") or ";base64," not in url:
|
|
60
|
+
return None
|
|
61
|
+
head, data = url[5:].split(";base64,", 1)
|
|
62
|
+
mime = head.split(";", 1)[0].strip().lower()
|
|
63
|
+
if not mime.startswith("image/") or not data:
|
|
64
|
+
return None
|
|
65
|
+
return mime, data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def translate(body: Dict[str, Any]) -> Dict[str, Any]:
|
|
69
|
+
"""The session a chat completions request becomes: {model, system, prompt, attachments, schema, json_only}."""
|
|
70
|
+
if not isinstance(body, dict):
|
|
71
|
+
raise GatewayError(400, "The request body must be a JSON object.")
|
|
72
|
+
model = str(body.get("model") or "").strip()
|
|
73
|
+
if not model:
|
|
74
|
+
raise GatewayError(400, "The request names no model.")
|
|
75
|
+
if body.get("stream"):
|
|
76
|
+
raise GatewayError(400, "Streaming is not supported by ForgeQE's Copilot gateway.")
|
|
77
|
+
if body.get("tools") or body.get("functions"):
|
|
78
|
+
raise GatewayError(400, "Tool calls are not supported by ForgeQE's Copilot gateway yet.")
|
|
79
|
+
messages = body.get("messages")
|
|
80
|
+
if not isinstance(messages, list) or not messages:
|
|
81
|
+
raise GatewayError(400, "The request has no messages.")
|
|
82
|
+
|
|
83
|
+
system: List[str] = []
|
|
84
|
+
turns: List[Tuple[str, List[Dict[str, Any]]]] = []
|
|
85
|
+
for m in messages:
|
|
86
|
+
if not isinstance(m, dict):
|
|
87
|
+
continue
|
|
88
|
+
role = str(m.get("role") or "user")
|
|
89
|
+
parts = _parts(m.get("content"))
|
|
90
|
+
if role in ("system", "developer"):
|
|
91
|
+
system.extend(str(p.get("text") or "") for p in parts if p.get("type") == "text")
|
|
92
|
+
else:
|
|
93
|
+
turns.append((role, parts))
|
|
94
|
+
if not turns:
|
|
95
|
+
raise GatewayError(400, "The request has no user message.")
|
|
96
|
+
|
|
97
|
+
# Only the latest message's images go to the model; the browser agent sends the current screenshot there.
|
|
98
|
+
last_with_images = max((i for i, (_, parts) in enumerate(turns) if any(p.get("type") == "image_url" for p in parts)), default=-1)
|
|
99
|
+
attachments: List[Dict[str, Any]] = []
|
|
100
|
+
lines: List[str] = []
|
|
101
|
+
for i, (role, parts) in enumerate(turns):
|
|
102
|
+
texts: List[str] = []
|
|
103
|
+
for p in parts:
|
|
104
|
+
if p.get("type") == "text":
|
|
105
|
+
texts.append(str(p.get("text") or ""))
|
|
106
|
+
elif p.get("type") == "image_url":
|
|
107
|
+
url = p.get("image_url", {}).get("url") if isinstance(p.get("image_url"), dict) else p.get("image_url")
|
|
108
|
+
img = _data_url(str(url or ""))
|
|
109
|
+
if img and i == last_with_images:
|
|
110
|
+
attachments.append({"type": "blob", "mimeType": img[0], "data": img[1], "displayName": f"screenshot-{len(attachments) + 1}"})
|
|
111
|
+
texts.append(f"[image attached: screenshot-{len(attachments)}]")
|
|
112
|
+
else:
|
|
113
|
+
texts.append("[earlier image omitted]")
|
|
114
|
+
text = "\n".join(t for t in texts if t)
|
|
115
|
+
lines.append(text if len(turns) == 1 else f"<{role}>\n{text}\n</{role}>")
|
|
116
|
+
prompt = "\n\n".join(lines)
|
|
117
|
+
if len(turns) > 1:
|
|
118
|
+
prompt = "The conversation so far, oldest first. Reply as the assistant to the last message.\n\n" + prompt
|
|
119
|
+
|
|
120
|
+
schema = None
|
|
121
|
+
json_only = False
|
|
122
|
+
fmt = body.get("response_format")
|
|
123
|
+
if isinstance(fmt, dict):
|
|
124
|
+
if fmt.get("type") == "json_schema" and isinstance(fmt.get("json_schema"), dict):
|
|
125
|
+
schema = fmt["json_schema"].get("schema")
|
|
126
|
+
if not isinstance(schema, dict):
|
|
127
|
+
raise GatewayError(400, "response_format.json_schema has no schema.")
|
|
128
|
+
elif fmt.get("type") == "json_object":
|
|
129
|
+
json_only = True
|
|
130
|
+
prompt += "\n\nReply with a single JSON object and nothing else."
|
|
131
|
+
return {"model": model, "system": "\n\n".join(s for s in system if s), "prompt": prompt, "attachments": attachments, "schema": schema, "json_only": json_only}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def completion(model: str, content: str, usage: Dict[str, int]) -> Dict[str, Any]:
|
|
135
|
+
"""An OpenAI chat.completion object."""
|
|
136
|
+
prompt_tokens = int(usage.get("input", 0))
|
|
137
|
+
completion_tokens = int(usage.get("output", 0))
|
|
138
|
+
out: Dict[str, Any] = {
|
|
139
|
+
"id": f"chatcmpl-{uuid.uuid4().hex}",
|
|
140
|
+
"object": "chat.completion",
|
|
141
|
+
"created": int(time.time()),
|
|
142
|
+
"model": model,
|
|
143
|
+
"choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
|
|
144
|
+
"usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens},
|
|
145
|
+
}
|
|
146
|
+
if usage.get("cached"):
|
|
147
|
+
out["usage"]["prompt_tokens_details"] = {"cached_tokens": int(usage["cached"])}
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def strip_fences(text: str) -> str:
|
|
152
|
+
"""```json … ``` → the JSON inside, for models that wrap structured replies in a code fence."""
|
|
153
|
+
t = text.strip()
|
|
154
|
+
if t.startswith("```") and t.endswith("```"):
|
|
155
|
+
t = t[3:-3]
|
|
156
|
+
first, _, rest = t.partition("\n")
|
|
157
|
+
return (rest if first.strip().isalpha() or not first.strip() else t).strip()
|
|
158
|
+
return text
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
162
|
+
# Requests from this machine only
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def authorized(headers: Dict[str, str], key: str) -> bool:
|
|
166
|
+
expected = f"Bearer {key}".encode()
|
|
167
|
+
got = headers.get("authorization", "").encode()
|
|
168
|
+
return bool(key) and hmac.compare_digest(got, expected)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def local_origin(headers: Dict[str, str]) -> bool:
|
|
172
|
+
"""True when no browser page sent the request: no Origin, or one on this machine."""
|
|
173
|
+
origin = headers.get("origin")
|
|
174
|
+
if origin is None:
|
|
175
|
+
return True
|
|
176
|
+
o = origin.strip().lower()
|
|
177
|
+
return any(o == base or o.startswith(base + ":") for base in LOCAL_ORIGINS)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
181
|
+
# The Copilot SDK
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Copilot:
|
|
185
|
+
def __init__(self, token: str):
|
|
186
|
+
from copilot import CopilotClient
|
|
187
|
+
|
|
188
|
+
# The SDK keeps its state under this folder, apart from the user's own Copilot CLI.
|
|
189
|
+
home = os.path.join(os.environ.get("FORGEQE_HOME") or os.path.join(os.path.expanduser("~"), ".forgeqe"), "copilot")
|
|
190
|
+
os.makedirs(home, exist_ok=True)
|
|
191
|
+
# An empty working folder: the session reads no project files and no custom instructions.
|
|
192
|
+
self.workdir = tempfile.mkdtemp(prefix="forgeqe-copilot-")
|
|
193
|
+
self.client = CopilotClient(github_token=token, use_logged_in_user=False, base_directory=home, working_directory=self.workdir, log_level="error")
|
|
194
|
+
|
|
195
|
+
async def start(self) -> None:
|
|
196
|
+
await self.client.start()
|
|
197
|
+
status = await self.client.get_auth_status()
|
|
198
|
+
self.login = status.login or ""
|
|
199
|
+
if not status.isAuthenticated:
|
|
200
|
+
raise GatewayError(401, "GitHub Copilot did not accept the token: use a fine-grained token with the Copilot Requests permission, or the GitHub CLI's sign-in, for an account with a Copilot seat.")
|
|
201
|
+
|
|
202
|
+
async def stop(self) -> None:
|
|
203
|
+
try:
|
|
204
|
+
await self.client.stop()
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
async def models(self) -> List[Dict[str, Any]]:
|
|
209
|
+
out = []
|
|
210
|
+
for m in await self.client.list_models():
|
|
211
|
+
supports = getattr(getattr(m, "capabilities", None), "supports", None)
|
|
212
|
+
out.append({"id": m.id, "object": "model", "owned_by": "github-copilot", "vision": bool(getattr(supports, "vision", False))})
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
async def complete(self, req: Dict[str, Any]) -> Dict[str, Any]:
|
|
216
|
+
from copilot.generated.rpc import PermissionDecisionReject
|
|
217
|
+
from copilot.session_events import AssistantMessageData, AssistantUsageData, SessionErrorData
|
|
218
|
+
|
|
219
|
+
usage = {"input": 0, "output": 0, "cached": 0}
|
|
220
|
+
errors: List[str] = []
|
|
221
|
+
|
|
222
|
+
def on_event(event: Any) -> None:
|
|
223
|
+
data = getattr(event, "data", None)
|
|
224
|
+
if isinstance(data, AssistantUsageData):
|
|
225
|
+
usage["input"] += data.input_tokens or 0
|
|
226
|
+
usage["output"] += data.output_tokens or 0
|
|
227
|
+
usage["cached"] += data.cache_read_tokens or 0
|
|
228
|
+
elif isinstance(data, SessionErrorData):
|
|
229
|
+
errors.append(data.message or data.error_type or "error")
|
|
230
|
+
|
|
231
|
+
session = await self.client.create_session(
|
|
232
|
+
model=req["model"],
|
|
233
|
+
# The caller's prompt is the whole prompt; without one the session still gets no tools.
|
|
234
|
+
system_message={"mode": "replace", "content": req["system"] or "You are a helpful assistant."},
|
|
235
|
+
available_tools=[],
|
|
236
|
+
on_permission_request=lambda *_: PermissionDecisionReject(),
|
|
237
|
+
working_directory=self.workdir,
|
|
238
|
+
skip_custom_instructions=True,
|
|
239
|
+
enable_config_discovery=False,
|
|
240
|
+
enable_skills=False,
|
|
241
|
+
enable_session_store=False,
|
|
242
|
+
on_event=on_event,
|
|
243
|
+
)
|
|
244
|
+
try:
|
|
245
|
+
reply = await session.send_and_wait(req["prompt"], attachments=req["attachments"] or None, response_schema=req["schema"], timeout=REQUEST_TIMEOUT_S)
|
|
246
|
+
except TimeoutError:
|
|
247
|
+
raise GatewayError(504, f"GitHub Copilot did not answer within {int(REQUEST_TIMEOUT_S)} seconds.")
|
|
248
|
+
finally:
|
|
249
|
+
try:
|
|
250
|
+
await session.disconnect()
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
if errors:
|
|
254
|
+
msg = errors[-1]
|
|
255
|
+
raise GatewayError(401 if "auth" in msg.lower() or "401" in msg else 502, f"GitHub Copilot: {msg}")
|
|
256
|
+
data = getattr(reply, "data", None)
|
|
257
|
+
if not isinstance(data, AssistantMessageData):
|
|
258
|
+
raise GatewayError(502, "GitHub Copilot sent no reply.")
|
|
259
|
+
content = data.content or ""
|
|
260
|
+
if req["schema"] is not None or req["json_only"]:
|
|
261
|
+
content = strip_fences(content)
|
|
262
|
+
return completion(req["model"], content, usage)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
266
|
+
# A small HTTP/1.1 server: one request per connection
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _response(status: int, payload: Dict[str, Any]) -> bytes:
|
|
270
|
+
body = json.dumps(payload).encode()
|
|
271
|
+
reason = {200: "OK", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 413: "Payload Too Large", 502: "Bad Gateway", 504: "Gateway Timeout"}.get(status, "Error")
|
|
272
|
+
head = f"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {len(body)}\r\nConnection: close\r\n\r\n"
|
|
273
|
+
return head.encode() + body
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _error(status: int, message: str) -> bytes:
|
|
277
|
+
return _response(status, {"error": {"message": message, "type": "forgeqe_copilot_gateway"}})
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, copilot: Any, key: str) -> None:
|
|
281
|
+
try:
|
|
282
|
+
try:
|
|
283
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
284
|
+
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError):
|
|
285
|
+
return
|
|
286
|
+
lines = head.decode("latin-1").split("\r\n")
|
|
287
|
+
method, path = (lines[0].split(" ") + ["", ""])[:2]
|
|
288
|
+
headers = {}
|
|
289
|
+
for line in lines[1:]:
|
|
290
|
+
if ":" in line:
|
|
291
|
+
k, v = line.split(":", 1)
|
|
292
|
+
headers[k.strip().lower()] = v.strip()
|
|
293
|
+
if not local_origin(headers):
|
|
294
|
+
writer.write(_error(403, "Requests from web pages are refused."))
|
|
295
|
+
return
|
|
296
|
+
if not authorized(headers, key):
|
|
297
|
+
writer.write(_error(401, "Unauthorized"))
|
|
298
|
+
return
|
|
299
|
+
path = path.split("?", 1)[0].rstrip("/")
|
|
300
|
+
if path.startswith("/v1/"):
|
|
301
|
+
path = path[3:]
|
|
302
|
+
if method == "GET" and path == "/models":
|
|
303
|
+
writer.write(_response(200, {"object": "list", "data": await copilot.models()}))
|
|
304
|
+
return
|
|
305
|
+
if method != "POST" or path != "/chat/completions":
|
|
306
|
+
writer.write(_error(404, f"{method} {path} is not served here."))
|
|
307
|
+
return
|
|
308
|
+
try:
|
|
309
|
+
length = int(headers.get("content-length", "0"))
|
|
310
|
+
except ValueError:
|
|
311
|
+
length = -1
|
|
312
|
+
if length < 0 or length > MAX_BODY:
|
|
313
|
+
writer.write(_error(413, "The request body is too large."))
|
|
314
|
+
return
|
|
315
|
+
raw = await reader.readexactly(length)
|
|
316
|
+
try:
|
|
317
|
+
body = json.loads(raw.decode("utf-8"))
|
|
318
|
+
except ValueError:
|
|
319
|
+
writer.write(_error(400, "The request body is not JSON."))
|
|
320
|
+
return
|
|
321
|
+
writer.write(_response(200, await copilot.complete(translate(body))))
|
|
322
|
+
except GatewayError as e:
|
|
323
|
+
writer.write(_error(e.status, e.message))
|
|
324
|
+
except Exception as e: # The SDK's own errors: report them rather than drop the connection.
|
|
325
|
+
writer.write(_error(502, f"GitHub Copilot: {type(e).__name__}: {e}"[:2000]))
|
|
326
|
+
finally:
|
|
327
|
+
try:
|
|
328
|
+
await writer.drain()
|
|
329
|
+
writer.close()
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def serve(out: TextIO, copilot: Any = None) -> int:
|
|
335
|
+
key = os.environ.get(KEY_ENV, "")
|
|
336
|
+
token = os.environ.get(TOKEN_ENV, "").strip()
|
|
337
|
+
if len(key) < 32:
|
|
338
|
+
out.write(json.dumps({"error": f"{KEY_ENV} must hold the gateway's one-time key."}) + "\n")
|
|
339
|
+
return 2
|
|
340
|
+
if copilot is None:
|
|
341
|
+
if not token:
|
|
342
|
+
out.write(json.dumps({"error": "No GitHub token for Copilot: run forgeqe auth login copilot."}) + "\n")
|
|
343
|
+
return 1
|
|
344
|
+
if token.startswith("ghp_"):
|
|
345
|
+
out.write(json.dumps({"error": "GitHub Copilot does not take classic tokens (ghp_): use a fine-grained token with the Copilot Requests permission."}) + "\n")
|
|
346
|
+
return 1
|
|
347
|
+
try:
|
|
348
|
+
copilot = Copilot(token)
|
|
349
|
+
await copilot.start()
|
|
350
|
+
except GatewayError as e:
|
|
351
|
+
out.write(json.dumps({"error": e.message}) + "\n")
|
|
352
|
+
return 1
|
|
353
|
+
except Exception as e:
|
|
354
|
+
out.write(json.dumps({"error": f"GitHub Copilot could not start: {type(e).__name__}: {e}"[:1500]}) + "\n")
|
|
355
|
+
return 1
|
|
356
|
+
server = await asyncio.start_server(lambda r, w: handle(r, w, copilot, key), host="127.0.0.1", port=0, limit=64 * 1024)
|
|
357
|
+
port = server.sockets[0].getsockname()[1]
|
|
358
|
+
out.write(json.dumps({"port": port, "login": getattr(copilot, "login", "")}) + "\n")
|
|
359
|
+
out.flush()
|
|
360
|
+
loop = asyncio.get_running_loop()
|
|
361
|
+
# Stop when ForgeQE closes stdin (or exits).
|
|
362
|
+
await loop.run_in_executor(None, sys.stdin.read)
|
|
363
|
+
server.close()
|
|
364
|
+
await copilot.stop()
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def main(out: TextIO) -> int:
|
|
369
|
+
return asyncio.run(serve(out))
|
|
@@ -46,11 +46,18 @@ from .llm import (
|
|
|
46
46
|
_agent_usage,
|
|
47
47
|
)
|
|
48
48
|
from .browser import (
|
|
49
|
+
VIDEO_FILE,
|
|
49
50
|
FreshTabAfterPassword,
|
|
50
51
|
browser_connection_label,
|
|
51
52
|
browser_launch_kwargs,
|
|
52
53
|
local_browser_launch_kwargs,
|
|
54
|
+
maximize_window,
|
|
53
55
|
settle_page,
|
|
56
|
+
start_video,
|
|
57
|
+
stop_video,
|
|
58
|
+
video_available,
|
|
59
|
+
wants_maximized,
|
|
60
|
+
window_launch_kwargs,
|
|
54
61
|
_shutdown_browser_instance,
|
|
55
62
|
_stop_agent_instance,
|
|
56
63
|
browser_profile,
|
|
@@ -1004,6 +1011,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1004
1011
|
browser_provider = str(browser_cfg.get("provider") or "local")
|
|
1005
1012
|
browser_remote = browser_cfg.get("remote") if isinstance(browser_cfg.get("remote"), dict) else {}
|
|
1006
1013
|
browser_kw = browser_launch_kwargs(provider=browser_provider, remote=browser_remote, headless=headless)
|
|
1014
|
+
browser_kw.update(window_launch_kwargs(browser_cfg, headless=headless))
|
|
1007
1015
|
if allowed_domains is not None:
|
|
1008
1016
|
browser_kw["allowed_domains"] = allowed_domains
|
|
1009
1017
|
logins = parse_logins(req.logins)
|
|
@@ -1142,6 +1150,21 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1142
1150
|
shared = mem.store.shared_start(goal, normalized_initial)
|
|
1143
1151
|
if shared:
|
|
1144
1152
|
flow, kind = shared, "shared"
|
|
1153
|
+
maximize = wants_maximized(browser_cfg, headless=headless)
|
|
1154
|
+
video = browser_cfg.get("video") is True
|
|
1155
|
+
if video and not video_available():
|
|
1156
|
+
emit({"type": "log", "level": "warn", "text": "No video for this run: the engine's video packages are missing. Run: forgeqe setup"})
|
|
1157
|
+
video = False
|
|
1158
|
+
if maximize or video:
|
|
1159
|
+
# Started before the agent so the window is maximised, and the video sized, before the first page opens.
|
|
1160
|
+
try:
|
|
1161
|
+
await browser.start()
|
|
1162
|
+
if maximize and not await maximize_window(browser):
|
|
1163
|
+
emit({"type": "log", "level": "debug", "text": "The browser window could not be maximised"})
|
|
1164
|
+
if video and await start_video(browser, out_dir) is None:
|
|
1165
|
+
emit({"type": "log", "level": "warn", "text": "No video for this run: recording did not start"})
|
|
1166
|
+
except Exception as ex:
|
|
1167
|
+
logger.debug("early browser start failed: %s", ex)
|
|
1145
1168
|
if flow and kind and flow.get("steps"):
|
|
1146
1169
|
if kind == "interrupted":
|
|
1147
1170
|
replay_text = (
|
|
@@ -1668,6 +1691,8 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1668
1691
|
await _save_logins()
|
|
1669
1692
|
if overlay is not None:
|
|
1670
1693
|
overlay.close()
|
|
1694
|
+
if await stop_video(browser):
|
|
1695
|
+
emit({"type": "log", "level": "info", "text": "Video saved: video.mp4"})
|
|
1671
1696
|
await _shutdown_browser_instance(browser)
|
|
1672
1697
|
|
|
1673
1698
|
|
|
@@ -1694,5 +1719,9 @@ async def run_with_events(req: RunRequest, emit: Emit, cancel_event: asyncio.Eve
|
|
|
1694
1719
|
except Exception as ex:
|
|
1695
1720
|
logger.warning("artifact write failed: %s", ex)
|
|
1696
1721
|
out["files"] = []
|
|
1722
|
+
# The video is finished when the browser closes, after the other files were written.
|
|
1723
|
+
video = Path(req.out_dir) / VIDEO_FILE
|
|
1724
|
+
if video.is_file() and str(video) not in out["files"]:
|
|
1725
|
+
out["files"].append(str(video))
|
|
1697
1726
|
emit({"type": "done", "result": {k: v for k, v in out.items() if k != "step_events"}})
|
|
1698
1727
|
return out
|