@capagents/forgeqe 2.3.0 → 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 +725 -725
- 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 +181 -42
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
|
|
|
@@ -290,6 +290,9 @@ class Copilot:
|
|
|
290
290
|
self.workdir = tempfile.mkdtemp(prefix="forgeqe-copilot-")
|
|
291
291
|
self.signed_in_user = not token
|
|
292
292
|
self.client = CopilotClient(**client_options(token, home, self.workdir))
|
|
293
|
+
# Caller tools named like one of Copilot's own: they must say they replace it. Learned from Copilot's answers
|
|
294
|
+
# (BUILTIN_TOOLS to start with), kept for the gateway's life, so only a first request pays a retry.
|
|
295
|
+
self.replaces: set = set(BUILTIN_TOOLS)
|
|
293
296
|
|
|
294
297
|
async def start(self) -> None:
|
|
295
298
|
await self.client.start()
|
|
@@ -300,6 +303,14 @@ class Copilot:
|
|
|
300
303
|
raise GatewayError(401, "GitHub Copilot is not signed in: run forgeqe auth login copilot, which opens GitHub in your browser.")
|
|
301
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.")
|
|
302
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
|
+
|
|
303
314
|
async def stop(self) -> None:
|
|
304
315
|
try:
|
|
305
316
|
await self.client.stop()
|
|
@@ -326,9 +337,14 @@ class Copilot:
|
|
|
326
337
|
})
|
|
327
338
|
return out
|
|
328
339
|
|
|
329
|
-
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)."""
|
|
330
343
|
from copilot.generated.rpc import PermissionDecisionReject
|
|
331
|
-
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}
|
|
332
348
|
|
|
333
349
|
usage = {"input": 0, "output": 0, "cached": 0}
|
|
334
350
|
errors: List[str] = []
|
|
@@ -342,42 +358,68 @@ class Copilot:
|
|
|
342
358
|
usage["cached"] += data.cache_read_tokens or 0
|
|
343
359
|
elif isinstance(data, SessionErrorData):
|
|
344
360
|
errors.append(data.message or data.error_type or "error")
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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)
|
|
365
|
+
|
|
366
|
+
tried: set = set()
|
|
367
|
+
reply: Any = None
|
|
368
|
+
for _ in range(MAX_TOOL_RETRIES + 2):
|
|
369
|
+
errors.clear()
|
|
370
|
+
calls.clear()
|
|
371
|
+
names = {t["name"] for t in req["tools"]}
|
|
372
|
+
tools = relay_tools(req["tools"], calls, replaces=self.replaces & names)
|
|
373
|
+
options: Dict[str, Any] = dict(
|
|
374
|
+
model=req["model"],
|
|
375
|
+
# The caller's prompt is the whole prompt; Copilot's own tools are never offered, only the caller's.
|
|
376
|
+
system_message={"mode": "replace", "content": req["system"] or "You are a helpful assistant."},
|
|
377
|
+
tools=tools or None,
|
|
378
|
+
available_tools=[t.name for t in tools],
|
|
379
|
+
on_permission_request=lambda *_: PermissionDecisionReject(),
|
|
380
|
+
working_directory=self.workdir,
|
|
381
|
+
skip_custom_instructions=True,
|
|
382
|
+
enable_config_discovery=False,
|
|
383
|
+
enable_skills=False,
|
|
384
|
+
enable_session_store=False,
|
|
385
|
+
on_event=on_event,
|
|
386
|
+
**({"streaming": True} if stream["to"] else {}),
|
|
387
|
+
)
|
|
388
|
+
failure: Optional[str] = None
|
|
389
|
+
session = None
|
|
374
390
|
try:
|
|
375
|
-
await
|
|
376
|
-
|
|
377
|
-
|
|
391
|
+
session = await self.client.create_session(**options)
|
|
392
|
+
reply = await session.send_and_wait(req["prompt"], attachments=req["attachments"] or None, response_schema=req["schema"], timeout=REQUEST_TIMEOUT_S)
|
|
393
|
+
except TimeoutError:
|
|
394
|
+
raise GatewayError(504, f"GitHub Copilot did not answer within {int(REQUEST_TIMEOUT_S)} seconds.")
|
|
395
|
+
except Exception as e: # Copilot names a tool it will not take; anything else is reported below.
|
|
396
|
+
failure = str(e)
|
|
397
|
+
finally:
|
|
398
|
+
if session is not None:
|
|
399
|
+
try:
|
|
400
|
+
await session.disconnect()
|
|
401
|
+
except Exception:
|
|
402
|
+
pass
|
|
403
|
+
# Copilot reports a clash with its own tools when the session starts or when the turn starts.
|
|
404
|
+
fix = tool_conflict(failure or (errors[-1] if errors and not calls else ""), names)
|
|
405
|
+
if fix and fix not in tried:
|
|
406
|
+
tried.add(fix)
|
|
407
|
+
name, replace = fix
|
|
408
|
+
(self.replaces.add if replace else self.replaces.discard)(name)
|
|
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
|
|
416
|
+
if failure:
|
|
417
|
+
raise self.refusal(failure)
|
|
418
|
+
break
|
|
419
|
+
else:
|
|
420
|
+
raise GatewayError(502, "GitHub Copilot kept refusing the names of the caller's tools.")
|
|
378
421
|
if errors and not calls:
|
|
379
|
-
|
|
380
|
-
raise GatewayError(401 if "auth" in msg.lower() or "401" in msg else 502, f"GitHub Copilot: {msg}")
|
|
422
|
+
raise self.refusal(errors[-1])
|
|
381
423
|
data = getattr(reply, "data", None)
|
|
382
424
|
content = (data.content or "") if isinstance(data, AssistantMessageData) else ""
|
|
383
425
|
if calls:
|
|
@@ -389,10 +431,44 @@ class Copilot:
|
|
|
389
431
|
return completion(req["model"], content, usage)
|
|
390
432
|
|
|
391
433
|
|
|
392
|
-
|
|
434
|
+
# Copilot's own tools that the agent runtime's tools are named like (bash, glob, grep, edit, …). A caller tool with one
|
|
435
|
+
# of these names says it replaces Copilot's: the caller's tool is the one offered, and it runs nothing here.
|
|
436
|
+
BUILTIN_TOOLS = frozenset({"bash", "powershell", "glob", "grep", "view", "edit", "create", "task", "skill", "web_fetch", "think", "update_todo", "report_intent"})
|
|
437
|
+
MAX_TOOL_RETRIES = 8
|
|
438
|
+
|
|
439
|
+
_CONFLICT = re.compile(r'(?:external )?tool "([A-Za-z0-9_-]{1,64})" conflicts with a built-in tool', re.I)
|
|
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)
|
|
441
|
+
|
|
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
|
+
|
|
451
|
+
def tool_conflict(message: str, names: set) -> Optional[Tuple[str, bool]]:
|
|
452
|
+
"""
|
|
453
|
+
From Copilot's error, the caller tool to retry with: (name, True) when it clashes with a built-in and must replace
|
|
454
|
+
it, (name, False) when Copilot has no built-in of that name to replace. None for any other error.
|
|
455
|
+
"""
|
|
456
|
+
if not message:
|
|
457
|
+
return None
|
|
458
|
+
m = _CONFLICT.search(message)
|
|
459
|
+
if m and m.group(1) in names:
|
|
460
|
+
return m.group(1), True
|
|
461
|
+
m = _NO_BUILTIN.search(message)
|
|
462
|
+
if m and m.group(1) in names:
|
|
463
|
+
return m.group(1), False
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def relay_tools(specs: List[Dict[str, Any]], calls: List[Dict[str, Any]], override: bool = False, replaces: Optional[set] = None) -> List[Any]:
|
|
393
468
|
"""
|
|
394
469
|
The caller's tools as Copilot SDK tools that run nothing: each records the call in `calls` and, being terminal, ends
|
|
395
|
-
the turn, so the call goes back to the caller as OpenAI tool_calls.
|
|
470
|
+
the turn, so the call goes back to the caller as OpenAI tool_calls. A tool in `replaces` (or every tool, with
|
|
471
|
+
`override`) replaces Copilot's built-in tool of the same name.
|
|
396
472
|
"""
|
|
397
473
|
if not specs:
|
|
398
474
|
return []
|
|
@@ -408,7 +484,7 @@ def relay_tools(specs: List[Dict[str, Any]], calls: List[Dict[str, Any]], overri
|
|
|
408
484
|
return ToolResult(text_result_for_llm="The caller runs this tool and sends its result in the next message.")
|
|
409
485
|
|
|
410
486
|
return [
|
|
411
|
-
Tool(name=t["name"], description=t["description"], parameters=t["parameters"], handler=handler, skip_permission=True, is_terminal=True, defer="never", overrides_built_in_tool=override)
|
|
487
|
+
Tool(name=t["name"], description=t["description"], parameters=t["parameters"], handler=handler, skip_permission=True, is_terminal=True, defer="never", overrides_built_in_tool=override or t["name"] in (replaces or set()))
|
|
412
488
|
for t in specs
|
|
413
489
|
]
|
|
414
490
|
|
|
@@ -435,6 +511,61 @@ def _error(status: int, message: str) -> bytes:
|
|
|
435
511
|
return _response(status, {"error": {"message": message, "type": "forgeqe_copilot_gateway"}})
|
|
436
512
|
|
|
437
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
|
+
|
|
438
569
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, copilot: Any, key: str) -> None:
|
|
439
570
|
try:
|
|
440
571
|
try:
|
|
@@ -477,8 +608,16 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, cop
|
|
|
477
608
|
writer.write(_error(400, "The request body is not JSON."))
|
|
478
609
|
return
|
|
479
610
|
req = translate(body)
|
|
480
|
-
|
|
481
|
-
|
|
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)))
|
|
482
621
|
except GatewayError as e:
|
|
483
622
|
writer.write(_error(e.status, e.message))
|
|
484
623
|
except Exception as e: # The SDK's own errors: report them rather than drop the connection.
|