copilot-reverse 0.0.1
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/GUIDE.md +142 -0
- package/README.md +60 -0
- package/dist/cli/auth.js +9 -0
- package/dist/cli/index.js +133 -0
- package/dist/core/anthropic-inbound.js +63 -0
- package/dist/core/canonical.js +6 -0
- package/dist/core/fuzzy.js +35 -0
- package/dist/core/openai-inbound.js +83 -0
- package/dist/core/tokens.js +22 -0
- package/dist/daemon/lifecycle.js +32 -0
- package/dist/providers/copilot/adapter.js +146 -0
- package/dist/providers/copilot/auth.js +30 -0
- package/dist/providers/copilot/models.js +49 -0
- package/dist/providers/copilot/token.js +53 -0
- package/dist/providers/types.js +1 -0
- package/dist/shared/client-setup.js +19 -0
- package/dist/shared/config.js +20 -0
- package/dist/shared/control-types.js +1 -0
- package/dist/shared/creds.js +14 -0
- package/dist/shared/format.js +28 -0
- package/dist/shared/ipc.js +1 -0
- package/dist/shared/open-url.js +17 -0
- package/dist/shared/paths.js +11 -0
- package/dist/shared/prefs.js +28 -0
- package/dist/supervisor/api.js +24 -0
- package/dist/supervisor/dashboard.js +110 -0
- package/dist/supervisor/db.js +35 -0
- package/dist/supervisor/events.js +6 -0
- package/dist/supervisor/index.js +66 -0
- package/dist/supervisor/monitor.js +91 -0
- package/dist/tui/app.js +184 -0
- package/dist/tui/assistant/on-chat.js +25 -0
- package/dist/tui/assistant/runtime.js +104 -0
- package/dist/tui/assistant/tools.js +24 -0
- package/dist/tui/components/select.js +30 -0
- package/dist/tui/daemon-client.js +16 -0
- package/dist/tui/panels/metrics-agg.js +22 -0
- package/dist/tui/panels/metrics.js +7 -0
- package/dist/tui/repl.js +45 -0
- package/dist/tui/report.js +35 -0
- package/dist/tui/screens/config.js +13 -0
- package/dist/tui/screens/model.js +16 -0
- package/dist/tui/setup/apply.js +119 -0
- package/dist/tui/setup/clients.js +38 -0
- package/dist/tui/setup/codex-toml.js +47 -0
- package/dist/tui/setup/status.js +35 -0
- package/dist/tui/setup/wizard.js +37 -0
- package/dist/tui/slash/commands.js +68 -0
- package/dist/tui/slash/registry.js +16 -0
- package/dist/tui/theme.js +16 -0
- package/dist/worker/anthropic-server.js +108 -0
- package/dist/worker/errors.js +12 -0
- package/dist/worker/index.js +30 -0
- package/dist/worker/openai-server.js +44 -0
- package/dist/worker/router.js +34 -0
- package/dist/worker/server.js +11 -0
- package/images/dashboard.png +0 -0
- package/package.json +69 -0
package/GUIDE.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# copilot-reverse — User Guide
|
|
2
|
+
|
|
3
|
+
**Use the Copilot subscription you already pay for as a local Claude Code / Codex backend.**
|
|
4
|
+
No new API keys. No per-token bills. One terminal app.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
|
|
8
|
+
│ Claude Code │ ─────▶ │ copilot-reverse │ ─────▶ │ Copilot │
|
|
9
|
+
│ / Codex │ local │ (your machine) │ proxy │ (your sub) │
|
|
10
|
+
└─────────────┘ └──────────────────┘ └─────────────┘
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 60-second start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx copilot-reverse
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
1. It asks you to log in to GitHub (device code — paste a code in your browser). One time only.
|
|
22
|
+
2. The terminal app launches. You'll see a prompt and a status bar.
|
|
23
|
+
3. In the app, type:
|
|
24
|
+
```
|
|
25
|
+
/setup-claude
|
|
26
|
+
```
|
|
27
|
+
Pick a model (e.g. **claude-opus-4.8 (1M)**), choose **global**, done.
|
|
28
|
+
4. Open a **new** terminal and run `claude`. It's now talking to Copilot through copilot-reverse. 🎉
|
|
29
|
+
|
|
30
|
+
That's it. Codex users: run `/setup-codex` instead.
|
|
31
|
+
|
|
32
|
+
Here's the app itself — a prompt, a live status bar, and slash-command autocomplete:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
✳ copilot-reverse worker: ready
|
|
36
|
+
|
|
37
|
+
Type a message to chat with the assistant, or /help for commands.
|
|
38
|
+
╭─────────────────────────────────────────────────────────────────────────────────────╮
|
|
39
|
+
│ › /setup │
|
|
40
|
+
╰─────────────────────────────────────────────────────────────────────────────────────╯
|
|
41
|
+
❯ /setup-claude print Claude Code config
|
|
42
|
+
/setup-codex print Codex/OpenAI config
|
|
43
|
+
/setup-status show configured endpoints
|
|
44
|
+
↑↓ navigate · tab complete · enter run
|
|
45
|
+
model claude-opus-4.8 · daemon ready · claude u:✓ p:○ codex u:✓ p:○ · /help
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## What can I do in the app?
|
|
51
|
+
|
|
52
|
+
Just **talk to it** — it understands plain English and will do the work for you:
|
|
53
|
+
|
|
54
|
+
> *"list models"* → shows every model + its context window
|
|
55
|
+
> *"set up claude"* → configures Claude Code
|
|
56
|
+
> *"is the worker healthy?"* → runs a health check
|
|
57
|
+
> *"why did my last request fail?"* → shows the error
|
|
58
|
+
|
|
59
|
+
Prefer commands? Type `/` to see them all. The essentials:
|
|
60
|
+
|
|
61
|
+
| Command | What it does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `/setup-claude` · `/setup-codex` | Point Claude Code / Codex at copilot-reverse |
|
|
64
|
+
| `/model` | Switch the chat model (1M-context models marked) |
|
|
65
|
+
| `/status` · `/doctor` | Is everything healthy? |
|
|
66
|
+
| `/logs` · `/metrics` | What ran, what failed, and why |
|
|
67
|
+
| `/dashboard` | Open a live web dashboard in your browser |
|
|
68
|
+
| `/report` | File a pre-filled bug report (diagnostics only — no prompts) |
|
|
69
|
+
| `/reset-claude` · `/reset-codex` | Undo setup, restore original config |
|
|
70
|
+
| `/help` · `/quit` | List commands · exit |
|
|
71
|
+
|
|
72
|
+
### The live dashboard
|
|
73
|
+
|
|
74
|
+
`/dashboard` opens a self-refreshing web view of everything happening through the proxy — worker
|
|
75
|
+
health, request volume, and (most useful) recent **errors with their real messages**:
|
|
76
|
+
|
|
77
|
+

|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Connect your own tools
|
|
82
|
+
|
|
83
|
+
Already have something that speaks OpenAI or Anthropic? Point it here:
|
|
84
|
+
|
|
85
|
+
- **OpenAI-compatible:** `http://127.0.0.1:7891/v1`
|
|
86
|
+
- **Anthropic-compatible:** `http://127.0.0.1:7891`
|
|
87
|
+
|
|
88
|
+
Any API key value works locally (it's your machine). Example:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
export ANTHROPIC_BASE_URL=http://127.0.0.1:7891
|
|
92
|
+
export ANTHROPIC_API_KEY=local
|
|
93
|
+
claude
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## The status bar, decoded
|
|
99
|
+
|
|
100
|
+
The bottom line of the app (see the screenshot above) tells you everything at a glance:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
model claude-opus-4.8 · daemon ready · claude u:✓ p:○ codex u:○ p:○ · /help
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- **worker / daemon** — green `ready` means the proxy is up and self-healing.
|
|
107
|
+
- **claude u:✓ p:○** — Claude Code is configured at the **u**ser (global) level, not in this **p**roject. Read live from your real config files.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Troubleshooting
|
|
112
|
+
|
|
113
|
+
**"context 100%" or `/compact` fails in Claude Code**
|
|
114
|
+
Re-run `/setup-claude` and pick a **1M** model (e.g. `claude-opus-4.8 (1M)`). copilot-reverse writes
|
|
115
|
+
the right context-window hint so the client stops assuming a small window. Then restart Claude Code.
|
|
116
|
+
|
|
117
|
+
**"GitHub login expired"**
|
|
118
|
+
Your Copilot session lapsed. Restart copilot-reverse — it'll prompt you to log in again.
|
|
119
|
+
|
|
120
|
+
**A request failed and I don't know why**
|
|
121
|
+
Type `/logs` (or ask *"why did that fail?"*). Every failure is captured with its real upstream
|
|
122
|
+
message. Still stuck? `/report` opens a pre-filled GitHub issue with diagnostics — **never** your
|
|
123
|
+
prompt content.
|
|
124
|
+
|
|
125
|
+
**Want to undo everything**
|
|
126
|
+
`/reset-claude` and `/reset-codex` remove exactly the keys copilot-reverse added and leave the rest
|
|
127
|
+
of your config untouched.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Good to know
|
|
132
|
+
|
|
133
|
+
- **Your data stays local.** The app proxies between your editor and Copilot on `127.0.0.1`. Your
|
|
134
|
+
GitHub token lives only in `~/.copilot-reverse/creds.json` on your own disk.
|
|
135
|
+
- **It heals itself.** If the proxy crashes, the supervisor restarts it with backoff and records why.
|
|
136
|
+
- **Unofficial endpoints.** This uses community-documented Copilot endpoints with *your own*
|
|
137
|
+
subscription. It may break if GitHub changes them — that's the trade-off for not needing extra keys.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
Questions or bugs? Use `/report` from inside the app, or open an issue on
|
|
142
|
+
[GitHub](https://github.com/wangcansunking/copilot-reverse). Happy hacking. 🚀
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# copilot-reverse
|
|
2
|
+
|
|
3
|
+
Interactive terminal app that turns your GitHub Copilot subscription into local
|
|
4
|
+
OpenAI- and Anthropic-compatible endpoints, with a self-healing daemon and a
|
|
5
|
+
built-in assistant.
|
|
6
|
+
|
|
7
|
+
> **New here? Read the [User Guide](GUIDE.md) — a 60-second start, no jargon.**
|
|
8
|
+
|
|
9
|
+
> **Disclaimer:** The GitHub Copilot integration uses community-documented,
|
|
10
|
+
> unofficial endpoints, for use with your own Copilot subscription only. It may
|
|
11
|
+
> break if GitHub changes these endpoints.
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx copilot-reverse # device-code login, then the TUI launches
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
In the TUI: `/help`, `/doctor`, `/setup-claude`, `/setup-codex`, `/metrics`, or
|
|
22
|
+
just talk to the assistant in natural language.
|
|
23
|
+
|
|
24
|
+
Point clients at:
|
|
25
|
+
- OpenAI: `http://127.0.0.1:7891/v1`
|
|
26
|
+
- Anthropic: `http://127.0.0.1:7891`
|
|
27
|
+
|
|
28
|
+
## Architecture (M1)
|
|
29
|
+
|
|
30
|
+
- **TUI** (Ink) — the `copilot-reverse` process: REPL + slash commands + claude-agent-sdk
|
|
31
|
+
assistant (which dogfoods copilot-reverse's own Anthropic endpoint).
|
|
32
|
+
- **Supervisor** (:7890) — control API + SQLite + self-healing worker supervision.
|
|
33
|
+
- **Worker** (:7891) — OpenAI `/v1/chat/completions` + Anthropic `/v1/messages`
|
|
34
|
+
→ Copilot, with tool-use translation both ways.
|
|
35
|
+
|
|
36
|
+
## Development
|
|
37
|
+
|
|
38
|
+
Requires Node >=20.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install && npm test && npm run build
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### End-to-end tests
|
|
45
|
+
|
|
46
|
+
The [`e2e/`](e2e/) folder holds cross-module end-to-end scenarios (real worker + supervisor +
|
|
47
|
+
TUI wiring, fake Copilot provider). The case catalog is [`e2e/cases.md`](e2e/cases.md) and the
|
|
48
|
+
latest run is [`e2e/RESULTS.md`](e2e/RESULTS.md).
|
|
49
|
+
|
|
50
|
+
**Every code change must keep the full e2e suite green.** `npm test` runs it (the suite is
|
|
51
|
+
included in the default vitest run); `npm run test:e2e` runs only the e2e cases. After a change,
|
|
52
|
+
re-run and update `e2e/RESULTS.md`.
|
|
53
|
+
|
|
54
|
+
### Test notes
|
|
55
|
+
|
|
56
|
+
- **TUI input tests** (`tests/tui/app.test.tsx`): the test waits ~30 ms after
|
|
57
|
+
`render()` before writing to `stdin`. This is not flakiness padding — Ink's
|
|
58
|
+
`useInput` subscribes to stdin asynchronously after mount, so writes issued in
|
|
59
|
+
the same tick as `render()` are dropped. The delay lets the subscription
|
|
60
|
+
attach; assertions are otherwise unchanged.
|
package/dist/cli/auth.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { requestDeviceCode, pollForToken } from "../providers/copilot/auth.js";
|
|
2
|
+
import { writeGhToken } from "../shared/creds.js";
|
|
3
|
+
export async function runDeviceLogin(dir, fetchFn = fetch, log = console.log) {
|
|
4
|
+
const code = await requestDeviceCode(fetchFn);
|
|
5
|
+
log(`\nOpen ${code.verification_uri} and enter code: ${code.user_code}\n`);
|
|
6
|
+
const token = await pollForToken(code.device_code, code.interval * 1000, fetchFn);
|
|
7
|
+
writeGhToken(token, dir);
|
|
8
|
+
log("GitHub authorization complete.");
|
|
9
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { App } from "../tui/app.js";
|
|
6
|
+
import { buildRegistry } from "../tui/slash/commands.js";
|
|
7
|
+
import { DaemonClient } from "../tui/daemon-client.js";
|
|
8
|
+
import { runDeviceLogin } from "./auth.js";
|
|
9
|
+
import { probeSupervisor } from "../daemon/lifecycle.js";
|
|
10
|
+
import { startSupervisor } from "../supervisor/index.js";
|
|
11
|
+
import { runAssistantTurn } from "../tui/assistant/runtime.js";
|
|
12
|
+
import { makeOnChat } from "../tui/assistant/on-chat.js";
|
|
13
|
+
import { readGhToken } from "../shared/creds.js";
|
|
14
|
+
import { readClientSetup, writeClientSetup } from "../shared/client-setup.js";
|
|
15
|
+
import { readChatModel, writeChatModel } from "../shared/prefs.js";
|
|
16
|
+
import { CopilotTokenStore, isCopilotTokenValid } from "../providers/copilot/token.js";
|
|
17
|
+
import { fetchCopilotModels, fetchModelLimits } from "../providers/copilot/models.js";
|
|
18
|
+
import { applyClaude, applyCodex, resetClaude, resetCodex, CLAUDE_ENV_KEYS, CODEX_ENV_KEYS } from "../tui/setup/apply.js";
|
|
19
|
+
import { readClientStatus } from "../tui/setup/status.js";
|
|
20
|
+
import { applyCodexToml } from "../tui/setup/codex-toml.js";
|
|
21
|
+
import { claudeCopilotReverseEnv } from "../tui/setup/clients.js";
|
|
22
|
+
import { dataDir } from "../shared/paths.js";
|
|
23
|
+
import { defaultConfig } from "../shared/config.js";
|
|
24
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
const DEFAULT_MODEL = "gpt-4o"; // a valid Copilot model id; pass-through routing uses it as-is
|
|
26
|
+
// Conservative context budget that drives the assistant's auto-compaction. Sized below the
|
|
27
|
+
// common Copilot prompt window (gpt-4o ≈ 128K) so the engine compacts before the upstream
|
|
28
|
+
// rejects an over-long turn. TODO: read each model's real max_prompt_tokens from /models.
|
|
29
|
+
const DEFAULT_MAX_INPUT_TOKENS = 110_000;
|
|
30
|
+
async function launchTui() {
|
|
31
|
+
const cfg = defaultConfig();
|
|
32
|
+
const existingToken = readGhToken(dataDir());
|
|
33
|
+
if (!existingToken) {
|
|
34
|
+
console.log("No GitHub login found — starting device-code login.");
|
|
35
|
+
await runDeviceLogin(dataDir());
|
|
36
|
+
}
|
|
37
|
+
else if (!(await isCopilotTokenValid(existingToken))) {
|
|
38
|
+
console.log("GitHub login expired — re-authenticating.");
|
|
39
|
+
await runDeviceLogin(dataDir());
|
|
40
|
+
}
|
|
41
|
+
// Run the daemon IN-PROCESS — no separate console window pops up. Reuse one if already running.
|
|
42
|
+
let stopSupervisor;
|
|
43
|
+
if (!(await probeSupervisor())) {
|
|
44
|
+
process.stdout.write("starting copilot-reverse…\n");
|
|
45
|
+
stopSupervisor = startSupervisor().stop;
|
|
46
|
+
for (let i = 0; i < 60 && !(await probeSupervisor()); i++)
|
|
47
|
+
await delay(100);
|
|
48
|
+
}
|
|
49
|
+
const base = `http://${cfg.bindHost}:${cfg.supervisorPort}`;
|
|
50
|
+
const client = new DaemonClient(base);
|
|
51
|
+
const workerBase = `http://${cfg.bindHost}:${cfg.workerPort}`;
|
|
52
|
+
const endpoint = { host: cfg.bindHost, port: cfg.workerPort, apiKey: "copilot-reverse-local" };
|
|
53
|
+
let app;
|
|
54
|
+
const quit = () => { stopSupervisor?.(); app?.unmount(); process.exit(0); };
|
|
55
|
+
// Restore a client's config: strip copilot-reverse's keys from BOTH scopes and clear the HUD flag.
|
|
56
|
+
const resetClient = async (clientKind) => {
|
|
57
|
+
const fn = clientKind === "claude" ? resetClaude : resetCodex;
|
|
58
|
+
const keys = clientKind === "claude" ? CLAUDE_ENV_KEYS : CODEX_ENV_KEYS;
|
|
59
|
+
const results = ["global", "project"].map((scope) => fn(scope, keys));
|
|
60
|
+
writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), [clientKind]: false });
|
|
61
|
+
const lines = results
|
|
62
|
+
.filter((r) => r.changed.length)
|
|
63
|
+
.map((r) => `removed ${r.changed.join(", ")} from ${r.path}`);
|
|
64
|
+
return lines.length ? lines : [`no copilot-reverse ${clientKind} config found to remove`];
|
|
65
|
+
};
|
|
66
|
+
const registry = buildRegistry({ client, quit }, endpoint, {
|
|
67
|
+
dashboardUrl: `http://${cfg.bindHost}:${cfg.supervisorPort}/`,
|
|
68
|
+
reportRepo: cfg.reportRepo,
|
|
69
|
+
appVersion: "0.0.1",
|
|
70
|
+
platform: `${process.platform} node-${process.version}`,
|
|
71
|
+
resetClient,
|
|
72
|
+
});
|
|
73
|
+
// Filled in below once we have a token; the assistant prefers a model's real window over the default.
|
|
74
|
+
const modelLimits = {};
|
|
75
|
+
const tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
|
|
76
|
+
const loadModels = async () => {
|
|
77
|
+
const token = await tokenStore.get();
|
|
78
|
+
const [ids, limits] = await Promise.all([fetchCopilotModels(token), fetchModelLimits(token)]);
|
|
79
|
+
Object.assign(modelLimits, limits); // so the picker shows windows and auto-compaction is sized
|
|
80
|
+
return ids;
|
|
81
|
+
};
|
|
82
|
+
// Pull each model's real context window in the background too, in case the picker never opens.
|
|
83
|
+
void tokenStore.get().then((t) => fetchModelLimits(t)).then((m) => Object.assign(modelLimits, m)).catch(() => { });
|
|
84
|
+
// Apply a client's config (shared by the /setup wizard and the assistant's setup_* tools).
|
|
85
|
+
// For Claude Code we also write the selected model's real context window so the client doesn't
|
|
86
|
+
// assume the default 200K (which makes a 1M model read "context 100%" far too early). For Codex
|
|
87
|
+
// we write BOTH a .env (legacy) and ~/.codex/config.toml (the native Codex config, with the
|
|
88
|
+
// model's context window) so either Codex setup style works.
|
|
89
|
+
const applyClient = (clientKind, scope, model) => {
|
|
90
|
+
if (clientKind === "claude") {
|
|
91
|
+
const r = applyClaude(scope, claudeCopilotReverseEnv(workerBase, "copilot-reverse-local", model, modelLimits[model]));
|
|
92
|
+
writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), claude: true });
|
|
93
|
+
return r;
|
|
94
|
+
}
|
|
95
|
+
const r = applyCodex(scope, { OPENAI_BASE_URL: `${workerBase}/v1`, OPENAI_API_KEY: "copilot-reverse-local", OPENAI_MODEL: model });
|
|
96
|
+
applyCodexToml({ baseUrl: `${workerBase}/v1`, model, contextWindow: modelLimits[model] });
|
|
97
|
+
writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), codex: true });
|
|
98
|
+
return r;
|
|
99
|
+
};
|
|
100
|
+
const setup = { apply: async (clientKind, scope, model) => applyClient(clientKind, scope, model) };
|
|
101
|
+
const onChat = makeOnChat({
|
|
102
|
+
client, workerBaseUrl: workerBase, apiKey: "copilot-reverse-local", model: DEFAULT_MODEL,
|
|
103
|
+
maxInputTokens: DEFAULT_MAX_INPUT_TOKENS, modelLimits,
|
|
104
|
+
listModels: loadModels,
|
|
105
|
+
setupClient: async (c, s, m) => applyClient(c, s, m),
|
|
106
|
+
}, (c, p, print, abort) => runAssistantTurn(c, p, print, undefined, abort));
|
|
107
|
+
const persistedModel = readChatModel(dataDir());
|
|
108
|
+
app = render(React.createElement(App, {
|
|
109
|
+
registry,
|
|
110
|
+
title: "copilot-reverse",
|
|
111
|
+
initialModel: persistedModel ?? DEFAULT_MODEL,
|
|
112
|
+
statusSource: () => client.status(),
|
|
113
|
+
readStatus: () => readClientStatus(),
|
|
114
|
+
modelLimits,
|
|
115
|
+
onChat,
|
|
116
|
+
loadModels,
|
|
117
|
+
setup,
|
|
118
|
+
info: {
|
|
119
|
+
openai: `${workerBase}/v1`,
|
|
120
|
+
anthropic: workerBase,
|
|
121
|
+
supervisorPort: cfg.supervisorPort,
|
|
122
|
+
workerPort: cfg.workerPort,
|
|
123
|
+
dataDir: dataDir(),
|
|
124
|
+
},
|
|
125
|
+
onModelChange: (m) => writeChatModel(dataDir(), m),
|
|
126
|
+
pickModelOnStart: !persistedModel,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
const program = new Command();
|
|
130
|
+
program.name("copilot-reverse").description("copilot-reverse: interactive Copilot proxy").version("0.0.1");
|
|
131
|
+
program.command("login").description("GitHub device-code login").action(() => runDeviceLogin(dataDir()));
|
|
132
|
+
program.action(() => { void launchTui(); });
|
|
133
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The Anthropic `system` field may be a plain string or an array of text blocks (the Claude Code
|
|
2
|
+
// SDK sends blocks with cache_control). Flatten either shape to a string — otherwise it stringifies
|
|
3
|
+
// to "[object Object]" and the model gets garbage instructions.
|
|
4
|
+
function systemText(system) {
|
|
5
|
+
if (!system)
|
|
6
|
+
return "";
|
|
7
|
+
if (typeof system === "string")
|
|
8
|
+
return system;
|
|
9
|
+
return system.filter((b) => b.type === "text" && b.text != null).map((b) => b.text).join("");
|
|
10
|
+
}
|
|
11
|
+
function blocksToCanonical(content) {
|
|
12
|
+
if (typeof content === "string")
|
|
13
|
+
return content ? [{ type: "text", text: content }] : [];
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const b of content) {
|
|
16
|
+
if (b.type === "text" && b.text != null)
|
|
17
|
+
out.push({ type: "text", text: b.text });
|
|
18
|
+
else if (b.type === "image" && b.source) {
|
|
19
|
+
// Anthropic image: base64 (media_type + data) or a url source. Normalize to a data URL.
|
|
20
|
+
const dataUrl = b.source.type === "url" && b.source.url
|
|
21
|
+
? b.source.url
|
|
22
|
+
: `data:${b.source.media_type ?? "image/png"};base64,${b.source.data ?? ""}`;
|
|
23
|
+
out.push({ type: "image", dataUrl });
|
|
24
|
+
}
|
|
25
|
+
else if (b.type === "tool_use")
|
|
26
|
+
out.push({ type: "tool_use", id: b.id, name: b.name, input: b.input });
|
|
27
|
+
else if (b.type === "tool_result")
|
|
28
|
+
out.push({ type: "tool_result", toolUseId: b.tool_use_id, content: typeof b.content === "string" ? b.content : JSON.stringify(b.content) });
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
export function anthropicRequestToCanonical(req) {
|
|
33
|
+
const messages = [];
|
|
34
|
+
const sys = systemText(req.system);
|
|
35
|
+
if (sys)
|
|
36
|
+
messages.push({ role: "system", content: [{ type: "text", text: sys }] });
|
|
37
|
+
for (const m of req.messages) {
|
|
38
|
+
const content = blocksToCanonical(m.content);
|
|
39
|
+
const isToolResult = content.some((b) => b.type === "tool_result");
|
|
40
|
+
messages.push({ role: isToolResult ? "tool" : m.role, content });
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
model: req.model, stream: Boolean(req.stream), temperature: req.temperature, maxTokens: req.max_tokens,
|
|
44
|
+
// Keep only custom tools with a real JSON-Schema. Anthropic server-side tools (web_search,
|
|
45
|
+
// bash, computer, …) arrive with a `type` and no `input_schema`; forwarding them produces an
|
|
46
|
+
// invalid tool the model can't fulfil, and the client hangs forever waiting for a tool_result.
|
|
47
|
+
tools: req.tools
|
|
48
|
+
?.filter((t) => t.input_schema != null && typeof t.input_schema === "object")
|
|
49
|
+
.map((t) => ({ name: t.name, description: t.description, parameters: t.input_schema })),
|
|
50
|
+
messages,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function canonicalToAnthropicResponse(r) {
|
|
54
|
+
const content = r.content.map((b) => b.type === "text" ? { type: "text", text: b.text } :
|
|
55
|
+
b.type === "tool_use" ? { type: "tool_use", id: b.id, name: b.name, input: b.input } :
|
|
56
|
+
{ type: "text", text: "" });
|
|
57
|
+
const stop = r.finishReason === "tool_use" ? "tool_use" : r.finishReason === "length" ? "max_tokens" : "end_turn";
|
|
58
|
+
return {
|
|
59
|
+
id: r.id, type: "message", role: "assistant", model: r.model,
|
|
60
|
+
content, stop_reason: stop, stop_sequence: null,
|
|
61
|
+
usage: { input_tokens: r.usage.promptTokens, output_tokens: r.usage.completionTokens },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Fuzzy model matching (agent-maestro v2.6.0): clients send ids like `claude-opus-4-8` or
|
|
2
|
+
// `claude-opus-4-8-20251101`, but Copilot advertises `claude-opus-4.8`. Map the request to the
|
|
3
|
+
// closest available model by Jaccard similarity over normalized tokens, so a near-miss id doesn't
|
|
4
|
+
// pass straight through and 404. Date stamps (6+ digit runs) are dropped before comparing.
|
|
5
|
+
function tokenize(id) {
|
|
6
|
+
return new Set(id.toLowerCase()
|
|
7
|
+
.replace(/\b\d{6,}\b/g, " ") // strip date/version stamps like 20251101
|
|
8
|
+
.replace(/[-_.]+/g, " ")
|
|
9
|
+
.split(/\s+/)
|
|
10
|
+
.filter(Boolean));
|
|
11
|
+
}
|
|
12
|
+
function jaccard(a, b) {
|
|
13
|
+
if (!a.size && !b.size)
|
|
14
|
+
return 0;
|
|
15
|
+
let inter = 0;
|
|
16
|
+
for (const x of a)
|
|
17
|
+
if (b.has(x))
|
|
18
|
+
inter++;
|
|
19
|
+
return inter / (a.size + b.size - inter);
|
|
20
|
+
}
|
|
21
|
+
export function bestModelMatch(requested, available, threshold = 0.6) {
|
|
22
|
+
if (available.includes(requested))
|
|
23
|
+
return requested;
|
|
24
|
+
const rt = tokenize(requested);
|
|
25
|
+
let best = null;
|
|
26
|
+
let bestScore = 0;
|
|
27
|
+
for (const m of available) {
|
|
28
|
+
const s = jaccard(rt, tokenize(m));
|
|
29
|
+
if (s > bestScore) {
|
|
30
|
+
bestScore = s;
|
|
31
|
+
best = m;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return bestScore >= threshold ? best : null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { joinText } from "./canonical.js";
|
|
2
|
+
// OpenAI content may be a plain string or an array of text parts (clients that split long
|
|
3
|
+
// system/user prompts do this). Collapse text parts to a single string.
|
|
4
|
+
function textOf(content) {
|
|
5
|
+
if (Array.isArray(content))
|
|
6
|
+
return content.map((p) => (typeof p === "string" ? p : p?.text ?? "")).join("");
|
|
7
|
+
return content ?? "";
|
|
8
|
+
}
|
|
9
|
+
// Extract any image_url parts as canonical image blocks (vision support).
|
|
10
|
+
function imagesOf(content) {
|
|
11
|
+
if (!Array.isArray(content))
|
|
12
|
+
return [];
|
|
13
|
+
return content
|
|
14
|
+
.filter((p) => typeof p !== "string" && p?.type === "image_url" && !!p.image_url?.url)
|
|
15
|
+
.map((p) => ({ type: "image", dataUrl: p.image_url.url }));
|
|
16
|
+
}
|
|
17
|
+
function msgToCanonical(m) {
|
|
18
|
+
const role = (["system", "user", "assistant", "tool"].includes(m.role) ? m.role : "user");
|
|
19
|
+
const content = [];
|
|
20
|
+
if (m.role === "tool" && m.tool_call_id) {
|
|
21
|
+
content.push({ type: "tool_result", toolUseId: m.tool_call_id, content: textOf(m.content) });
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
const text = textOf(m.content);
|
|
25
|
+
if (text)
|
|
26
|
+
content.push({ type: "text", text });
|
|
27
|
+
content.push(...imagesOf(m.content));
|
|
28
|
+
for (const tc of m.tool_calls ?? []) {
|
|
29
|
+
content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: safeJson(tc.function.arguments) });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { role, content };
|
|
33
|
+
}
|
|
34
|
+
function safeJson(s) { try {
|
|
35
|
+
return JSON.parse(s);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return {};
|
|
39
|
+
} }
|
|
40
|
+
export function openaiRequestToCanonical(req) {
|
|
41
|
+
return {
|
|
42
|
+
model: req.model,
|
|
43
|
+
stream: Boolean(req.stream),
|
|
44
|
+
temperature: req.temperature,
|
|
45
|
+
maxTokens: req.max_tokens,
|
|
46
|
+
tools: req.tools?.map((t) => ({ name: t.function.name, description: t.function.description, parameters: t.function.parameters })),
|
|
47
|
+
messages: req.messages.map(msgToCanonical),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function canonicalToOpenAIResponse(r) {
|
|
51
|
+
const toolCalls = r.content
|
|
52
|
+
.filter((b) => b.type === "tool_use")
|
|
53
|
+
.map((b, i) => ({ index: i, id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input) } }));
|
|
54
|
+
return {
|
|
55
|
+
id: r.id, object: "chat.completion", created: 0, model: r.model,
|
|
56
|
+
choices: [{
|
|
57
|
+
index: 0,
|
|
58
|
+
message: { role: "assistant", content: joinText(r.content) || null, ...(toolCalls.length ? { tool_calls: toolCalls } : {}) },
|
|
59
|
+
finish_reason: r.finishReason === "tool_use" ? "tool_calls" : r.finishReason,
|
|
60
|
+
}],
|
|
61
|
+
usage: { prompt_tokens: r.usage.promptTokens, completion_tokens: r.usage.completionTokens, total_tokens: r.usage.promptTokens + r.usage.completionTokens },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function canonicalChunkToOpenAISSE(chunk, id, model) {
|
|
65
|
+
if (chunk.done) {
|
|
66
|
+
// Emit a final usage chunk (OpenAI stream_options.include_usage shape) before [DONE].
|
|
67
|
+
if (chunk.usage) {
|
|
68
|
+
const u = { prompt_tokens: chunk.usage.promptTokens, completion_tokens: chunk.usage.completionTokens, total_tokens: chunk.usage.promptTokens + chunk.usage.completionTokens };
|
|
69
|
+
const usageChunk = { id, object: "chat.completion.chunk", created: 0, model, choices: [], usage: u };
|
|
70
|
+
return `data: ${JSON.stringify(usageChunk)}\n\ndata: [DONE]\n\n`;
|
|
71
|
+
}
|
|
72
|
+
return "data: [DONE]\n\n";
|
|
73
|
+
}
|
|
74
|
+
let delta = {};
|
|
75
|
+
if (chunk.kind === "text")
|
|
76
|
+
delta = { content: chunk.delta };
|
|
77
|
+
else if (chunk.kind === "tool_use_start")
|
|
78
|
+
delta = { tool_calls: [{ index: chunk.index, id: chunk.id, type: "function", function: { name: chunk.name, arguments: "" } }] };
|
|
79
|
+
else if (chunk.kind === "tool_use_delta")
|
|
80
|
+
delta = { tool_calls: [{ index: chunk.index, function: { arguments: chunk.argsDelta } }] };
|
|
81
|
+
const payload = { id, object: "chat.completion.chunk", created: 0, model, choices: [{ index: 0, delta, finish_reason: null }] };
|
|
82
|
+
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// A rough char/4 token estimate over a canonical request — message text plus tool schemas.
|
|
2
|
+
// It is not a model-exact tokenizer, but it is positive and monotonic in input size, which is
|
|
3
|
+
// enough to back /v1/messages/count_tokens so clients (Claude Code) can time auto-compaction.
|
|
4
|
+
// Mirrors agent-maestro's pragmatic "estimate then calibrate" approach to token counting.
|
|
5
|
+
export function estimateTokens(req) {
|
|
6
|
+
let chars = 0;
|
|
7
|
+
for (const m of req.messages) {
|
|
8
|
+
chars += m.role.length;
|
|
9
|
+
for (const b of m.content) {
|
|
10
|
+
if (b.type === "text")
|
|
11
|
+
chars += b.text.length;
|
|
12
|
+
else if (b.type === "tool_use")
|
|
13
|
+
chars += b.name.length + JSON.stringify(b.input ?? {}).length;
|
|
14
|
+
else if (b.type === "tool_result")
|
|
15
|
+
chars += b.content.length;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (const t of req.tools ?? []) {
|
|
19
|
+
chars += t.name.length + (t.description?.length ?? 0) + JSON.stringify(t.parameters ?? {}).length;
|
|
20
|
+
}
|
|
21
|
+
return Math.max(1, Math.ceil(chars / 4));
|
|
22
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { defaultConfig } from "../shared/config.js";
|
|
5
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
export async function ensureDaemon(opts) {
|
|
7
|
+
if (await opts.probe())
|
|
8
|
+
return "already-running";
|
|
9
|
+
opts.spawn();
|
|
10
|
+
for (let i = 0; i < opts.retries; i++) {
|
|
11
|
+
await sleep(opts.delayMs);
|
|
12
|
+
if (await opts.probe())
|
|
13
|
+
return "started";
|
|
14
|
+
}
|
|
15
|
+
throw new Error("daemon did not become healthy in time");
|
|
16
|
+
}
|
|
17
|
+
// Real implementations wired by the CLI/TUI.
|
|
18
|
+
export function spawnSupervisor() {
|
|
19
|
+
const entry = join(dirname(fileURLToPath(import.meta.url)), "..", "supervisor", "index.js");
|
|
20
|
+
const child = nodeSpawn(process.execPath, [entry], { detached: true, stdio: "ignore" });
|
|
21
|
+
child.unref();
|
|
22
|
+
}
|
|
23
|
+
export async function probeSupervisor(fetchFn = fetch) {
|
|
24
|
+
const cfg = defaultConfig();
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetchFn(`http://${cfg.bindHost}:${cfg.supervisorPort}/api/status`);
|
|
27
|
+
return res.ok;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|