codex-copilot-dx 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 +44 -0
- package/bin/cli.mjs +49 -0
- package/package.json +26 -0
- package/src/adapter.mjs +371 -0
- package/src/config.mjs +33 -0
- package/src/launcher.mjs +88 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# codex-copilot-dx
|
|
2
|
+
|
|
3
|
+
Use [Codex Desktop](https://openai.com/codex) app with your **GitHub Copilot** subscription.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
Codex Desktop uses OpenAI's Responses API (`/v1/responses`), which isn't directly exposed by the GitHub Copilot API proxy. This tool bridges the gap:
|
|
8
|
+
|
|
9
|
+
- **GPT-5.5, GPT-5.4** and other Responses-only models → proxied directly to `api.githubcopilot.com/v1/responses`
|
|
10
|
+
- **GPT-4o, GPT-4.1** and other chat models → converted from Responses API to Chat Completions format via `copilot-api`
|
|
11
|
+
|
|
12
|
+
Supports both HTTP SSE streaming and WebSocket connections.
|
|
13
|
+
|
|
14
|
+
## Prerequisites
|
|
15
|
+
|
|
16
|
+
- [Codex Desktop](https://openai.com/codex) app installed
|
|
17
|
+
- GitHub Copilot subscription (Individual, Business, or Enterprise)
|
|
18
|
+
- Node.js 18+
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx codex-copilot-dx
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
On first run, it will:
|
|
27
|
+
1. Authenticate with GitHub via device flow (if needed)
|
|
28
|
+
2. Start `copilot-api` proxy (port 4141)
|
|
29
|
+
3. Start the Responses API adapter (port 4142)
|
|
30
|
+
4. Configure Codex to use the adapter
|
|
31
|
+
5. Launch Codex Desktop
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
Environment variables:
|
|
36
|
+
|
|
37
|
+
| Variable | Default | Description |
|
|
38
|
+
|----------|---------|-------------|
|
|
39
|
+
| `COPILOT_API_PORT` | `4141` | Port for copilot-api |
|
|
40
|
+
| `ADAPTER_PORT` | `4142` | Port for the adapter |
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { ensureAuth, startCopilotApi, openCodex } from "../src/launcher.mjs";
|
|
4
|
+
import { ensureCodexConfig } from "../src/config.mjs";
|
|
5
|
+
import { startAdapter } from "../src/adapter.mjs";
|
|
6
|
+
|
|
7
|
+
const COPILOT_PORT = parseInt(process.env.COPILOT_API_PORT || "4141");
|
|
8
|
+
const ADAPTER_PORT = parseInt(process.env.ADAPTER_PORT || "4142");
|
|
9
|
+
|
|
10
|
+
console.log(`
|
|
11
|
+
codex-copilot-dx
|
|
12
|
+
Use Codex Desktop with GitHub Copilot
|
|
13
|
+
`);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
// 1. Ensure GitHub auth
|
|
17
|
+
await ensureAuth();
|
|
18
|
+
|
|
19
|
+
// 2. Start copilot-api (for legacy models)
|
|
20
|
+
const copilotProc = await startCopilotApi(COPILOT_PORT);
|
|
21
|
+
|
|
22
|
+
// 3. Start adapter
|
|
23
|
+
await startAdapter(ADAPTER_PORT);
|
|
24
|
+
|
|
25
|
+
// 4. Configure Codex
|
|
26
|
+
ensureCodexConfig(ADAPTER_PORT);
|
|
27
|
+
|
|
28
|
+
// 5. Launch Codex
|
|
29
|
+
openCodex();
|
|
30
|
+
|
|
31
|
+
console.log(`
|
|
32
|
+
Ready! Codex is using your GitHub Copilot subscription.
|
|
33
|
+
|
|
34
|
+
Adapter: http://localhost:${ADAPTER_PORT}
|
|
35
|
+
copilot-api: http://localhost:${COPILOT_PORT}
|
|
36
|
+
|
|
37
|
+
Press Ctrl+C to stop.
|
|
38
|
+
`);
|
|
39
|
+
|
|
40
|
+
// Keep alive
|
|
41
|
+
process.on("SIGINT", () => {
|
|
42
|
+
console.log("\nShutting down...");
|
|
43
|
+
copilotProc?.kill();
|
|
44
|
+
process.exit(0);
|
|
45
|
+
});
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.error(`Error: ${e.message}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codex-copilot-dx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Use Codex Desktop app with your GitHub Copilot subscription",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"codex-copilot-dx": "./bin/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"codex",
|
|
16
|
+
"copilot",
|
|
17
|
+
"github",
|
|
18
|
+
"openai",
|
|
19
|
+
"proxy",
|
|
20
|
+
"responses-api"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"copilot-api": "^0.7.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/adapter.mjs
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
const COPILOT_API_PORT = parseInt(process.env.COPILOT_API_PORT || "4141");
|
|
9
|
+
const COPILOT_API = "https://api.githubcopilot.com";
|
|
10
|
+
const GITHUB_API = "https://api.github.com";
|
|
11
|
+
|
|
12
|
+
// Models that only support Responses API (not chat/completions)
|
|
13
|
+
const RESPONSES_ONLY = new Set([
|
|
14
|
+
"gpt-5.5",
|
|
15
|
+
"gpt-5.4",
|
|
16
|
+
"gpt-5.4-mini",
|
|
17
|
+
"gpt-5.3-codex",
|
|
18
|
+
"gpt-5.2-codex",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// ── Copilot Token ──
|
|
22
|
+
|
|
23
|
+
let copilotToken = null;
|
|
24
|
+
let copilotTokenExpiry = 0;
|
|
25
|
+
|
|
26
|
+
function getGithubToken() {
|
|
27
|
+
const p = path.join(os.homedir(), ".local", "share", "copilot-api", "github_token");
|
|
28
|
+
if (!fs.existsSync(p)) throw new Error("GitHub token not found. Run: npx copilot-api auth");
|
|
29
|
+
return fs.readFileSync(p, "utf-8").trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function getCopilotToken() {
|
|
33
|
+
if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
|
|
34
|
+
const ghToken = getGithubToken();
|
|
35
|
+
const resp = await fetch(`${GITHUB_API}/copilot_internal/v2/token`, {
|
|
36
|
+
headers: { Authorization: `token ${ghToken}`, Accept: "application/json" },
|
|
37
|
+
});
|
|
38
|
+
if (!resp.ok) throw new Error(`Failed to get Copilot token: ${resp.status}`);
|
|
39
|
+
const data = await resp.json();
|
|
40
|
+
copilotToken = data.token;
|
|
41
|
+
copilotTokenExpiry = data.expires_at * 1000;
|
|
42
|
+
console.log("[codex-copilot-dx] Copilot token refreshed");
|
|
43
|
+
return copilotToken;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Direct Copilot Responses API proxy ──
|
|
47
|
+
|
|
48
|
+
async function proxyCopilotResponses(reqBody, req, res) {
|
|
49
|
+
const token = await getCopilotToken();
|
|
50
|
+
const resp = await fetch(`${COPILOT_API}/v1/responses`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${token}`,
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
"Editor-Version": "vscode/1.90.0",
|
|
56
|
+
"Copilot-Integration-Id": "vscode-chat",
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify(reqBody),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (reqBody.stream) {
|
|
62
|
+
res.writeHead(resp.status, {
|
|
63
|
+
"Content-Type": resp.headers.get("content-type") || "text/event-stream",
|
|
64
|
+
"Cache-Control": "no-cache",
|
|
65
|
+
Connection: "keep-alive",
|
|
66
|
+
});
|
|
67
|
+
const reader = resp.body.getReader();
|
|
68
|
+
while (true) {
|
|
69
|
+
const { done, value } = await reader.read();
|
|
70
|
+
if (done) { res.end(); return; }
|
|
71
|
+
res.write(value);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
const data = await resp.text();
|
|
75
|
+
res.writeHead(resp.status, { "Content-Type": "application/json" });
|
|
76
|
+
res.end(data);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function proxyCopilotResponsesWS(reqBody, wsSend, onDone) {
|
|
81
|
+
const token = await getCopilotToken();
|
|
82
|
+
const resp = await fetch(`${COPILOT_API}/v1/responses`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
Authorization: `Bearer ${token}`,
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
"Editor-Version": "vscode/1.90.0",
|
|
88
|
+
"Copilot-Integration-Id": "vscode-chat",
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify({ ...reqBody, stream: true }),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!resp.ok) {
|
|
94
|
+
const err = await resp.text();
|
|
95
|
+
wsSend(JSON.stringify({ type: "error", error: { message: err, code: resp.status } }));
|
|
96
|
+
onDone();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const reader = resp.body.getReader();
|
|
101
|
+
const decoder = new TextDecoder();
|
|
102
|
+
let buf = "";
|
|
103
|
+
while (true) {
|
|
104
|
+
const { done, value } = await reader.read();
|
|
105
|
+
if (done) break;
|
|
106
|
+
buf += decoder.decode(value, { stream: true });
|
|
107
|
+
const lines = buf.split("\n");
|
|
108
|
+
buf = lines.pop();
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
if (!line.startsWith("data: ")) continue;
|
|
111
|
+
const data = line.slice(6).trim();
|
|
112
|
+
if (data === "[DONE]") continue;
|
|
113
|
+
try { wsSend(JSON.stringify(JSON.parse(data))); } catch {}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
onDone();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Chat Completions conversion (for older models via copilot-api) ──
|
|
120
|
+
|
|
121
|
+
function responsesToChat(body) {
|
|
122
|
+
const messages = [];
|
|
123
|
+
if (body.instructions) messages.push({ role: "system", content: body.instructions });
|
|
124
|
+
|
|
125
|
+
if (typeof body.input === "string") {
|
|
126
|
+
messages.push({ role: "user", content: body.input });
|
|
127
|
+
} else if (Array.isArray(body.input)) {
|
|
128
|
+
for (const item of body.input) {
|
|
129
|
+
if (item.type === "message") {
|
|
130
|
+
const content =
|
|
131
|
+
typeof item.content === "string"
|
|
132
|
+
? item.content
|
|
133
|
+
: Array.isArray(item.content)
|
|
134
|
+
? item.content.map((p) => (p.type === "input_text" || p.type === "text" ? p.text : JSON.stringify(p))).join("")
|
|
135
|
+
: JSON.stringify(item.content);
|
|
136
|
+
messages.push({ role: item.role, content });
|
|
137
|
+
} else if (item.type === "function_call") {
|
|
138
|
+
messages.push({
|
|
139
|
+
role: "assistant",
|
|
140
|
+
content: null,
|
|
141
|
+
tool_calls: [{ id: item.call_id || randomUUID(), type: "function", function: { name: item.name, arguments: item.arguments } }],
|
|
142
|
+
});
|
|
143
|
+
} else if (item.type === "function_call_output") {
|
|
144
|
+
messages.push({ role: "tool", tool_call_id: item.call_id, content: typeof item.output === "string" ? item.output : JSON.stringify(item.output) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const chatReq = { model: body.model, messages, stream: true };
|
|
150
|
+
for (const k of ["temperature", "top_p", "stop", "presence_penalty", "frequency_penalty"]) {
|
|
151
|
+
if (body[k] !== undefined) chatReq[k] = body[k];
|
|
152
|
+
}
|
|
153
|
+
const maxTok = body.max_output_tokens ?? body.max_tokens ?? body.max_completion_tokens;
|
|
154
|
+
if (maxTok !== undefined) chatReq.max_completion_tokens = maxTok;
|
|
155
|
+
|
|
156
|
+
if (body.tools?.length) {
|
|
157
|
+
chatReq.tools = body.tools
|
|
158
|
+
.map((t) => (t.type === "function" ? t : { type: "function", function: t }))
|
|
159
|
+
.filter((t) => t.function?.name);
|
|
160
|
+
if (!chatReq.tools.length) delete chatReq.tools;
|
|
161
|
+
}
|
|
162
|
+
return chatReq;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function uid() { return randomUUID().replace(/-/g, ""); }
|
|
166
|
+
|
|
167
|
+
function chatToResponses(chatResp, model) {
|
|
168
|
+
const id = `resp_${uid()}`, choice = chatResp.choices?.[0], msg = choice?.message, output = [];
|
|
169
|
+
if (msg?.content) output.push({ type: "message", id: `msg_${uid()}`, role: "assistant", status: "completed", content: [{ type: "output_text", text: msg.content }] });
|
|
170
|
+
if (msg?.tool_calls) for (const tc of msg.tool_calls) output.push({ type: "function_call", id: tc.id, call_id: tc.id, name: tc.function.name, arguments: tc.function.arguments, status: "completed" });
|
|
171
|
+
return { id, object: "response", status: "completed", model: chatResp.model || model, output, usage: chatResp.usage ? { input_tokens: chatResp.usage.prompt_tokens || 0, output_tokens: chatResp.usage.completion_tokens || 0, total_tokens: chatResp.usage.total_tokens || 0 } : undefined };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function processStream(upstreamRes, emitEvent, onDone) {
|
|
175
|
+
const respId = `resp_${uid()}`, itemId = `item_${uid()}`;
|
|
176
|
+
let actualModel = "unknown";
|
|
177
|
+
|
|
178
|
+
emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
|
|
179
|
+
emitEvent("response.output_item.added", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] } });
|
|
180
|
+
emitEvent("response.content_part.added", { output_index: 0, content_index: 0, part: { type: "output_text", text: "" } });
|
|
181
|
+
|
|
182
|
+
let buf = "", fullText = "", toolCalls = {}, hasToolCalls = false;
|
|
183
|
+
|
|
184
|
+
upstreamRes.on("data", (chunk) => {
|
|
185
|
+
buf += chunk.toString();
|
|
186
|
+
let lines = buf.split("\n");
|
|
187
|
+
buf = lines.pop();
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
if (!line.startsWith("data: ")) continue;
|
|
190
|
+
const data = line.slice(6).trim();
|
|
191
|
+
if (data === "[DONE]") {
|
|
192
|
+
if (!hasToolCalls) {
|
|
193
|
+
emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
|
|
194
|
+
emitEvent("response.output_item.done", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] } });
|
|
195
|
+
}
|
|
196
|
+
const output = hasToolCalls
|
|
197
|
+
? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
|
|
198
|
+
: [{ type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] }];
|
|
199
|
+
emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
|
|
200
|
+
onDone();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
let parsed;
|
|
204
|
+
try { parsed = JSON.parse(data); } catch { continue; }
|
|
205
|
+
if (parsed.model) actualModel = parsed.model;
|
|
206
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
207
|
+
if (!delta) continue;
|
|
208
|
+
if (delta.content) { fullText += delta.content; emitEvent("response.output_text.delta", { output_index: 0, content_index: 0, delta: delta.content }); }
|
|
209
|
+
if (delta.tool_calls) {
|
|
210
|
+
hasToolCalls = true;
|
|
211
|
+
for (const tc of delta.tool_calls) {
|
|
212
|
+
const id = tc.id || Object.keys(toolCalls)[tc.index] || `call_${tc.index}`;
|
|
213
|
+
if (!toolCalls[id]) { toolCalls[id] = { name: "", arguments: "" }; emitEvent("response.output_item.added", { output_index: Object.keys(toolCalls).length - 1, item: { type: "function_call", id, call_id: id, name: tc.function?.name || "", arguments: "", status: "in_progress" } }); }
|
|
214
|
+
if (tc.function?.name) toolCalls[id].name += tc.function.name;
|
|
215
|
+
if (tc.function?.arguments) toolCalls[id].arguments += tc.function.arguments;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
upstreamRes.on("end", () => onDone());
|
|
221
|
+
upstreamRes.on("error", () => onDone());
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function forwardToChat(chatReq, emitEvent, onDone, onError) {
|
|
225
|
+
delete chatReq.max_tokens;
|
|
226
|
+
const body = JSON.stringify(chatReq);
|
|
227
|
+
const opts = { hostname: "localhost", port: COPILOT_API_PORT, path: "/v1/chat/completions", method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } };
|
|
228
|
+
const proxy = http.request(opts, (upRes) => {
|
|
229
|
+
if (upRes.statusCode !== 200) { let d = ""; upRes.on("data", c => d += c); upRes.on("end", () => onError(upRes.statusCode, d)); return; }
|
|
230
|
+
processStream(upRes, emitEvent, onDone);
|
|
231
|
+
});
|
|
232
|
+
proxy.on("error", (e) => onError(502, e.message));
|
|
233
|
+
proxy.write(body);
|
|
234
|
+
proxy.end();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── WebSocket (RFC 6455 minimal) ──
|
|
238
|
+
|
|
239
|
+
function wsRead(socket) {
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
const chunks = [];
|
|
242
|
+
const onData = (data) => {
|
|
243
|
+
chunks.push(data);
|
|
244
|
+
const buf = Buffer.concat(chunks);
|
|
245
|
+
if (buf.length < 2) return;
|
|
246
|
+
const masked = (buf[1] & 0x80) !== 0;
|
|
247
|
+
let payloadLen = buf[1] & 0x7f, offset = 2;
|
|
248
|
+
if (payloadLen === 126) { if (buf.length < 4) return; payloadLen = buf.readUInt16BE(2); offset = 4; }
|
|
249
|
+
else if (payloadLen === 127) { if (buf.length < 10) return; payloadLen = Number(buf.readBigUInt64BE(2)); offset = 10; }
|
|
250
|
+
const maskLen = masked ? 4 : 0;
|
|
251
|
+
if (buf.length < offset + maskLen + payloadLen) return;
|
|
252
|
+
let payload = buf.slice(offset + maskLen, offset + maskLen + payloadLen);
|
|
253
|
+
if (masked) { const mask = buf.slice(offset, offset + 4); for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]; }
|
|
254
|
+
socket.removeListener("data", onData);
|
|
255
|
+
const opcode = buf[0] & 0x0f;
|
|
256
|
+
if (opcode === 0x8) resolve(null);
|
|
257
|
+
else if (opcode === 0x9) { wsSend(socket, "", 0xa); chunks.length = 0; socket.on("data", onData); }
|
|
258
|
+
else resolve(payload.toString("utf-8"));
|
|
259
|
+
};
|
|
260
|
+
socket.on("data", onData);
|
|
261
|
+
socket.on("error", reject);
|
|
262
|
+
socket.on("close", () => resolve(null));
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function wsSend(socket, data, opcode = 0x1) {
|
|
267
|
+
const payload = typeof data === "string" ? Buffer.from(data) : data;
|
|
268
|
+
const len = payload.length;
|
|
269
|
+
let header;
|
|
270
|
+
if (len < 126) { header = Buffer.alloc(2); header[0] = 0x80 | opcode; header[1] = len; }
|
|
271
|
+
else if (len < 65536) { header = Buffer.alloc(4); header[0] = 0x80 | opcode; header[1] = 126; header.writeUInt16BE(len, 2); }
|
|
272
|
+
else { header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2); }
|
|
273
|
+
try { socket.write(Buffer.concat([header, payload])); } catch {}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function handleWebSocket(req, socket) {
|
|
277
|
+
const key = req.headers["sec-websocket-key"];
|
|
278
|
+
const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-5AB9FC6AB199").digest("base64");
|
|
279
|
+
socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n");
|
|
280
|
+
|
|
281
|
+
while (true) {
|
|
282
|
+
const msg = await wsRead(socket);
|
|
283
|
+
if (msg === null) break;
|
|
284
|
+
let parsed;
|
|
285
|
+
try { parsed = JSON.parse(msg); } catch { continue; }
|
|
286
|
+
let reqBody = parsed;
|
|
287
|
+
if (parsed.type === "response.create" && parsed.response) reqBody = parsed.response;
|
|
288
|
+
const model = reqBody.model || "unknown";
|
|
289
|
+
|
|
290
|
+
if (RESPONSES_ONLY.has(model)) {
|
|
291
|
+
proxyCopilotResponsesWS(reqBody, (d) => wsSend(socket, d), () => {}).catch((e) => {
|
|
292
|
+
wsSend(socket, JSON.stringify({ type: "error", error: { message: e.message, code: 500 } }));
|
|
293
|
+
});
|
|
294
|
+
} else {
|
|
295
|
+
const chatReq = responsesToChat(reqBody);
|
|
296
|
+
chatReq.stream = true;
|
|
297
|
+
forwardToChat(chatReq, (event, data) => wsSend(socket, JSON.stringify({ type: event, ...data })), () => {}, (status, errMsg) => {
|
|
298
|
+
wsSend(socket, JSON.stringify({ type: "error", error: { message: errMsg, code: status } }));
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── HTTP passthrough ──
|
|
305
|
+
|
|
306
|
+
function proxyPassthrough(req, res) {
|
|
307
|
+
const opts = { hostname: "localhost", port: COPILOT_API_PORT, path: req.url, method: req.method, headers: { ...req.headers, host: `localhost:${COPILOT_API_PORT}` } };
|
|
308
|
+
const proxy = http.request(opts, (upRes) => { res.writeHead(upRes.statusCode, upRes.headers); upRes.pipe(res); });
|
|
309
|
+
proxy.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end("Bad Gateway"); });
|
|
310
|
+
req.pipe(proxy);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Export ──
|
|
314
|
+
|
|
315
|
+
export function startAdapter(port = 4142) {
|
|
316
|
+
const server = http.createServer((req, res) => {
|
|
317
|
+
if (req.method === "POST" && req.url?.startsWith("/v1/responses")) {
|
|
318
|
+
let body = "";
|
|
319
|
+
req.on("data", (c) => (body += c));
|
|
320
|
+
req.on("end", async () => {
|
|
321
|
+
try {
|
|
322
|
+
const parsed = JSON.parse(body);
|
|
323
|
+
const model = parsed.model || "unknown";
|
|
324
|
+
console.log(`[codex-copilot-dx] ${model} stream=${!!parsed.stream}`);
|
|
325
|
+
if (RESPONSES_ONLY.has(model)) {
|
|
326
|
+
await proxyCopilotResponses(parsed, req, res);
|
|
327
|
+
} else {
|
|
328
|
+
const chatReq = responsesToChat(parsed);
|
|
329
|
+
if (parsed.stream) {
|
|
330
|
+
forwardToChat(chatReq, (event, data) => {
|
|
331
|
+
if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
332
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
333
|
+
}, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); });
|
|
334
|
+
} else {
|
|
335
|
+
chatReq.stream = false;
|
|
336
|
+
delete chatReq.max_tokens;
|
|
337
|
+
const reqBody = JSON.stringify(chatReq);
|
|
338
|
+
const opts = { hostname: "localhost", port: COPILOT_API_PORT, path: "/v1/chat/completions", method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(reqBody) } };
|
|
339
|
+
const proxy = http.request(opts, (upRes) => {
|
|
340
|
+
let data = ""; upRes.on("data", (c) => (data += c));
|
|
341
|
+
upRes.on("end", () => { try { const resp = chatToResponses(JSON.parse(data), model); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(resp)); } catch { res.writeHead(500).end("Parse error"); } });
|
|
342
|
+
});
|
|
343
|
+
proxy.on("error", () => res.writeHead(502).end("Bad Gateway"));
|
|
344
|
+
proxy.write(reqBody);
|
|
345
|
+
proxy.end();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch (e) {
|
|
349
|
+
console.error("[codex-copilot-dx] error:", e.message);
|
|
350
|
+
if (!res.headersSent) res.writeHead(400);
|
|
351
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
proxyPassthrough(req, res);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
server.on("upgrade", (req, socket) => {
|
|
360
|
+
if (req.url?.startsWith("/v1/responses")) {
|
|
361
|
+
handleWebSocket(req, socket).catch((e) => { console.error("[codex-copilot-dx] WS error:", e.message); socket.destroy(); });
|
|
362
|
+
} else socket.destroy();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return new Promise((resolve) => {
|
|
366
|
+
server.listen(port, () => {
|
|
367
|
+
console.log(`[codex-copilot-dx] Adapter listening on http://localhost:${port}`);
|
|
368
|
+
resolve(server);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
|
6
|
+
|
|
7
|
+
export function ensureCodexConfig(adapterPort = 4142) {
|
|
8
|
+
const baseUrl = `http://localhost:${adapterPort}/v1`;
|
|
9
|
+
|
|
10
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
11
|
+
// Codex not installed yet, create minimal config
|
|
12
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
13
|
+
fs.writeFileSync(CONFIG_PATH, `openai_base_url = "${baseUrl}"\n`);
|
|
14
|
+
console.log("[codex-copilot-dx] Created ~/.codex/config.toml");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let content = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
19
|
+
|
|
20
|
+
if (content.includes("openai_base_url")) {
|
|
21
|
+
// Update existing
|
|
22
|
+
content = content.replace(
|
|
23
|
+
/openai_base_url\s*=\s*"[^"]*"/,
|
|
24
|
+
`openai_base_url = "${baseUrl}"`,
|
|
25
|
+
);
|
|
26
|
+
} else {
|
|
27
|
+
// Add at top of file
|
|
28
|
+
content = `openai_base_url = "${baseUrl}"\n` + content;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fs.writeFileSync(CONFIG_PATH, content);
|
|
32
|
+
console.log(`[codex-copilot-dx] Configured openai_base_url = "${baseUrl}"`);
|
|
33
|
+
}
|
package/src/launcher.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn, execSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
|
|
7
|
+
const GITHUB_TOKEN_PATH = path.join(os.homedir(), ".local", "share", "copilot-api", "github_token");
|
|
8
|
+
|
|
9
|
+
function isPortInUse(port) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const server = net.createServer();
|
|
12
|
+
server.once("error", () => resolve(true));
|
|
13
|
+
server.once("listening", () => { server.close(); resolve(false); });
|
|
14
|
+
server.listen(port);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function ensureAuth() {
|
|
19
|
+
if (fs.existsSync(GITHUB_TOKEN_PATH)) {
|
|
20
|
+
console.log("[codex-copilot-dx] GitHub token found");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
console.log("[codex-copilot-dx] No GitHub token. Running auth...");
|
|
24
|
+
const copilotApiBin = findCopilotApi();
|
|
25
|
+
execSync(`${copilotApiBin} auth`, { stdio: "inherit" });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function findCopilotApi() {
|
|
29
|
+
try {
|
|
30
|
+
return execSync("which copilot-api", { encoding: "utf-8" }).trim();
|
|
31
|
+
} catch {
|
|
32
|
+
// Try npx path
|
|
33
|
+
const npxPath = path.join(os.homedir(), ".npm/_npx");
|
|
34
|
+
if (fs.existsSync(npxPath)) {
|
|
35
|
+
for (const dir of fs.readdirSync(npxPath)) {
|
|
36
|
+
const bin = path.join(npxPath, dir, "node_modules/.bin/copilot-api");
|
|
37
|
+
if (fs.existsSync(bin)) return bin;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return "npx copilot-api";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function startCopilotApi(port = 4141) {
|
|
45
|
+
if (await isPortInUse(port)) {
|
|
46
|
+
console.log(`[codex-copilot-dx] copilot-api already running on :${port}`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
console.log(`[codex-copilot-dx] Starting copilot-api on :${port}...`);
|
|
50
|
+
const copilotApiBin = findCopilotApi();
|
|
51
|
+
const child = spawn(copilotApiBin, ["start", "--port", String(port)], {
|
|
52
|
+
stdio: "pipe",
|
|
53
|
+
detached: false,
|
|
54
|
+
});
|
|
55
|
+
child.stdout?.on("data", (d) => {
|
|
56
|
+
const line = d.toString().trim();
|
|
57
|
+
if (line) console.log(`[copilot-api] ${line}`);
|
|
58
|
+
});
|
|
59
|
+
child.stderr?.on("data", (d) => {
|
|
60
|
+
const line = d.toString().trim();
|
|
61
|
+
if (line) console.log(`[copilot-api] ${line}`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Wait for it to be ready
|
|
65
|
+
for (let i = 0; i < 30; i++) {
|
|
66
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
67
|
+
if (await isPortInUse(port)) {
|
|
68
|
+
console.log(`[codex-copilot-dx] copilot-api ready on :${port}`);
|
|
69
|
+
return child;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw new Error("copilot-api failed to start");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function openCodex() {
|
|
76
|
+
if (process.platform === "darwin") {
|
|
77
|
+
try {
|
|
78
|
+
spawn("open", ["/Applications/Codex.app"], { detached: true, stdio: "ignore" });
|
|
79
|
+
console.log("[codex-copilot-dx] Codex app launched");
|
|
80
|
+
} catch {
|
|
81
|
+
console.log("[codex-copilot-dx] Codex app not found at /Applications/Codex.app");
|
|
82
|
+
console.log(" Download from: https://openai.com/codex");
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
console.log("[codex-copilot-dx] Auto-launch not supported on this platform.");
|
|
86
|
+
console.log(" Open Codex manually, it will connect to the adapter.");
|
|
87
|
+
}
|
|
88
|
+
}
|