copilot-reverse 0.5.1 → 0.5.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/dist/providers/copilot/adapter.js +21 -6
- package/dist/providers/copilot/responses-upstream.js +41 -4
- package/dist/shared/config.js +1 -1
- package/dist/supervisor/index.js +4 -0
- package/dist/supervisor/monitor.js +8 -1
- package/dist/version.js +1 -1
- package/dist/worker/index.js +22 -3
- package/package.json +1 -1
|
@@ -86,13 +86,26 @@ export class CopilotAdapter {
|
|
|
86
86
|
const data = (await res.json());
|
|
87
87
|
const choice = data.choices[0];
|
|
88
88
|
const content = [];
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
// Recover inline-XML tool calls in non-stream replies too (same reason as the stream path).
|
|
90
|
+
let xmlTool = false;
|
|
91
|
+
if (choice.message.content) {
|
|
92
|
+
const ex = new ToolCallExtractor();
|
|
93
|
+
for (const ev of [...ex.feed(choice.message.content), ...ex.flush()]) {
|
|
94
|
+
if (ev.kind === "text") {
|
|
95
|
+
if (ev.text)
|
|
96
|
+
content.push({ type: "text", text: ev.text });
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
xmlTool = true;
|
|
100
|
+
content.push({ type: "tool_use", id: ev.tool.id, name: ev.tool.name, input: ev.tool.input });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
91
104
|
for (const tc of choice.message.tool_calls ?? [])
|
|
92
105
|
content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: safeJson(tc.function.arguments) });
|
|
93
106
|
return {
|
|
94
107
|
id: data.id ?? `cmpl-${randomUUID().replace(/-/g, "")}`, model: req.model, content,
|
|
95
|
-
finishReason: choice.finish_reason === "tool_calls" ? "tool_use" : choice.finish_reason === "length" ? "length" : "stop",
|
|
108
|
+
finishReason: choice.finish_reason === "tool_calls" || xmlTool ? "tool_use" : choice.finish_reason === "length" ? "length" : "stop",
|
|
96
109
|
usage: { promptTokens: data.usage?.prompt_tokens ?? 0, completionTokens: data.usage?.completion_tokens ?? 0 },
|
|
97
110
|
};
|
|
98
111
|
}
|
|
@@ -134,9 +147,11 @@ export class CopilotAdapter {
|
|
|
134
147
|
let usage;
|
|
135
148
|
const mapFinish = (f) => f === "tool_calls" ? "tool_use" : f === "length" ? "length" : "stop";
|
|
136
149
|
// Some models emit a tool call as inline XML text instead of native tool_calls (more likely on
|
|
137
|
-
// long/tool-heavy turns)
|
|
138
|
-
// that
|
|
139
|
-
|
|
150
|
+
// long/tool-heavy turns) — and they do it even when THIS request declared no tools (a follow-up
|
|
151
|
+
// turn, or a model that ignores the tools field). Always run assistant text through the extractor;
|
|
152
|
+
// it only captures on the distinctive `<invoke>`/`<function_calls>` sentinel and flushes anything
|
|
153
|
+
// unparseable back as text, so plain prose is unaffected.
|
|
154
|
+
const extractor = new ToolCallExtractor();
|
|
140
155
|
let extractedTool = false;
|
|
141
156
|
let extIdx = 100; // separate index space so recovered tools never collide with native tool_calls
|
|
142
157
|
const toChunks = (events) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { ToolCallExtractor } from "../../core/tool-xml.js";
|
|
2
3
|
// Outbound translation to GitHub Copilot's OpenAI Responses API. Newer Copilot models (e.g. gpt-5.5)
|
|
3
4
|
// are served ONLY on /responses — their `supported_endpoints` omits /chat/completions — so the adapter
|
|
4
5
|
// routes them here instead of the chat path. This is the mirror image of core/responses-inbound.ts
|
|
@@ -68,8 +69,20 @@ export function parseResponsesResult(data) {
|
|
|
68
69
|
for (const item of data.output ?? []) {
|
|
69
70
|
if (item.type === "message") {
|
|
70
71
|
const text = (item.content ?? []).filter((p) => p.type === "output_text").map((p) => p.text ?? "").join("");
|
|
71
|
-
if (text)
|
|
72
|
-
|
|
72
|
+
if (text) {
|
|
73
|
+
// Recover inline-XML tool calls here too (some models emit them as output_text).
|
|
74
|
+
const ex = new ToolCallExtractor();
|
|
75
|
+
for (const ev of [...ex.feed(text), ...ex.flush()]) {
|
|
76
|
+
if (ev.kind === "text") {
|
|
77
|
+
if (ev.text)
|
|
78
|
+
content.push({ type: "text", text: ev.text });
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
sawTool = true;
|
|
82
|
+
content.push({ type: "tool_use", id: ev.tool.id, name: ev.tool.name, input: ev.tool.input });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
73
86
|
}
|
|
74
87
|
else if (item.type === "function_call") {
|
|
75
88
|
sawTool = true;
|
|
@@ -99,6 +112,25 @@ export async function* streamResponses(res) {
|
|
|
99
112
|
let usage;
|
|
100
113
|
const toolByOutputIndex = new Map(); // responses output_index -> canonical tool index
|
|
101
114
|
let nextToolIndex = 0;
|
|
115
|
+
// Some models stream a tool call as inline XML text instead of a function_call item; recover it.
|
|
116
|
+
// Extracted tools use a high index space so they never collide with native function_call indices.
|
|
117
|
+
const extractor = new ToolCallExtractor();
|
|
118
|
+
let extIdx = 100, extractedTool = false;
|
|
119
|
+
const toChunks = (events) => {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const ev of events) {
|
|
122
|
+
if (ev.kind === "text") {
|
|
123
|
+
if (ev.text)
|
|
124
|
+
out.push({ kind: "text", delta: ev.text, done: false });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const index = extIdx++;
|
|
128
|
+
extractedTool = true;
|
|
129
|
+
out.push({ kind: "tool_use_start", index, id: ev.tool.id, name: ev.tool.name, done: false });
|
|
130
|
+
out.push({ kind: "tool_use_delta", index, argsDelta: JSON.stringify(ev.tool.input), done: false });
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
};
|
|
102
134
|
const usageOf = (u) => u ? { promptTokens: u.input_tokens ?? 0, completionTokens: u.output_tokens ?? 0, cachedTokens: u.input_tokens_details?.cached_tokens ?? 0 } : undefined;
|
|
103
135
|
for (;;) {
|
|
104
136
|
const { value, done } = await reader.read();
|
|
@@ -133,7 +165,8 @@ export async function* streamResponses(res) {
|
|
|
133
165
|
}
|
|
134
166
|
case "response.output_text.delta":
|
|
135
167
|
if (ev.delta)
|
|
136
|
-
|
|
168
|
+
for (const ch of toChunks(extractor.feed(ev.delta)))
|
|
169
|
+
yield ch;
|
|
137
170
|
break;
|
|
138
171
|
case "response.function_call_arguments.delta": {
|
|
139
172
|
const idx = toolByOutputIndex.get(ev.output_index);
|
|
@@ -142,7 +175,7 @@ export async function* streamResponses(res) {
|
|
|
142
175
|
break;
|
|
143
176
|
}
|
|
144
177
|
case "response.completed":
|
|
145
|
-
if (toolByOutputIndex.size)
|
|
178
|
+
if (toolByOutputIndex.size || extractedTool)
|
|
146
179
|
finishReason = "tool_use";
|
|
147
180
|
usage = usageOf(ev.response?.usage) ?? usage;
|
|
148
181
|
break;
|
|
@@ -157,5 +190,9 @@ export async function* streamResponses(res) {
|
|
|
157
190
|
}
|
|
158
191
|
}
|
|
159
192
|
}
|
|
193
|
+
for (const ch of toChunks(extractor.flush()))
|
|
194
|
+
yield ch;
|
|
195
|
+
if (extractedTool && finishReason === "stop")
|
|
196
|
+
finishReason = "tool_use";
|
|
160
197
|
yield { kind: "done", done: true, finishReason, usage };
|
|
161
198
|
}
|
package/dist/shared/config.js
CHANGED
|
@@ -3,7 +3,7 @@ export function defaultConfig() {
|
|
|
3
3
|
bindHost: "127.0.0.1",
|
|
4
4
|
supervisorPort: 7890,
|
|
5
5
|
workerPort: 7891,
|
|
6
|
-
restart: { maxCrashes: 5, windowMs: 60_000, baseBackoffMs: 500, maxBackoffMs: 8_000 },
|
|
6
|
+
restart: { maxCrashes: 5, windowMs: 60_000, baseBackoffMs: 500, maxBackoffMs: 8_000, unhealthyCooldownMs: 30_000 },
|
|
7
7
|
// Empty = pass the requested model straight through to Copilot. Add entries (or "*") to remap.
|
|
8
8
|
modelMap: {},
|
|
9
9
|
// Set MAESTRO_REPORT_REPO=owner/repo to override where /report files diagnostics issues.
|
package/dist/supervisor/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { dataDir, dbPath } from "../shared/paths.js";
|
|
|
10
10
|
import { readGhToken } from "../shared/creds.js";
|
|
11
11
|
import { probeGithubAuth } from "../providers/copilot/token.js";
|
|
12
12
|
import { GithubHeartbeat, SIGNED_OUT_DETAIL } from "./github-heartbeat.js";
|
|
13
|
+
import { appendCrashLog } from "../shared/crash-log.js";
|
|
13
14
|
export function startSupervisor() {
|
|
14
15
|
const config = defaultConfig();
|
|
15
16
|
mkdirSync(dataDir(), { recursive: true });
|
|
@@ -21,6 +22,9 @@ export function startSupervisor() {
|
|
|
21
22
|
onStateChange: (s) => { state = s; bus.emit("state", { state: s }); },
|
|
22
23
|
onCrash: (d, exitCode, stderrTail) => {
|
|
23
24
|
recordRestart(db, { ts: Date.now(), reason: d.markedUnhealthy ? "unhealthy" : "crash", exitCode, stderrTail, backoffMs: d.backoffMs, markedUnhealthy: d.markedUnhealthy ? 1 : 0 });
|
|
25
|
+
// Also persist to crash.log so a worker crash is diagnosable post-mortem — the DB stderrTail can
|
|
26
|
+
// be empty if the worker died before flushing; this keeps whatever it did emit.
|
|
27
|
+
appendCrashLog("worker-crash", `exit=${exitCode} unhealthy=${d.markedUnhealthy} backoff=${d.backoffMs}ms\n${stderrTail || "(no stderr captured)"}`);
|
|
24
28
|
bus.emit("crash", { exitCode, ...d });
|
|
25
29
|
},
|
|
26
30
|
onWorkerMessage: (m) => {
|
|
@@ -16,7 +16,7 @@ export class RestartController {
|
|
|
16
16
|
const backoffMs = Math.min(this.policy.baseBackoffMs * 2 ** (this.consecutive - 1), this.policy.maxBackoffMs);
|
|
17
17
|
return { backoffMs, markedUnhealthy: this.crashTimes.length >= this.policy.maxCrashes, crashesInWindow: this.crashTimes.length };
|
|
18
18
|
}
|
|
19
|
-
reset() { this.consecutive = 0; }
|
|
19
|
+
reset() { this.consecutive = 0; this.crashTimes = []; }
|
|
20
20
|
}
|
|
21
21
|
export class WorkerMonitor {
|
|
22
22
|
config;
|
|
@@ -58,7 +58,14 @@ export class WorkerMonitor {
|
|
|
58
58
|
const d = this.controller.onCrash();
|
|
59
59
|
this.hooks.onCrash(d, code, this.stderrTail);
|
|
60
60
|
if (d.markedUnhealthy) {
|
|
61
|
+
// Don't give up forever: a transient crash burst (token rotation, a flaky upstream) shouldn't
|
|
62
|
+
// leave the daemon permanently dead. Mark unhealthy, then after a cooldown reset the window and
|
|
63
|
+
// try once more — recovering on its own if the cause has passed.
|
|
61
64
|
this.set("unhealthy");
|
|
65
|
+
setTimeout(() => { if (!this.stopped) {
|
|
66
|
+
this.controller.reset();
|
|
67
|
+
this.spawn();
|
|
68
|
+
} }, this.config.restart.unhealthyCooldownMs);
|
|
62
69
|
return;
|
|
63
70
|
}
|
|
64
71
|
this.set("crashed");
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/gen-version.mjs from package.json — do not edit.
|
|
2
|
-
export const APP_VERSION = "0.5.
|
|
2
|
+
export const APP_VERSION = "0.5.3";
|
package/dist/worker/index.js
CHANGED
|
@@ -9,8 +9,13 @@ import { makeGatewayRunner } from "../core/server-tools.js";
|
|
|
9
9
|
import { borrowSearch } from "../providers/copilot/borrow-search.js";
|
|
10
10
|
import { dataDir } from "../shared/paths.js";
|
|
11
11
|
import { defaultConfig } from "../shared/config.js";
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// Sending after the parent tore down the IPC channel throws ERR_IPC_CHANNEL_CLOSED; guard it so a
|
|
13
|
+
// crash-time report can't itself become a second, masking crash.
|
|
14
|
+
function send(msg) { try {
|
|
15
|
+
if (process.connected)
|
|
16
|
+
process.send?.(msg);
|
|
17
|
+
}
|
|
18
|
+
catch { /* channel gone */ } }
|
|
14
19
|
const cfg = defaultConfig();
|
|
15
20
|
const port = Number(process.env.WORKER_PORT ?? cfg.workerPort);
|
|
16
21
|
const host = process.env.BIND_HOST ?? cfg.bindHost;
|
|
@@ -47,4 +52,18 @@ process.on("message", (m) => { if (m?.type === "shutdown") {
|
|
|
47
52
|
clearInterval(hb);
|
|
48
53
|
server.close(() => process.exit(0));
|
|
49
54
|
} });
|
|
50
|
-
|
|
55
|
+
// Crash diagnostics: write to STDERR FIRST so the supervisor's stderr capture (and crash.log) records
|
|
56
|
+
// the reason even when the IPC channel is already gone; the IPC send is a best-effort extra. Without
|
|
57
|
+
// the unhandledRejection handler, a stray floating rejection silently kills the worker on Node ≥15 —
|
|
58
|
+
// the exact "exit 1, empty stderr" crash loop we kept seeing.
|
|
59
|
+
function fatal(kind, e) {
|
|
60
|
+
const detail = e instanceof Error ? `${e.message}\n${e.stack ?? ""}` : String(e);
|
|
61
|
+
try {
|
|
62
|
+
process.stderr.write(`worker ${kind}: ${detail}\n`);
|
|
63
|
+
}
|
|
64
|
+
catch { /* nothing more we can do */ }
|
|
65
|
+
send({ type: "error", message: e instanceof Error ? e.message : String(e), stack: e instanceof Error ? e.stack : undefined });
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
process.on("uncaughtException", (e) => fatal("uncaughtException", e));
|
|
69
|
+
process.on("unhandledRejection", (e) => fatal("unhandledRejection", e));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copilot-reverse",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Interactive terminal app that exposes your GitHub Copilot subscription as local OpenAI- and Anthropic-compatible endpoints, with a self-healing daemon and a built-in assistant.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|