codex-copilot-dx 0.1.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/adapter.mjs CHANGED
@@ -1,142 +1,10 @@
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";
7
+ import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
140
8
 
141
9
  // Models that only support Responses API (not chat/completions)
142
10
  const RESPONSES_ONLY = new Set([
@@ -147,162 +15,148 @@ const RESPONSES_ONLY = new Set([
147
15
  "gpt-5.2-codex",
148
16
  ]);
149
17
 
150
- // ── Copilot Token ──
18
+ // Direct Copilot Responses API proxy.
151
19
 
152
- let copilotToken = null;
153
- let copilotTokenExpiry = 0;
20
+ const RESPONSE_HISTORY_LIMIT = 64;
21
+ const responseHistories = new Map();
154
22
 
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();
23
+ export function requestPath(reqUrl) {
24
+ return new URL(reqUrl || "/", "http://localhost").pathname;
159
25
  }
160
26
 
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;
27
+ function cloneJson(value) {
28
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
173
29
  }
174
30
 
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
- });
31
+ function responsesInputItems(input) {
32
+ if (input === undefined || input === null) return [];
33
+ if (typeof input === "string") {
34
+ return [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }];
35
+ }
36
+ return Array.isArray(input) ? cloneJson(input) : [cloneJson(input)];
37
+ }
199
38
 
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;
39
+ function responsesOutputItems(output) {
40
+ if (!Array.isArray(output)) return [];
41
+ return cloneJson(output.filter((item) => item?.type === "message" || item?.type === "function_call"));
42
+ }
43
+
44
+ export function clearResponseHistoryForTests() {
45
+ responseHistories.clear();
46
+ }
47
+
48
+ export function prepareResponsesRequest(reqBody) {
49
+ const body = cloneJson(reqBody);
50
+ const currentInputItems = responsesInputItems(body.input);
51
+ const previousId = body.previous_response_id;
52
+
53
+ if (previousId !== undefined && previousId !== null) {
54
+ const previousItems = responseHistories.get(previousId);
55
+ if (!previousItems) {
56
+ const err = new Error(`previous_response_id is not available in local adapter history: ${previousId}`);
57
+ err.statusCode = 400;
58
+ throw err;
59
+ }
60
+ body.input = [...cloneJson(previousItems), ...currentInputItems];
61
+ } else {
62
+ body.input = currentInputItems;
208
63
  }
209
64
 
210
- if (reqBody.stream) {
65
+ delete body.previous_response_id;
66
+ delete body.store;
67
+
68
+ return { body, inputItems: body.input };
69
+ }
70
+
71
+ export function rememberResponseHistory(reqContext, responseJson) {
72
+ if (!responseJson?.id || !Array.isArray(reqContext?.inputItems)) return;
73
+ const items = [...cloneJson(reqContext.inputItems), ...responsesOutputItems(responseJson.output)];
74
+ responseHistories.set(responseJson.id, items);
75
+ while (responseHistories.size > RESPONSE_HISTORY_LIMIT) {
76
+ responseHistories.delete(responseHistories.keys().next().value);
77
+ }
78
+ }
79
+
80
+ function storeCompletedResponseFromSse(reqContext, data) {
81
+ if (!data || data === "[DONE]") return;
82
+ try {
83
+ const event = JSON.parse(data);
84
+ const response = event.response;
85
+ if (response?.id && response.status === "completed") {
86
+ rememberResponseHistory(reqContext, response);
87
+ recordResponsesUsage({
88
+ surface: reqContext.surface,
89
+ mode: "stream",
90
+ model: reqContext.body?.model,
91
+ response,
92
+ event,
93
+ });
94
+ }
95
+ } catch {
96
+ // Ignore non-JSON stream fragments.
97
+ }
98
+ }
99
+
100
+ function readSseEvents(buffer, onData) {
101
+ while (true) {
102
+ const match = buffer.match(/\r?\n\r?\n/);
103
+ if (!match) return buffer;
104
+ const chunk = buffer.slice(0, match.index);
105
+ buffer = buffer.slice(match.index + match[0].length);
106
+ const data = chunk
107
+ .split(/\r?\n/)
108
+ .filter((line) => line.startsWith("data:"))
109
+ .map((line) => line.slice(5).trimStart())
110
+ .join("\n");
111
+ if (data) onData(data);
112
+ }
113
+ }
114
+
115
+ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses) {
116
+ const resp = await upstream(reqContext.body);
117
+
118
+ if (reqContext.body.stream) {
211
119
  res.writeHead(resp.status, {
212
120
  "Content-Type": resp.headers.get("content-type") || "text/event-stream",
213
121
  "Cache-Control": "no-cache",
214
122
  Connection: "keep-alive",
215
123
  });
216
124
  const reader = resp.body.getReader();
125
+ const decoder = new TextDecoder();
126
+ let buffer = "";
217
127
  while (true) {
218
128
  const { done, value } = await reader.read();
219
- if (done) { res.end(); return; }
129
+ if (done) {
130
+ buffer += decoder.decode();
131
+ readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
132
+ res.end();
133
+ return;
134
+ }
135
+ buffer += decoder.decode(value, { stream: true });
136
+ buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
220
137
  res.write(value);
221
138
  }
222
139
  } else {
223
140
  const data = await resp.text();
224
141
  res.writeHead(resp.status, { "Content-Type": "application/json" });
225
142
  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
- }
143
+ if (resp.ok) {
144
+ try {
145
+ const response = JSON.parse(data);
146
+ rememberResponseHistory(reqContext, response);
147
+ recordResponsesUsage({
148
+ surface: reqContext.surface,
149
+ mode: "json",
150
+ model: reqContext.body?.model,
151
+ response,
152
+ event: response,
153
+ });
154
+ } catch {}
290
155
  }
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
156
  }
302
- onDone();
303
157
  }
304
158
 
305
- // ── Chat Completions conversion (for older models via copilot-api) ──
159
+ // Chat Completions conversion for older models.
306
160
 
307
161
  function responsesToChat(body) {
308
162
  const messages = [];
@@ -357,52 +211,42 @@ function chatToResponses(chatResp, model) {
357
211
  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
212
  }
359
213
 
360
- function processStream(upstreamRes, emitEvent, onDone) {
214
+ async function forwardToChat(chatReq, emitEvent, onDone, onError) {
215
+ delete chatReq.max_tokens;
216
+ let resp;
217
+ try {
218
+ resp = await chatCompletions({ ...chatReq, stream: true });
219
+ } catch (e) {
220
+ onError(502, e.message);
221
+ return;
222
+ }
223
+ if (!resp.ok) {
224
+ onError(resp.status, await resp.text());
225
+ return;
226
+ }
361
227
  const respId = `resp_${uid()}`, itemId = `item_${uid()}`;
362
- let actualModel = "unknown";
228
+ let actualModel = "unknown", fullText = "", toolCalls = {}, hasToolCalls = false;
363
229
 
364
230
  emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
365
231
  emitEvent("response.output_item.added", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] } });
366
232
  emitEvent("response.content_part.added", { output_index: 0, content_index: 0, part: { type: "output_text", text: "" } });
367
233
 
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 {
234
+ const emitCompleted = () => {
235
+ if (!hasToolCalls) {
376
236
  emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
377
237
  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
238
  }
383
- onDone();
239
+ const output = hasToolCalls
240
+ ? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
241
+ : [{ type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] }];
242
+ emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
384
243
  };
385
244
 
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) {
245
+ try {
246
+ for await (const line of webStreamLines(resp)) {
391
247
  if (!line.startsWith("data: ")) continue;
392
248
  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
- }
249
+ if (data === "[DONE]") { emitCompleted(); onDone(); return; }
406
250
  let parsed;
407
251
  try { parsed = JSON.parse(data); } catch { continue; }
408
252
  if (parsed.model) actualModel = parsed.model;
@@ -419,153 +263,161 @@ function processStream(upstreamRes, emitEvent, onDone) {
419
263
  }
420
264
  }
421
265
  }
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();
266
+ } catch (e) {
267
+ onError(500, e?.message || "upstream stream error");
488
268
  return;
489
269
  }
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);
270
+ emitCompleted();
271
+ onDone();
523
272
  }
524
273
 
525
- // ── Export ──
274
+ // Public server entry point.
526
275
 
527
- export function startAdapter(port = 4142) {
276
+ export function startAdapter(port = 2026) {
528
277
  const server = http.createServer((req, res) => {
529
- if (req.method === "POST" && req.url?.startsWith("/v1/responses")) {
278
+ const pathname = requestPath(req.url);
279
+
280
+ if (req.method === "POST" && pathname === "/v1/responses") {
530
281
  let body = "";
531
282
  req.on("data", (c) => (body += c));
532
283
  req.on("end", async () => {
533
284
  try {
534
285
  const parsed = JSON.parse(body);
286
+ const prepared = prepareResponsesRequest(parsed);
287
+ prepared.surface = "responses";
535
288
  const model = parsed.model || "unknown";
536
- console.log(`[codex-copilot-dx] ${model} stream=${!!parsed.stream}`);
289
+ console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
537
290
  if (RESPONSES_ONLY.has(model)) {
538
- await proxyCopilotResponses(parsed, req, res);
291
+ await proxyCopilotResponses(prepared, req, res);
539
292
  } else {
540
- const chatReq = responsesToChat(parsed);
293
+ const chatReq = responsesToChat(prepared.body);
541
294
  if (parsed.stream) {
542
295
  forwardToChat(chatReq, (event, data) => {
543
296
  if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
544
297
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
298
+ if (event === "response.completed") {
299
+ rememberResponseHistory(prepared, data.response);
300
+ recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
301
+ }
545
302
  }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); });
546
303
  } else {
547
304
  chatReq.stream = false;
548
305
  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();
306
+ try {
307
+ const upstream = await chatCompletions({ ...chatReq, stream: false });
308
+ const data = await upstream.text();
309
+ const resp = chatToResponses(JSON.parse(data), model);
310
+ rememberResponseHistory(prepared, resp);
311
+ recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response: resp, event: resp });
312
+ res.writeHead(200, { "Content-Type": "application/json" });
313
+ res.end(JSON.stringify(resp));
314
+ } catch (e) {
315
+ if (!res.headersSent) res.writeHead(502);
316
+ res.end("Bad Gateway");
317
+ }
318
+ }
319
+ }
320
+ } catch (e) {
321
+ console.error(status("err", `Responses request failed: ${e.message}`));
322
+ if (!res.headersSent) res.writeHead(400);
323
+ res.end(JSON.stringify({ error: e.message }));
324
+ }
325
+ });
326
+ return;
327
+ }
328
+
329
+ if (req.method === "POST" && pathname === "/v1/responses/compact") {
330
+ let body = "";
331
+ req.on("data", (c) => (body += c));
332
+ req.on("end", async () => {
333
+ try {
334
+ const parsed = JSON.parse(body);
335
+ const prepared = prepareResponsesRequest(parsed);
336
+ prepared.surface = "responses_compact";
337
+ const model = parsed.model || "unknown";
338
+ console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
339
+ await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact);
340
+ } catch (e) {
341
+ console.error(status("err", `Responses compact request failed: ${e.message}`));
342
+ if (!res.headersSent) res.writeHead(400);
343
+ res.end(JSON.stringify({ error: e.message }));
344
+ }
345
+ });
346
+ return;
347
+ }
348
+
349
+ if (req.method === "GET" && pathname === "/v1/models") {
350
+ listModels()
351
+ .then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
352
+ .catch((e) => { if (!res.headersSent) res.writeHead(502); res.end(JSON.stringify({ error: e.message })); });
353
+ return;
354
+ }
355
+
356
+ if (req.method === "POST" && pathname === "/v1/messages/count_tokens") {
357
+ let body = "";
358
+ req.on("data", (c) => (body += c));
359
+ req.on("end", () => {
360
+ try {
361
+ const parsed = JSON.parse(body);
362
+ res.writeHead(200, { "Content-Type": "application/json" });
363
+ res.end(JSON.stringify(countTokens(parsed)));
364
+ } catch (e) {
365
+ if (!res.headersSent) res.writeHead(400);
366
+ res.end(JSON.stringify({ error: e.message }));
367
+ }
368
+ });
369
+ return;
370
+ }
371
+
372
+ if (req.method === "POST" && pathname === "/v1/messages") {
373
+ let body = "";
374
+ req.on("data", (c) => (body += c));
375
+ req.on("end", async () => {
376
+ try {
377
+ const parsed = JSON.parse(body);
378
+ const model = parsed.model || "unknown";
379
+ console.log(status("info", `messages model=${model} stream=${!!parsed.stream}`));
380
+ const chatReq = anthropicToChat(parsed);
381
+ if (parsed.stream) {
382
+ const upstream = await chatCompletions({ ...chatReq, stream: true });
383
+ if (!upstream.ok) {
384
+ if (!res.headersSent) res.writeHead(upstream.status);
385
+ res.end(await upstream.text());
386
+ return;
558
387
  }
388
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
389
+ let messageId;
390
+ let usage;
391
+ await streamAnthropicFromLines(
392
+ webStreamLines(upstream),
393
+ (event, data) => {
394
+ if (event === "message_start") messageId = data.message?.id;
395
+ if (event === "message_delta") usage = data.usage;
396
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
397
+ },
398
+ model,
399
+ );
400
+ recordAnthropicUsage({ surface: "messages", mode: "stream", model, responseId: messageId, usage });
401
+ if (!res.writableEnded) res.end();
402
+ } else {
403
+ const upstream = await chatCompletions({ ...chatReq, stream: false });
404
+ const data = await upstream.text();
405
+ const anthropicMsg = chatToAnthropic(JSON.parse(data), model);
406
+ recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
407
+ res.writeHead(200, { "Content-Type": "application/json" });
408
+ res.end(JSON.stringify(anthropicMsg));
559
409
  }
560
410
  } catch (e) {
561
- console.error("[codex-copilot-dx] error:", e.message);
411
+ console.error(status("err", `Messages request failed: ${e.message}`));
562
412
  if (!res.headersSent) res.writeHead(400);
563
413
  res.end(JSON.stringify({ error: e.message }));
564
414
  }
565
415
  });
566
416
  return;
567
417
  }
568
- proxyPassthrough(req, res);
418
+
419
+ res.writeHead(404, { "Content-Type": "application/json" });
420
+ res.end(JSON.stringify({ error: "Not found" }));
569
421
  });
570
422
 
571
423
  server.on("upgrade", (req, socket) => {
@@ -580,7 +432,7 @@ export function startAdapter(port = 4142) {
580
432
 
581
433
  return new Promise((resolve) => {
582
434
  server.listen(port, () => {
583
- console.log(`[codex-copilot-dx] Adapter listening on http://localhost:${port}`);
435
+ console.log(status("ok", `Adapter listening on http://localhost:${port}`));
584
436
  resolve(server);
585
437
  });
586
438
  });