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/README.md +27 -15
- package/bin/cli.mjs +22 -16
- package/package.json +7 -5
- package/src/adapter.mjs +269 -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/src/usage.mjs +192 -0
- package/CHANGELOG.md +0 -19
package/src/adapter.mjs
CHANGED
|
@@ -1,142 +1,10 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
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
|
-
//
|
|
18
|
+
// Direct Copilot Responses API proxy.
|
|
151
19
|
|
|
152
|
-
|
|
153
|
-
|
|
20
|
+
const RESPONSE_HISTORY_LIMIT = 64;
|
|
21
|
+
const responseHistories = new Map();
|
|
154
22
|
|
|
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();
|
|
23
|
+
export function requestPath(reqUrl) {
|
|
24
|
+
return new URL(reqUrl || "/", "http://localhost").pathname;
|
|
159
25
|
}
|
|
160
26
|
|
|
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;
|
|
27
|
+
function cloneJson(value) {
|
|
28
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
173
29
|
}
|
|
174
30
|
|
|
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
|
-
});
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
270
|
+
emitCompleted();
|
|
271
|
+
onDone();
|
|
523
272
|
}
|
|
524
273
|
|
|
525
|
-
//
|
|
274
|
+
// Public server entry point.
|
|
526
275
|
|
|
527
|
-
export function startAdapter(port =
|
|
276
|
+
export function startAdapter(port = 2026) {
|
|
528
277
|
const server = http.createServer((req, res) => {
|
|
529
|
-
|
|
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(`
|
|
289
|
+
console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
|
|
537
290
|
if (RESPONSES_ONLY.has(model)) {
|
|
538
|
-
await proxyCopilotResponses(
|
|
291
|
+
await proxyCopilotResponses(prepared, req, res);
|
|
539
292
|
} else {
|
|
540
|
-
const chatReq = responsesToChat(
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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("
|
|
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
|
-
|
|
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(`
|
|
435
|
+
console.log(status("ok", `Adapter listening on http://localhost:${port}`));
|
|
584
436
|
resolve(server);
|
|
585
437
|
});
|
|
586
438
|
});
|