codex-copilot-dx 0.4.25 → 0.4.27
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 +21 -2
- package/bin/cli.mjs +89 -36
- package/package.json +9 -2
- package/src/adapter.mjs +122 -383
- package/src/atomic-file.mjs +63 -0
- package/src/claude-config.mjs +7 -7
- package/src/claude-desktop-config.mjs +3 -4
- package/src/cli-options.mjs +69 -0
- package/src/config.mjs +10 -6
- package/src/copilot.mjs +20 -161
- package/src/file-rotation.mjs +30 -0
- package/src/http-transport.mjs +182 -0
- package/src/image-optimization.mjs +151 -0
- package/src/log.mjs +9 -1
- package/src/models.mjs +16 -0
- package/src/response-history.mjs +195 -0
- package/src/shutdown.mjs +29 -0
- package/src/startup.mjs +19 -0
- package/src/stream.mjs +2 -1
- package/src/usage.mjs +57 -14
package/src/adapter.mjs
CHANGED
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { promisify } from "node:util";
|
|
4
|
-
import * as zlib from "node:zlib";
|
|
5
3
|
import { chatCompletions, listModels, responses as copilotResponses, responsesCompact as copilotResponsesCompact, getCachedModelEndpoints } from "./copilot.mjs";
|
|
6
4
|
import { webStreamLines } from "./stream.mjs";
|
|
7
5
|
import { anthropicToChat, chatToAnthropic, streamAnthropicFromLines, countTokens } from "./anthropic.mjs";
|
|
8
|
-
import { claudeDesktopModelsResponse, resolveAnthropicModel, modelIsResponsesOnly, modelSupportsChatCompletions } from "./models.mjs";
|
|
6
|
+
import { CODEX_AUTO_REVIEW_MODEL, claudeDesktopModelsResponse, resolveAnthropicModel, resolveOpenAIModel, modelIsResponsesOnly, modelSupportsChatCompletions } from "./models.mjs";
|
|
9
7
|
import { status } from "./status.mjs";
|
|
10
8
|
import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
|
|
11
9
|
import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
|
|
10
|
+
import {
|
|
11
|
+
abortErrorStatusCode,
|
|
12
|
+
createRequestAbort,
|
|
13
|
+
httpError,
|
|
14
|
+
isAbortLikeError,
|
|
15
|
+
logRequestFailure,
|
|
16
|
+
readJsonBody,
|
|
17
|
+
sendJsonError,
|
|
18
|
+
sendUpstreamError,
|
|
19
|
+
writeOrDrain,
|
|
20
|
+
} from "./http-transport.mjs";
|
|
21
|
+
import {
|
|
22
|
+
clearResponseHistoryForTests,
|
|
23
|
+
configureResponseHistoryForTests,
|
|
24
|
+
materializeResponseHistory,
|
|
25
|
+
rememberResponseHistoryNode,
|
|
26
|
+
responseHistoryStats,
|
|
27
|
+
} from "./response-history.mjs";
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
abortErrorStatusCode,
|
|
31
|
+
createRequestAbort,
|
|
32
|
+
isAbortLikeError,
|
|
33
|
+
readJsonBody,
|
|
34
|
+
writeOrDrain,
|
|
35
|
+
} from "./http-transport.mjs";
|
|
36
|
+
export {
|
|
37
|
+
clearResponseHistoryForTests,
|
|
38
|
+
configureResponseHistoryForTests,
|
|
39
|
+
responseHistoryStats,
|
|
40
|
+
} from "./response-history.mjs";
|
|
12
41
|
|
|
13
42
|
// Fallback list for models known to only support the Responses API, used when
|
|
14
43
|
// live model metadata (supported_endpoints) has not been cached yet.
|
|
@@ -37,183 +66,25 @@ function isResponsesOnlyModel(model) {
|
|
|
37
66
|
|
|
38
67
|
// Direct Copilot Responses API proxy.
|
|
39
68
|
|
|
40
|
-
const responseHistories = new Map();
|
|
41
|
-
const responseHistoryChildren = new Map();
|
|
42
|
-
const evictedResponseIds = new Set();
|
|
43
|
-
const DEFAULT_RESPONSE_HISTORY_MAX_BYTES = 64 * 1024 * 1024;
|
|
44
|
-
const DEFAULT_RESPONSE_HISTORY_MAX_ENTRIES = 4096;
|
|
45
|
-
let responseHistoryBytes = 0;
|
|
46
|
-
const DEFAULT_MAX_BODY_BYTES = 128 * 1024 * 1024;
|
|
47
|
-
const DEFAULT_MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;
|
|
48
69
|
const DEFAULT_UPSTREAM_TIMEOUT_MS = 120 * 1000;
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const brotliDecompressAsync = promisify(zlib.brotliDecompress);
|
|
52
|
-
const zstdDecompressAsync = zlib.zstdDecompress ? promisify(zlib.zstdDecompress) : null;
|
|
70
|
+
const DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS = 120 * 1000;
|
|
71
|
+
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120 * 1000;
|
|
53
72
|
|
|
54
73
|
function positiveInt(value, fallback) {
|
|
55
74
|
const n = Number.parseInt(value, 10);
|
|
56
75
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
57
76
|
}
|
|
58
77
|
|
|
59
|
-
const MAX_BODY_BYTES = positiveInt(process.env.CCDX_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
|
|
60
|
-
const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
|
|
61
78
|
const UPSTREAM_TIMEOUT_MS = positiveInt(process.env.CCDX_UPSTREAM_TIMEOUT_MS, DEFAULT_UPSTREAM_TIMEOUT_MS);
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
const STREAM_HANDSHAKE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS);
|
|
80
|
+
const STREAM_IDLE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_IDLE_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS);
|
|
64
81
|
|
|
65
82
|
export function requestPath(reqUrl) {
|
|
66
83
|
return new URL(reqUrl || "/", "http://localhost").pathname;
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
function cloneJson(value) {
|
|
70
|
-
return value === undefined ? undefined :
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function jsonStringByteLength(value) {
|
|
74
|
-
let bytes = 2;
|
|
75
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
76
|
-
const code = value.charCodeAt(index);
|
|
77
|
-
if (code === 0x22 || code === 0x5c) {
|
|
78
|
-
bytes += 2;
|
|
79
|
-
} else if (code <= 0x1f) {
|
|
80
|
-
bytes += [0x08, 0x09, 0x0a, 0x0c, 0x0d].includes(code) ? 2 : 6;
|
|
81
|
-
} else if (code <= 0x7f) {
|
|
82
|
-
bytes += 1;
|
|
83
|
-
} else if (code <= 0x7ff) {
|
|
84
|
-
bytes += 2;
|
|
85
|
-
} else if (code >= 0xd800 && code <= 0xdbff) {
|
|
86
|
-
const next = value.charCodeAt(index + 1);
|
|
87
|
-
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
88
|
-
bytes += 4;
|
|
89
|
-
index += 1;
|
|
90
|
-
} else {
|
|
91
|
-
bytes += 6;
|
|
92
|
-
}
|
|
93
|
-
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
94
|
-
bytes += 6;
|
|
95
|
-
} else {
|
|
96
|
-
bytes += 3;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return bytes;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function jsonByteLength(value, seen = new Set()) {
|
|
103
|
-
if (value === null) return 4;
|
|
104
|
-
if (typeof value === "string") return jsonStringByteLength(value);
|
|
105
|
-
if (typeof value === "number") return Buffer.byteLength(JSON.stringify(value));
|
|
106
|
-
if (typeof value === "boolean") return value ? 4 : 5;
|
|
107
|
-
if (Array.isArray(value)) {
|
|
108
|
-
if (seen.has(value)) throw new TypeError("Circular response history value");
|
|
109
|
-
seen.add(value);
|
|
110
|
-
let bytes = 2 + Math.max(0, value.length - 1);
|
|
111
|
-
for (const item of value) {
|
|
112
|
-
bytes += item === undefined || typeof item === "function" || typeof item === "symbol"
|
|
113
|
-
? 4
|
|
114
|
-
: jsonByteLength(item, seen);
|
|
115
|
-
}
|
|
116
|
-
seen.delete(value);
|
|
117
|
-
return bytes;
|
|
118
|
-
}
|
|
119
|
-
if (value && typeof value === "object") {
|
|
120
|
-
if (seen.has(value)) throw new TypeError("Circular response history value");
|
|
121
|
-
seen.add(value);
|
|
122
|
-
const entries = Object.entries(value)
|
|
123
|
-
.filter(([, item]) => item !== undefined && typeof item !== "function" && typeof item !== "symbol");
|
|
124
|
-
let bytes = 2 + Math.max(0, entries.length - 1);
|
|
125
|
-
for (const [key, item] of entries) bytes += jsonStringByteLength(key) + 1 + jsonByteLength(item, seen);
|
|
126
|
-
seen.delete(value);
|
|
127
|
-
return bytes;
|
|
128
|
-
}
|
|
129
|
-
return 4;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function httpError(message, statusCode) {
|
|
133
|
-
const err = new Error(message);
|
|
134
|
-
err.statusCode = statusCode;
|
|
135
|
-
return err;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function payloadTooLarge(kind, maxBytes) {
|
|
139
|
-
return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async function readRequestBuffer(req, maxBytes) {
|
|
143
|
-
const contentLength = Number.parseInt(req.headers?.["content-length"] || "", 10);
|
|
144
|
-
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
145
|
-
throw payloadTooLarge("Raw", maxBytes);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const chunks = [];
|
|
149
|
-
let total = 0;
|
|
150
|
-
for await (const chunk of req) {
|
|
151
|
-
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
152
|
-
total += buf.length;
|
|
153
|
-
if (total > maxBytes) throw payloadTooLarge("Raw", maxBytes);
|
|
154
|
-
chunks.push(buf);
|
|
155
|
-
}
|
|
156
|
-
return Buffer.concat(chunks);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async function decompressBody(decompress, buffer, maxBytes) {
|
|
160
|
-
try {
|
|
161
|
-
return await decompress(buffer, { maxOutputLength: maxBytes });
|
|
162
|
-
} catch (e) {
|
|
163
|
-
if (e?.code === "ERR_BUFFER_TOO_LARGE" || /maxOutputLength/i.test(e?.message || "")) {
|
|
164
|
-
throw payloadTooLarge("Decoded", maxBytes);
|
|
165
|
-
}
|
|
166
|
-
throw e;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
|
|
171
|
-
const encodings = String(contentEncoding || "identity")
|
|
172
|
-
.split(",")
|
|
173
|
-
.map((v) => v.trim().toLowerCase())
|
|
174
|
-
.filter(Boolean);
|
|
175
|
-
|
|
176
|
-
let decoded = buffer;
|
|
177
|
-
for (const encoding of encodings.reverse()) {
|
|
178
|
-
if (encoding === "identity") continue;
|
|
179
|
-
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
180
|
-
decoded = await decompressBody(gunzipAsync, decoded, maxBytes);
|
|
181
|
-
} else if (encoding === "deflate") {
|
|
182
|
-
decoded = await decompressBody(inflateAsync, decoded, maxBytes);
|
|
183
|
-
} else if (encoding === "br") {
|
|
184
|
-
decoded = await decompressBody(brotliDecompressAsync, decoded, maxBytes);
|
|
185
|
-
} else if (encoding === "zstd") {
|
|
186
|
-
if (!zstdDecompressAsync) throw new Error("Unsupported Content-Encoding: zstd");
|
|
187
|
-
decoded = await decompressBody(zstdDecompressAsync, decoded, maxBytes);
|
|
188
|
-
} else {
|
|
189
|
-
throw new Error(`Unsupported Content-Encoding: ${encoding}`);
|
|
190
|
-
}
|
|
191
|
-
if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
|
|
192
|
-
}
|
|
193
|
-
if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
|
|
194
|
-
return decoded;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export async function readJsonBody(req, {
|
|
198
|
-
maxBodyBytes = MAX_BODY_BYTES,
|
|
199
|
-
maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
|
|
200
|
-
} = {}) {
|
|
201
|
-
const buffer = await readRequestBuffer(req, maxBodyBytes);
|
|
202
|
-
const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
|
|
203
|
-
return JSON.parse(decoded.toString("utf8"));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function sendJsonError(res, err, fallbackStatus = 400) {
|
|
207
|
-
if (res.destroyed || res.writableEnded) return;
|
|
208
|
-
if (!res.headersSent) res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
|
|
209
|
-
res.end(JSON.stringify({ error: err?.message || "Request failed" }));
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function sendUpstreamError(res, resp, text) {
|
|
213
|
-
if (!res.headersSent) {
|
|
214
|
-
res.writeHead(resp.status || 502, { "Content-Type": resp.headers?.get("content-type") || "application/json" });
|
|
215
|
-
}
|
|
216
|
-
res.end(text || JSON.stringify({ error: "Upstream request failed" }));
|
|
87
|
+
return value === undefined ? undefined : structuredClone(value);
|
|
217
88
|
}
|
|
218
89
|
|
|
219
90
|
function bearerToken(headers = {}) {
|
|
@@ -229,85 +100,13 @@ export function shouldServeClaudeDesktopModels(req, claudeDesktopApiKey) {
|
|
|
229
100
|
return token === claudeDesktopApiKey || xApiKey === claudeDesktopApiKey;
|
|
230
101
|
}
|
|
231
102
|
|
|
232
|
-
|
|
233
|
-
if (res.destroyed || res.writableEnded) return Promise.resolve(false);
|
|
234
|
-
if (res.write(chunk)) return Promise.resolve(true);
|
|
235
|
-
return new Promise((resolve, reject) => {
|
|
236
|
-
const cleanup = () => {
|
|
237
|
-
res.off("drain", onDrain);
|
|
238
|
-
res.off("error", onError);
|
|
239
|
-
res.off("close", onClose);
|
|
240
|
-
};
|
|
241
|
-
const onDrain = () => { cleanup(); resolve(true); };
|
|
242
|
-
const onClose = () => { cleanup(); resolve(false); };
|
|
243
|
-
const onError = (err) => { cleanup(); reject(err); };
|
|
244
|
-
res.once("drain", onDrain);
|
|
245
|
-
res.once("close", onClose);
|
|
246
|
-
res.once("error", onError);
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export function isAbortLikeError(err) {
|
|
251
|
-
return err?.name === "AbortError" || /operation was aborted/i.test(String(err?.message || ""));
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export function abortErrorStatusCode(reason) {
|
|
255
|
-
if (reason === "upstream_timeout") return 504;
|
|
256
|
-
if (reason === "client_aborted" || reason === "client_closed") return 499;
|
|
257
|
-
return 400;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
export function createRequestAbort(req, res) {
|
|
261
|
-
const controller = new AbortController();
|
|
262
|
-
let timer = null;
|
|
263
|
-
let cleaned = false;
|
|
264
|
-
let reason = null;
|
|
265
|
-
const abort = (nextReason = "aborted") => {
|
|
266
|
-
if (!cleaned && !controller.signal.aborted) {
|
|
267
|
-
reason = nextReason;
|
|
268
|
-
controller.abort();
|
|
269
|
-
}
|
|
270
|
-
};
|
|
271
|
-
const onReqAborted = () => abort("client_aborted");
|
|
272
|
-
const onResClose = () => {
|
|
273
|
-
if (!res.writableEnded) abort("client_closed");
|
|
274
|
-
};
|
|
275
|
-
req.on("aborted", onReqAborted);
|
|
276
|
-
res.on("close", onResClose);
|
|
277
|
-
return {
|
|
278
|
-
signal: controller.signal,
|
|
279
|
-
get reason() { return reason; },
|
|
280
|
-
setTimeout(ms) {
|
|
281
|
-
if (timer) clearTimeout(timer);
|
|
282
|
-
if (ms > 0) timer = setTimeout(() => abort("upstream_timeout"), ms);
|
|
283
|
-
},
|
|
284
|
-
cleanup() {
|
|
285
|
-
cleaned = true;
|
|
286
|
-
if (timer) clearTimeout(timer);
|
|
287
|
-
req.off("aborted", onReqAborted);
|
|
288
|
-
res.off("close", onResClose);
|
|
289
|
-
},
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function logRequestFailure(label, err, abort) {
|
|
294
|
-
if (!isAbortLikeError(err)) {
|
|
295
|
-
console.error(status("err", `${label} request failed: ${err.message}`));
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const reason = abort?.reason || "aborted";
|
|
300
|
-
err.statusCode ||= abortErrorStatusCode(reason);
|
|
301
|
-
const detail = reason === "upstream_timeout" ? `${reason} after ${UPSTREAM_TIMEOUT_MS}ms` : reason;
|
|
302
|
-
console.warn(status("warn", `${label} request aborted: ${detail}`));
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function responsesInputItems(input) {
|
|
103
|
+
function responsesInputItems(input, { clone = true } = {}) {
|
|
306
104
|
if (input === undefined || input === null) return [];
|
|
307
105
|
if (typeof input === "string") {
|
|
308
106
|
return [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }];
|
|
309
107
|
}
|
|
310
|
-
|
|
108
|
+
if (Array.isArray(input)) return clone ? cloneJson(input) : input;
|
|
109
|
+
return [clone ? cloneJson(input) : input];
|
|
311
110
|
}
|
|
312
111
|
|
|
313
112
|
export function stripInternalResponsesInputFields(inputItems) {
|
|
@@ -449,96 +248,6 @@ export async function openCopilotResponse(reqContext, upstream = copilotResponse
|
|
|
449
248
|
throw httpError("Responses compatibility retry limit exceeded", 502);
|
|
450
249
|
}
|
|
451
250
|
|
|
452
|
-
export function clearResponseHistoryForTests() {
|
|
453
|
-
responseHistories.clear();
|
|
454
|
-
responseHistoryChildren.clear();
|
|
455
|
-
evictedResponseIds.clear();
|
|
456
|
-
responseHistoryBytes = 0;
|
|
457
|
-
responseHistoryMaxBytes = DEFAULT_RESPONSE_HISTORY_MAX_BYTES;
|
|
458
|
-
responseHistoryMaxEntries = DEFAULT_RESPONSE_HISTORY_MAX_ENTRIES;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
export function configureResponseHistoryForTests({ maxBytes, maxEntries } = {}) {
|
|
462
|
-
if (maxBytes !== undefined) responseHistoryMaxBytes = positiveInt(maxBytes, DEFAULT_RESPONSE_HISTORY_MAX_BYTES);
|
|
463
|
-
if (maxEntries !== undefined) responseHistoryMaxEntries = positiveInt(maxEntries, DEFAULT_RESPONSE_HISTORY_MAX_ENTRIES);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
export function responseHistoryStats() {
|
|
467
|
-
return { entries: responseHistories.size, bytes: responseHistoryBytes, evicted: evictedResponseIds.size };
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function rememberEvictedId(id) {
|
|
471
|
-
evictedResponseIds.add(id);
|
|
472
|
-
while (evictedResponseIds.size > 256) evictedResponseIds.delete(evictedResponseIds.values().next().value);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function linkHistoryChild(parentId, id) {
|
|
476
|
-
if (!parentId) return;
|
|
477
|
-
let children = responseHistoryChildren.get(parentId);
|
|
478
|
-
if (!children) {
|
|
479
|
-
children = new Set();
|
|
480
|
-
responseHistoryChildren.set(parentId, children);
|
|
481
|
-
}
|
|
482
|
-
children.add(id);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function unlinkHistoryChild(parentId, id) {
|
|
486
|
-
if (!parentId) return;
|
|
487
|
-
const children = responseHistoryChildren.get(parentId);
|
|
488
|
-
if (!children) return;
|
|
489
|
-
children.delete(id);
|
|
490
|
-
if (!children.size) responseHistoryChildren.delete(parentId);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function removeHistorySubtree(rootId) {
|
|
494
|
-
const pending = [rootId];
|
|
495
|
-
const removed = new Set();
|
|
496
|
-
while (pending.length) {
|
|
497
|
-
const id = pending.pop();
|
|
498
|
-
if (removed.has(id)) continue;
|
|
499
|
-
removed.add(id);
|
|
500
|
-
const children = responseHistoryChildren.get(id);
|
|
501
|
-
if (children) pending.push(...children);
|
|
502
|
-
}
|
|
503
|
-
for (const id of removed) {
|
|
504
|
-
const entry = responseHistories.get(id);
|
|
505
|
-
if (!entry) continue;
|
|
506
|
-
unlinkHistoryChild(entry.parentId, id);
|
|
507
|
-
responseHistoryChildren.delete(id);
|
|
508
|
-
responseHistoryBytes -= entry.bytes;
|
|
509
|
-
responseHistories.delete(id);
|
|
510
|
-
rememberEvictedId(id);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function enforceResponseHistoryLimits() {
|
|
515
|
-
while (responseHistories.size > responseHistoryMaxEntries || responseHistoryBytes > responseHistoryMaxBytes) {
|
|
516
|
-
const oldestId = responseHistories.keys().next().value;
|
|
517
|
-
if (!oldestId) break;
|
|
518
|
-
removeHistorySubtree(oldestId);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
function materializeResponseHistory(responseId) {
|
|
523
|
-
const chain = [];
|
|
524
|
-
const seen = new Set();
|
|
525
|
-
let currentId = responseId;
|
|
526
|
-
while (currentId) {
|
|
527
|
-
if (seen.has(currentId)) throw httpError(`Cycle detected in local response history: ${currentId}`, 500);
|
|
528
|
-
seen.add(currentId);
|
|
529
|
-
const entry = responseHistories.get(currentId);
|
|
530
|
-
if (!entry) {
|
|
531
|
-
const reason = evictedResponseIds.has(currentId) ? " was evicted after reaching the local history limit" : " is not available";
|
|
532
|
-
throw httpError(`previous_response_id${reason}: ${currentId}`, 400);
|
|
533
|
-
}
|
|
534
|
-
chain.push(entry);
|
|
535
|
-
currentId = entry.parentId;
|
|
536
|
-
}
|
|
537
|
-
const items = [];
|
|
538
|
-
for (const entry of chain.reverse()) items.push(...entry.inputItems, ...entry.outputItems);
|
|
539
|
-
return cloneJson(items);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
251
|
// The GPT (gpt-5.6) Responses backend registers a built-in `image_gen` tool
|
|
543
252
|
// namespace. When the client also sends an image generation tool, the upstream
|
|
544
253
|
// rejects the request with "User-defined namespace 'image_gen' collides ...".
|
|
@@ -548,9 +257,9 @@ function isBuiltinImageTool(tool) {
|
|
|
548
257
|
return isImageNamespaceTool(tool);
|
|
549
258
|
}
|
|
550
259
|
|
|
551
|
-
export function prepareResponsesRequest(reqBody) {
|
|
552
|
-
const body = cloneJson(reqBody);
|
|
553
|
-
const currentInputItems = responsesInputItems(body.input);
|
|
260
|
+
export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
|
|
261
|
+
const body = mutate ? reqBody : cloneJson(reqBody);
|
|
262
|
+
const currentInputItems = responsesInputItems(body.input, { clone: !mutate });
|
|
554
263
|
const previousId = body.previous_response_id;
|
|
555
264
|
|
|
556
265
|
if (previousId !== undefined && previousId !== null) {
|
|
@@ -573,6 +282,7 @@ export function prepareResponsesRequest(reqBody) {
|
|
|
573
282
|
inputItems: body.input,
|
|
574
283
|
historyParentId: previousId ?? null,
|
|
575
284
|
historyInputItems: currentInputItems,
|
|
285
|
+
takeHistoryOwnership: mutate,
|
|
576
286
|
};
|
|
577
287
|
}
|
|
578
288
|
|
|
@@ -580,30 +290,13 @@ export function rememberResponseHistory(reqContext, responseJson) {
|
|
|
580
290
|
if (!responseJson?.id || !Array.isArray(reqContext?.historyInputItems || reqContext?.inputItems)) return;
|
|
581
291
|
const sourceInputItems = reqContext.historyInputItems || reqContext.inputItems;
|
|
582
292
|
const sourceOutputItems = responsesOutputItems(responseJson.output);
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const outputItems = cloneJson(sourceOutputItems);
|
|
591
|
-
const entry = {
|
|
592
|
-
parentId: reqContext.historyParentId || null,
|
|
593
|
-
inputItems,
|
|
594
|
-
outputItems,
|
|
595
|
-
bytes,
|
|
596
|
-
};
|
|
597
|
-
const existing = responseHistories.get(responseJson.id);
|
|
598
|
-
if (existing) {
|
|
599
|
-
responseHistoryBytes -= existing.bytes;
|
|
600
|
-
unlinkHistoryChild(existing.parentId, responseJson.id);
|
|
601
|
-
}
|
|
602
|
-
responseHistories.set(responseJson.id, entry);
|
|
603
|
-
linkHistoryChild(entry.parentId, responseJson.id);
|
|
604
|
-
responseHistoryBytes += entry.bytes;
|
|
605
|
-
evictedResponseIds.delete(responseJson.id);
|
|
606
|
-
enforceResponseHistoryLimits();
|
|
293
|
+
rememberResponseHistoryNode({
|
|
294
|
+
id: responseJson.id,
|
|
295
|
+
parentId: reqContext.historyParentId,
|
|
296
|
+
inputItems: sourceInputItems,
|
|
297
|
+
outputItems: sourceOutputItems,
|
|
298
|
+
takeOwnership: reqContext.takeHistoryOwnership,
|
|
299
|
+
});
|
|
607
300
|
}
|
|
608
301
|
|
|
609
302
|
function storeCompletedResponseFromSse(reqContext, data) {
|
|
@@ -651,6 +344,7 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
|
|
|
651
344
|
}
|
|
652
345
|
|
|
653
346
|
if (reqContext.body.stream) {
|
|
347
|
+
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
654
348
|
res.writeHead(resp.status, {
|
|
655
349
|
"Content-Type": resp.headers.get("content-type") || "text/event-stream",
|
|
656
350
|
"Cache-Control": "no-cache",
|
|
@@ -659,17 +353,23 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
|
|
|
659
353
|
const reader = resp.body.getReader();
|
|
660
354
|
const decoder = new TextDecoder();
|
|
661
355
|
let buffer = "";
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
356
|
+
try {
|
|
357
|
+
while (true) {
|
|
358
|
+
const { done, value } = await reader.read();
|
|
359
|
+
if (done) {
|
|
360
|
+
buffer += decoder.decode();
|
|
361
|
+
readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
|
|
362
|
+
res.end();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
366
|
+
buffer += decoder.decode(value, { stream: true });
|
|
367
|
+
buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
|
|
368
|
+
if (!await writeOrDrain(res, value)) return;
|
|
669
369
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
370
|
+
} finally {
|
|
371
|
+
await reader.cancel().catch(() => {});
|
|
372
|
+
reader.releaseLock();
|
|
673
373
|
}
|
|
674
374
|
} else {
|
|
675
375
|
const data = await resp.text();
|
|
@@ -807,13 +507,15 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
807
507
|
stream: true,
|
|
808
508
|
}, { signal: options.signal });
|
|
809
509
|
} catch (e) {
|
|
810
|
-
|
|
510
|
+
const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
|
|
511
|
+
onError(statusCode, e.message);
|
|
811
512
|
return;
|
|
812
513
|
}
|
|
813
514
|
if (!resp.ok) {
|
|
814
515
|
onError(resp.status, await resp.text());
|
|
815
516
|
return;
|
|
816
517
|
}
|
|
518
|
+
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
817
519
|
const respId = `resp_${uid()}`;
|
|
818
520
|
let actualModel = chatReq.model || "unknown";
|
|
819
521
|
let fullText = "";
|
|
@@ -911,7 +613,9 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
911
613
|
};
|
|
912
614
|
|
|
913
615
|
try {
|
|
914
|
-
for await (const line of webStreamLines(resp
|
|
616
|
+
for await (const line of webStreamLines(resp, {
|
|
617
|
+
onChunk: () => options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout"),
|
|
618
|
+
})) {
|
|
915
619
|
if (!line.startsWith("data: ")) continue;
|
|
916
620
|
const data = line.slice(6).trim();
|
|
917
621
|
if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
|
|
@@ -969,6 +673,10 @@ export function createAdapterHandler(options = {}) {
|
|
|
969
673
|
const responsesFn = options.responsesFn || copilotResponses;
|
|
970
674
|
const responsesCompactFn = options.responsesCompactFn || copilotResponsesCompact;
|
|
971
675
|
const listModelsFn = options.listModelsFn || listModels;
|
|
676
|
+
const openAIModelEnv = options.openAIModelEnv || process.env;
|
|
677
|
+
const upstreamTimeoutMs = positiveInt(options.upstreamTimeoutMs, UPSTREAM_TIMEOUT_MS);
|
|
678
|
+
const streamHandshakeTimeoutMs = positiveInt(options.streamHandshakeTimeoutMs, STREAM_HANDSHAKE_TIMEOUT_MS);
|
|
679
|
+
const streamIdleTimeoutMs = positiveInt(options.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_MS);
|
|
972
680
|
const claudeDesktopModelOptions = () => {
|
|
973
681
|
const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
|
|
974
682
|
return Array.isArray(modelDefs) ? { modelDefs } : {};
|
|
@@ -988,13 +696,23 @@ export function createAdapterHandler(options = {}) {
|
|
|
988
696
|
const abort = createRequestAbort(req, res);
|
|
989
697
|
try {
|
|
990
698
|
const parsed = await readJsonBody(req);
|
|
991
|
-
|
|
992
|
-
|
|
699
|
+
abort.setTimeout(
|
|
700
|
+
parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
|
|
701
|
+
parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
|
|
702
|
+
);
|
|
703
|
+
const prepared = prepareResponsesRequest(parsed, { mutate: true });
|
|
993
704
|
prepared.surface = "responses";
|
|
994
705
|
const model = parsed.model || "unknown";
|
|
995
|
-
|
|
996
|
-
if (
|
|
997
|
-
|
|
706
|
+
const { requestedModel, upstreamModel } = resolveOpenAIModel(model, openAIModelEnv);
|
|
707
|
+
if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
|
|
708
|
+
const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
|
|
709
|
+
console.log(status("info", `responses model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
|
|
710
|
+
if (requestedModel === CODEX_AUTO_REVIEW_MODEL || isResponsesOnlyModel(upstreamModel)) {
|
|
711
|
+
await proxyCopilotResponses(prepared, req, res, responsesFn, {
|
|
712
|
+
signal: abort.signal,
|
|
713
|
+
abort,
|
|
714
|
+
streamIdleTimeoutMs,
|
|
715
|
+
});
|
|
998
716
|
} else {
|
|
999
717
|
const chatReq = responsesToChat(prepared.body);
|
|
1000
718
|
if (parsed.stream) {
|
|
@@ -1005,7 +723,12 @@ export function createAdapterHandler(options = {}) {
|
|
|
1005
723
|
rememberResponseHistory(prepared, data.response);
|
|
1006
724
|
recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
|
|
1007
725
|
}
|
|
1008
|
-
}, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, {
|
|
726
|
+
}, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, {
|
|
727
|
+
signal: abort.signal,
|
|
728
|
+
abort,
|
|
729
|
+
streamIdleTimeoutMs,
|
|
730
|
+
chatCompletionsFn,
|
|
731
|
+
});
|
|
1009
732
|
} else {
|
|
1010
733
|
chatReq.stream = false;
|
|
1011
734
|
delete chatReq.max_tokens;
|
|
@@ -1044,12 +767,22 @@ export function createAdapterHandler(options = {}) {
|
|
|
1044
767
|
const abort = createRequestAbort(req, res);
|
|
1045
768
|
try {
|
|
1046
769
|
const parsed = await readJsonBody(req);
|
|
1047
|
-
|
|
1048
|
-
|
|
770
|
+
abort.setTimeout(
|
|
771
|
+
parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
|
|
772
|
+
parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
|
|
773
|
+
);
|
|
774
|
+
const prepared = prepareResponsesRequest(parsed, { mutate: true });
|
|
1049
775
|
prepared.surface = "responses_compact";
|
|
1050
776
|
const model = parsed.model || "unknown";
|
|
1051
|
-
|
|
1052
|
-
|
|
777
|
+
const { requestedModel, upstreamModel } = resolveOpenAIModel(model, openAIModelEnv);
|
|
778
|
+
if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
|
|
779
|
+
const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
|
|
780
|
+
console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
|
|
781
|
+
await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
|
|
782
|
+
signal: abort.signal,
|
|
783
|
+
abort,
|
|
784
|
+
streamIdleTimeoutMs,
|
|
785
|
+
});
|
|
1053
786
|
} catch (e) {
|
|
1054
787
|
logRequestFailure("Responses compact", e, abort);
|
|
1055
788
|
sendJsonError(res, e);
|
|
@@ -1067,7 +800,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
1067
800
|
return;
|
|
1068
801
|
}
|
|
1069
802
|
const abort = createRequestAbort(req, res);
|
|
1070
|
-
abort.setTimeout(
|
|
803
|
+
abort.setTimeout(upstreamTimeoutMs);
|
|
1071
804
|
listModelsFn({ signal: abort.signal })
|
|
1072
805
|
.then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
|
|
1073
806
|
.catch((e) => {
|
|
@@ -1102,7 +835,10 @@ export function createAdapterHandler(options = {}) {
|
|
|
1102
835
|
const abort = createRequestAbort(req, res);
|
|
1103
836
|
try {
|
|
1104
837
|
const parsed = await readJsonBody(req);
|
|
1105
|
-
|
|
838
|
+
abort.setTimeout(
|
|
839
|
+
parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
|
|
840
|
+
parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
|
|
841
|
+
);
|
|
1106
842
|
const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown", process.env, claudeDesktopModelOptions());
|
|
1107
843
|
const modelNote = upstreamModel === requestedModel ? requestedModel : `${requestedModel} -> ${upstreamModel}`;
|
|
1108
844
|
console.log(status("info", `messages model=${modelNote} stream=${!!parsed.stream}`));
|
|
@@ -1115,11 +851,14 @@ export function createAdapterHandler(options = {}) {
|
|
|
1115
851
|
res.end(await upstream.text());
|
|
1116
852
|
return;
|
|
1117
853
|
}
|
|
854
|
+
abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
|
|
1118
855
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
1119
856
|
let messageId;
|
|
1120
857
|
let usage;
|
|
1121
858
|
await streamAnthropicFromLines(
|
|
1122
|
-
webStreamLines(upstream
|
|
859
|
+
webStreamLines(upstream, {
|
|
860
|
+
onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
|
|
861
|
+
}),
|
|
1123
862
|
async (event, data) => {
|
|
1124
863
|
if (event === "message_start") messageId = data.message?.id;
|
|
1125
864
|
if (event === "message_delta") usage = data.usage;
|