copilot-reverse 0.0.2 → 0.2.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.
package/README.md CHANGED
@@ -87,13 +87,13 @@ health, request volume, and (most useful) recent **errors with their real messag
87
87
 
88
88
  Already have something that speaks OpenAI or Anthropic? Point it here:
89
89
 
90
- - **OpenAI-compatible:** `http://127.0.0.1:7891/v1`
91
- - **Anthropic-compatible:** `http://127.0.0.1:7891`
90
+ - **OpenAI-compatible:** `http://127.0.0.1:7891/openai`
91
+ - **Anthropic-compatible:** `http://127.0.0.1:7891/anthropic`
92
92
 
93
93
  Any API key value works locally (it's your machine). Example:
94
94
 
95
95
  ```bash
96
- export ANTHROPIC_BASE_URL=http://127.0.0.1:7891
96
+ export ANTHROPIC_BASE_URL=http://127.0.0.1:7891/anthropic
97
97
  export ANTHROPIC_API_KEY=local
98
98
  claude
99
99
  ```
@@ -159,8 +159,8 @@ Three processes, one terminal app:
159
159
  - **TUI** (Ink) — the `copilot-reverse` process: REPL + slash commands + a claude-agent-sdk
160
160
  assistant (which dogfoods copilot-reverse's own Anthropic endpoint).
161
161
  - **Supervisor** (:7890) — control API + SQLite + self-healing worker supervision.
162
- - **Worker** (:7891) — OpenAI `/v1/chat/completions` + Anthropic `/v1/messages` → Copilot,
163
- with tool-use translation both ways.
162
+ - **Worker** (:7891) — OpenAI `/openai/chat/completions` + Anthropic `/anthropic/v1/messages` → Copilot,
163
+ with tool-use translation both ways. Each protocol also serves a `…/models` discovery endpoint.
164
164
 
165
165
  ## Development
166
166
 
package/dist/cli/index.js CHANGED
@@ -21,6 +21,7 @@ import { applyCodexToml } from "../tui/setup/codex-toml.js";
21
21
  import { claudeCopilotReverseEnv } from "../tui/setup/clients.js";
22
22
  import { dataDir } from "../shared/paths.js";
23
23
  import { defaultConfig } from "../shared/config.js";
24
+ import { APP_VERSION } from "../version.js";
24
25
  const delay = (ms) => new Promise((r) => setTimeout(r, ms));
25
26
  const DEFAULT_MODEL = "gpt-4o"; // a valid Copilot model id; pass-through routing uses it as-is
26
27
  // Conservative context budget that drives the assistant's auto-compaction. Sized below the
@@ -49,6 +50,10 @@ async function launchTui() {
49
50
  const base = `http://${cfg.bindHost}:${cfg.supervisorPort}`;
50
51
  const client = new DaemonClient(base);
51
52
  const workerBase = `http://${cfg.bindHost}:${cfg.workerPort}`;
53
+ // Per-protocol base URLs the worker now serves under: OpenAI clients -> /openai/*,
54
+ // Anthropic clients (and the assistant's own dogfood SDK) -> /anthropic/*.
55
+ const openaiBase = `${workerBase}/openai`;
56
+ const anthropicBase = `${workerBase}/anthropic`;
52
57
  const endpoint = { host: cfg.bindHost, port: cfg.workerPort, apiKey: "copilot-reverse-local" };
53
58
  let app;
54
59
  const quit = () => { stopSupervisor?.(); app?.unmount(); process.exit(0); };
@@ -66,7 +71,7 @@ async function launchTui() {
66
71
  const registry = buildRegistry({ client, quit }, endpoint, {
67
72
  dashboardUrl: `http://${cfg.bindHost}:${cfg.supervisorPort}/`,
68
73
  reportRepo: cfg.reportRepo,
69
- appVersion: "0.0.2",
74
+ appVersion: APP_VERSION,
70
75
  platform: `${process.platform} node-${process.version}`,
71
76
  resetClient,
72
77
  // Re-run device-code login, then restart the worker so it picks up the new token.
@@ -102,18 +107,18 @@ async function launchTui() {
102
107
  // model's context window) so either Codex setup style works.
103
108
  const applyClient = (clientKind, scope, model) => {
104
109
  if (clientKind === "claude") {
105
- const r = applyClaude(scope, claudeCopilotReverseEnv(workerBase, "copilot-reverse-local", model, modelLimits[model]));
110
+ const r = applyClaude(scope, claudeCopilotReverseEnv(anthropicBase, "copilot-reverse-local", model, modelLimits[model]));
106
111
  writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), claude: true });
107
112
  return r;
108
113
  }
109
- const r = applyCodex(scope, { OPENAI_BASE_URL: `${workerBase}/v1`, OPENAI_API_KEY: "copilot-reverse-local", OPENAI_MODEL: model });
110
- applyCodexToml({ baseUrl: `${workerBase}/v1`, model, contextWindow: modelLimits[model] });
114
+ const r = applyCodex(scope, { OPENAI_BASE_URL: openaiBase, OPENAI_API_KEY: "copilot-reverse-local", OPENAI_MODEL: model });
115
+ applyCodexToml({ baseUrl: openaiBase, model, contextWindow: modelLimits[model] });
111
116
  writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), codex: true });
112
117
  return r;
113
118
  };
114
119
  const setup = { apply: async (clientKind, scope, model) => applyClient(clientKind, scope, model) };
115
120
  const onChat = makeOnChat({
116
- client, workerBaseUrl: workerBase, apiKey: "copilot-reverse-local", model: DEFAULT_MODEL,
121
+ client, workerBaseUrl: anthropicBase, apiKey: "copilot-reverse-local", model: DEFAULT_MODEL,
117
122
  maxInputTokens: DEFAULT_MAX_INPUT_TOKENS, modelLimits,
118
123
  listModels: loadModels,
119
124
  setupClient: async (c, s, m) => applyClient(c, s, m),
@@ -130,8 +135,8 @@ async function launchTui() {
130
135
  loadModels,
131
136
  setup,
132
137
  info: {
133
- openai: `${workerBase}/v1`,
134
- anthropic: workerBase,
138
+ openai: openaiBase,
139
+ anthropic: anthropicBase,
135
140
  supervisorPort: cfg.supervisorPort,
136
141
  workerPort: cfg.workerPort,
137
142
  dataDir: dataDir(),
@@ -141,7 +146,7 @@ async function launchTui() {
141
146
  }));
142
147
  }
143
148
  const program = new Command();
144
- program.name("copilot-reverse").description("copilot-reverse: interactive Copilot proxy").version("0.0.2");
149
+ program.name("copilot-reverse").description("copilot-reverse: interactive Copilot proxy").version(APP_VERSION);
145
150
  program.command("login").description("GitHub device-code login").action(() => runDeviceLogin(dataDir()));
146
151
  program.action(() => { void launchTui(); });
147
152
  program.parseAsync(process.argv);
@@ -0,0 +1,92 @@
1
+ import { randomUUID } from "node:crypto";
2
+ // Opening sentinels that switch the parser into capture mode. The `antml:` namespaced variants are
3
+ // the un-stripped originals; the bare forms are what survives when the namespace prefix is dropped.
4
+ const TRIGGER_RE = /<(?:antml:)?(?:function_calls>|invoke\b)/;
5
+ // Longest suffix of `s` that is a proper prefix of a trigger token — text we must hold back because
6
+ // it might be the front of a sentinel split across chunk boundaries (e.g. "…<inv" then "oke name=").
7
+ const PREFIX_TOKENS = ["<function_calls>", "<function_calls>", "<invoke", "<invoke"];
8
+ function heldBackLen(s) {
9
+ let max = 0;
10
+ for (const t of PREFIX_TOKENS) {
11
+ for (let k = Math.min(s.length, t.length - 1); k > 0; k--) {
12
+ if (s.endsWith(t.slice(0, k))) {
13
+ if (k > max)
14
+ max = k;
15
+ break;
16
+ }
17
+ }
18
+ }
19
+ return max;
20
+ }
21
+ // Index just past a `</tag>` (or `</tag>`) close, or -1 if not yet present in `s`.
22
+ function closeIndex(s, tag) {
23
+ const m = new RegExp(`</(?:antml:)?${tag}>`).exec(s);
24
+ return m ? m.index + m[0].length : -1;
25
+ }
26
+ // A scalar parameter value is raw text in the XML; recover its intended type by trying JSON, so
27
+ // `42`/`true`/`{"a":1}` become real values while a bare command string stays a string.
28
+ function coerce(raw) {
29
+ const v = raw.replace(/^\n/, "").replace(/\n$/, "");
30
+ try {
31
+ return JSON.parse(v);
32
+ }
33
+ catch {
34
+ return v;
35
+ }
36
+ }
37
+ function parseInvokes(block) {
38
+ const tools = [];
39
+ const invokeRe = /<(?:antml:)?invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/(?:antml:)?invoke>/g;
40
+ for (let m = invokeRe.exec(block); m; m = invokeRe.exec(block)) {
41
+ const [, name, body] = m;
42
+ const input = {};
43
+ const paramRe = /<(?:antml:)?parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/(?:antml:)?parameter>/g;
44
+ for (let p = paramRe.exec(body); p; p = paramRe.exec(body))
45
+ input[p[1]] = coerce(p[2]);
46
+ tools.push({ id: `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`, name, input });
47
+ }
48
+ return tools;
49
+ }
50
+ export class ToolCallExtractor {
51
+ buf = "";
52
+ capturing = false;
53
+ feed(chunk) {
54
+ this.buf += chunk;
55
+ const events = [];
56
+ for (;;) {
57
+ if (!this.capturing) {
58
+ const m = TRIGGER_RE.exec(this.buf);
59
+ if (!m) {
60
+ // No trigger; emit everything except a possible partial-sentinel tail.
61
+ const keep = heldBackLen(this.buf);
62
+ const emit = this.buf.slice(0, this.buf.length - keep);
63
+ if (emit)
64
+ events.push({ kind: "text", text: emit });
65
+ this.buf = keep ? this.buf.slice(this.buf.length - keep) : "";
66
+ return events;
67
+ }
68
+ if (m.index > 0)
69
+ events.push({ kind: "text", text: this.buf.slice(0, m.index) });
70
+ this.buf = this.buf.slice(m.index);
71
+ this.capturing = true;
72
+ }
73
+ const isWrapper = /^<(?:antml:)?function_calls>/.test(this.buf);
74
+ const end = closeIndex(this.buf, isWrapper ? "function_calls" : "invoke");
75
+ if (end < 0)
76
+ return events; // incomplete block — wait for more data
77
+ const block = this.buf.slice(0, end);
78
+ for (const tool of parseInvokes(block))
79
+ events.push({ kind: "tool", tool });
80
+ this.buf = this.buf.slice(end);
81
+ this.capturing = false; // a following <invoke> re-triggers via the passthrough branch
82
+ }
83
+ }
84
+ // Stream ended. Anything still buffered is an incomplete block we couldn't parse — emit it as
85
+ // text so nothing is silently dropped.
86
+ flush() {
87
+ const out = this.buf ? [{ kind: "text", text: this.buf }] : [];
88
+ this.buf = "";
89
+ this.capturing = false;
90
+ return out;
91
+ }
92
+ }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { ToolCallExtractor } from "../../core/tool-xml.js";
2
3
  const CHAT_URL = "https://api.githubcopilot.com/chat/completions";
3
4
  // Canonical messages -> OpenAI wire messages (Copilot is OpenAI-shaped).
4
5
  function toWireMessages(messages) {
@@ -88,6 +89,27 @@ export class CopilotAdapter {
88
89
  let finishReason = "stop";
89
90
  let usage;
90
91
  const mapFinish = (f) => f === "tool_calls" ? "tool_use" : f === "length" ? "length" : "stop";
92
+ // Some models emit a tool call as inline XML text instead of native tool_calls (more likely on
93
+ // long/tool-heavy turns). When the request has tools, route assistant text through an extractor
94
+ // that recovers those blocks into structured tool calls; otherwise text passes straight through.
95
+ const extractor = req.tools?.length ? new ToolCallExtractor() : undefined;
96
+ let extractedTool = false;
97
+ let extIdx = 100; // separate index space so recovered tools never collide with native tool_calls
98
+ const toChunks = (events) => {
99
+ const out = [];
100
+ for (const ev of events) {
101
+ if (ev.kind === "text") {
102
+ if (ev.text)
103
+ out.push({ kind: "text", delta: ev.text, done: false });
104
+ continue;
105
+ }
106
+ const index = extIdx++;
107
+ extractedTool = true;
108
+ out.push({ kind: "tool_use_start", index, id: ev.tool.id, name: ev.tool.name, done: false });
109
+ out.push({ kind: "tool_use_delta", index, argsDelta: JSON.stringify(ev.tool.input), done: false });
110
+ }
111
+ return out;
112
+ };
91
113
  for (;;) {
92
114
  const { value, done } = await reader.read();
93
115
  if (done)
@@ -101,6 +123,11 @@ export class CopilotAdapter {
101
123
  continue;
102
124
  const payload = line.slice(6).trim();
103
125
  if (payload === "[DONE]") {
126
+ if (extractor)
127
+ for (const ch of toChunks(extractor.flush()))
128
+ yield ch;
129
+ if (extractedTool && finishReason === "stop")
130
+ finishReason = "tool_use";
104
131
  yield { kind: "done", done: true, finishReason, usage };
105
132
  return;
106
133
  }
@@ -122,8 +149,14 @@ export class CopilotAdapter {
122
149
  const delta = choice.delta;
123
150
  if (!delta)
124
151
  continue;
125
- if (delta.content)
126
- yield { kind: "text", delta: delta.content, done: false };
152
+ if (delta.content) {
153
+ if (extractor) {
154
+ for (const ch of toChunks(extractor.feed(delta.content)))
155
+ yield ch;
156
+ }
157
+ else
158
+ yield { kind: "text", delta: delta.content, done: false };
159
+ }
127
160
  for (const tc of delta.tool_calls ?? []) {
128
161
  const idx = tc.index ?? 0;
129
162
  if (!startedTools.has(idx) && tc.function?.name) {
@@ -135,6 +168,11 @@ export class CopilotAdapter {
135
168
  }
136
169
  }
137
170
  }
171
+ if (extractor)
172
+ for (const ch of toChunks(extractor.flush()))
173
+ yield ch;
174
+ if (extractedTool && finishReason === "stop")
175
+ finishReason = "tool_use";
138
176
  yield { kind: "done", done: true, finishReason, usage };
139
177
  }
140
178
  }
@@ -1,6 +1,6 @@
1
1
  // Live model list from Copilot. Falls back to a curated list if the endpoint is unavailable.
2
2
  const MODELS_URL = "https://api.githubcopilot.com/models";
3
- const FALLBACK = ["gpt-4o", "gpt-4o-mini", "claude-sonnet-4-6", "claude-opus-4-8", "o3-mini"];
3
+ export const FALLBACK_MODELS = ["gpt-4o", "gpt-4o-mini", "claude-sonnet-4-6", "claude-opus-4-8", "o3-mini"];
4
4
  const HEADERS = (token) => ({
5
5
  authorization: `Bearer ${token}`,
6
6
  "content-type": "application/json",
@@ -28,9 +28,9 @@ async function getModels(token, fetchFn, timeoutMs) {
28
28
  export async function fetchCopilotModels(token, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
29
29
  const data = await getModels(token, fetchFn, timeoutMs);
30
30
  if (!data)
31
- return FALLBACK;
31
+ return FALLBACK_MODELS;
32
32
  const ids = [...new Set(data.map((m) => m.id).filter((x) => Boolean(x)))];
33
- return ids.length ? ids : FALLBACK;
33
+ return ids.length ? ids : FALLBACK_MODELS;
34
34
  }
35
35
  // Map of model id -> its real input/context window, used to size auto-compaction per model and
36
36
  // to show the window in the picker. Returns {} on failure/timeout so callers fall back gracefully.
@@ -1,5 +1,5 @@
1
1
  export function claudeCodeConfig(e) {
2
- const base = `http://${e.host}:${e.port}`;
2
+ const base = `http://${e.host}:${e.port}/anthropic`;
3
3
  return {
4
4
  env: { ANTHROPIC_BASE_URL: base, ANTHROPIC_API_KEY: e.apiKey },
5
5
  instructions: `Set these env vars for Claude Code:\n ANTHROPIC_BASE_URL=${base}\n ANTHROPIC_API_KEY=${e.apiKey}`,
@@ -30,7 +30,7 @@ export function claudeCopilotReverseEnv(base, apiKey, model, contextWindow) {
30
30
  };
31
31
  }
32
32
  export function codexConfig(e) {
33
- const base = `http://${e.host}:${e.port}/v1`;
33
+ const base = `http://${e.host}:${e.port}/openai`;
34
34
  return {
35
35
  env: { OPENAI_BASE_URL: base, OPENAI_API_KEY: e.apiKey },
36
36
  instructions: `Set these env vars for Codex / OpenAI clients:\n OPENAI_BASE_URL=${base}\n OPENAI_API_KEY=${e.apiKey}`,
@@ -39,7 +39,7 @@ export function buildRegistry(ctx, endpoint, opts = {}) {
39
39
  } });
40
40
  reg.add({ name: "/setup-claude", describe: "print Claude Code config", run: async () => claudeCodeConfig(endpoint).instructions.split("\n") });
41
41
  reg.add({ name: "/setup-codex", describe: "print Codex/OpenAI config", run: async () => codexConfig(endpoint).instructions.split("\n") });
42
- reg.add({ name: "/setup-status", describe: "show configured endpoints", run: async () => [`OpenAI: http://${endpoint.host}:${endpoint.port}/v1`, `Anthropic: http://${endpoint.host}:${endpoint.port}`] });
42
+ reg.add({ name: "/setup-status", describe: "show configured endpoints", run: async () => [`OpenAI: http://${endpoint.host}:${endpoint.port}/openai`, `Anthropic: http://${endpoint.host}:${endpoint.port}/anthropic`] });
43
43
  reg.add({ name: "/reset-claude", describe: "restore Claude Code config (remove copilot-reverse's keys)", run: async () => opts.resetClient ? opts.resetClient("claude") : ["reset not available"] });
44
44
  reg.add({ name: "/reset-codex", describe: "restore Codex/OpenAI config (remove copilot-reverse's keys)", run: async () => opts.resetClient ? opts.resetClient("codex") : ["reset not available"] });
45
45
  reg.add({ name: "/login", describe: "sign in to GitHub (device-code)", run: async () => opts.login ? opts.login() : ["login not available"] });
@@ -0,0 +1,2 @@
1
+ // AUTO-GENERATED by scripts/gen-version.mjs from package.json — do not edit.
2
+ export const APP_VERSION = "0.2.0";
@@ -5,16 +5,21 @@ import { errorHint } from "./errors.js";
5
5
  import { CopilotAuthError } from "../providers/copilot/token.js";
6
6
  const frame = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
7
7
  export function mountAnthropic(app, router, onMetric) {
8
+ // Model discovery — Anthropic list shape. Claude Desktop / Anthropic-protocol clients GET this
9
+ // before chatting; without it they 404 on the connection test.
10
+ app.get("/anthropic/v1/models", (_req, res) => {
11
+ res.json({ data: router.listModels().map((id) => ({ type: "model", id, display_name: id })), has_more: false });
12
+ });
8
13
  // Anthropic clients (Claude Code) call this to size the prompt and decide when to auto-compact.
9
- app.post("/v1/messages/count_tokens", (req, res) => {
14
+ app.post("/anthropic/v1/messages/count_tokens", (req, res) => {
10
15
  res.json({ input_tokens: estimateTokens(anthropicRequestToCanonical(req.body)) });
11
16
  });
12
- app.post("/v1/messages", async (req, res) => {
17
+ app.post("/anthropic/v1/messages", async (req, res) => {
13
18
  const start = Date.now();
14
19
  const canon = anthropicRequestToCanonical(req.body);
15
20
  canon.model = router.resolveModel(canon.model);
16
21
  const provider = router.pick(canon.model);
17
- const metric = (status, error) => onMetric({ endpoint: "/v1/messages", model: canon.model, status, latencyMs: Date.now() - start, error });
22
+ const metric = (status, error) => onMetric({ endpoint: "/anthropic/v1/messages", model: canon.model, status, latencyMs: Date.now() - start, error });
18
23
  try {
19
24
  if (canon.stream) {
20
25
  res.setHeader("content-type", "text/event-stream");
@@ -3,12 +3,17 @@ import { openaiRequestToCanonical, canonicalToOpenAIResponse, canonicalChunkToOp
3
3
  import { errorHint } from "./errors.js";
4
4
  import { CopilotAuthError } from "../providers/copilot/token.js";
5
5
  export function mountOpenAI(app, router, onMetric) {
6
- app.post("/v1/chat/completions", async (req, res) => {
6
+ // Model discovery — OpenAI list shape. Clients (LiteLLM-style gateways, "test connection" probes)
7
+ // GET this before chatting; without it they 404 and refuse to connect.
8
+ app.get("/openai/models", (_req, res) => {
9
+ res.json({ object: "list", data: router.listModels().map((id) => ({ id, object: "model", owned_by: "copilot-reverse" })) });
10
+ });
11
+ app.post("/openai/chat/completions", async (req, res) => {
7
12
  const start = Date.now();
8
13
  const canon = openaiRequestToCanonical(req.body);
9
14
  canon.model = router.resolveModel(canon.model);
10
15
  const provider = router.pick(canon.model);
11
- const metric = (status, error) => onMetric({ endpoint: "/v1/chat/completions", model: canon.model, status, latencyMs: Date.now() - start, error });
16
+ const metric = (status, error) => onMetric({ endpoint: "/openai/chat/completions", model: canon.model, status, latencyMs: Date.now() - start, error });
12
17
  try {
13
18
  if (canon.stream) {
14
19
  res.setHeader("content-type", "text/event-stream");
@@ -1,4 +1,5 @@
1
1
  import { bestModelMatch } from "../core/fuzzy.js";
2
+ import { FALLBACK_MODELS } from "../providers/copilot/models.js";
2
3
  // M1: single provider. Model name is remapped to the provider's actual id.
3
4
  export class Router {
4
5
  providers;
@@ -10,6 +11,9 @@ export class Router {
10
11
  }
11
12
  // The live Copilot model list, used for fuzzy matching (set once fetched at worker startup).
12
13
  setAvailableModels(ids) { this.available = ids; }
14
+ // Model ids to advertise from the /models discovery endpoints. Falls back to a curated list
15
+ // until the live fetch resolves, so discovery never returns an empty list.
16
+ listModels() { return this.available.length ? this.available : FALLBACK_MODELS; }
13
17
  resolveModel(requested) {
14
18
  // Claude Code appends [1m] to signal its 1M context window; Copilot doesn't know that id, so
15
19
  // strip it back to the real model before mapping/forwarding.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-reverse",
3
- "version": "0.0.2",
3
+ "version": "0.2.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,12 +32,14 @@
32
32
  "llm"
33
33
  ],
34
34
  "scripts": {
35
+ "prebuild": "node scripts/gen-version.mjs",
35
36
  "build": "tsc -p tsconfig.json",
36
37
  "test": "vitest run",
37
38
  "test:coverage": "vitest run --coverage",
38
39
  "test:e2e": "vitest run e2e/copilot-reverse.e2e.test.ts tests/e2e",
39
40
  "test:integration": "vitest run --config vitest.integration.config.ts",
40
41
  "dev": "tsx src/cli/index.ts",
42
+ "changeset": "node scripts/changesets.mjs new",
41
43
  "prepublishOnly": "npm run build && npm run test"
42
44
  },
43
45
  "engines": {