copilot-reverse 0.5.5 → 0.7.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.
@@ -0,0 +1,52 @@
1
+ export const APP_CHANGES = [
2
+ {
3
+ "version": "0.6.0",
4
+ "date": "2026-06-29",
5
+ "summary": "feat(tui): add a `/changes` command listing the 10 most recent releases (version, date, summary) with a link to the full CHANGELOG, and refocus the startup \"what's new\" banner on important messages — it now points to `/changes` instead of advertising a bug fix, and still self-suppresses after 3 launches."
6
+ },
7
+ {
8
+ "version": "0.5.5",
9
+ "date": "2026-06-29",
10
+ "summary": "ci: gate PRs on a changeset. A pull request with no file in `.changes/` now fails the `changeset` check, so merges can't silently skip the release (the v0.5.3 freeze). Docs/test-only PRs opt out with a `no-changeset` label."
11
+ },
12
+ {
13
+ "version": "0.5.4",
14
+ "date": "2026-06-29",
15
+ "summary": "fix(worker): stop the empty-tool-call loop (\"call: call: call:…\") that froze sessions. Inline-XML blocks that recover no tool are now passed through verbatim instead of silently swallowed; nameless `function_call` items on the /responses path are dropped instead of streamed as a blank `call:`; and the runaway deadline now covers tool-call streams, not just text — a model looping on tool calls is cut cleanly instead of relaying forever."
16
+ },
17
+ {
18
+ "version": "0.5.3",
19
+ "date": "2026-06-29",
20
+ "summary": "Fix inline tool-call XML (`<invoke name=…>`) leaking as literal text instead of running. The extractor that recovers these blocks only ran on the chat path when the request declared tools, and never on the Codex `/responses` path. It now runs always-on across both streaming and non-stream paths, so a follow-up turn or a `/responses` model can no longer dump raw XML into the reply."
21
+ },
22
+ {
23
+ "version": "0.5.2",
24
+ "date": "2026-06-29",
25
+ "summary": "Fix the daemon going permanently dead during dogfooding. The worker had no `unhandledRejection` handler, so a stray floating rejection silently killed it (exit 1, empty stderr) on Node ≥15; once that happened 5×/60s the supervisor marked it `unhealthy` and gave up forever, leaving a running daemon with a dead worker. The worker now handles `unhandledRejection`, writes the cause to stderr *before* the IPC report (so crashes are no longer blind), the supervisor persists each crash to `crash.log`, and `unhealthy` now recovers: after a 30s cooldown it resets the window and tries again instead of staying down."
26
+ },
27
+ {
28
+ "version": "0.5.1",
29
+ "date": "2026-06-28",
30
+ "summary": "Fix the app dropping back to the shell during concurrent use. The TUI and supervisor share one process, but several synchronous throw sites had no handler — most importantly an SSE write to a client socket that died between broadcasts (likely with multiple clients connected), which crashed the whole process. Each broadcast listener is now isolated and a dead SSE connection is dropped instead of retried; `readGhToken` returns null on a corrupt/locked read instead of throwing on the heartbeat tick; and a process-level backstop logs any remaining stray throw/rejection to `~/.copilot-reverse/crash.log` and keeps the TUI alive."
31
+ },
32
+ {
33
+ "version": "0.5.0",
34
+ "date": "2026-06-28",
35
+ "summary": "Add a GitHub-token heartbeat: the supervisor now re-checks every ~60s whether the stored GitHub login still works, and the TUI footer shows a live `github ✓` / `✗ /login` badge — so an expired or revoked login surfaces within ~60s instead of only on the next failed request or a manual `/status`. A transient network/rate-limit hiccup is distinguished from a real auth failure, so the badge never flips on a single blip."
36
+ },
37
+ {
38
+ "version": "0.4.0",
39
+ "date": "2026-06-26",
40
+ "summary": "Codex `/responses` support, web search via Microsoft Web IQ, and a tool-call recovery fix:"
41
+ },
42
+ {
43
+ "version": "0.3.0",
44
+ "date": "2026-06-26",
45
+ "summary": "Restore `web_search` and `web_fetch` for Claude Code through the gateway: the worker now runs these tools internally against Microsoft Web IQ in a transparent agentic loop, and a new `/web-search-support` command stores the WebIQ API key."
46
+ },
47
+ {
48
+ "version": "0.2.1",
49
+ "date": "2026-06-25",
50
+ "summary": "Fix `/login` hanging with no output: the device-code prompt is now shown immediately while authorization is pending, instead of being buffered behind the blocking token poll."
51
+ }
52
+ ];
package/dist/cli/index.js CHANGED
@@ -181,11 +181,12 @@ async function launchTui() {
181
181
  }
182
182
  });
183
183
  const persistedModel = readChatModel(dataDir());
184
- // "What's new" banner: MAJOR changes only keyed by version so each release re-announces, shown
185
- // ~3 launches then quiet. Minor fixes/polish do NOT go here; reserve it for things worth noticing.
184
+ // "What's new" banner: IMPORTANT messages only (new capabilities, things worth noticing) NOT
185
+ // bug fixes. Keyed by version so each release re-announces, shown ~3 launches then quiet. The full
186
+ // list lives behind /changes; this is just a nudge.
186
187
  const CHANGE_ID = `v${APP_VERSION}`;
187
188
  const changeBanner = shouldShowChange(dataDir(), CHANGE_ID)
188
- ? { lines: ["• runaway streams now cut cleanly no more frozen 'code code code' sessions"] }
189
+ ? { lines: ["• type /changes to see what's new across recent releases"] }
189
190
  : undefined;
190
191
  // Startup overview. The token was already validated above (re-auth happens before we get here), so
191
192
  // GitHub is connected; web search readiness and configured clients are read from disk.
@@ -9,12 +9,16 @@ export function openDb(file) {
9
9
  exit_code INTEGER, stderr_tail TEXT NOT NULL, backoff_ms INTEGER NOT NULL, marked_unhealthy INTEGER NOT NULL DEFAULT 0);
10
10
  CREATE TABLE IF NOT EXISTS request_log (
11
11
  id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, endpoint TEXT NOT NULL,
12
- model TEXT NOT NULL, status INTEGER NOT NULL, latency_ms INTEGER NOT NULL, error TEXT);
12
+ model TEXT NOT NULL, status INTEGER NOT NULL, latency_ms INTEGER NOT NULL, tokens_in INTEGER, tokens_out INTEGER, error TEXT);
13
13
  `);
14
- // Migrate request_log tables created before the error column existed.
14
+ // Migrate request_log tables created before later columns existed.
15
15
  const cols = db.prepare(`PRAGMA table_info(request_log)`).all();
16
16
  if (!cols.some((c) => c.name === "error"))
17
17
  db.exec(`ALTER TABLE request_log ADD COLUMN error TEXT`);
18
+ if (!cols.some((c) => c.name === "tokens_in"))
19
+ db.exec(`ALTER TABLE request_log ADD COLUMN tokens_in INTEGER`);
20
+ if (!cols.some((c) => c.name === "tokens_out"))
21
+ db.exec(`ALTER TABLE request_log ADD COLUMN tokens_out INTEGER`);
18
22
  return db;
19
23
  }
20
24
  export function recordRestart(db, e) {
@@ -26,10 +30,10 @@ export function listRestarts(db, limit) {
26
30
  FROM restart_events ORDER BY ts DESC LIMIT ?`).all(limit);
27
31
  }
28
32
  export function recordRequest(db, m) {
29
- db.prepare(`INSERT INTO request_log (ts, endpoint, model, status, latency_ms, error) VALUES (@ts, @endpoint, @model, @status, @latencyMs, @error)`)
30
- .run({ error: null, ...m });
33
+ db.prepare(`INSERT INTO request_log (ts, endpoint, model, status, latency_ms, tokens_in, tokens_out, error) VALUES (@ts, @endpoint, @model, @status, @latencyMs, @tokensIn, @tokensOut, @error)`)
34
+ .run({ tokensIn: null, tokensOut: null, error: null, ...m });
31
35
  }
32
36
  export function recentRequests(db, limit) {
33
- return db.prepare(`SELECT ts, endpoint, model, status, latency_ms as latencyMs, error FROM request_log ORDER BY ts DESC LIMIT ?`).all(limit)
34
- .map(({ error, ...r }) => (error == null ? r : { ...r, error }));
37
+ return db.prepare(`SELECT ts, endpoint, model, status, latency_ms as latencyMs, tokens_in as tokensIn, tokens_out as tokensOut, error FROM request_log ORDER BY ts DESC LIMIT ?`).all(limit)
38
+ .map(({ tokensIn, tokensOut, error, ...r }) => ({ ...r, ...(tokensIn != null ? { tokensIn } : {}), ...(tokensOut != null ? { tokensOut } : {}), ...(error != null ? { error } : {}) }));
35
39
  }
@@ -29,7 +29,7 @@ export function startSupervisor() {
29
29
  },
30
30
  onWorkerMessage: (m) => {
31
31
  if (m.type === "request-metric") {
32
- const sample = { ts: Date.now(), endpoint: m.endpoint, model: m.model, status: m.status, latencyMs: m.latencyMs, error: m.error };
32
+ const sample = { ts: Date.now(), endpoint: m.endpoint, model: m.model, status: m.status, latencyMs: m.latencyMs, tokensIn: m.tokensIn, tokensOut: m.tokensOut, error: m.error };
33
33
  recordRequest(db, sample);
34
34
  bus.emit("metric", sample);
35
35
  }
package/dist/tui/app.js CHANGED
@@ -267,6 +267,10 @@ export function App({ registry, title, workerState = "starting", initialModel =
267
267
  const tokens = Math.ceil(e.text.length / 4);
268
268
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.accent, children: ["\u273D ", _jsxs(Text, { color: theme.muted, children: [frame, " ", loadingVerb(elapsed), "\u2026 (esc to interrupt \u00B7 ", fmtElapsed(elapsed), " \u00B7 \u2193 ", fmtTokens(tokens), " tokens \u00B7 thinking)"] })] }), e.text ? _jsx(Text, { color: color, children: e.text }) : null] }, i));
269
269
  }
270
+ // User turns get a clay-on-dark highlight bar so they stand out from muted system notes and
271
+ // gray assistant output — a clear visual anchor for "this is what I said".
272
+ if (e.type === "user")
273
+ return _jsx(Box, { marginTop: 1, children: _jsx(Text, { backgroundColor: theme.accent, color: "black", bold: true, children: ` ${e.text.replace(/^›\s*/, "")} ` }) }, i);
270
274
  return _jsx(Text, { color: color, children: e.text }, i);
271
275
  }) }), body, _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [github && _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: "github " }), _jsx(Text, { color: github === "connected" ? theme.ready : theme.error, children: github === "connected" ? "✓" : "✗ /login" })] }), _jsxs(Text, { color: theme.muted, children: [github ? " · " : "", "daemon "] }), _jsx(Text, { color: stateColor[state], children: state })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "web " }), _jsx(Text, { color: webBackend === "unavailable" ? theme.muted : theme.ready, children: webBackend === "webiq" ? "✓ webiq" : webBackend === "copilot" ? "✓ copilot" : "✗ /webiq" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(ClientBadge, { name: "claude", status: status.claude }), _jsx(Text, { color: theme.muted, children: " " }), _jsx(ClientBadge, { name: "codex", status: status.codex }), _jsx(Text, { color: theme.muted, children: " \u00B7 /help" })] })] })] }));
272
276
  }
@@ -31,7 +31,7 @@ export function buildActions(client) {
31
31
  const a = aggregate(await client.requests());
32
32
  if (!a.total)
33
33
  return "no requests yet";
34
- return `requests: ${a.total}, errors: ${a.errors}; ` + a.byModel.map((r) => `${r.model} n=${r.count} avg=${r.avgMs}ms`).join("; ");
34
+ return `requests: ${a.total}, errors: ${a.errors}, tokens: ${a.tokensIn}↑/${a.tokensOut}↓, est. cost: $${a.costUsd.toFixed(3)}; ` + a.byModel.map((r) => `${r.model} n=${r.count} avg=${r.avgMs}ms`).join("; ");
35
35
  },
36
36
  };
37
37
  }
@@ -1,21 +1,47 @@
1
1
  // A request "failed" if it returned a 4xx/5xx OR carried an error message — runaway streams finish
2
2
  // 200 but tag an error (model degenerated, cut early), and those are exactly what we want to surface.
3
3
  const isError = (s) => s.status >= 400 || s.error != null;
4
+ // Indicative $/1M-token list prices (in, out) used ONLY to estimate spend — Copilot is flat-fee, so
5
+ // this is "what these tokens would cost at provider list price", not a real bill. Matched by substring;
6
+ // unknown models fall back to a mid GPT-4o-class rate. Update as needed; precision isn't the point.
7
+ const PRICING = [
8
+ { match: "opus", in: 15, out: 75 },
9
+ { match: "sonnet", in: 3, out: 15 },
10
+ { match: "haiku", in: 0.8, out: 4 },
11
+ { match: "gpt-5", in: 1.25, out: 10 },
12
+ { match: "gpt-4o-mini", in: 0.15, out: 0.6 },
13
+ { match: "gpt-4o", in: 2.5, out: 10 },
14
+ { match: "o1", in: 15, out: 60 },
15
+ ];
16
+ const RATE_FALLBACK = { in: 2.5, out: 10 };
17
+ const rate = (model) => PRICING.find((p) => model.toLowerCase().includes(p.match)) ?? RATE_FALLBACK;
18
+ export function estimateCost(model, tokensIn, tokensOut) {
19
+ const r = rate(model);
20
+ return (tokensIn * r.in + tokensOut * r.out) / 1_000_000;
21
+ }
4
22
  export function aggregate(samples) {
5
23
  const map = new Map();
6
24
  let errors = 0;
7
25
  for (const s of samples) {
8
26
  if (isError(s))
9
27
  errors++;
10
- const m = map.get(s.model) ?? { count: 0, sum: 0 };
28
+ const m = map.get(s.model) ?? { count: 0, sum: 0, tin: 0, tout: 0 };
11
29
  m.count++;
12
30
  m.sum += s.latencyMs;
31
+ m.tin += s.tokensIn ?? 0;
32
+ m.tout += s.tokensOut ?? 0;
13
33
  map.set(s.model, m);
14
34
  }
35
+ const byModel = [...map.entries()].map(([model, v]) => ({
36
+ model, count: v.count, avgMs: Math.round(v.sum / v.count),
37
+ tokensIn: v.tin, tokensOut: v.tout, costUsd: estimateCost(model, v.tin, v.tout),
38
+ }));
15
39
  return {
16
- total: samples.length,
17
- errors,
18
- byModel: [...map.entries()].map(([model, v]) => ({ model, count: v.count, avgMs: Math.round(v.sum / v.count) })),
40
+ total: samples.length, errors,
41
+ tokensIn: byModel.reduce((n, r) => n + r.tokensIn, 0),
42
+ tokensOut: byModel.reduce((n, r) => n + r.tokensOut, 0),
43
+ costUsd: byModel.reduce((n, r) => n + r.costUsd, 0),
44
+ byModel,
19
45
  };
20
46
  }
21
47
  // The failed requests (status >= 400 or any tagged error), newest-first, capped at `limit`. This is
@@ -3,6 +3,7 @@ import { claudeCodeConfig, codexConfig } from "../setup/clients.js";
3
3
  import { aggregate, recentErrors } from "../panels/metrics-agg.js";
4
4
  import { openUrl as defaultOpenUrl } from "../../shared/open-url.js";
5
5
  import { buildIssueUrl, PLACEHOLDER_REPO } from "../report.js";
6
+ import { APP_CHANGES } from "../../changes.js";
6
7
  export function buildRegistry(ctx, endpoint, opts = {}) {
7
8
  const reg = new Registry(ctx);
8
9
  const openUrl = opts.openUrl ?? defaultOpenUrl;
@@ -23,12 +24,18 @@ export function buildRegistry(ctx, endpoint, opts = {}) {
23
24
  return ["no request errors logged — everything's green ✓"];
24
25
  return errs.map((e) => `${new Date(e.ts).toISOString()} ${e.status} ${e.endpoint} ${e.model} — ${e.error ?? "(no message)"}`);
25
26
  } });
26
- reg.add({ name: "/metrics", describe: "request metrics + recent errors", run: async (_a, c) => {
27
+ reg.add({ name: "/metrics", describe: "request metrics, tokens, cost + recent errors", run: async (_a, c) => {
27
28
  const reqs = await c.client.requests();
28
29
  const a = aggregate(reqs);
29
30
  if (!a.total)
30
31
  return ["no requests yet"];
31
- const lines = [`requests: ${a.total} errors: ${a.errors}`, ...a.byModel.map((r) => ` ${r.model.padEnd(20)} n=${r.count} avg=${r.avgMs}ms`)];
32
+ const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
33
+ const usd = (n) => `$${n < 1 ? n.toFixed(3) : n.toFixed(2)}`;
34
+ const lines = [
35
+ `requests: ${a.total} errors: ${a.errors} tokens: ${k(a.tokensIn)}↑ ${k(a.tokensOut)}↓ est. cost: ${usd(a.costUsd)}`,
36
+ ...a.byModel.map((r) => ` ${r.model.padEnd(20)} n=${r.count} avg=${r.avgMs}ms ${k(r.tokensIn)}↑ ${k(r.tokensOut)}↓ ~${usd(r.costUsd)}`),
37
+ " cost is a list-price estimate (Copilot is flat-fee)",
38
+ ];
32
39
  const errs = recentErrors(reqs, 5);
33
40
  if (errs.length) {
34
41
  lines.push("recent errors:");
@@ -68,6 +75,17 @@ export function buildRegistry(ctx, endpoint, opts = {}) {
68
75
  openUrl(url);
69
76
  return [`opening a pre-filled GitHub issue for ${repo} in your browser…`];
70
77
  } });
78
+ reg.add({ name: "/changes", describe: "what's new — recent releases", run: async () => {
79
+ if (!APP_CHANGES.length)
80
+ return ["no changelog bundled"];
81
+ const lines = APP_CHANGES.slice(0, 10).map((c) => {
82
+ const s = c.summary.length > 90 ? c.summary.slice(0, 87) + "…" : c.summary;
83
+ return `v${c.version} (${c.date}) — ${s}`;
84
+ });
85
+ const repo = opts.reportRepo && opts.reportRepo !== PLACEHOLDER_REPO ? opts.reportRepo : "wangcansunking/copilot-reverse";
86
+ lines.push("", `full changelog: https://github.com/${repo}/blob/master/CHANGELOG.md`);
87
+ return lines;
88
+ } });
71
89
  reg.add({ name: "/quit", describe: "exit copilot-reverse", run: async (_a, c) => { c.quit(); return ["bye"]; } });
72
90
  reg.add({ name: "/help", describe: "list commands", run: async () => reg.list().map((c) => `${c.name.padEnd(14)} ${c.describe}`) });
73
91
  return reg;
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.5";
2
+ export const APP_VERSION = "0.7.0";
@@ -35,7 +35,7 @@ export function mountAnthropic(app, router, onMetric, runner) {
35
35
  const canon = anthropicRequestToCanonical(req.body);
36
36
  canon.model = router.resolveModel(canon.model);
37
37
  const provider = router.pick(canon.model);
38
- const metric = (status, error) => onMetric({ endpoint: "/anthropic/v1/messages", model: canon.model, status, latencyMs: Date.now() - start, error });
38
+ const metric = (status, opts = {}) => onMetric({ endpoint: "/anthropic/v1/messages", model: canon.model, status, latencyMs: Date.now() - start, tokensIn: opts.tokensIn, tokensOut: opts.tokensOut, error: opts.error });
39
39
  try {
40
40
  if (canon.stream) {
41
41
  res.setHeader("content-type", "text/event-stream");
@@ -157,7 +157,7 @@ export function mountAnthropic(app, router, onMetric, runner) {
157
157
  res.write(frame("message_delta", { type: "message_delta", delta: { stop_reason: finalStop === "tool_use" ? "tool_use" : finalStop === "length" ? "max_tokens" : "end_turn" }, usage: deltaUsage }));
158
158
  res.write(frame("message_stop", { type: "message_stop" }));
159
159
  res.end();
160
- metric(200, runaway ? `runaway stream cut (${runawayReason}) — model degenerated, ended early as max_tokens` : undefined);
160
+ metric(200, { tokensIn: inputTokens, tokensOut: sumCompletion, error: runaway ? `runaway stream cut (${runawayReason}) — model degenerated, ended early as max_tokens` : undefined });
161
161
  }
162
162
  else {
163
163
  // Non-stream: same gateway loop without SSE — run gateway tools and re-complete until the
@@ -182,7 +182,7 @@ export function mountAnthropic(app, router, onMetric, runner) {
182
182
  if (runner)
183
183
  resp = { ...resp, content: resp.content.filter((b) => b.type !== "tool_use" || !isGatewayTool(b.name)) };
184
184
  res.json(canonicalToAnthropicResponse(resp));
185
- metric(200);
185
+ metric(200, { tokensIn: resp.usage?.promptTokens, tokensOut: resp.usage?.completionTokens });
186
186
  }
187
187
  }
188
188
  catch (err) {
@@ -201,7 +201,7 @@ export function mountAnthropic(app, router, onMetric, runner) {
201
201
  res.write(frame("error", { type: "error", error: { type: errorType, message } }));
202
202
  res.end();
203
203
  }
204
- metric(status, message);
204
+ metric(status, { error: message });
205
205
  }
206
206
  });
207
207
  }
@@ -18,7 +18,7 @@ export function mountOpenAI(app, router, onMetric) {
18
18
  const canon = openaiRequestToCanonical(req.body);
19
19
  canon.model = router.resolveModel(canon.model);
20
20
  const provider = router.pick(canon.model);
21
- const metric = (status, error) => onMetric({ endpoint: "/openai/chat/completions", model: canon.model, status, latencyMs: Date.now() - start, error });
21
+ const metric = (status, opts = {}) => onMetric({ endpoint: "/openai/chat/completions", model: canon.model, status, latencyMs: Date.now() - start, tokensIn: opts.tokensIn, tokensOut: opts.tokensOut, error: opts.error });
22
22
  try {
23
23
  if (canon.stream) {
24
24
  res.setHeader("content-type", "text/event-stream");
@@ -27,8 +27,11 @@ export function mountOpenAI(app, router, onMetric) {
27
27
  const guard = new RunawayGuard();
28
28
  const deadline = start + STREAM_DEADLINE_MS;
29
29
  let runawayReason = "";
30
+ let usage;
30
31
  for await (const chunk of provider.stream(canon)) {
31
32
  res.write(canonicalChunkToOpenAISSE(chunk, id, canon.model));
33
+ if (chunk.done)
34
+ usage = chunk.usage;
32
35
  // Backstop covers tool-call streams too: a model can loop on tool calls forever, which
33
36
  // never feeds the text guard — the wall clock cuts those cleanly instead of freezing.
34
37
  if (chunk.kind === "text" && guard.push(chunk.delta)) {
@@ -41,11 +44,12 @@ export function mountOpenAI(app, router, onMetric) {
41
44
  }
42
45
  }
43
46
  res.end();
44
- metric(200, runawayReason ? `runaway stream cut (${runawayReason}) — model degenerated, ended early` : undefined);
47
+ metric(200, { tokensIn: usage?.promptTokens, tokensOut: usage?.completionTokens, error: runawayReason ? `runaway stream cut (${runawayReason}) — model degenerated, ended early` : undefined });
45
48
  }
46
49
  else {
47
- res.json(canonicalToOpenAIResponse(await provider.complete(canon)));
48
- metric(200);
50
+ const resp = await provider.complete(canon);
51
+ res.json(canonicalToOpenAIResponse(resp));
52
+ metric(200, { tokensIn: resp.usage?.promptTokens, tokensOut: resp.usage?.completionTokens });
49
53
  }
50
54
  }
51
55
  catch (err) {
@@ -62,7 +66,7 @@ export function mountOpenAI(app, router, onMetric) {
62
66
  res.write(`data: ${JSON.stringify({ error: { message } })}\n\n`);
63
67
  res.end();
64
68
  }
65
- metric(status, message);
69
+ metric(status, { error: message });
66
70
  }
67
71
  });
68
72
  // OpenAI Responses API — Codex speaks ONLY this after codex#7782 removed wire_api="chat". Codex
@@ -73,7 +77,7 @@ export function mountOpenAI(app, router, onMetric) {
73
77
  const canon = responsesRequestToCanonical(req.body);
74
78
  canon.model = router.resolveModel(canon.model);
75
79
  const provider = router.pick(canon.model);
76
- const metric = (status, error) => onMetric({ endpoint: "/openai/responses", model: canon.model, status, latencyMs: Date.now() - start, error });
80
+ const metric = (status, opts = {}) => onMetric({ endpoint: "/openai/responses", model: canon.model, status, latencyMs: Date.now() - start, tokensIn: opts.tokensIn, tokensOut: opts.tokensOut, error: opts.error });
77
81
  try {
78
82
  if (canon.stream) {
79
83
  res.setHeader("content-type", "text/event-stream");
@@ -119,11 +123,12 @@ export function mountOpenAI(app, router, onMetric) {
119
123
  for (const f of sse.finish(usage, finish, argsByIdx))
120
124
  res.write(f);
121
125
  res.end();
122
- metric(200, runawayReason ? `runaway stream cut (${runawayReason}) — model degenerated, ended early` : undefined);
126
+ metric(200, { tokensIn: usage?.promptTokens, tokensOut: usage?.completionTokens, error: runawayReason ? `runaway stream cut (${runawayReason}) — model degenerated, ended early` : undefined });
123
127
  }
124
128
  else {
125
- res.json(canonicalToResponsesResponse(await provider.complete(canon)));
126
- metric(200);
129
+ const resp = await provider.complete(canon);
130
+ res.json(canonicalToResponsesResponse(resp));
131
+ metric(200, { tokensIn: resp.usage?.promptTokens, tokensOut: resp.usage?.completionTokens });
127
132
  }
128
133
  }
129
134
  catch (err) {
@@ -138,7 +143,7 @@ export function mountOpenAI(app, router, onMetric) {
138
143
  res.write(`data: ${JSON.stringify({ type: "error", message })}\n\n`);
139
144
  res.end();
140
145
  }
141
- metric(status, message);
146
+ metric(status, { error: message });
142
147
  }
143
148
  });
144
149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-reverse",
3
- "version": "0.5.5",
3
+ "version": "0.7.0",
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",
@@ -32,7 +32,7 @@
32
32
  "llm"
33
33
  ],
34
34
  "scripts": {
35
- "prebuild": "node scripts/gen-version.mjs",
35
+ "prebuild": "node scripts/gen-version.mjs && node scripts/gen-changes.mjs",
36
36
  "build": "tsc -p tsconfig.json",
37
37
  "test": "vitest run",
38
38
  "test:coverage": "vitest run --coverage",