codex-copilot-dx 0.1.6 → 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.
package/src/adapter.mjs CHANGED
@@ -1,142 +1,9 @@
1
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
- import sharp from "sharp";
8
-
9
- const COPILOT_API_PORT = parseInt(process.env.COPILOT_API_PORT || "4141");
10
- const COPILOT_API = "https://api.githubcopilot.com";
11
- const GITHUB_API = "https://api.github.com";
12
-
13
- // Image optimization knobs
14
- const IMG_MAX_DIM = parseInt(process.env.CCDX_IMG_MAX_DIM || "2048");
15
- const IMG_QUALITY = parseInt(process.env.CCDX_IMG_QUALITY || "85");
16
- const IMG_MIN_BYTES = parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000");
17
- const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
18
- const CCDX_DEBUG = process.env.CCDX_DEBUG === "1";
19
-
20
- async function optimizeImageDataUrl(dataUrl) {
21
- if (IMG_OPT_DISABLED) return dataUrl;
22
- if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return dataUrl;
23
- const m = /^data:(image\/[a-z+.-]+);base64,(.+)$/i.exec(dataUrl);
24
- if (!m) return dataUrl;
25
- const mime = m[1].toLowerCase();
26
- const b64 = m[2];
27
- const raw = Buffer.from(b64, "base64");
28
- if (raw.length < IMG_MIN_BYTES) return dataUrl;
29
- // Skip animated formats — sharp will pick first frame and lose animation; better to leave alone
30
- if (mime.includes("gif")) return dataUrl;
31
- try {
32
- const out = await sharp(raw, { failOn: "none" })
33
- .rotate() // honor EXIF orientation
34
- .resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
35
- .webp({ quality: IMG_QUALITY, effort: 4 })
36
- .toBuffer();
37
- const ratio = ((out.length / raw.length) * 100).toFixed(1);
38
- console.log(`[codex-copilot-dx] image: ${(raw.length/1024).toFixed(0)}KB ${mime} → ${(out.length/1024).toFixed(0)}KB webp (${ratio}%)`);
39
- return `data:image/webp;base64,${out.toString("base64")}`;
40
- } catch (e) {
41
- console.warn(`[codex-copilot-dx] image optimize failed (${mime}, ${raw.length}b): ${e.message}`);
42
- return dataUrl;
43
- }
44
- }
45
-
46
- async function optimizeImagesInBody(reqBody) {
47
- if (IMG_OPT_DISABLED) return reqBody;
48
- if (!Array.isArray(reqBody.input)) return reqBody;
49
- const tasks = [];
50
-
51
- const visitParts = (parts) => {
52
- if (!Array.isArray(parts)) return;
53
- for (const part of parts) {
54
- if (!part) continue;
55
- // Responses API image part shape: { type: "input_image", image_url: "data:..." }
56
- if (part.type === "input_image" && typeof part.image_url === "string") {
57
- tasks.push((async () => { part.image_url = await optimizeImageDataUrl(part.image_url); })());
58
- } else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
59
- // Anthropic style
60
- tasks.push((async () => {
61
- const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
62
- const opt = await optimizeImageDataUrl(dataUrl);
63
- const m = /^data:([^;]+);base64,(.+)$/.exec(opt);
64
- if (m) { part.source.media_type = m[1]; part.source.data = m[2]; }
65
- })());
66
- }
67
- }
68
- };
69
-
70
- for (const item of reqBody.input) {
71
- if (!item) continue;
72
- // Plain user/assistant message
73
- if (item.type === "message" && Array.isArray(item.content)) {
74
- visitParts(item.content);
75
- }
76
- // computer-use / tool results: function_call_output.output can be a
77
- // structured array of parts (input_text, input_image, ...) carrying screenshots.
78
- if (item.type === "function_call_output") {
79
- if (Array.isArray(item.output)) {
80
- visitParts(item.output);
81
- } else if (typeof item.output === "string") {
82
- // Some tools stringify a JSON array of parts. Parse, optimize images
83
- // inside, then re-serialize AFTER all image tasks for this array finish.
84
- const trimmed = item.output.trim();
85
- if (trimmed.startsWith("[")) {
86
- try {
87
- const parsed = JSON.parse(trimmed);
88
- if (Array.isArray(parsed)) {
89
- const before = tasks.length;
90
- visitParts(parsed);
91
- const localTasks = tasks.slice(before); // tasks added by visitParts
92
- if (localTasks.length) {
93
- tasks.push(
94
- Promise.all(localTasks).then(() => {
95
- item.output = JSON.stringify(parsed);
96
- })
97
- );
98
- }
99
- }
100
- } catch { /* not JSON, leave it */ }
101
- }
102
- }
103
- }
104
- }
105
- if (tasks.length) await Promise.all(tasks);
106
- return reqBody;
107
- }
108
-
109
- function summarizeReqBody(reqBody) {
110
- try {
111
- const inp = reqBody.input;
112
- if (!Array.isArray(inp)) return { items: 0, images: 0, biggest: 0, biggestKind: "n/a" };
113
- let biggest = 0, biggestKind = "", biggestItem = null, images = 0;
114
- const countImages = (parts) => {
115
- if (!Array.isArray(parts)) return;
116
- for (const p of parts) {
117
- if (p?.type === "input_image" || p?.type === "image") images++;
118
- }
119
- };
120
- for (const it of inp) {
121
- const s = JSON.stringify(it);
122
- if (s.length > biggest) { biggest = s.length; biggestKind = it.type || "?"; biggestItem = it; }
123
- if (it?.type === "message") countImages(it.content);
124
- if (it?.type === "function_call_output") {
125
- if (Array.isArray(it.output)) countImages(it.output);
126
- else if (typeof it.output === "string" && it.output.trim().startsWith("[")) {
127
- try { const p = JSON.parse(it.output); if (Array.isArray(p)) countImages(p); } catch {}
128
- }
129
- }
130
- }
131
- // Diagnostic: peek at the biggest function_call_output to see if it embeds images
132
- let biggestPeek = "";
133
- if (CCDX_DEBUG && biggestItem?.type === "function_call_output") {
134
- const out = typeof biggestItem.output === "string" ? biggestItem.output : JSON.stringify(biggestItem.output);
135
- biggestPeek = out.slice(0, 200);
136
- }
137
- return { items: inp.length, images, biggest, biggestKind, biggestPeek };
138
- } catch { return { items: -1, images: -1, biggest: -1, biggestKind: "err", biggestPeek: "" }; }
139
- }
2
+ import { randomUUID } from "node:crypto";
3
+ import { chatCompletions, listModels, responses as copilotResponses, responsesCompact as copilotResponsesCompact } from "./copilot.mjs";
4
+ import { webStreamLines } from "./stream.mjs";
5
+ import { anthropicToChat, chatToAnthropic, streamAnthropicFromLines, countTokens } from "./anthropic.mjs";
6
+ import { status } from "./status.mjs";
140
7
 
141
8
  // Models that only support Responses API (not chat/completions)
142
9
  const RESPONSES_ONLY = new Set([
@@ -147,162 +14,129 @@ const RESPONSES_ONLY = new Set([
147
14
  "gpt-5.2-codex",
148
15
  ]);
149
16
 
150
- // ── Copilot Token ──
17
+ // Direct Copilot Responses API proxy.
151
18
 
152
- let copilotToken = null;
153
- let copilotTokenExpiry = 0;
19
+ const RESPONSE_HISTORY_LIMIT = 64;
20
+ const responseHistories = new Map();
154
21
 
155
- function getGithubToken() {
156
- const p = path.join(os.homedir(), ".local", "share", "copilot-api", "github_token");
157
- if (!fs.existsSync(p)) throw new Error("GitHub token not found. Run: npx copilot-api auth");
158
- return fs.readFileSync(p, "utf-8").trim();
22
+ export function requestPath(reqUrl) {
23
+ return new URL(reqUrl || "/", "http://localhost").pathname;
159
24
  }
160
25
 
161
- async function getCopilotToken() {
162
- if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
163
- const ghToken = getGithubToken();
164
- const resp = await fetch(`${GITHUB_API}/copilot_internal/v2/token`, {
165
- headers: { Authorization: `token ${ghToken}`, Accept: "application/json" },
166
- });
167
- if (!resp.ok) throw new Error(`Failed to get Copilot token: ${resp.status}`);
168
- const data = await resp.json();
169
- copilotToken = data.token;
170
- copilotTokenExpiry = data.expires_at * 1000;
171
- console.log("[codex-copilot-dx] Copilot token refreshed");
172
- return copilotToken;
26
+ function cloneJson(value) {
27
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
173
28
  }
174
29
 
175
- // ── Direct Copilot Responses API proxy ──
176
-
177
- async function proxyCopilotResponses(reqBody, req, res) {
178
- const beforeBytes = Buffer.byteLength(JSON.stringify(reqBody));
179
- await optimizeImagesInBody(reqBody);
180
- const token = await getCopilotToken();
181
- const bodyStr = JSON.stringify(reqBody);
182
- const bodyBuf = Buffer.from(bodyStr, "utf-8");
183
- const summary = summarizeReqBody(reqBody);
184
- console.log(`[codex-copilot-dx] → responses model=${reqBody.model} stream=${!!reqBody.stream} bytes=${bodyBuf.length} (was ${beforeBytes}) input_items=${summary.items} images=${summary.images} biggest_item=${summary.biggest}b (${summary.biggestKind})`);
185
- if (summary.biggestPeek) console.log(`[codex-copilot-dx] peek: ${JSON.stringify(summary.biggestPeek)}`);
186
-
187
- const resp = await fetch(`${COPILOT_API}/v1/responses`, {
188
- method: "POST",
189
- headers: {
190
- Authorization: `Bearer ${token}`,
191
- "Content-Type": "application/json; charset=utf-8",
192
- "Content-Length": String(bodyBuf.length),
193
- "Editor-Version": "vscode/1.90.0",
194
- "Copilot-Integration-Id": "vscode-chat",
195
- "Accept": reqBody.stream ? "text/event-stream" : "application/json",
196
- },
197
- body: bodyBuf,
198
- });
30
+ function responsesInputItems(input) {
31
+ if (input === undefined || input === null) return [];
32
+ if (typeof input === "string") {
33
+ return [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }];
34
+ }
35
+ return Array.isArray(input) ? cloneJson(input) : [cloneJson(input)];
36
+ }
199
37
 
200
- if (!resp.ok) {
201
- const errText = await resp.text();
202
- console.error(`[codex-copilot-dx] ✗ upstream ${resp.status} ${resp.statusText}`);
203
- console.error(`[codex-copilot-dx] headers:`, Object.fromEntries(resp.headers));
204
- console.error(`[codex-copilot-dx] body:`, errText.slice(0, 2000));
205
- res.writeHead(resp.status, { "Content-Type": resp.headers.get("content-type") || "application/json" });
206
- res.end(errText);
207
- return;
38
+ function responsesOutputItems(output) {
39
+ if (!Array.isArray(output)) return [];
40
+ return cloneJson(output.filter((item) => item?.type === "message" || item?.type === "function_call"));
41
+ }
42
+
43
+ export function clearResponseHistoryForTests() {
44
+ responseHistories.clear();
45
+ }
46
+
47
+ export function prepareResponsesRequest(reqBody) {
48
+ const body = cloneJson(reqBody);
49
+ const currentInputItems = responsesInputItems(body.input);
50
+ const previousId = body.previous_response_id;
51
+
52
+ if (previousId !== undefined && previousId !== null) {
53
+ const previousItems = responseHistories.get(previousId);
54
+ if (!previousItems) {
55
+ const err = new Error(`previous_response_id is not available in local adapter history: ${previousId}`);
56
+ err.statusCode = 400;
57
+ throw err;
58
+ }
59
+ body.input = [...cloneJson(previousItems), ...currentInputItems];
60
+ } else {
61
+ body.input = currentInputItems;
208
62
  }
209
63
 
210
- if (reqBody.stream) {
64
+ delete body.previous_response_id;
65
+ delete body.store;
66
+
67
+ return { body, inputItems: body.input };
68
+ }
69
+
70
+ export function rememberResponseHistory(reqContext, responseJson) {
71
+ if (!responseJson?.id || !Array.isArray(reqContext?.inputItems)) return;
72
+ const items = [...cloneJson(reqContext.inputItems), ...responsesOutputItems(responseJson.output)];
73
+ responseHistories.set(responseJson.id, items);
74
+ while (responseHistories.size > RESPONSE_HISTORY_LIMIT) {
75
+ responseHistories.delete(responseHistories.keys().next().value);
76
+ }
77
+ }
78
+
79
+ function storeCompletedResponseFromSse(reqContext, data) {
80
+ if (!data || data === "[DONE]") return;
81
+ try {
82
+ const event = JSON.parse(data);
83
+ const response = event.response;
84
+ if (response?.id && response.status === "completed") rememberResponseHistory(reqContext, response);
85
+ } catch {
86
+ // Ignore non-JSON stream fragments.
87
+ }
88
+ }
89
+
90
+ function readSseEvents(buffer, onData) {
91
+ while (true) {
92
+ const match = buffer.match(/\r?\n\r?\n/);
93
+ if (!match) return buffer;
94
+ const chunk = buffer.slice(0, match.index);
95
+ buffer = buffer.slice(match.index + match[0].length);
96
+ const data = chunk
97
+ .split(/\r?\n/)
98
+ .filter((line) => line.startsWith("data:"))
99
+ .map((line) => line.slice(5).trimStart())
100
+ .join("\n");
101
+ if (data) onData(data);
102
+ }
103
+ }
104
+
105
+ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses) {
106
+ const resp = await upstream(reqContext.body);
107
+
108
+ if (reqContext.body.stream) {
211
109
  res.writeHead(resp.status, {
212
110
  "Content-Type": resp.headers.get("content-type") || "text/event-stream",
213
111
  "Cache-Control": "no-cache",
214
112
  Connection: "keep-alive",
215
113
  });
216
114
  const reader = resp.body.getReader();
115
+ const decoder = new TextDecoder();
116
+ let buffer = "";
217
117
  while (true) {
218
118
  const { done, value } = await reader.read();
219
- if (done) { res.end(); return; }
119
+ if (done) {
120
+ buffer += decoder.decode();
121
+ readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
122
+ res.end();
123
+ return;
124
+ }
125
+ buffer += decoder.decode(value, { stream: true });
126
+ buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
220
127
  res.write(value);
221
128
  }
222
129
  } else {
223
130
  const data = await resp.text();
224
131
  res.writeHead(resp.status, { "Content-Type": "application/json" });
225
132
  res.end(data);
226
- }
227
- }
228
-
229
- async function proxyCopilotResponsesWS(reqBody, wsSend, onDone) {
230
- const token = await getCopilotToken();
231
- const payload = { ...reqBody, stream: true };
232
- const beforeBytes = Buffer.byteLength(JSON.stringify(payload));
233
- await optimizeImagesInBody(payload);
234
- const bodyStr = JSON.stringify(payload);
235
- const bodyBuf = Buffer.from(bodyStr, "utf-8");
236
- const summary = summarizeReqBody(payload);
237
- console.log(`[codex-copilot-dx] → responses (ws) model=${payload.model} bytes=${bodyBuf.length} (was ${beforeBytes}) input_items=${summary.items} images=${summary.images} biggest_item=${summary.biggest}b (${summary.biggestKind})`);
238
-
239
- const resp = await fetch(`${COPILOT_API}/v1/responses`, {
240
- method: "POST",
241
- headers: {
242
- Authorization: `Bearer ${token}`,
243
- "Content-Type": "application/json; charset=utf-8",
244
- "Content-Length": String(bodyBuf.length),
245
- "Editor-Version": "vscode/1.90.0",
246
- "Copilot-Integration-Id": "vscode-chat",
247
- "Accept": "text/event-stream",
248
- },
249
- body: bodyBuf,
250
- });
251
-
252
- if (!resp.ok) {
253
- const err = await resp.text();
254
- console.error(`[codex-copilot-dx] ✗ upstream(ws) ${resp.status} ${resp.statusText}`);
255
- console.error(`[codex-copilot-dx] headers:`, Object.fromEntries(resp.headers));
256
- console.error(`[codex-copilot-dx] body:`, err.slice(0, 2000));
257
- wsSend(JSON.stringify({ type: "error", error: { message: err, code: resp.status } }));
258
- onDone();
259
- return;
260
- }
261
-
262
- const reader = resp.body.getReader();
263
- const decoder = new TextDecoder();
264
- let buf = "";
265
- let currentEvent = null;
266
- let sawCompleted = false;
267
- let lastResponseId = `resp_${uid()}`;
268
- let lastModel = reqBody.model || "unknown";
269
- try {
270
- while (true) {
271
- const { done, value } = await reader.read();
272
- if (done) break;
273
- buf += decoder.decode(value, { stream: true });
274
- const lines = buf.split("\n");
275
- buf = lines.pop();
276
- for (const line of lines) {
277
- if (line.startsWith("event: ")) { currentEvent = line.slice(7).trim(); continue; }
278
- if (!line.startsWith("data: ")) { if (line === "") currentEvent = null; continue; }
279
- const data = line.slice(6).trim();
280
- if (data === "[DONE]") continue;
281
- let parsed;
282
- try { parsed = JSON.parse(data); } catch { continue; }
283
- if (!parsed.type && currentEvent) parsed.type = currentEvent;
284
- if (!parsed.type) continue;
285
- if (parsed.response?.id) lastResponseId = parsed.response.id;
286
- if (parsed.response?.model) lastModel = parsed.response.model;
287
- if (parsed.type === "response.completed") sawCompleted = true;
288
- wsSend(JSON.stringify(parsed));
289
- }
133
+ if (resp.ok) {
134
+ try { rememberResponseHistory(reqContext, JSON.parse(data)); } catch {}
290
135
  }
291
- } catch (e) {
292
- wsSend(JSON.stringify({ type: "error", error: { message: e.message || String(e), code: 500 } }));
293
- onDone();
294
- return;
295
- }
296
- if (!sawCompleted) {
297
- wsSend(JSON.stringify({
298
- type: "response.completed",
299
- response: { id: lastResponseId, object: "response", status: "completed", model: lastModel, output: [], usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } },
300
- }));
301
136
  }
302
- onDone();
303
137
  }
304
138
 
305
- // ── Chat Completions conversion (for older models via copilot-api) ──
139
+ // Chat Completions conversion for older models.
306
140
 
307
141
  function responsesToChat(body) {
308
142
  const messages = [];
@@ -357,52 +191,42 @@ function chatToResponses(chatResp, model) {
357
191
  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 };
358
192
  }
359
193
 
360
- function processStream(upstreamRes, emitEvent, onDone) {
194
+ async function forwardToChat(chatReq, emitEvent, onDone, onError) {
195
+ delete chatReq.max_tokens;
196
+ let resp;
197
+ try {
198
+ resp = await chatCompletions({ ...chatReq, stream: true });
199
+ } catch (e) {
200
+ onError(502, e.message);
201
+ return;
202
+ }
203
+ if (!resp.ok) {
204
+ onError(resp.status, await resp.text());
205
+ return;
206
+ }
361
207
  const respId = `resp_${uid()}`, itemId = `item_${uid()}`;
362
- let actualModel = "unknown";
208
+ let actualModel = "unknown", fullText = "", toolCalls = {}, hasToolCalls = false;
363
209
 
364
210
  emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
365
211
  emitEvent("response.output_item.added", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] } });
366
212
  emitEvent("response.content_part.added", { output_index: 0, content_index: 0, part: { type: "output_text", text: "" } });
367
213
 
368
- let buf = "", fullText = "", toolCalls = {}, hasToolCalls = false, finished = false;
369
-
370
- const finish = (errMsg) => {
371
- if (finished) return;
372
- finished = true;
373
- if (errMsg) {
374
- emitEvent("error", { error: { message: errMsg, code: 500 } });
375
- } else {
214
+ const emitCompleted = () => {
215
+ if (!hasToolCalls) {
376
216
  emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
377
217
  emitEvent("response.output_item.done", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] } });
378
- const output = hasToolCalls
379
- ? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
380
- : [{ type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] }];
381
- emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
382
218
  }
383
- onDone();
219
+ const output = hasToolCalls
220
+ ? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
221
+ : [{ type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] }];
222
+ emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
384
223
  };
385
224
 
386
- upstreamRes.on("data", (chunk) => {
387
- buf += chunk.toString();
388
- let lines = buf.split("\n");
389
- buf = lines.pop();
390
- for (const line of lines) {
225
+ try {
226
+ for await (const line of webStreamLines(resp)) {
391
227
  if (!line.startsWith("data: ")) continue;
392
228
  const data = line.slice(6).trim();
393
- if (data === "[DONE]") {
394
- if (!hasToolCalls) {
395
- emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
396
- emitEvent("response.output_item.done", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] } });
397
- }
398
- const output = hasToolCalls
399
- ? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
400
- : [{ type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] }];
401
- emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
402
- finished = true;
403
- onDone();
404
- return;
405
- }
229
+ if (data === "[DONE]") { emitCompleted(); onDone(); return; }
406
230
  let parsed;
407
231
  try { parsed = JSON.parse(data); } catch { continue; }
408
232
  if (parsed.model) actualModel = parsed.model;
@@ -419,153 +243,147 @@ function processStream(upstreamRes, emitEvent, onDone) {
419
243
  }
420
244
  }
421
245
  }
422
- });
423
- upstreamRes.on("end", () => finish());
424
- upstreamRes.on("error", (e) => finish(e?.message || "upstream stream error"));
425
- }
426
-
427
- function forwardToChat(chatReq, emitEvent, onDone, onError) {
428
- delete chatReq.max_tokens;
429
- const body = JSON.stringify(chatReq);
430
- const opts = { hostname: "localhost", port: COPILOT_API_PORT, path: "/v1/chat/completions", method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } };
431
- const proxy = http.request(opts, (upRes) => {
432
- if (upRes.statusCode !== 200) { let d = ""; upRes.on("data", c => d += c); upRes.on("end", () => onError(upRes.statusCode, d)); return; }
433
- processStream(upRes, emitEvent, onDone);
434
- });
435
- proxy.on("error", (e) => onError(502, e.message));
436
- proxy.write(body);
437
- proxy.end();
438
- }
439
-
440
- // ── WebSocket (RFC 6455 minimal) ──
441
-
442
- function wsRead(socket) {
443
- return new Promise((resolve, reject) => {
444
- const chunks = [];
445
- const onData = (data) => {
446
- chunks.push(data);
447
- const buf = Buffer.concat(chunks);
448
- if (buf.length < 2) return;
449
- const masked = (buf[1] & 0x80) !== 0;
450
- let payloadLen = buf[1] & 0x7f, offset = 2;
451
- if (payloadLen === 126) { if (buf.length < 4) return; payloadLen = buf.readUInt16BE(2); offset = 4; }
452
- else if (payloadLen === 127) { if (buf.length < 10) return; payloadLen = Number(buf.readBigUInt64BE(2)); offset = 10; }
453
- const maskLen = masked ? 4 : 0;
454
- if (buf.length < offset + maskLen + payloadLen) return;
455
- let payload = buf.slice(offset + maskLen, offset + maskLen + payloadLen);
456
- if (masked) { const mask = buf.slice(offset, offset + 4); for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]; }
457
- socket.removeListener("data", onData);
458
- const opcode = buf[0] & 0x0f;
459
- if (opcode === 0x8) resolve(null);
460
- else if (opcode === 0x9) { wsSend(socket, "", 0xa); chunks.length = 0; socket.on("data", onData); }
461
- else resolve(payload.toString("utf-8"));
462
- };
463
- socket.on("data", onData);
464
- socket.on("error", reject);
465
- socket.on("close", () => resolve(null));
466
- });
467
- }
468
-
469
- function wsSend(socket, data, opcode = 0x1) {
470
- const payload = typeof data === "string" ? Buffer.from(data) : data;
471
- const len = payload.length;
472
- let header;
473
- if (len < 126) { header = Buffer.alloc(2); header[0] = 0x80 | opcode; header[1] = len; }
474
- else if (len < 65536) { header = Buffer.alloc(4); header[0] = 0x80 | opcode; header[1] = 126; header.writeUInt16BE(len, 2); }
475
- else { header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2); }
476
- try { socket.write(Buffer.concat([header, payload])); } catch {}
477
- }
478
-
479
- async function handleWebSocket(req, socket) {
480
- let rawKey = req.headers["sec-websocket-key"];
481
- if (Array.isArray(rawKey)) rawKey = rawKey[0];
482
- const key = (rawKey || "").trim();
483
- const upgrade = String(req.headers["upgrade"] || "").toLowerCase();
484
- const connection = String(req.headers["connection"] || "").toLowerCase();
485
- if (!key || upgrade !== "websocket" || !connection.includes("upgrade")) {
486
- socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
487
- socket.destroy();
246
+ } catch (e) {
247
+ onError(500, e?.message || "upstream stream error");
488
248
  return;
489
249
  }
490
- const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-5AB9FC6AB199").digest("base64");
491
- socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n");
492
-
493
- while (true) {
494
- const msg = await wsRead(socket);
495
- if (msg === null) break;
496
- let parsed;
497
- try { parsed = JSON.parse(msg); } catch { continue; }
498
- let reqBody = parsed;
499
- if (parsed.type === "response.create" && parsed.response) reqBody = parsed.response;
500
- const model = reqBody.model || "unknown";
501
-
502
- if (RESPONSES_ONLY.has(model)) {
503
- proxyCopilotResponsesWS(reqBody, (d) => wsSend(socket, d), () => {}).catch((e) => {
504
- wsSend(socket, JSON.stringify({ type: "error", error: { message: e.message, code: 500 } }));
505
- });
506
- } else {
507
- const chatReq = responsesToChat(reqBody);
508
- chatReq.stream = true;
509
- forwardToChat(chatReq, (event, data) => wsSend(socket, JSON.stringify({ type: event, ...data })), () => {}, (status, errMsg) => {
510
- wsSend(socket, JSON.stringify({ type: "error", error: { message: errMsg, code: status } }));
511
- });
512
- }
513
- }
514
- }
515
-
516
- // ── HTTP passthrough ──
517
-
518
- function proxyPassthrough(req, res) {
519
- const opts = { hostname: "localhost", port: COPILOT_API_PORT, path: req.url, method: req.method, headers: { ...req.headers, host: `localhost:${COPILOT_API_PORT}` } };
520
- const proxy = http.request(opts, (upRes) => { res.writeHead(upRes.statusCode, upRes.headers); upRes.pipe(res); });
521
- proxy.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end("Bad Gateway"); });
522
- req.pipe(proxy);
250
+ emitCompleted();
251
+ onDone();
523
252
  }
524
253
 
525
- // ── Export ──
254
+ // Public server entry point.
526
255
 
527
- export function startAdapter(port = 4142) {
256
+ export function startAdapter(port = 2026) {
528
257
  const server = http.createServer((req, res) => {
529
- if (req.method === "POST" && req.url?.startsWith("/v1/responses")) {
258
+ const pathname = requestPath(req.url);
259
+
260
+ if (req.method === "POST" && pathname === "/v1/responses") {
530
261
  let body = "";
531
262
  req.on("data", (c) => (body += c));
532
263
  req.on("end", async () => {
533
264
  try {
534
265
  const parsed = JSON.parse(body);
266
+ const prepared = prepareResponsesRequest(parsed);
535
267
  const model = parsed.model || "unknown";
536
- console.log(`[codex-copilot-dx] ${model} stream=${!!parsed.stream}`);
268
+ console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
537
269
  if (RESPONSES_ONLY.has(model)) {
538
- await proxyCopilotResponses(parsed, req, res);
270
+ await proxyCopilotResponses(prepared, req, res);
539
271
  } else {
540
- const chatReq = responsesToChat(parsed);
272
+ const chatReq = responsesToChat(prepared.body);
541
273
  if (parsed.stream) {
542
274
  forwardToChat(chatReq, (event, data) => {
543
275
  if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
544
276
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
277
+ if (event === "response.completed") rememberResponseHistory(prepared, data.response);
545
278
  }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); });
546
279
  } else {
547
280
  chatReq.stream = false;
548
281
  delete chatReq.max_tokens;
549
- const reqBody = JSON.stringify(chatReq);
550
- const opts = { hostname: "localhost", port: COPILOT_API_PORT, path: "/v1/chat/completions", method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(reqBody) } };
551
- const proxy = http.request(opts, (upRes) => {
552
- let data = ""; upRes.on("data", (c) => (data += c));
553
- 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"); } });
554
- });
555
- proxy.on("error", () => res.writeHead(502).end("Bad Gateway"));
556
- proxy.write(reqBody);
557
- proxy.end();
282
+ try {
283
+ const upstream = await chatCompletions({ ...chatReq, stream: false });
284
+ const data = await upstream.text();
285
+ const resp = chatToResponses(JSON.parse(data), model);
286
+ rememberResponseHistory(prepared, resp);
287
+ res.writeHead(200, { "Content-Type": "application/json" });
288
+ res.end(JSON.stringify(resp));
289
+ } catch (e) {
290
+ if (!res.headersSent) res.writeHead(502);
291
+ res.end("Bad Gateway");
292
+ }
293
+ }
294
+ }
295
+ } catch (e) {
296
+ console.error(status("err", `Responses request failed: ${e.message}`));
297
+ if (!res.headersSent) res.writeHead(400);
298
+ res.end(JSON.stringify({ error: e.message }));
299
+ }
300
+ });
301
+ return;
302
+ }
303
+
304
+ if (req.method === "POST" && pathname === "/v1/responses/compact") {
305
+ let body = "";
306
+ req.on("data", (c) => (body += c));
307
+ req.on("end", async () => {
308
+ try {
309
+ const parsed = JSON.parse(body);
310
+ const prepared = prepareResponsesRequest(parsed);
311
+ const model = parsed.model || "unknown";
312
+ console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
313
+ await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact);
314
+ } catch (e) {
315
+ console.error(status("err", `Responses compact request failed: ${e.message}`));
316
+ if (!res.headersSent) res.writeHead(400);
317
+ res.end(JSON.stringify({ error: e.message }));
318
+ }
319
+ });
320
+ return;
321
+ }
322
+
323
+ if (req.method === "GET" && pathname === "/v1/models") {
324
+ listModels()
325
+ .then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
326
+ .catch((e) => { if (!res.headersSent) res.writeHead(502); res.end(JSON.stringify({ error: e.message })); });
327
+ return;
328
+ }
329
+
330
+ if (req.method === "POST" && pathname === "/v1/messages/count_tokens") {
331
+ let body = "";
332
+ req.on("data", (c) => (body += c));
333
+ req.on("end", () => {
334
+ try {
335
+ const parsed = JSON.parse(body);
336
+ res.writeHead(200, { "Content-Type": "application/json" });
337
+ res.end(JSON.stringify(countTokens(parsed)));
338
+ } catch (e) {
339
+ if (!res.headersSent) res.writeHead(400);
340
+ res.end(JSON.stringify({ error: e.message }));
341
+ }
342
+ });
343
+ return;
344
+ }
345
+
346
+ if (req.method === "POST" && pathname === "/v1/messages") {
347
+ let body = "";
348
+ req.on("data", (c) => (body += c));
349
+ req.on("end", async () => {
350
+ try {
351
+ const parsed = JSON.parse(body);
352
+ const model = parsed.model || "unknown";
353
+ console.log(status("info", `messages model=${model} stream=${!!parsed.stream}`));
354
+ const chatReq = anthropicToChat(parsed);
355
+ if (parsed.stream) {
356
+ const upstream = await chatCompletions({ ...chatReq, stream: true });
357
+ if (!upstream.ok) {
358
+ if (!res.headersSent) res.writeHead(upstream.status);
359
+ res.end(await upstream.text());
360
+ return;
558
361
  }
362
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
363
+ await streamAnthropicFromLines(
364
+ webStreamLines(upstream),
365
+ (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
366
+ model,
367
+ );
368
+ if (!res.writableEnded) res.end();
369
+ } else {
370
+ const upstream = await chatCompletions({ ...chatReq, stream: false });
371
+ const data = await upstream.text();
372
+ const anthropicMsg = chatToAnthropic(JSON.parse(data), model);
373
+ res.writeHead(200, { "Content-Type": "application/json" });
374
+ res.end(JSON.stringify(anthropicMsg));
559
375
  }
560
376
  } catch (e) {
561
- console.error("[codex-copilot-dx] error:", e.message);
377
+ console.error(status("err", `Messages request failed: ${e.message}`));
562
378
  if (!res.headersSent) res.writeHead(400);
563
379
  res.end(JSON.stringify({ error: e.message }));
564
380
  }
565
381
  });
566
382
  return;
567
383
  }
568
- proxyPassthrough(req, res);
384
+
385
+ res.writeHead(404, { "Content-Type": "application/json" });
386
+ res.end(JSON.stringify({ error: "Not found" }));
569
387
  });
570
388
 
571
389
  server.on("upgrade", (req, socket) => {
@@ -580,7 +398,7 @@ export function startAdapter(port = 4142) {
580
398
 
581
399
  return new Promise((resolve) => {
582
400
  server.listen(port, () => {
583
- console.log(`[codex-copilot-dx] Adapter listening on http://localhost:${port}`);
401
+ console.log(status("ok", `Adapter listening on http://localhost:${port}`));
584
402
  resolve(server);
585
403
  });
586
404
  });