@capagents/forgeqe 2.3.1 → 2.3.2
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 +2 -1
- package/dist/forgeqe.js +717 -717
- package/dist/template.html +1 -1
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/user-guide.html +2 -1
- package/package.json +1 -1
- package/py/forgeqe_engine/copilot_gateway.py +103 -9
package/docs/user-guide.html
CHANGED
|
@@ -598,7 +598,7 @@ bun src/index.ts init --global --profile azure-gpt4o
|
|
|
598
598
|
<p>Copilot runs one SDK session per model request, with none of Copilot's own tools and your request's system prompt in place of Copilot's. The agent's tools are offered to the model as the session's only tools; when it calls one, the gateway ends the turn and hands the call back to ForgeQE's agent runtime, which runs it (with the same permissions as on any other model) and sends the result with the next request. The gateway itself runs nothing.</p>
|
|
599
599
|
<p><strong>With Copilot and a key-based model both in <code>llms.json</code></strong>, Copilot comes first: a sign-in makes it the default, and without a <code>"default"</code> it is the active profile. Before a model is used, ForgeQE checks that it works (its sign-in or key, and one live call, kept for a day once it passes). If Copilot does not work (not signed in, the Copilot SDK missing, the model not on your seat), the other model is used and ForgeQE says why and how to fix Copilot; if a key-based model you made the default does not work, Copilot is tried first. A model that fails its check is never used, and when none works the error lists each one with what to do. <code>--profile</code> turns this off: the model you name is used as it is. Each request is billed to the seat (GitHub AI credits or premium requests, by your plan), and GitHub may rate-limit frequent automated requests. If a model is refused, check that your organization's Copilot policies allow it. ForgeQE shows no cost for a <code>github_copilot</code> profile unless you give it a <code>price</code>.</p>
|
|
600
600
|
<p><strong>In pipelines.</strong> <code>forgeqe suite ... --no-code</code> uses only the browser engine. Set <code>COPILOT_GITHUB_TOKEN</code> to a fine-grained token with the Copilot Requests permission, stored as a pipeline secret. GitHub also documents the workflow's own <code>GITHUB_TOKEN</code> with <code>permissions: copilot-requests: write</code> for organization repositories whose Copilot policy allows it (<a href="https://docs.github.com/en/copilot/how-tos/copilot-sdk/auth/server-to-server-tokens" rel="noopener">server tokens</a>). A Copilot seat belongs to one person: prefer an organization-billed token over signing a shared pipeline in to someone's seat.</p>
|
|
601
|
-
<p>GitHub Models, which ForgeQE used for the engine before, was <a href="https://github.blog/changelog/2026-07-30-github-models-is-now-retired/" rel="noopener">retired by GitHub</a> on July 30, 2026. A profile that still has <code>engine_model: "openai/..."</code> or <code>github_org</code> needs updating: drop <code>github_org</code>, and use a Copilot model id.</p>
|
|
601
|
+
<p>GitHub Models, which ForgeQE used for the engine before, was <a href="https://github.blog/changelog/2026-07-30-github-models-is-now-retired/" rel="noopener">retired by GitHub</a> on July 30, 2026. A profile that still has <code>engine_model: "openai/..."</code> or <code>github_org</code> needs updating: drop <code>github_org</code>, and use a Copilot model id. A profile whose endpoint is GitHub Models (<code>models.github.ai</code>) is never called: remove it from <code>llms.json</code>. Copilot needs no <code>GITHUB_TOKEN</code> or <code>GH_TOKEN</code>; ForgeQE does not pass them to Copilot.</p>
|
|
602
602
|
<p><strong>When something goes wrong.</strong></p>
|
|
603
603
|
<div class="tbl"><table>
|
|
604
604
|
<thead>
|
|
@@ -609,6 +609,7 @@ bun src/index.ts init --global --profile azure-gpt4o
|
|
|
609
609
|
<tr><td><code>The GitHub sign-in did not finish</code></td><td>The browser sign-in was cancelled or denied. Run <code>forgeqe auth login copilot</code> again; with SAML single sign-on, authorize your organization when GitHub asks.</td></tr>
|
|
610
610
|
<tr><td><code>GitHub Copilot does not take classic tokens (ghp_)</code></td><td>Sign in in the browser instead, or make a fine-grained token with the Copilot Requests permission, owned by your personal account.</td></tr>
|
|
611
611
|
<tr><td><code>GitHub Copilot did not accept the token</code></td><td>The token has no Copilot Requests permission, has expired, or the account has no Copilot seat.</td></tr>
|
|
612
|
+
<tr><td><code>GitHub Copilot refused the sign-in</code> or <code>refused the token</code> (<code>Authorization error. Your credentials may be expired or invalid</code>)</td><td>Copilot received the credential and would not take it. A token wins over the browser sign-in: if <code>COPILOT_GITHUB_TOKEN</code> is set, or you once pasted a token, that is what was refused; remove it and run <code>forgeqe auth login copilot</code> in the browser. Otherwise sign in again. If it still fails and your seat comes from your company, a GitHub admin must allow Copilot CLI in the Copilot policies: the Copilot SDK runs on it.</td></tr>
|
|
612
613
|
<tr><td><code>… must be a Copilot model id</code>, <code>engine_model must be a Copilot model id</code></td><td>Fix that field in <code>llms.json</code>: the sign-in lists the seat's models. A GitHub Models id such as <code>openai/gpt-4.1</code> is not a Copilot id; use <code>gpt-4.1</code>.</td></tr>
|
|
613
614
|
<tr><td><code>No model in llms.json can … right now</code></td><td>Each model is listed with why it cannot be used and the fix. <code>forgeqe doctor</code> checks everything at once.</td></tr>
|
|
614
615
|
<tr><td><code>GitHub's Copilot SDK is not installed in the ForgeQE engine</code></td><td>Run <code>forgeqe setup</code>. It installs the SDK from pypi.org; if that is blocked, <code>forgeqe doctor</code> says what to ask IT for.</td></tr>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capagents/forgeqe",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.2",
|
|
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
|
"repository": {
|
|
@@ -30,7 +30,7 @@ import sys
|
|
|
30
30
|
import tempfile
|
|
31
31
|
import time
|
|
32
32
|
import uuid
|
|
33
|
-
from typing import Any, Dict, List, Optional, TextIO, Tuple
|
|
33
|
+
from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple
|
|
34
34
|
|
|
35
35
|
from .systemcerts import use_system_certificates
|
|
36
36
|
|
|
@@ -303,6 +303,14 @@ class Copilot:
|
|
|
303
303
|
raise GatewayError(401, "GitHub Copilot is not signed in: run forgeqe auth login copilot, which opens GitHub in your browser.")
|
|
304
304
|
raise GatewayError(401, "GitHub Copilot did not accept the token: use a fine-grained token with the Copilot Requests permission, for an account with a Copilot seat.")
|
|
305
305
|
|
|
306
|
+
def refusal(self, message: str) -> "GatewayError":
|
|
307
|
+
"""Copilot's error as the gateway reports it. A refused sign-in says whose, and whether it was a token."""
|
|
308
|
+
if not signin_refused(message):
|
|
309
|
+
return GatewayError(502, f"GitHub Copilot: {message}"[:2000])
|
|
310
|
+
what = "the sign-in" if self.signed_in_user else "the token"
|
|
311
|
+
who = f" of {self.login}" if getattr(self, "login", "") else ""
|
|
312
|
+
return GatewayError(401, f"GitHub Copilot refused {what}{who}: {message}"[:2000])
|
|
313
|
+
|
|
306
314
|
async def stop(self) -> None:
|
|
307
315
|
try:
|
|
308
316
|
await self.client.stop()
|
|
@@ -329,9 +337,14 @@ class Copilot:
|
|
|
329
337
|
})
|
|
330
338
|
return out
|
|
331
339
|
|
|
332
|
-
async def complete(self, req: Dict[str, Any]) -> Dict[str, Any]:
|
|
340
|
+
async def complete(self, req: Dict[str, Any], on_text: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
|
|
341
|
+
"""The answer to one request. With on_text, the reply's text goes to it as Copilot writes it (not for a JSON
|
|
342
|
+
reply, whose code fences are removed once it is whole)."""
|
|
333
343
|
from copilot.generated.rpc import PermissionDecisionReject
|
|
334
|
-
from copilot.session_events import AssistantMessageData, AssistantUsageData, SessionErrorData
|
|
344
|
+
from copilot.session_events import AssistantMessageData, AssistantMessageDeltaData, AssistantUsageData, SessionErrorData
|
|
345
|
+
|
|
346
|
+
# The text callback while this request streams; None once it is asked for again without streaming.
|
|
347
|
+
stream: Dict[str, Any] = {"to": None if req["schema"] is not None or req["json_only"] else on_text, "sent": False}
|
|
335
348
|
|
|
336
349
|
usage = {"input": 0, "output": 0, "cached": 0}
|
|
337
350
|
errors: List[str] = []
|
|
@@ -345,10 +358,14 @@ class Copilot:
|
|
|
345
358
|
usage["cached"] += data.cache_read_tokens or 0
|
|
346
359
|
elif isinstance(data, SessionErrorData):
|
|
347
360
|
errors.append(data.message or data.error_type or "error")
|
|
361
|
+
# A sub-agent's text is not the reply.
|
|
362
|
+
elif stream["to"] and isinstance(data, AssistantMessageDeltaData) and data.delta_content and not data.parent_tool_call_id:
|
|
363
|
+
stream["sent"] = True
|
|
364
|
+
stream["to"](data.delta_content)
|
|
348
365
|
|
|
349
366
|
tried: set = set()
|
|
350
367
|
reply: Any = None
|
|
351
|
-
for _ in range(MAX_TOOL_RETRIES +
|
|
368
|
+
for _ in range(MAX_TOOL_RETRIES + 2):
|
|
352
369
|
errors.clear()
|
|
353
370
|
calls.clear()
|
|
354
371
|
names = {t["name"] for t in req["tools"]}
|
|
@@ -366,6 +383,7 @@ class Copilot:
|
|
|
366
383
|
enable_skills=False,
|
|
367
384
|
enable_session_store=False,
|
|
368
385
|
on_event=on_event,
|
|
386
|
+
**({"streaming": True} if stream["to"] else {}),
|
|
369
387
|
)
|
|
370
388
|
failure: Optional[str] = None
|
|
371
389
|
session = None
|
|
@@ -389,14 +407,19 @@ class Copilot:
|
|
|
389
407
|
name, replace = fix
|
|
390
408
|
(self.replaces.add if replace else self.replaces.discard)(name)
|
|
391
409
|
continue
|
|
410
|
+
# A streamed request that failed before any text: asked once more the way that worked before streaming, so
|
|
411
|
+
# streaming is never what stops Copilot. A refused sign-in would fail the same way, so it is not asked again.
|
|
412
|
+
problem = failure or (errors[-1] if errors and not calls else "")
|
|
413
|
+
if problem and stream["to"] and not stream["sent"] and not signin_refused(problem):
|
|
414
|
+
stream["to"] = None
|
|
415
|
+
continue
|
|
392
416
|
if failure:
|
|
393
|
-
raise
|
|
417
|
+
raise self.refusal(failure)
|
|
394
418
|
break
|
|
395
419
|
else:
|
|
396
420
|
raise GatewayError(502, "GitHub Copilot kept refusing the names of the caller's tools.")
|
|
397
421
|
if errors and not calls:
|
|
398
|
-
|
|
399
|
-
raise GatewayError(401 if "auth" in msg.lower() or "401" in msg else 502, f"GitHub Copilot: {msg}")
|
|
422
|
+
raise self.refusal(errors[-1])
|
|
400
423
|
data = getattr(reply, "data", None)
|
|
401
424
|
content = (data.content or "") if isinstance(data, AssistantMessageData) else ""
|
|
402
425
|
if calls:
|
|
@@ -417,6 +440,14 @@ _CONFLICT = re.compile(r'(?:external )?tool "([A-Za-z0-9_-]{1,64})" conflicts wi
|
|
|
417
440
|
_NO_BUILTIN = re.compile(r'tool "([A-Za-z0-9_-]{1,64})"[^"\n]{0,80}(?:does not|doesn\'t|no) (?:override|built-in|builtin)', re.I)
|
|
418
441
|
|
|
419
442
|
|
|
443
|
+
_SIGNIN_REFUSED = re.compile(r"authori[sz]ation error|credentials may be expired|bad credentials|unauthori[sz]ed|not authenticated|no github oauth token|\b401\b", re.I)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def signin_refused(message: str) -> bool:
|
|
447
|
+
"""Whether Copilot's error says it would not take the sign-in or token (as opposed to the model or the request)."""
|
|
448
|
+
return bool(_SIGNIN_REFUSED.search(message or ""))
|
|
449
|
+
|
|
450
|
+
|
|
420
451
|
def tool_conflict(message: str, names: set) -> Optional[Tuple[str, bool]]:
|
|
421
452
|
"""
|
|
422
453
|
From Copilot's error, the caller tool to retry with: (name, True) when it clashes with a built-in and must replace
|
|
@@ -480,6 +511,61 @@ def _error(status: int, message: str) -> bytes:
|
|
|
480
511
|
return _response(status, {"error": {"message": message, "type": "forgeqe_copilot_gateway"}})
|
|
481
512
|
|
|
482
513
|
|
|
514
|
+
class Stream:
|
|
515
|
+
"""
|
|
516
|
+
A streaming answer sent as Copilot writes it. The head goes out with the first piece of text, so a request that
|
|
517
|
+
fails before any text still gets a plain HTTP error; one that fails later ends with an error event.
|
|
518
|
+
"""
|
|
519
|
+
|
|
520
|
+
def __init__(self, writer: Any, model: str) -> None:
|
|
521
|
+
self.writer = writer
|
|
522
|
+
self.base = {"id": f"chatcmpl-{uuid.uuid4().hex}", "object": "chat.completion.chunk", "created": int(time.time()), "model": model}
|
|
523
|
+
self.sent = ""
|
|
524
|
+
self.loop = asyncio.get_running_loop()
|
|
525
|
+
|
|
526
|
+
def text(self, delta: str) -> None:
|
|
527
|
+
"""Called by the SDK, maybe from a thread of its own: the write happens on the server's loop."""
|
|
528
|
+
try:
|
|
529
|
+
on_loop = asyncio.get_running_loop() is self.loop
|
|
530
|
+
except RuntimeError:
|
|
531
|
+
on_loop = False
|
|
532
|
+
if on_loop:
|
|
533
|
+
self._write(delta)
|
|
534
|
+
else:
|
|
535
|
+
self.loop.call_soon_threadsafe(self._write, delta)
|
|
536
|
+
|
|
537
|
+
def _write(self, delta: str) -> None:
|
|
538
|
+
if not self.sent:
|
|
539
|
+
self.writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n")
|
|
540
|
+
role = {} if self.sent else {"role": "assistant"}
|
|
541
|
+
self.sent += delta
|
|
542
|
+
self._event({**self.base, "choices": [{"index": 0, "delta": {**role, "content": delta}, "finish_reason": None}]})
|
|
543
|
+
|
|
544
|
+
def _event(self, payload: Any) -> None:
|
|
545
|
+
self.writer.write(f"data: {json.dumps(payload)}\n\n".encode())
|
|
546
|
+
|
|
547
|
+
async def finish(self, done: Dict[str, Any]) -> None:
|
|
548
|
+
await asyncio.sleep(0) # text the SDK handed over from its own thread is written first
|
|
549
|
+
if not self.sent:
|
|
550
|
+
self.writer.write(_stream(done))
|
|
551
|
+
return
|
|
552
|
+
message = done["choices"][0]["message"]
|
|
553
|
+
content = message.get("content") or ""
|
|
554
|
+
# Only what was not sent yet: the final text normally is exactly what streamed.
|
|
555
|
+
rest = content[len(self.sent):] if content.startswith(self.sent) else ""
|
|
556
|
+
for c in chunks({**done, **self.base, "choices": [{**done["choices"][0], "message": {**message, "content": rest}}]}):
|
|
557
|
+
c["choices"] = [{**ch, "delta": {k: v for k, v in ch["delta"].items() if k != "role" and (k != "content" or v)}} for ch in c["choices"]]
|
|
558
|
+
self._event(c)
|
|
559
|
+
self.writer.write(b"data: [DONE]\n\n")
|
|
560
|
+
|
|
561
|
+
async def fail(self, status: int, message: str) -> None:
|
|
562
|
+
await asyncio.sleep(0)
|
|
563
|
+
if not self.sent:
|
|
564
|
+
self.writer.write(_error(status, message))
|
|
565
|
+
return
|
|
566
|
+
self._event({"error": {"message": message, "type": "forgeqe_copilot_gateway"}})
|
|
567
|
+
|
|
568
|
+
|
|
483
569
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, copilot: Any, key: str) -> None:
|
|
484
570
|
try:
|
|
485
571
|
try:
|
|
@@ -522,8 +608,16 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, cop
|
|
|
522
608
|
writer.write(_error(400, "The request body is not JSON."))
|
|
523
609
|
return
|
|
524
610
|
req = translate(body)
|
|
525
|
-
|
|
526
|
-
|
|
611
|
+
if req["stream"]:
|
|
612
|
+
stream = Stream(writer, req["model"])
|
|
613
|
+
try:
|
|
614
|
+
await stream.finish(await copilot.complete(req, stream.text))
|
|
615
|
+
except GatewayError as e:
|
|
616
|
+
await stream.fail(e.status, e.message)
|
|
617
|
+
except Exception as e:
|
|
618
|
+
await stream.fail(502, f"GitHub Copilot: {type(e).__name__}: {e}"[:2000])
|
|
619
|
+
return
|
|
620
|
+
writer.write(_response(200, await copilot.complete(req)))
|
|
527
621
|
except GatewayError as e:
|
|
528
622
|
writer.write(_error(e.status, e.message))
|
|
529
623
|
except Exception as e: # The SDK's own errors: report them rather than drop the connection.
|