codex-copilot-dx 0.4.4 → 0.4.6
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 +6 -1
- package/bin/cli.mjs +11 -2
- package/package.json +1 -1
- package/src/adapter.mjs +246 -49
- package/src/anthropic.mjs +17 -17
- package/src/claude-config.mjs +2 -2
- package/src/config.mjs +5 -5
- package/src/copilot.mjs +42 -19
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ npx codex-copilot-dx@latest
|
|
|
28
28
|
On first run, it will:
|
|
29
29
|
1. Authenticate with GitHub via device flow (if needed)
|
|
30
30
|
2. Print the local package version and warn when a newer npm release is available
|
|
31
|
-
3. Start the adapter (
|
|
31
|
+
3. Start the adapter on loopback (`127.0.0.1:2026`)
|
|
32
32
|
4. Configure Codex (`~/.codex/config.toml`) to use the adapter, including stale shell env base URLs if present
|
|
33
33
|
5. Configure Claude Code (`~/.claude/settings.json`) to use the adapter; it creates the file when missing, otherwise backs up `settings.json.bak` before updating the local API env keys
|
|
34
34
|
6. Launch Codex Desktop
|
|
@@ -43,10 +43,15 @@ Environment variables:
|
|
|
43
43
|
|
|
44
44
|
| Variable | Default | Description |
|
|
45
45
|
|----------|---------|-------------|
|
|
46
|
+
| `ADAPTER_HOST` | `127.0.0.1` | Host for the adapter; set `0.0.0.0` only if you knowingly expose it on a trusted network |
|
|
46
47
|
| `ADAPTER_PORT` | `2026` | Port for the adapter |
|
|
48
|
+
| `CCDX_MAX_BODY_BYTES` | `134217728` | Maximum compressed/raw request body size |
|
|
49
|
+
| `CCDX_MAX_DECODED_BODY_BYTES` | `268435456` | Maximum decoded request body size after decompression |
|
|
50
|
+
| `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
|
|
47
51
|
| `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
|
|
48
52
|
| `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
|
|
49
53
|
| `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
|
|
54
|
+
| `CCDX_IMG_CONCURRENCY` | `4` | Concurrent image optimization tasks; values above `12` are capped at `12` |
|
|
50
55
|
| `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
|
|
51
56
|
| `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token usage log |
|
|
52
57
|
| `CCDX_DISABLE_USAGE` | unset | Set to `1` to disable usage logging |
|
package/bin/cli.mjs
CHANGED
|
@@ -10,9 +10,14 @@ import { printUsageSummary } from "../src/usage.mjs";
|
|
|
10
10
|
import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
|
|
11
11
|
|
|
12
12
|
const ADAPTER_PORT = parseInt(process.env.ADAPTER_PORT || "2026");
|
|
13
|
+
const ADAPTER_HOST = process.env.ADAPTER_HOST || "127.0.0.1";
|
|
13
14
|
const LOCAL_VERSION = localPackageVersion();
|
|
14
15
|
const command = process.argv[2];
|
|
15
16
|
|
|
17
|
+
function isLoopbackHost(host) {
|
|
18
|
+
return ["127.0.0.1", "localhost", "::1"].includes(String(host).toLowerCase());
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
17
22
|
console.log(`codex-copilot-dx v${LOCAL_VERSION}`);
|
|
18
23
|
process.exit(0);
|
|
@@ -41,8 +46,12 @@ try {
|
|
|
41
46
|
// Refresh the VS Code version in the background; fallback is non-blocking.
|
|
42
47
|
refreshVSCodeVersion();
|
|
43
48
|
|
|
49
|
+
if (!isLoopbackHost(ADAPTER_HOST)) {
|
|
50
|
+
console.log(status("warn", `ADAPTER_HOST=${ADAPTER_HOST} exposes the adapter beyond loopback. Use only on trusted networks.`));
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
// Start the in-process adapter.
|
|
45
|
-
await startAdapter(ADAPTER_PORT);
|
|
54
|
+
await startAdapter(ADAPTER_PORT, ADAPTER_HOST);
|
|
46
55
|
|
|
47
56
|
// Point Codex and Claude Code at the adapter.
|
|
48
57
|
ensureCodexConfig(ADAPTER_PORT);
|
|
@@ -54,7 +63,7 @@ try {
|
|
|
54
63
|
console.log(`
|
|
55
64
|
${status("ok", "Ready. Codex and Claude Code are using your GitHub Copilot subscription.")}
|
|
56
65
|
|
|
57
|
-
Adapter: http
|
|
66
|
+
Adapter: http://${ADAPTER_HOST}:${ADAPTER_PORT}
|
|
58
67
|
|
|
59
68
|
Press Ctrl+C to stop.
|
|
60
69
|
`);
|
package/package.json
CHANGED
package/src/adapter.mjs
CHANGED
|
@@ -21,11 +21,23 @@ const RESPONSES_ONLY = new Set([
|
|
|
21
21
|
|
|
22
22
|
const RESPONSE_HISTORY_LIMIT = 64;
|
|
23
23
|
const responseHistories = new Map();
|
|
24
|
+
const DEFAULT_MAX_BODY_BYTES = 128 * 1024 * 1024;
|
|
25
|
+
const DEFAULT_MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;
|
|
26
|
+
const DEFAULT_UPSTREAM_TIMEOUT_MS = 120 * 1000;
|
|
24
27
|
const gunzipAsync = promisify(zlib.gunzip);
|
|
25
28
|
const inflateAsync = promisify(zlib.inflate);
|
|
26
29
|
const brotliDecompressAsync = promisify(zlib.brotliDecompress);
|
|
27
30
|
const zstdDecompressAsync = zlib.zstdDecompress ? promisify(zlib.zstdDecompress) : null;
|
|
28
31
|
|
|
32
|
+
function positiveInt(value, fallback) {
|
|
33
|
+
const n = Number.parseInt(value, 10);
|
|
34
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MAX_BODY_BYTES = positiveInt(process.env.CCDX_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
|
|
38
|
+
const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
|
|
39
|
+
const UPSTREAM_TIMEOUT_MS = positiveInt(process.env.CCDX_UPSTREAM_TIMEOUT_MS, DEFAULT_UPSTREAM_TIMEOUT_MS);
|
|
40
|
+
|
|
29
41
|
export function requestPath(reqUrl) {
|
|
30
42
|
return new URL(reqUrl || "/", "http://localhost").pathname;
|
|
31
43
|
}
|
|
@@ -34,15 +46,45 @@ function cloneJson(value) {
|
|
|
34
46
|
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
35
47
|
}
|
|
36
48
|
|
|
37
|
-
|
|
49
|
+
function httpError(message, statusCode) {
|
|
50
|
+
const err = new Error(message);
|
|
51
|
+
err.statusCode = statusCode;
|
|
52
|
+
return err;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function payloadTooLarge(kind, maxBytes) {
|
|
56
|
+
return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readRequestBuffer(req, maxBytes) {
|
|
60
|
+
const contentLength = Number.parseInt(req.headers?.["content-length"] || "", 10);
|
|
61
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
62
|
+
throw payloadTooLarge("Raw", maxBytes);
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
const chunks = [];
|
|
66
|
+
let total = 0;
|
|
39
67
|
for await (const chunk of req) {
|
|
40
|
-
|
|
68
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
69
|
+
total += buf.length;
|
|
70
|
+
if (total > maxBytes) throw payloadTooLarge("Raw", maxBytes);
|
|
71
|
+
chunks.push(buf);
|
|
41
72
|
}
|
|
42
73
|
return Buffer.concat(chunks);
|
|
43
74
|
}
|
|
44
75
|
|
|
45
|
-
async function
|
|
76
|
+
async function decompressBody(decompress, buffer, maxBytes) {
|
|
77
|
+
try {
|
|
78
|
+
return await decompress(buffer, { maxOutputLength: maxBytes });
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (e?.code === "ERR_BUFFER_TOO_LARGE" || /maxOutputLength/i.test(e?.message || "")) {
|
|
81
|
+
throw payloadTooLarge("Decoded", maxBytes);
|
|
82
|
+
}
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
|
|
46
88
|
const encodings = String(contentEncoding || "identity")
|
|
47
89
|
.split(",")
|
|
48
90
|
.map((v) => v.trim().toLowerCase())
|
|
@@ -52,27 +94,86 @@ async function decodeRequestBuffer(buffer, contentEncoding) {
|
|
|
52
94
|
for (const encoding of encodings.reverse()) {
|
|
53
95
|
if (encoding === "identity") continue;
|
|
54
96
|
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
55
|
-
decoded = await gunzipAsync
|
|
97
|
+
decoded = await decompressBody(gunzipAsync, decoded, maxBytes);
|
|
56
98
|
} else if (encoding === "deflate") {
|
|
57
|
-
decoded = await inflateAsync
|
|
99
|
+
decoded = await decompressBody(inflateAsync, decoded, maxBytes);
|
|
58
100
|
} else if (encoding === "br") {
|
|
59
|
-
decoded = await brotliDecompressAsync
|
|
101
|
+
decoded = await decompressBody(brotliDecompressAsync, decoded, maxBytes);
|
|
60
102
|
} else if (encoding === "zstd") {
|
|
61
103
|
if (!zstdDecompressAsync) throw new Error("Unsupported Content-Encoding: zstd");
|
|
62
|
-
decoded = await zstdDecompressAsync
|
|
104
|
+
decoded = await decompressBody(zstdDecompressAsync, decoded, maxBytes);
|
|
63
105
|
} else {
|
|
64
106
|
throw new Error(`Unsupported Content-Encoding: ${encoding}`);
|
|
65
107
|
}
|
|
108
|
+
if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
|
|
66
109
|
}
|
|
110
|
+
if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
|
|
67
111
|
return decoded;
|
|
68
112
|
}
|
|
69
113
|
|
|
70
|
-
export async function readJsonBody(req
|
|
71
|
-
|
|
72
|
-
|
|
114
|
+
export async function readJsonBody(req, {
|
|
115
|
+
maxBodyBytes = MAX_BODY_BYTES,
|
|
116
|
+
maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
|
|
117
|
+
} = {}) {
|
|
118
|
+
const buffer = await readRequestBuffer(req, maxBodyBytes);
|
|
119
|
+
const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
|
|
73
120
|
return JSON.parse(decoded.toString("utf8"));
|
|
74
121
|
}
|
|
75
122
|
|
|
123
|
+
function sendJsonError(res, err, fallbackStatus = 400) {
|
|
124
|
+
if (!res.headersSent) res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
|
|
125
|
+
res.end(JSON.stringify({ error: err?.message || "Request failed" }));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sendUpstreamError(res, resp, text) {
|
|
129
|
+
if (!res.headersSent) {
|
|
130
|
+
res.writeHead(resp.status || 502, { "Content-Type": resp.headers?.get("content-type") || "application/json" });
|
|
131
|
+
}
|
|
132
|
+
res.end(text || JSON.stringify({ error: "Upstream request failed" }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function writeOrDrain(res, chunk) {
|
|
136
|
+
if (res.destroyed || res.writableEnded) return Promise.resolve(false);
|
|
137
|
+
if (res.write(chunk)) return Promise.resolve(true);
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const cleanup = () => {
|
|
140
|
+
res.off("drain", onDrain);
|
|
141
|
+
res.off("error", onError);
|
|
142
|
+
res.off("close", onClose);
|
|
143
|
+
};
|
|
144
|
+
const onDrain = () => { cleanup(); resolve(true); };
|
|
145
|
+
const onClose = () => { cleanup(); resolve(false); };
|
|
146
|
+
const onError = (err) => { cleanup(); reject(err); };
|
|
147
|
+
res.once("drain", onDrain);
|
|
148
|
+
res.once("close", onClose);
|
|
149
|
+
res.once("error", onError);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function createRequestAbort(req, res) {
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
let timer = null;
|
|
156
|
+
let cleaned = false;
|
|
157
|
+
const abort = () => {
|
|
158
|
+
if (!cleaned && !controller.signal.aborted) controller.abort();
|
|
159
|
+
};
|
|
160
|
+
req.on("aborted", abort);
|
|
161
|
+
res.on("close", abort);
|
|
162
|
+
return {
|
|
163
|
+
signal: controller.signal,
|
|
164
|
+
setTimeout(ms) {
|
|
165
|
+
if (timer) clearTimeout(timer);
|
|
166
|
+
if (ms > 0) timer = setTimeout(abort, ms);
|
|
167
|
+
},
|
|
168
|
+
cleanup() {
|
|
169
|
+
cleaned = true;
|
|
170
|
+
if (timer) clearTimeout(timer);
|
|
171
|
+
req.off("aborted", abort);
|
|
172
|
+
res.off("close", abort);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
76
177
|
function responsesInputItems(input) {
|
|
77
178
|
if (input === undefined || input === null) return [];
|
|
78
179
|
if (typeof input === "string") {
|
|
@@ -86,6 +187,81 @@ function responsesOutputItems(output) {
|
|
|
86
187
|
return cloneJson(output.filter((item) => item?.type === "message" || item?.type === "function_call"));
|
|
87
188
|
}
|
|
88
189
|
|
|
190
|
+
function stripEncryptedReasoningValue(value, state) {
|
|
191
|
+
if (Array.isArray(value)) {
|
|
192
|
+
return value.map((item) => stripEncryptedReasoningValue(item, state));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (value && typeof value === "object") {
|
|
196
|
+
const out = {};
|
|
197
|
+
for (const [key, child] of Object.entries(value)) {
|
|
198
|
+
if (key === "encrypted_content") {
|
|
199
|
+
state.changed = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
out[key] = stripEncryptedReasoningValue(child, state);
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isEncryptedReasoningInputItem(item) {
|
|
211
|
+
return item && typeof item === "object"
|
|
212
|
+
&& (item.type === "reasoning" || Object.prototype.hasOwnProperty.call(item, "encrypted_content"));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function sanitizeEncryptedReasoningRequest(reqContext) {
|
|
216
|
+
const state = { changed: false };
|
|
217
|
+
let body = cloneJson(reqContext.body);
|
|
218
|
+
if (Array.isArray(body.input)) {
|
|
219
|
+
const input = [];
|
|
220
|
+
for (const item of body.input) {
|
|
221
|
+
if (isEncryptedReasoningInputItem(item)) {
|
|
222
|
+
state.changed = true;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
input.push(stripEncryptedReasoningValue(item, state));
|
|
226
|
+
}
|
|
227
|
+
body.input = input;
|
|
228
|
+
} else {
|
|
229
|
+
body = stripEncryptedReasoningValue(body, state);
|
|
230
|
+
}
|
|
231
|
+
if (!state.changed) return null;
|
|
232
|
+
return {
|
|
233
|
+
...reqContext,
|
|
234
|
+
body,
|
|
235
|
+
inputItems: Array.isArray(body.input) ? body.input : reqContext.inputItems,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function isEncryptedContentVerificationError(statusCode, text) {
|
|
240
|
+
if (statusCode < 400 || !text) return false;
|
|
241
|
+
const lower = String(text).toLowerCase();
|
|
242
|
+
return lower.includes("encrypted content")
|
|
243
|
+
&& lower.includes("could not be verified")
|
|
244
|
+
&& (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
|
|
248
|
+
let resp = await upstream(reqContext.body, { signal: options.signal });
|
|
249
|
+
if (resp.ok) return { resp, reqContext };
|
|
250
|
+
|
|
251
|
+
const errorText = await resp.text();
|
|
252
|
+
if (!isEncryptedContentVerificationError(resp.status, errorText)) {
|
|
253
|
+
return { resp, reqContext, errorText };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
|
|
257
|
+
if (!retryContext) return { resp, reqContext, errorText };
|
|
258
|
+
|
|
259
|
+
console.warn(status("warn", "encrypted reasoning rejected by upstream; retrying without encrypted reasoning"));
|
|
260
|
+
resp = await upstream(retryContext.body, { signal: options.signal });
|
|
261
|
+
if (resp.ok) return { resp, reqContext: retryContext };
|
|
262
|
+
return { resp, reqContext: retryContext, errorText: await resp.text() };
|
|
263
|
+
}
|
|
264
|
+
|
|
89
265
|
export function clearResponseHistoryForTests() {
|
|
90
266
|
responseHistories.clear();
|
|
91
267
|
}
|
|
@@ -161,8 +337,14 @@ function readSseEvents(buffer, onData) {
|
|
|
161
337
|
}
|
|
162
338
|
}
|
|
163
339
|
|
|
164
|
-
async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses) {
|
|
165
|
-
const
|
|
340
|
+
async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
|
|
341
|
+
const opened = await openCopilotResponse(reqContext, upstream, options);
|
|
342
|
+
const { resp, errorText } = opened;
|
|
343
|
+
reqContext = opened.reqContext;
|
|
344
|
+
if (errorText !== undefined) {
|
|
345
|
+
sendUpstreamError(res, resp, errorText);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
166
348
|
|
|
167
349
|
if (reqContext.body.stream) {
|
|
168
350
|
res.writeHead(resp.status, {
|
|
@@ -183,7 +365,7 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
|
|
|
183
365
|
}
|
|
184
366
|
buffer += decoder.decode(value, { stream: true });
|
|
185
367
|
buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
|
|
186
|
-
res
|
|
368
|
+
await writeOrDrain(res, value);
|
|
187
369
|
}
|
|
188
370
|
} else {
|
|
189
371
|
const data = await resp.text();
|
|
@@ -260,11 +442,11 @@ function chatToResponses(chatResp, model) {
|
|
|
260
442
|
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 };
|
|
261
443
|
}
|
|
262
444
|
|
|
263
|
-
async function forwardToChat(chatReq, emitEvent, onDone, onError) {
|
|
445
|
+
async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
|
|
264
446
|
delete chatReq.max_tokens;
|
|
265
447
|
let resp;
|
|
266
448
|
try {
|
|
267
|
-
resp = await chatCompletions({ ...chatReq, stream: true });
|
|
449
|
+
resp = await chatCompletions({ ...chatReq, stream: true }, { signal: options.signal });
|
|
268
450
|
} catch (e) {
|
|
269
451
|
onError(502, e.message);
|
|
270
452
|
return;
|
|
@@ -276,37 +458,37 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError) {
|
|
|
276
458
|
const respId = `resp_${uid()}`, itemId = `item_${uid()}`;
|
|
277
459
|
let actualModel = "unknown", fullText = "", toolCalls = {}, hasToolCalls = false;
|
|
278
460
|
|
|
279
|
-
emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
|
|
280
|
-
emitEvent("response.output_item.added", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] } });
|
|
281
|
-
emitEvent("response.content_part.added", { output_index: 0, content_index: 0, part: { type: "output_text", text: "" } });
|
|
461
|
+
await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
|
|
462
|
+
await emitEvent("response.output_item.added", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] } });
|
|
463
|
+
await emitEvent("response.content_part.added", { output_index: 0, content_index: 0, part: { type: "output_text", text: "" } });
|
|
282
464
|
|
|
283
|
-
const emitCompleted = () => {
|
|
465
|
+
const emitCompleted = async () => {
|
|
284
466
|
if (!hasToolCalls) {
|
|
285
|
-
emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
|
|
286
|
-
emitEvent("response.output_item.done", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] } });
|
|
467
|
+
await emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
|
|
468
|
+
await emitEvent("response.output_item.done", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] } });
|
|
287
469
|
}
|
|
288
470
|
const output = hasToolCalls
|
|
289
471
|
? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
|
|
290
472
|
: [{ type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] }];
|
|
291
|
-
emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
|
|
473
|
+
await emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
|
|
292
474
|
};
|
|
293
475
|
|
|
294
476
|
try {
|
|
295
477
|
for await (const line of webStreamLines(resp)) {
|
|
296
478
|
if (!line.startsWith("data: ")) continue;
|
|
297
479
|
const data = line.slice(6).trim();
|
|
298
|
-
if (data === "[DONE]") { emitCompleted(); onDone(); return; }
|
|
480
|
+
if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
|
|
299
481
|
let parsed;
|
|
300
482
|
try { parsed = JSON.parse(data); } catch { continue; }
|
|
301
483
|
if (parsed.model) actualModel = parsed.model;
|
|
302
484
|
const delta = parsed.choices?.[0]?.delta;
|
|
303
485
|
if (!delta) continue;
|
|
304
|
-
if (delta.content) { fullText += delta.content; emitEvent("response.output_text.delta", { output_index: 0, content_index: 0, delta: delta.content }); }
|
|
486
|
+
if (delta.content) { fullText += delta.content; await emitEvent("response.output_text.delta", { output_index: 0, content_index: 0, delta: delta.content }); }
|
|
305
487
|
if (delta.tool_calls) {
|
|
306
488
|
hasToolCalls = true;
|
|
307
489
|
for (const tc of delta.tool_calls) {
|
|
308
490
|
const id = tc.id || Object.keys(toolCalls)[tc.index] || `call_${tc.index}`;
|
|
309
|
-
if (!toolCalls[id]) { toolCalls[id] = { name: "", arguments: "" }; emitEvent("response.output_item.added", { output_index: Object.keys(toolCalls).length - 1, item: { type: "function_call", id, call_id: id, name: tc.function?.name || "", arguments: "", status: "in_progress" } }); }
|
|
491
|
+
if (!toolCalls[id]) { toolCalls[id] = { name: "", arguments: "" }; await emitEvent("response.output_item.added", { output_index: Object.keys(toolCalls).length - 1, item: { type: "function_call", id, call_id: id, name: tc.function?.name || "", arguments: "", status: "in_progress" } }); }
|
|
310
492
|
if (tc.function?.name) toolCalls[id].name += tc.function.name;
|
|
311
493
|
if (tc.function?.arguments) toolCalls[id].arguments += tc.function.arguments;
|
|
312
494
|
}
|
|
@@ -316,42 +498,44 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError) {
|
|
|
316
498
|
onError(500, e?.message || "upstream stream error");
|
|
317
499
|
return;
|
|
318
500
|
}
|
|
319
|
-
emitCompleted();
|
|
501
|
+
await emitCompleted();
|
|
320
502
|
onDone();
|
|
321
503
|
}
|
|
322
504
|
|
|
323
505
|
// Public server entry point.
|
|
324
506
|
|
|
325
|
-
export function startAdapter(port = 2026) {
|
|
507
|
+
export function startAdapter(port = 2026, host = "127.0.0.1") {
|
|
326
508
|
const server = http.createServer((req, res) => {
|
|
327
509
|
const pathname = requestPath(req.url);
|
|
328
510
|
|
|
329
511
|
if (req.method === "POST" && pathname === "/v1/responses") {
|
|
330
512
|
(async () => {
|
|
513
|
+
const abort = createRequestAbort(req, res);
|
|
331
514
|
try {
|
|
332
515
|
const parsed = await readJsonBody(req);
|
|
516
|
+
if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
|
|
333
517
|
const prepared = prepareResponsesRequest(parsed);
|
|
334
518
|
prepared.surface = "responses";
|
|
335
519
|
const model = parsed.model || "unknown";
|
|
336
520
|
console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
|
|
337
521
|
if (RESPONSES_ONLY.has(model)) {
|
|
338
|
-
await proxyCopilotResponses(prepared, req, res);
|
|
522
|
+
await proxyCopilotResponses(prepared, req, res, copilotResponses, { signal: abort.signal });
|
|
339
523
|
} else {
|
|
340
524
|
const chatReq = responsesToChat(prepared.body);
|
|
341
525
|
if (parsed.stream) {
|
|
342
|
-
forwardToChat(chatReq, (event, data) => {
|
|
526
|
+
await forwardToChat(chatReq, async (event, data) => {
|
|
343
527
|
if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
344
|
-
res
|
|
528
|
+
await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
345
529
|
if (event === "response.completed") {
|
|
346
530
|
rememberResponseHistory(prepared, data.response);
|
|
347
531
|
recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
|
|
348
532
|
}
|
|
349
|
-
}, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); });
|
|
533
|
+
}, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, { signal: abort.signal });
|
|
350
534
|
} else {
|
|
351
535
|
chatReq.stream = false;
|
|
352
536
|
delete chatReq.max_tokens;
|
|
353
537
|
try {
|
|
354
|
-
const upstream = await chatCompletions({ ...chatReq, stream: false });
|
|
538
|
+
const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
|
|
355
539
|
const data = await upstream.text();
|
|
356
540
|
const resp = chatToResponses(JSON.parse(data), model);
|
|
357
541
|
rememberResponseHistory(prepared, resp);
|
|
@@ -366,8 +550,9 @@ export function startAdapter(port = 2026) {
|
|
|
366
550
|
}
|
|
367
551
|
} catch (e) {
|
|
368
552
|
console.error(status("err", `Responses request failed: ${e.message}`));
|
|
369
|
-
|
|
370
|
-
|
|
553
|
+
sendJsonError(res, e);
|
|
554
|
+
} finally {
|
|
555
|
+
abort.cleanup();
|
|
371
556
|
}
|
|
372
557
|
})();
|
|
373
558
|
return;
|
|
@@ -375,38 +560,46 @@ export function startAdapter(port = 2026) {
|
|
|
375
560
|
|
|
376
561
|
if (req.method === "POST" && pathname === "/v1/responses/compact") {
|
|
377
562
|
(async () => {
|
|
563
|
+
const abort = createRequestAbort(req, res);
|
|
378
564
|
try {
|
|
379
565
|
const parsed = await readJsonBody(req);
|
|
566
|
+
if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
|
|
380
567
|
const prepared = prepareResponsesRequest(parsed);
|
|
381
568
|
prepared.surface = "responses_compact";
|
|
382
569
|
const model = parsed.model || "unknown";
|
|
383
570
|
console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
|
|
384
|
-
await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact);
|
|
571
|
+
await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact, { signal: abort.signal });
|
|
385
572
|
} catch (e) {
|
|
386
573
|
console.error(status("err", `Responses compact request failed: ${e.message}`));
|
|
387
|
-
|
|
388
|
-
|
|
574
|
+
sendJsonError(res, e);
|
|
575
|
+
} finally {
|
|
576
|
+
abort.cleanup();
|
|
389
577
|
}
|
|
390
578
|
})();
|
|
391
579
|
return;
|
|
392
580
|
}
|
|
393
581
|
|
|
394
582
|
if (req.method === "GET" && pathname === "/v1/models") {
|
|
395
|
-
|
|
583
|
+
const abort = createRequestAbort(req, res);
|
|
584
|
+
abort.setTimeout(UPSTREAM_TIMEOUT_MS);
|
|
585
|
+
listModels({ signal: abort.signal })
|
|
396
586
|
.then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
|
|
397
|
-
.catch((e) => { if (!res.headersSent) res.writeHead(502); res.end(JSON.stringify({ error: e.message })); })
|
|
587
|
+
.catch((e) => { if (!res.headersSent) res.writeHead(502); res.end(JSON.stringify({ error: e.message })); })
|
|
588
|
+
.finally(() => abort.cleanup());
|
|
398
589
|
return;
|
|
399
590
|
}
|
|
400
591
|
|
|
401
592
|
if (req.method === "POST" && pathname === "/v1/messages/count_tokens") {
|
|
402
593
|
(async () => {
|
|
594
|
+
const abort = createRequestAbort(req, res);
|
|
403
595
|
try {
|
|
404
596
|
const parsed = await readJsonBody(req);
|
|
405
597
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
406
598
|
res.end(JSON.stringify(countTokens(parsed)));
|
|
407
599
|
} catch (e) {
|
|
408
|
-
|
|
409
|
-
|
|
600
|
+
sendJsonError(res, e);
|
|
601
|
+
} finally {
|
|
602
|
+
abort.cleanup();
|
|
410
603
|
}
|
|
411
604
|
})();
|
|
412
605
|
return;
|
|
@@ -414,13 +607,15 @@ export function startAdapter(port = 2026) {
|
|
|
414
607
|
|
|
415
608
|
if (req.method === "POST" && pathname === "/v1/messages") {
|
|
416
609
|
(async () => {
|
|
610
|
+
const abort = createRequestAbort(req, res);
|
|
417
611
|
try {
|
|
418
612
|
const parsed = await readJsonBody(req);
|
|
613
|
+
if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
|
|
419
614
|
const model = parsed.model || "unknown";
|
|
420
615
|
console.log(status("info", `messages model=${model} stream=${!!parsed.stream}`));
|
|
421
616
|
const chatReq = anthropicToChat(parsed);
|
|
422
617
|
if (parsed.stream) {
|
|
423
|
-
const upstream = await chatCompletions({ ...chatReq, stream: true });
|
|
618
|
+
const upstream = await chatCompletions({ ...chatReq, stream: true }, { signal: abort.signal });
|
|
424
619
|
if (!upstream.ok) {
|
|
425
620
|
if (!res.headersSent) res.writeHead(upstream.status);
|
|
426
621
|
res.end(await upstream.text());
|
|
@@ -431,17 +626,17 @@ export function startAdapter(port = 2026) {
|
|
|
431
626
|
let usage;
|
|
432
627
|
await streamAnthropicFromLines(
|
|
433
628
|
webStreamLines(upstream),
|
|
434
|
-
(event, data) => {
|
|
629
|
+
async (event, data) => {
|
|
435
630
|
if (event === "message_start") messageId = data.message?.id;
|
|
436
631
|
if (event === "message_delta") usage = data.usage;
|
|
437
|
-
res
|
|
632
|
+
await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
438
633
|
},
|
|
439
634
|
model,
|
|
440
635
|
);
|
|
441
636
|
recordAnthropicUsage({ surface: "messages", mode: "stream", model, responseId: messageId, usage });
|
|
442
637
|
if (!res.writableEnded) res.end();
|
|
443
638
|
} else {
|
|
444
|
-
const upstream = await chatCompletions({ ...chatReq, stream: false });
|
|
639
|
+
const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
|
|
445
640
|
const data = await upstream.text();
|
|
446
641
|
const anthropicMsg = chatToAnthropic(JSON.parse(data), model);
|
|
447
642
|
recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
|
|
@@ -450,8 +645,9 @@ export function startAdapter(port = 2026) {
|
|
|
450
645
|
}
|
|
451
646
|
} catch (e) {
|
|
452
647
|
console.error(status("err", `Messages request failed: ${e.message}`));
|
|
453
|
-
|
|
454
|
-
|
|
648
|
+
sendJsonError(res, e);
|
|
649
|
+
} finally {
|
|
650
|
+
abort.cleanup();
|
|
455
651
|
}
|
|
456
652
|
})();
|
|
457
653
|
return;
|
|
@@ -472,8 +668,9 @@ export function startAdapter(port = 2026) {
|
|
|
472
668
|
});
|
|
473
669
|
|
|
474
670
|
return new Promise((resolve) => {
|
|
475
|
-
server.listen(port, () => {
|
|
476
|
-
|
|
671
|
+
server.listen(port, host, () => {
|
|
672
|
+
const actualPort = server.address()?.port || port;
|
|
673
|
+
console.log(status("ok", `Adapter listening on http://${host}:${actualPort}`));
|
|
477
674
|
resolve(server);
|
|
478
675
|
});
|
|
479
676
|
});
|
package/src/anthropic.mjs
CHANGED
|
@@ -163,26 +163,26 @@ export async function streamAnthropicFromLines(lineIterator, emit, model) {
|
|
|
163
163
|
let sawToolUse = false;
|
|
164
164
|
let outputTokens = 0;
|
|
165
165
|
|
|
166
|
-
const ensureStart = () => {
|
|
166
|
+
const ensureStart = async () => {
|
|
167
167
|
if (started) return;
|
|
168
168
|
started = true;
|
|
169
|
-
emit("message_start", { type: "message_start", message: {
|
|
169
|
+
await emit("message_start", { type: "message_start", message: {
|
|
170
170
|
id: msgId, type: "message", role: "assistant", model: actualModel,
|
|
171
171
|
content: [], stop_reason: null, stop_sequence: null,
|
|
172
172
|
usage: { input_tokens: 0, output_tokens: 0 },
|
|
173
173
|
} });
|
|
174
174
|
};
|
|
175
175
|
|
|
176
|
-
const openText = () => {
|
|
176
|
+
const openText = async () => {
|
|
177
177
|
if (textOpen) return;
|
|
178
178
|
blockIndex += 1;
|
|
179
179
|
textOpen = true;
|
|
180
|
-
emit("content_block_start", { type: "content_block_start", index: blockIndex,
|
|
180
|
+
await emit("content_block_start", { type: "content_block_start", index: blockIndex,
|
|
181
181
|
content_block: { type: "text", text: "" } });
|
|
182
182
|
};
|
|
183
|
-
const closeText = () => {
|
|
183
|
+
const closeText = async () => {
|
|
184
184
|
if (!textOpen) return;
|
|
185
|
-
emit("content_block_stop", { type: "content_block_stop", index: blockIndex });
|
|
185
|
+
await emit("content_block_stop", { type: "content_block_stop", index: blockIndex });
|
|
186
186
|
textOpen = false;
|
|
187
187
|
};
|
|
188
188
|
|
|
@@ -197,27 +197,27 @@ export async function streamAnthropicFromLines(lineIterator, emit, model) {
|
|
|
197
197
|
const choice = parsed.choices?.[0];
|
|
198
198
|
if (!choice) continue;
|
|
199
199
|
const delta = choice.delta || {};
|
|
200
|
-
ensureStart();
|
|
200
|
+
await ensureStart();
|
|
201
201
|
|
|
202
202
|
if (delta.content && !sawToolUse) {
|
|
203
|
-
openText();
|
|
204
|
-
emit("content_block_delta", { type: "content_block_delta", index: blockIndex,
|
|
203
|
+
await openText();
|
|
204
|
+
await emit("content_block_delta", { type: "content_block_delta", index: blockIndex,
|
|
205
205
|
delta: { type: "text_delta", text: delta.content } });
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
if (Array.isArray(delta.tool_calls)) {
|
|
209
209
|
sawToolUse = true;
|
|
210
|
-
closeText();
|
|
210
|
+
await closeText();
|
|
211
211
|
for (const tc of delta.tool_calls) {
|
|
212
212
|
const oi = tc.index ?? 0;
|
|
213
213
|
if (!toolBlocks[oi]) {
|
|
214
214
|
blockIndex += 1;
|
|
215
215
|
toolBlocks[oi] = { anthropicIndex: blockIndex };
|
|
216
|
-
emit("content_block_start", { type: "content_block_start", index: blockIndex,
|
|
216
|
+
await emit("content_block_start", { type: "content_block_start", index: blockIndex,
|
|
217
217
|
content_block: { type: "tool_use", id: tc.id || `tu_${uid()}`, name: tc.function?.name || "", input: {} } });
|
|
218
218
|
}
|
|
219
219
|
if (tc.function?.arguments) {
|
|
220
|
-
emit("content_block_delta", { type: "content_block_delta", index: toolBlocks[oi].anthropicIndex,
|
|
220
|
+
await emit("content_block_delta", { type: "content_block_delta", index: toolBlocks[oi].anthropicIndex,
|
|
221
221
|
delta: { type: "input_json_delta", partial_json: tc.function.arguments } });
|
|
222
222
|
}
|
|
223
223
|
}
|
|
@@ -226,16 +226,16 @@ export async function streamAnthropicFromLines(lineIterator, emit, model) {
|
|
|
226
226
|
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
ensureStart();
|
|
230
|
-
if (textOpen) closeText();
|
|
229
|
+
await ensureStart();
|
|
230
|
+
if (textOpen) await closeText();
|
|
231
231
|
for (const oi of Object.keys(toolBlocks)) {
|
|
232
|
-
emit("content_block_stop", { type: "content_block_stop", index: toolBlocks[oi].anthropicIndex });
|
|
232
|
+
await emit("content_block_stop", { type: "content_block_stop", index: toolBlocks[oi].anthropicIndex });
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
const stop_reason = finishReason ? mapStopReason(finishReason) : (sawToolUse ? "tool_use" : "end_turn");
|
|
236
|
-
emit("message_delta", { type: "message_delta", delta: { stop_reason, stop_sequence: null },
|
|
236
|
+
await emit("message_delta", { type: "message_delta", delta: { stop_reason, stop_sequence: null },
|
|
237
237
|
usage: { output_tokens: outputTokens } });
|
|
238
|
-
emit("message_stop", { type: "message_stop" });
|
|
238
|
+
await emit("message_stop", { type: "message_stop" });
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
// Copilot has no count_tokens endpoint; estimate locally with gpt-tokenizer.
|
package/src/claude-config.mjs
CHANGED
|
@@ -7,7 +7,7 @@ const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
|
7
7
|
|
|
8
8
|
// Return an updated settings object without mutating the input.
|
|
9
9
|
export function computeUpdatedSettings(settings, port) {
|
|
10
|
-
const target = `http://
|
|
10
|
+
const target = `http://127.0.0.1:${port}`;
|
|
11
11
|
const current = settings?.env?.ANTHROPIC_BASE_URL;
|
|
12
12
|
const currentToken = settings?.env?.ANTHROPIC_AUTH_TOKEN;
|
|
13
13
|
const changed = current !== target || currentToken !== "dummy";
|
|
@@ -24,7 +24,7 @@ export function computeUpdatedSettings(settings, port) {
|
|
|
24
24
|
|
|
25
25
|
// Point Claude Code at the adapter by updating ~/.claude/settings.json.
|
|
26
26
|
export function ensureClaudeConfig(port = 2026) {
|
|
27
|
-
const target = `http://
|
|
27
|
+
const target = `http://127.0.0.1:${port}`;
|
|
28
28
|
|
|
29
29
|
if (!fs.existsSync(SETTINGS_PATH)) {
|
|
30
30
|
fs.mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true });
|
package/src/config.mjs
CHANGED
|
@@ -33,8 +33,8 @@ function setTomlKey(lines, sectionName, key, value) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
|
|
36
|
-
const baseUrl = `http://
|
|
37
|
-
const anthropicBaseUrl = `http://
|
|
36
|
+
const baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
|
|
37
|
+
const anthropicBaseUrl = `http://127.0.0.1:${adapterPort}`;
|
|
38
38
|
const hadTrailingNewline = content.endsWith("\n");
|
|
39
39
|
const lines = content.split("\n");
|
|
40
40
|
if (hadTrailingNewline) lines.pop();
|
|
@@ -59,8 +59,8 @@ export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function initialCodexConfig(adapterPort) {
|
|
62
|
-
const baseUrl = `http://
|
|
63
|
-
const anthropicBaseUrl = `http://
|
|
62
|
+
const baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
|
|
63
|
+
const anthropicBaseUrl = `http://127.0.0.1:${adapterPort}`;
|
|
64
64
|
return `openai_base_url = "${baseUrl}"
|
|
65
65
|
|
|
66
66
|
[shell_environment_policy]
|
|
@@ -75,7 +75,7 @@ OPENAI_API_KEY = "dummy"
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export function ensureCodexConfig(adapterPort = 2026) {
|
|
78
|
-
const baseUrl = `http://
|
|
78
|
+
const baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
|
|
79
79
|
|
|
80
80
|
if (!fs.existsSync(CONFIG_PATH)) {
|
|
81
81
|
// Codex config does not exist yet; create the local proxy defaults.
|
package/src/copilot.mjs
CHANGED
|
@@ -10,9 +10,17 @@ let apiBase = DEFAULT_API_BASE;
|
|
|
10
10
|
const IMG_MAX_DIM = parseInt(process.env.CCDX_IMG_MAX_DIM || "2048", 10);
|
|
11
11
|
const IMG_QUALITY = parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
|
|
12
12
|
const IMG_MIN_BYTES = parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
|
|
13
|
+
const DEFAULT_IMG_CONCURRENCY = 4;
|
|
14
|
+
const IMG_MAX_CONCURRENCY = 12;
|
|
13
15
|
const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
|
|
16
|
+
const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
|
|
14
17
|
let sharpImport = null;
|
|
15
18
|
|
|
19
|
+
export function parseImageConcurrency(value) {
|
|
20
|
+
const n = Number.parseInt(value, 10);
|
|
21
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
export function parseApiBase(data) {
|
|
17
25
|
return (data && data.endpoints && typeof data.endpoints.api === "string" && data.endpoints.api)
|
|
18
26
|
? data.endpoints.api
|
|
@@ -59,14 +67,26 @@ export async function optimizeImageDataUrl(dataUrl) {
|
|
|
59
67
|
}
|
|
60
68
|
}
|
|
61
69
|
|
|
70
|
+
export async function runWithConcurrency(taskFns, concurrency) {
|
|
71
|
+
if (!Array.isArray(taskFns) || taskFns.length === 0) return;
|
|
72
|
+
const limit = Math.min(parseImageConcurrency(concurrency), taskFns.length);
|
|
73
|
+
let next = 0;
|
|
74
|
+
await Promise.all(Array.from({ length: limit }, async () => {
|
|
75
|
+
while (next < taskFns.length) {
|
|
76
|
+
const task = taskFns[next++];
|
|
77
|
+
await task();
|
|
78
|
+
}
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
function visitImageParts(parts, tasks) {
|
|
63
83
|
if (!Array.isArray(parts)) return;
|
|
64
84
|
for (const part of parts) {
|
|
65
85
|
if (!part) continue;
|
|
66
86
|
if (part.type === "input_image" && typeof part.image_url === "string") {
|
|
67
|
-
tasks.push(
|
|
87
|
+
tasks.push(async () => { part.image_url = await optimizeImageDataUrl(part.image_url); });
|
|
68
88
|
} else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
|
|
69
|
-
tasks.push(
|
|
89
|
+
tasks.push(async () => {
|
|
70
90
|
const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
|
|
71
91
|
const opt = await optimizeImageDataUrl(dataUrl);
|
|
72
92
|
const match = /^data:([^;]+);base64,(.+)$/.exec(opt);
|
|
@@ -74,7 +94,7 @@ function visitImageParts(parts, tasks) {
|
|
|
74
94
|
part.source.media_type = match[1];
|
|
75
95
|
part.source.data = match[2];
|
|
76
96
|
}
|
|
77
|
-
})
|
|
97
|
+
});
|
|
78
98
|
}
|
|
79
99
|
}
|
|
80
100
|
}
|
|
@@ -97,13 +117,13 @@ export async function optimizeImagesInBody(reqBody) {
|
|
|
97
117
|
try {
|
|
98
118
|
const parsed = JSON.parse(trimmed);
|
|
99
119
|
if (Array.isArray(parsed)) {
|
|
100
|
-
const
|
|
101
|
-
visitImageParts(parsed,
|
|
102
|
-
const localTasks = tasks.slice(before);
|
|
120
|
+
const localTasks = [];
|
|
121
|
+
visitImageParts(parsed, localTasks);
|
|
103
122
|
if (localTasks.length) {
|
|
104
|
-
tasks.push(
|
|
123
|
+
tasks.push(async () => {
|
|
124
|
+
await runWithConcurrency(localTasks, IMG_CONCURRENCY);
|
|
105
125
|
item.output = JSON.stringify(parsed);
|
|
106
|
-
})
|
|
126
|
+
});
|
|
107
127
|
}
|
|
108
128
|
}
|
|
109
129
|
} catch {
|
|
@@ -114,7 +134,7 @@ export async function optimizeImagesInBody(reqBody) {
|
|
|
114
134
|
}
|
|
115
135
|
}
|
|
116
136
|
|
|
117
|
-
if (tasks.length) await
|
|
137
|
+
if (tasks.length) await runWithConcurrency(tasks, IMG_CONCURRENCY);
|
|
118
138
|
return reqBody;
|
|
119
139
|
}
|
|
120
140
|
|
|
@@ -235,11 +255,12 @@ function getGithubToken() {
|
|
|
235
255
|
return fs.readFileSync(GITHUB_TOKEN_PATH, "utf-8").trim();
|
|
236
256
|
}
|
|
237
257
|
|
|
238
|
-
export async function getCopilotToken() {
|
|
258
|
+
export async function getCopilotToken({ signal } = {}) {
|
|
239
259
|
if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
|
|
240
260
|
const ghToken = getGithubToken();
|
|
241
261
|
const resp = await fetch(`${GITHUB_API}/copilot_internal/v2/token`, {
|
|
242
262
|
headers: { Authorization: `token ${ghToken}`, Accept: "application/json" },
|
|
263
|
+
signal,
|
|
243
264
|
});
|
|
244
265
|
if (!resp.ok) throw new Error(`Failed to get Copilot token: ${resp.status}`);
|
|
245
266
|
const data = await resp.json();
|
|
@@ -254,8 +275,8 @@ export async function getCopilotToken() {
|
|
|
254
275
|
}
|
|
255
276
|
|
|
256
277
|
// chatReq is already converted by the adapter. The caller parses the raw Response.
|
|
257
|
-
export async function chatCompletions(chatReq) {
|
|
258
|
-
const token = await getCopilotToken();
|
|
278
|
+
export async function chatCompletions(chatReq, { signal } = {}) {
|
|
279
|
+
const token = await getCopilotToken({ signal });
|
|
259
280
|
const messages = chatReq.messages || [];
|
|
260
281
|
const headers = buildHeaders({
|
|
261
282
|
token,
|
|
@@ -267,21 +288,22 @@ export async function chatCompletions(chatReq) {
|
|
|
267
288
|
method: "POST",
|
|
268
289
|
headers,
|
|
269
290
|
body: JSON.stringify(chatReq),
|
|
291
|
+
signal,
|
|
270
292
|
});
|
|
271
293
|
}
|
|
272
294
|
|
|
273
|
-
export async function listModels() {
|
|
274
|
-
const token = await getCopilotToken();
|
|
295
|
+
export async function listModels({ signal } = {}) {
|
|
296
|
+
const token = await getCopilotToken({ signal });
|
|
275
297
|
const headers = buildHeaders({
|
|
276
298
|
token, version: getVSCodeVersion(), initiator: "user", vision: false,
|
|
277
299
|
});
|
|
278
|
-
const resp = await fetch(`${getApiBase()}/models`, { headers });
|
|
300
|
+
const resp = await fetch(`${getApiBase()}/models`, { headers, signal });
|
|
279
301
|
return { status: resp.status, body: await resp.text() };
|
|
280
302
|
}
|
|
281
303
|
|
|
282
304
|
// Responses-only models go directly to Copilot's /responses endpoint.
|
|
283
|
-
export async function responses(reqBody) {
|
|
284
|
-
const token = await getCopilotToken();
|
|
305
|
+
export async function responses(reqBody, { signal } = {}) {
|
|
306
|
+
const token = await getCopilotToken({ signal });
|
|
285
307
|
const beforeBytes = Buffer.byteLength(JSON.stringify(reqBody));
|
|
286
308
|
await optimizeImagesInBody(reqBody);
|
|
287
309
|
const bodyBuf = Buffer.from(JSON.stringify(reqBody), "utf8");
|
|
@@ -302,6 +324,7 @@ export async function responses(reqBody) {
|
|
|
302
324
|
method: "POST",
|
|
303
325
|
headers,
|
|
304
326
|
body: bodyBuf,
|
|
327
|
+
signal,
|
|
305
328
|
});
|
|
306
329
|
} catch (e) {
|
|
307
330
|
const cause = e?.cause;
|
|
@@ -310,7 +333,7 @@ export async function responses(reqBody) {
|
|
|
310
333
|
}
|
|
311
334
|
}
|
|
312
335
|
|
|
313
|
-
export async function responsesCompact(reqBody) {
|
|
336
|
+
export async function responsesCompact(reqBody, options = {}) {
|
|
314
337
|
// GitHub Copilot accepts Codex compact payloads on the regular Responses endpoint.
|
|
315
|
-
return responses(reqBody);
|
|
338
|
+
return responses(reqBody, options);
|
|
316
339
|
}
|