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 +5 -5
- package/dist/cli/index.js +13 -8
- package/dist/core/tool-xml.js +92 -0
- package/dist/providers/copilot/adapter.js +40 -2
- package/dist/providers/copilot/models.js +3 -3
- package/dist/tui/setup/clients.js +2 -2
- package/dist/tui/slash/commands.js +1 -1
- package/dist/version.js +2 -0
- package/dist/worker/anthropic-server.js +8 -3
- package/dist/worker/openai-server.js +7 -2
- package/dist/worker/router.js +4 -0
- package/package.json +3 -1
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/
|
|
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 `/
|
|
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:
|
|
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(
|
|
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:
|
|
110
|
-
applyCodexToml({ baseUrl:
|
|
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:
|
|
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:
|
|
134
|
-
anthropic:
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
31
|
+
return FALLBACK_MODELS;
|
|
32
32
|
const ids = [...new Set(data.map((m) => m.id).filter((x) => Boolean(x)))];
|
|
33
|
-
return ids.length ? ids :
|
|
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}/
|
|
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}/
|
|
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"] });
|
package/dist/version.js
ADDED
|
@@ -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
|
-
|
|
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: "/
|
|
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");
|
package/dist/worker/router.js
CHANGED
|
@@ -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
|
|
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": {
|