codex-copilot-dx 0.1.5 → 0.4.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.
@@ -0,0 +1,316 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { randomUUID } from "node:crypto";
5
+ import { status } from "./status.mjs";
6
+
7
+ const GITHUB_TOKEN_PATH = path.join(os.homedir(), ".local", "share", "copilot-api", "github_token");
8
+ export const DEFAULT_API_BASE = "https://api.githubcopilot.com";
9
+ let apiBase = DEFAULT_API_BASE;
10
+ const IMG_MAX_DIM = parseInt(process.env.CCDX_IMG_MAX_DIM || "2048", 10);
11
+ const IMG_QUALITY = parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
12
+ const IMG_MIN_BYTES = parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
13
+ const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
14
+ let sharpImport = null;
15
+
16
+ export function parseApiBase(data) {
17
+ return (data && data.endpoints && typeof data.endpoints.api === "string" && data.endpoints.api)
18
+ ? data.endpoints.api
19
+ : DEFAULT_API_BASE;
20
+ }
21
+
22
+ export function getApiBase() {
23
+ return apiBase;
24
+ }
25
+
26
+ export function responsesEndpointPath() {
27
+ return "/responses";
28
+ }
29
+ const GITHUB_API = "https://api.github.com";
30
+
31
+ async function sharp() {
32
+ sharpImport ||= import("sharp");
33
+ const mod = await sharpImport;
34
+ return mod.default || mod;
35
+ }
36
+
37
+ export async function optimizeImageDataUrl(dataUrl) {
38
+ if (IMG_OPT_DISABLED) return dataUrl;
39
+ if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return dataUrl;
40
+ const match = /^data:(image\/[a-z+.-]+);base64,(.+)$/i.exec(dataUrl);
41
+ if (!match) return dataUrl;
42
+ const mime = match[1].toLowerCase();
43
+ const raw = Buffer.from(match[2], "base64");
44
+ if (raw.length < IMG_MIN_BYTES || mime.includes("gif")) return dataUrl;
45
+
46
+ try {
47
+ const resize = await sharp();
48
+ const out = await resize(raw, { failOn: "none" })
49
+ .rotate()
50
+ .resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
51
+ .webp({ quality: IMG_QUALITY, effort: 4 })
52
+ .toBuffer();
53
+ const ratio = ((out.length / raw.length) * 100).toFixed(1);
54
+ console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
55
+ return `data:image/webp;base64,${out.toString("base64")}`;
56
+ } catch (e) {
57
+ console.warn(status("warn", `image optimize failed (${mime}, ${raw.length}b): ${e.message}`));
58
+ return dataUrl;
59
+ }
60
+ }
61
+
62
+ function visitImageParts(parts, tasks) {
63
+ if (!Array.isArray(parts)) return;
64
+ for (const part of parts) {
65
+ if (!part) continue;
66
+ if (part.type === "input_image" && typeof part.image_url === "string") {
67
+ tasks.push((async () => { part.image_url = await optimizeImageDataUrl(part.image_url); })());
68
+ } else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
69
+ tasks.push((async () => {
70
+ const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
71
+ const opt = await optimizeImageDataUrl(dataUrl);
72
+ const match = /^data:([^;]+);base64,(.+)$/.exec(opt);
73
+ if (match) {
74
+ part.source.media_type = match[1];
75
+ part.source.data = match[2];
76
+ }
77
+ })());
78
+ }
79
+ }
80
+ }
81
+
82
+ export async function optimizeImagesInBody(reqBody) {
83
+ if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
84
+ const tasks = [];
85
+
86
+ for (const item of reqBody.input) {
87
+ if (!item) continue;
88
+ if (item.type === "message" && Array.isArray(item.content)) {
89
+ visitImageParts(item.content, tasks);
90
+ }
91
+ if (item.type === "function_call_output") {
92
+ if (Array.isArray(item.output)) {
93
+ visitImageParts(item.output, tasks);
94
+ } else if (typeof item.output === "string") {
95
+ const trimmed = item.output.trim();
96
+ if (trimmed.startsWith("[")) {
97
+ try {
98
+ const parsed = JSON.parse(trimmed);
99
+ if (Array.isArray(parsed)) {
100
+ const before = tasks.length;
101
+ visitImageParts(parsed, tasks);
102
+ const localTasks = tasks.slice(before);
103
+ if (localTasks.length) {
104
+ tasks.push(Promise.all(localTasks).then(() => {
105
+ item.output = JSON.stringify(parsed);
106
+ }));
107
+ }
108
+ }
109
+ } catch {
110
+ // Leave non-JSON tool output untouched.
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ if (tasks.length) await Promise.all(tasks);
118
+ return reqBody;
119
+ }
120
+
121
+ export function summarizeReqBody(reqBody) {
122
+ try {
123
+ const input = reqBody.input;
124
+ if (!Array.isArray(input)) return { items: 0, images: 0, biggest: 0, biggestKind: "n/a" };
125
+ let biggest = 0;
126
+ let biggestKind = "";
127
+ let images = 0;
128
+ const countImages = (parts) => {
129
+ if (!Array.isArray(parts)) return;
130
+ for (const part of parts) {
131
+ if (part?.type === "input_image" || part?.type === "image") images++;
132
+ }
133
+ };
134
+
135
+ for (const item of input) {
136
+ const size = JSON.stringify(item).length;
137
+ if (size > biggest) {
138
+ biggest = size;
139
+ biggestKind = item?.type || "?";
140
+ }
141
+ if (item?.type === "message") countImages(item.content);
142
+ if (item?.type === "function_call_output") {
143
+ if (Array.isArray(item.output)) {
144
+ countImages(item.output);
145
+ } else if (typeof item.output === "string" && item.output.trim().startsWith("[")) {
146
+ try {
147
+ const parsed = JSON.parse(item.output);
148
+ if (Array.isArray(parsed)) countImages(parsed);
149
+ } catch {
150
+ // Ignore non-JSON tool output.
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ return { items: input.length, images, biggest, biggestKind };
157
+ } catch {
158
+ return { items: -1, images: -1, biggest: -1, biggestKind: "err" };
159
+ }
160
+ }
161
+
162
+ export function computeInitiator(messages) {
163
+ const isAgent = Array.isArray(messages)
164
+ && messages.some((m) => m && ["assistant", "tool"].includes(m.role));
165
+ return isAgent ? "agent" : "user";
166
+ }
167
+
168
+ export function computeVision(messages) {
169
+ return Array.isArray(messages) && messages.some(
170
+ (m) => m && typeof m.content !== "string"
171
+ && Array.isArray(m.content)
172
+ && m.content.some((p) => p && p.type === "image_url"),
173
+ );
174
+ }
175
+
176
+ export function buildHeaders({ token, version, initiator, vision }) {
177
+ const h = {
178
+ "Authorization": `Bearer ${token}`,
179
+ "Content-Type": "application/json",
180
+ "Copilot-Integration-Id": "vscode-chat",
181
+ "Editor-Version": `vscode/${version}`,
182
+ "Editor-Plugin-Version": "copilot-chat/0.26.7",
183
+ "User-Agent": "GitHubCopilotChat/0.26.7",
184
+ "Openai-Intent": "conversation-panel",
185
+ "X-Github-Api-Version": "2025-04-01",
186
+ "X-Request-Id": randomUUID(),
187
+ "X-Vscode-User-Agent-Library-Version": "electron-fetch",
188
+ "X-Initiator": initiator,
189
+ };
190
+ if (vision) h["Copilot-Vision-Request"] = "true";
191
+ return h;
192
+ }
193
+
194
+ export const FALLBACK_VSCODE_VERSION = "1.122.1";
195
+
196
+ let cachedVersion = FALLBACK_VSCODE_VERSION;
197
+
198
+ export function parseVSCodeVersion(json) {
199
+ return (json && typeof json.productVersion === "string" && json.productVersion)
200
+ ? json.productVersion
201
+ : FALLBACK_VSCODE_VERSION;
202
+ }
203
+
204
+ export function getVSCodeVersion() {
205
+ return cachedVersion;
206
+ }
207
+
208
+ // Refresh the VS Code version asynchronously; keep the fallback on failure.
209
+ export async function refreshVSCodeVersion() {
210
+ try {
211
+ const ctrl = new AbortController();
212
+ const timer = setTimeout(() => ctrl.abort(), 5000);
213
+ const resp = await fetch(
214
+ "https://update.code.visualstudio.com/api/update/darwin-arm64/stable/latest",
215
+ { signal: ctrl.signal },
216
+ );
217
+ clearTimeout(timer);
218
+ if (resp.ok) {
219
+ cachedVersion = parseVSCodeVersion(await resp.json());
220
+ console.log(status("info", `VS Code version: ${cachedVersion}`));
221
+ }
222
+ } catch {
223
+ // Keep the fallback quietly.
224
+ }
225
+ return cachedVersion;
226
+ }
227
+
228
+ let copilotToken = null;
229
+ let copilotTokenExpiry = 0;
230
+
231
+ function getGithubToken() {
232
+ if (!fs.existsSync(GITHUB_TOKEN_PATH)) {
233
+ throw new Error("GitHub token not found. Run the tool once to log in.");
234
+ }
235
+ return fs.readFileSync(GITHUB_TOKEN_PATH, "utf-8").trim();
236
+ }
237
+
238
+ export async function getCopilotToken() {
239
+ if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
240
+ const ghToken = getGithubToken();
241
+ const resp = await fetch(`${GITHUB_API}/copilot_internal/v2/token`, {
242
+ headers: { Authorization: `token ${ghToken}`, Accept: "application/json" },
243
+ });
244
+ if (!resp.ok) throw new Error(`Failed to get Copilot token: ${resp.status}`);
245
+ const data = await resp.json();
246
+ if (!data.token) throw new Error("Copilot token response missing token field");
247
+ copilotToken = data.token;
248
+ apiBase = parseApiBase(data);
249
+ copilotTokenExpiry = typeof data.expires_at === "number"
250
+ ? data.expires_at * 1000
251
+ : Date.now() + 25 * 60 * 1000; // fallback if expires_at absent: refresh in ~25min
252
+ console.log(status("ok", "Copilot token refreshed"));
253
+ return copilotToken;
254
+ }
255
+
256
+ // chatReq is already converted by the adapter. The caller parses the raw Response.
257
+ export async function chatCompletions(chatReq) {
258
+ const token = await getCopilotToken();
259
+ const messages = chatReq.messages || [];
260
+ const headers = buildHeaders({
261
+ token,
262
+ version: getVSCodeVersion(),
263
+ initiator: computeInitiator(messages),
264
+ vision: computeVision(messages),
265
+ });
266
+ return fetch(`${getApiBase()}/chat/completions`, {
267
+ method: "POST",
268
+ headers,
269
+ body: JSON.stringify(chatReq),
270
+ });
271
+ }
272
+
273
+ export async function listModels() {
274
+ const token = await getCopilotToken();
275
+ const headers = buildHeaders({
276
+ token, version: getVSCodeVersion(), initiator: "user", vision: false,
277
+ });
278
+ const resp = await fetch(`${getApiBase()}/models`, { headers });
279
+ return { status: resp.status, body: await resp.text() };
280
+ }
281
+
282
+ // Responses-only models go directly to Copilot's /responses endpoint.
283
+ export async function responses(reqBody) {
284
+ const token = await getCopilotToken();
285
+ const beforeBytes = Buffer.byteLength(JSON.stringify(reqBody));
286
+ await optimizeImagesInBody(reqBody);
287
+ const bodyBuf = Buffer.from(JSON.stringify(reqBody), "utf8");
288
+ const summary = summarizeReqBody(reqBody);
289
+ console.log(status("info", `responses payload bytes=${bodyBuf.length} was=${beforeBytes} input_items=${summary.items} images=${summary.images} biggest_item=${summary.biggest}b (${summary.biggestKind})`));
290
+ const headers = buildHeaders({
291
+ token,
292
+ version: getVSCodeVersion(),
293
+ initiator: "user",
294
+ vision: false,
295
+ });
296
+ headers["Content-Type"] = "application/json; charset=utf-8";
297
+ headers["Content-Length"] = String(bodyBuf.length);
298
+ headers["Accept"] = reqBody.stream ? "text/event-stream" : "application/json";
299
+
300
+ try {
301
+ return await fetch(`${getApiBase()}${responsesEndpointPath()}`, {
302
+ method: "POST",
303
+ headers,
304
+ body: bodyBuf,
305
+ });
306
+ } catch (e) {
307
+ const cause = e?.cause;
308
+ const causeText = cause ? ` (${[cause.code, cause.message].filter(Boolean).join(": ")})` : "";
309
+ throw new Error(`Copilot responses fetch failed: ${e.message}${causeText}`);
310
+ }
311
+ }
312
+
313
+ export async function responsesCompact(reqBody) {
314
+ // GitHub Copilot accepts Codex compact payloads on the regular Responses endpoint.
315
+ return responses(reqBody);
316
+ }
package/src/launcher.mjs CHANGED
@@ -1,90 +1,19 @@
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";
1
+ import { spawn } from "node:child_process";
2
+ import { status } from "./status.mjs";
6
3
 
7
- const GITHUB_TOKEN_PATH = path.join(os.homedir(), ".local", "share", "copilot-api", "github_token");
8
-
9
- function isPortAccepting(port) {
10
- return new Promise((resolve) => {
11
- const sock = net.connect(port, "127.0.0.1");
12
- let done = false;
13
- const finish = (v) => { if (done) return; done = true; sock.destroy(); resolve(v); };
14
- sock.once("connect", () => finish(true));
15
- sock.once("error", () => finish(false));
16
- sock.setTimeout(1000, () => finish(false));
17
- });
18
- }
19
-
20
- export async function ensureAuth() {
21
- if (fs.existsSync(GITHUB_TOKEN_PATH)) {
22
- console.log("[codex-copilot-dx] GitHub token found");
23
- return;
24
- }
25
- console.log("[codex-copilot-dx] No GitHub token. Running auth...");
26
- const copilotApiBin = findCopilotApi();
27
- execSync(`${copilotApiBin} auth`, { stdio: "inherit" });
28
- }
29
-
30
- function findCopilotApi() {
31
- try {
32
- return execSync("which copilot-api", { encoding: "utf-8" }).trim();
33
- } catch {
34
- // Try npx path
35
- const npxPath = path.join(os.homedir(), ".npm/_npx");
36
- if (fs.existsSync(npxPath)) {
37
- for (const dir of fs.readdirSync(npxPath)) {
38
- const bin = path.join(npxPath, dir, "node_modules/.bin/copilot-api");
39
- if (fs.existsSync(bin)) return bin;
40
- }
41
- }
42
- return "npx copilot-api";
43
- }
44
- }
45
-
46
- export async function startCopilotApi(port = 4141) {
47
- if (await isPortAccepting(port)) {
48
- console.log(`[codex-copilot-dx] copilot-api already running on :${port}`);
49
- return null;
50
- }
51
- console.log(`[codex-copilot-dx] Starting copilot-api on :${port}...`);
52
- const copilotApiBin = findCopilotApi();
53
- const child = spawn(copilotApiBin, ["start", "--port", String(port)], {
54
- stdio: "pipe",
55
- detached: false,
56
- });
57
- child.stdout?.on("data", (d) => {
58
- const line = d.toString().trim();
59
- if (line) console.log(`[copilot-api] ${line}`);
60
- });
61
- child.stderr?.on("data", (d) => {
62
- const line = d.toString().trim();
63
- if (line) console.log(`[copilot-api] ${line}`);
64
- });
65
-
66
- // Wait for it to be ready
67
- for (let i = 0; i < 30; i++) {
68
- await new Promise((r) => setTimeout(r, 500));
69
- if (await isPortAccepting(port)) {
70
- console.log(`[codex-copilot-dx] copilot-api ready on :${port}`);
71
- return child;
72
- }
73
- }
74
- throw new Error("copilot-api failed to start");
75
- }
4
+ export { ensureAuth } from "./auth.mjs";
76
5
 
77
6
  export function openCodex() {
78
7
  if (process.platform === "darwin") {
79
8
  try {
80
9
  spawn("open", ["/Applications/Codex.app"], { detached: true, stdio: "ignore" });
81
- console.log("[codex-copilot-dx] Codex app launched");
10
+ console.log(status("ok", "Codex app launched"));
82
11
  } catch {
83
- console.log("[codex-copilot-dx] Codex app not found at /Applications/Codex.app");
84
- console.log(" Download from: https://openai.com/codex");
12
+ console.log(status("warn", "Codex app not found at /Applications/Codex.app"));
13
+ console.log(status("info", "Download Codex from https://openai.com/codex"));
85
14
  }
86
15
  } else {
87
- console.log("[codex-copilot-dx] Auto-launch not supported on this platform.");
88
- console.log(" Open Codex manually, it will connect to the adapter.");
16
+ console.log(status("warn", "Auto-launch is not supported on this platform"));
17
+ console.log(status("info", "Open Codex manually; it will connect to the adapter."));
89
18
  }
90
19
  }
package/src/status.mjs ADDED
@@ -0,0 +1,11 @@
1
+ const LABELS = {
2
+ info: "[INFO]",
3
+ ok: "[OK]",
4
+ wait: "[WAIT]",
5
+ warn: "[WARN]",
6
+ err: "[ERR]",
7
+ };
8
+
9
+ export function status(kind, message) {
10
+ return `${LABELS[kind] || LABELS.info} ${message}`;
11
+ }
package/src/stream.mjs ADDED
@@ -0,0 +1,22 @@
1
+ // Yield fetch Response body lines while preserving SSE semantics for callers.
2
+ // Strip CRLF tails and release the reader if iteration stops early.
3
+ export async function* webStreamLines(response) {
4
+ const reader = response.body.getReader();
5
+ const decoder = new TextDecoder();
6
+ let buf = "";
7
+ try {
8
+ while (true) {
9
+ const { done, value } = await reader.read();
10
+ if (done) break;
11
+ buf += decoder.decode(value, { stream: true });
12
+ const lines = buf.split("\n");
13
+ buf = lines.pop();
14
+ for (const line of lines) yield line.replace(/\r$/, "");
15
+ }
16
+ buf += decoder.decode();
17
+ if (buf.length > 0) yield buf.replace(/\r$/, "");
18
+ } finally {
19
+ await reader.cancel().catch(() => {});
20
+ reader.releaseLock();
21
+ }
22
+ }