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/README.md +17 -15
- package/bin/cli.mjs +16 -16
- package/package.json +7 -5
- package/src/adapter.mjs +235 -417
- package/src/anthropic.mjs +267 -0
- package/src/auth.mjs +95 -0
- package/src/claude-config.mjs +56 -0
- package/src/config.mjs +77 -17
- package/src/copilot.mjs +316 -0
- package/src/launcher.mjs +8 -79
- package/src/status.mjs +11 -0
- package/src/stream.mjs +22 -0
- package/CHANGELOG.md +0 -19
package/src/adapter.mjs
CHANGED
|
@@ -1,142 +1,9 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
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
|
-
//
|
|
17
|
+
// Direct Copilot Responses API proxy.
|
|
151
18
|
|
|
152
|
-
|
|
153
|
-
|
|
19
|
+
const RESPONSE_HISTORY_LIMIT = 64;
|
|
20
|
+
const responseHistories = new Map();
|
|
154
21
|
|
|
155
|
-
function
|
|
156
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
//
|
|
254
|
+
// Public server entry point.
|
|
526
255
|
|
|
527
|
-
export function startAdapter(port =
|
|
256
|
+
export function startAdapter(port = 2026) {
|
|
528
257
|
const server = http.createServer((req, res) => {
|
|
529
|
-
|
|
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(`
|
|
268
|
+
console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
|
|
537
269
|
if (RESPONSES_ONLY.has(model)) {
|
|
538
|
-
await proxyCopilotResponses(
|
|
270
|
+
await proxyCopilotResponses(prepared, req, res);
|
|
539
271
|
} else {
|
|
540
|
-
const chatReq = responsesToChat(
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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("
|
|
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
|
-
|
|
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(`
|
|
401
|
+
console.log(status("ok", `Adapter listening on http://localhost:${port}`));
|
|
584
402
|
resolve(server);
|
|
585
403
|
});
|
|
586
404
|
});
|