copilot-reverse 0.0.2 → 0.1.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
@@ -49,6 +49,10 @@ async function launchTui() {
49
49
  const base = `http://${cfg.bindHost}:${cfg.supervisorPort}`;
50
50
  const client = new DaemonClient(base);
51
51
  const workerBase = `http://${cfg.bindHost}:${cfg.workerPort}`;
52
+ // Per-protocol base URLs the worker now serves under: OpenAI clients -> /openai/*,
53
+ // Anthropic clients (and the assistant's own dogfood SDK) -> /anthropic/*.
54
+ const openaiBase = `${workerBase}/openai`;
55
+ const anthropicBase = `${workerBase}/anthropic`;
52
56
  const endpoint = { host: cfg.bindHost, port: cfg.workerPort, apiKey: "copilot-reverse-local" };
53
57
  let app;
54
58
  const quit = () => { stopSupervisor?.(); app?.unmount(); process.exit(0); };
@@ -66,7 +70,7 @@ async function launchTui() {
66
70
  const registry = buildRegistry({ client, quit }, endpoint, {
67
71
  dashboardUrl: `http://${cfg.bindHost}:${cfg.supervisorPort}/`,
68
72
  reportRepo: cfg.reportRepo,
69
- appVersion: "0.0.2",
73
+ appVersion: "0.1.0",
70
74
  platform: `${process.platform} node-${process.version}`,
71
75
  resetClient,
72
76
  // Re-run device-code login, then restart the worker so it picks up the new token.
@@ -102,18 +106,18 @@ async function launchTui() {
102
106
  // model's context window) so either Codex setup style works.
103
107
  const applyClient = (clientKind, scope, model) => {
104
108
  if (clientKind === "claude") {
105
- const r = applyClaude(scope, claudeCopilotReverseEnv(workerBase, "copilot-reverse-local", model, modelLimits[model]));
109
+ const r = applyClaude(scope, claudeCopilotReverseEnv(anthropicBase, "copilot-reverse-local", model, modelLimits[model]));
106
110
  writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), claude: true });
107
111
  return r;
108
112
  }
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] });
113
+ const r = applyCodex(scope, { OPENAI_BASE_URL: openaiBase, OPENAI_API_KEY: "copilot-reverse-local", OPENAI_MODEL: model });
114
+ applyCodexToml({ baseUrl: openaiBase, model, contextWindow: modelLimits[model] });
111
115
  writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), codex: true });
112
116
  return r;
113
117
  };
114
118
  const setup = { apply: async (clientKind, scope, model) => applyClient(clientKind, scope, model) };
115
119
  const onChat = makeOnChat({
116
- client, workerBaseUrl: workerBase, apiKey: "copilot-reverse-local", model: DEFAULT_MODEL,
120
+ client, workerBaseUrl: anthropicBase, apiKey: "copilot-reverse-local", model: DEFAULT_MODEL,
117
121
  maxInputTokens: DEFAULT_MAX_INPUT_TOKENS, modelLimits,
118
122
  listModels: loadModels,
119
123
  setupClient: async (c, s, m) => applyClient(c, s, m),
@@ -130,8 +134,8 @@ async function launchTui() {
130
134
  loadModels,
131
135
  setup,
132
136
  info: {
133
- openai: `${workerBase}/v1`,
134
- anthropic: workerBase,
137
+ openai: openaiBase,
138
+ anthropic: anthropicBase,
135
139
  supervisorPort: cfg.supervisorPort,
136
140
  workerPort: cfg.workerPort,
137
141
  dataDir: dataDir(),
@@ -141,7 +145,7 @@ async function launchTui() {
141
145
  }));
142
146
  }
143
147
  const program = new Command();
144
- program.name("copilot-reverse").description("copilot-reverse: interactive Copilot proxy").version("0.0.2");
148
+ program.name("copilot-reverse").description("copilot-reverse: interactive Copilot proxy").version("0.1.0");
145
149
  program.command("login").description("GitHub device-code login").action(() => runDeviceLogin(dataDir()));
146
150
  program.action(() => { void launchTui(); });
147
151
  program.parseAsync(process.argv);
@@ -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"] });
@@ -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.1.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",