codex-copilot-dx 0.4.23 → 0.4.26
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 +35 -3
- package/bin/cli.mjs +137 -44
- package/package.json +9 -2
- package/src/adapter.mjs +400 -282
- package/src/atomic-file.mjs +63 -0
- package/src/auth.mjs +148 -7
- 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 +29 -165
- package/src/doctor.mjs +82 -7
- 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/model-cache.mjs +45 -0
- package/src/response-history.mjs +195 -0
- package/src/running-adapter.mjs +22 -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,7 +1,5 @@
|
|
|
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";
|
|
@@ -9,6 +7,37 @@ import { claudeDesktopModelsResponse, resolveAnthropicModel, modelIsResponsesOnl
|
|
|
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,118 +66,25 @@ function isResponsesOnlyModel(model) {
|
|
|
37
66
|
|
|
38
67
|
// Direct Copilot Responses API proxy.
|
|
39
68
|
|
|
40
|
-
const RESPONSE_HISTORY_LIMIT = 64;
|
|
41
|
-
const responseHistories = new Map();
|
|
42
|
-
const DEFAULT_MAX_BODY_BYTES = 128 * 1024 * 1024;
|
|
43
|
-
const DEFAULT_MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;
|
|
44
69
|
const DEFAULT_UPSTREAM_TIMEOUT_MS = 120 * 1000;
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const brotliDecompressAsync = promisify(zlib.brotliDecompress);
|
|
48
|
-
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;
|
|
49
72
|
|
|
50
73
|
function positiveInt(value, fallback) {
|
|
51
74
|
const n = Number.parseInt(value, 10);
|
|
52
75
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
53
76
|
}
|
|
54
77
|
|
|
55
|
-
const MAX_BODY_BYTES = positiveInt(process.env.CCDX_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
|
|
56
|
-
const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
|
|
57
78
|
const UPSTREAM_TIMEOUT_MS = positiveInt(process.env.CCDX_UPSTREAM_TIMEOUT_MS, DEFAULT_UPSTREAM_TIMEOUT_MS);
|
|
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);
|
|
58
81
|
|
|
59
82
|
export function requestPath(reqUrl) {
|
|
60
83
|
return new URL(reqUrl || "/", "http://localhost").pathname;
|
|
61
84
|
}
|
|
62
85
|
|
|
63
86
|
function cloneJson(value) {
|
|
64
|
-
return value === undefined ? undefined :
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function httpError(message, statusCode) {
|
|
68
|
-
const err = new Error(message);
|
|
69
|
-
err.statusCode = statusCode;
|
|
70
|
-
return err;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function payloadTooLarge(kind, maxBytes) {
|
|
74
|
-
return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function readRequestBuffer(req, maxBytes) {
|
|
78
|
-
const contentLength = Number.parseInt(req.headers?.["content-length"] || "", 10);
|
|
79
|
-
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
80
|
-
throw payloadTooLarge("Raw", maxBytes);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const chunks = [];
|
|
84
|
-
let total = 0;
|
|
85
|
-
for await (const chunk of req) {
|
|
86
|
-
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
87
|
-
total += buf.length;
|
|
88
|
-
if (total > maxBytes) throw payloadTooLarge("Raw", maxBytes);
|
|
89
|
-
chunks.push(buf);
|
|
90
|
-
}
|
|
91
|
-
return Buffer.concat(chunks);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function decompressBody(decompress, buffer, maxBytes) {
|
|
95
|
-
try {
|
|
96
|
-
return await decompress(buffer, { maxOutputLength: maxBytes });
|
|
97
|
-
} catch (e) {
|
|
98
|
-
if (e?.code === "ERR_BUFFER_TOO_LARGE" || /maxOutputLength/i.test(e?.message || "")) {
|
|
99
|
-
throw payloadTooLarge("Decoded", maxBytes);
|
|
100
|
-
}
|
|
101
|
-
throw e;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
|
|
106
|
-
const encodings = String(contentEncoding || "identity")
|
|
107
|
-
.split(",")
|
|
108
|
-
.map((v) => v.trim().toLowerCase())
|
|
109
|
-
.filter(Boolean);
|
|
110
|
-
|
|
111
|
-
let decoded = buffer;
|
|
112
|
-
for (const encoding of encodings.reverse()) {
|
|
113
|
-
if (encoding === "identity") continue;
|
|
114
|
-
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
115
|
-
decoded = await decompressBody(gunzipAsync, decoded, maxBytes);
|
|
116
|
-
} else if (encoding === "deflate") {
|
|
117
|
-
decoded = await decompressBody(inflateAsync, decoded, maxBytes);
|
|
118
|
-
} else if (encoding === "br") {
|
|
119
|
-
decoded = await decompressBody(brotliDecompressAsync, decoded, maxBytes);
|
|
120
|
-
} else if (encoding === "zstd") {
|
|
121
|
-
if (!zstdDecompressAsync) throw new Error("Unsupported Content-Encoding: zstd");
|
|
122
|
-
decoded = await decompressBody(zstdDecompressAsync, decoded, maxBytes);
|
|
123
|
-
} else {
|
|
124
|
-
throw new Error(`Unsupported Content-Encoding: ${encoding}`);
|
|
125
|
-
}
|
|
126
|
-
if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
|
|
127
|
-
}
|
|
128
|
-
if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
|
|
129
|
-
return decoded;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export async function readJsonBody(req, {
|
|
133
|
-
maxBodyBytes = MAX_BODY_BYTES,
|
|
134
|
-
maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
|
|
135
|
-
} = {}) {
|
|
136
|
-
const buffer = await readRequestBuffer(req, maxBodyBytes);
|
|
137
|
-
const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
|
|
138
|
-
return JSON.parse(decoded.toString("utf8"));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function sendJsonError(res, err, fallbackStatus = 400) {
|
|
142
|
-
if (res.destroyed || res.writableEnded) return;
|
|
143
|
-
if (!res.headersSent) res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
|
|
144
|
-
res.end(JSON.stringify({ error: err?.message || "Request failed" }));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function sendUpstreamError(res, resp, text) {
|
|
148
|
-
if (!res.headersSent) {
|
|
149
|
-
res.writeHead(resp.status || 502, { "Content-Type": resp.headers?.get("content-type") || "application/json" });
|
|
150
|
-
}
|
|
151
|
-
res.end(text || JSON.stringify({ error: "Upstream request failed" }));
|
|
87
|
+
return value === undefined ? undefined : structuredClone(value);
|
|
152
88
|
}
|
|
153
89
|
|
|
154
90
|
function bearerToken(headers = {}) {
|
|
@@ -164,85 +100,13 @@ export function shouldServeClaudeDesktopModels(req, claudeDesktopApiKey) {
|
|
|
164
100
|
return token === claudeDesktopApiKey || xApiKey === claudeDesktopApiKey;
|
|
165
101
|
}
|
|
166
102
|
|
|
167
|
-
|
|
168
|
-
if (res.destroyed || res.writableEnded) return Promise.resolve(false);
|
|
169
|
-
if (res.write(chunk)) return Promise.resolve(true);
|
|
170
|
-
return new Promise((resolve, reject) => {
|
|
171
|
-
const cleanup = () => {
|
|
172
|
-
res.off("drain", onDrain);
|
|
173
|
-
res.off("error", onError);
|
|
174
|
-
res.off("close", onClose);
|
|
175
|
-
};
|
|
176
|
-
const onDrain = () => { cleanup(); resolve(true); };
|
|
177
|
-
const onClose = () => { cleanup(); resolve(false); };
|
|
178
|
-
const onError = (err) => { cleanup(); reject(err); };
|
|
179
|
-
res.once("drain", onDrain);
|
|
180
|
-
res.once("close", onClose);
|
|
181
|
-
res.once("error", onError);
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function isAbortLikeError(err) {
|
|
186
|
-
return err?.name === "AbortError" || /operation was aborted/i.test(String(err?.message || ""));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export function abortErrorStatusCode(reason) {
|
|
190
|
-
if (reason === "upstream_timeout") return 504;
|
|
191
|
-
if (reason === "client_aborted" || reason === "client_closed") return 499;
|
|
192
|
-
return 400;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function createRequestAbort(req, res) {
|
|
196
|
-
const controller = new AbortController();
|
|
197
|
-
let timer = null;
|
|
198
|
-
let cleaned = false;
|
|
199
|
-
let reason = null;
|
|
200
|
-
const abort = (nextReason = "aborted") => {
|
|
201
|
-
if (!cleaned && !controller.signal.aborted) {
|
|
202
|
-
reason = nextReason;
|
|
203
|
-
controller.abort();
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
const onReqAborted = () => abort("client_aborted");
|
|
207
|
-
const onResClose = () => {
|
|
208
|
-
if (!res.writableEnded) abort("client_closed");
|
|
209
|
-
};
|
|
210
|
-
req.on("aborted", onReqAborted);
|
|
211
|
-
res.on("close", onResClose);
|
|
212
|
-
return {
|
|
213
|
-
signal: controller.signal,
|
|
214
|
-
get reason() { return reason; },
|
|
215
|
-
setTimeout(ms) {
|
|
216
|
-
if (timer) clearTimeout(timer);
|
|
217
|
-
if (ms > 0) timer = setTimeout(() => abort("upstream_timeout"), ms);
|
|
218
|
-
},
|
|
219
|
-
cleanup() {
|
|
220
|
-
cleaned = true;
|
|
221
|
-
if (timer) clearTimeout(timer);
|
|
222
|
-
req.off("aborted", onReqAborted);
|
|
223
|
-
res.off("close", onResClose);
|
|
224
|
-
},
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function logRequestFailure(label, err, abort) {
|
|
229
|
-
if (!isAbortLikeError(err)) {
|
|
230
|
-
console.error(status("err", `${label} request failed: ${err.message}`));
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const reason = abort?.reason || "aborted";
|
|
235
|
-
err.statusCode ||= abortErrorStatusCode(reason);
|
|
236
|
-
const detail = reason === "upstream_timeout" ? `${reason} after ${UPSTREAM_TIMEOUT_MS}ms` : reason;
|
|
237
|
-
console.warn(status("warn", `${label} request aborted: ${detail}`));
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function responsesInputItems(input) {
|
|
103
|
+
function responsesInputItems(input, { clone = true } = {}) {
|
|
241
104
|
if (input === undefined || input === null) return [];
|
|
242
105
|
if (typeof input === "string") {
|
|
243
106
|
return [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }];
|
|
244
107
|
}
|
|
245
|
-
|
|
108
|
+
if (Array.isArray(input)) return clone ? cloneJson(input) : input;
|
|
109
|
+
return [clone ? cloneJson(input) : input];
|
|
246
110
|
}
|
|
247
111
|
|
|
248
112
|
export function stripInternalResponsesInputFields(inputItems) {
|
|
@@ -258,7 +122,7 @@ export function stripInternalResponsesInputFields(inputItems) {
|
|
|
258
122
|
|
|
259
123
|
function responsesOutputItems(output) {
|
|
260
124
|
if (!Array.isArray(output)) return [];
|
|
261
|
-
return
|
|
125
|
+
return output.filter((item) => item?.type === "message" || item?.type === "function_call");
|
|
262
126
|
}
|
|
263
127
|
|
|
264
128
|
function stripEncryptedReasoningValue(value, state) {
|
|
@@ -303,10 +167,16 @@ export function sanitizeEncryptedReasoningRequest(reqContext) {
|
|
|
303
167
|
body = stripEncryptedReasoningValue(body, state);
|
|
304
168
|
}
|
|
305
169
|
if (!state.changed) return null;
|
|
170
|
+
const historyInputItems = Array.isArray(reqContext.historyInputItems)
|
|
171
|
+
? reqContext.historyInputItems
|
|
172
|
+
.filter((item) => !isEncryptedReasoningInputItem(item))
|
|
173
|
+
.map((item) => stripEncryptedReasoningValue(item, { changed: false }))
|
|
174
|
+
: reqContext.historyInputItems;
|
|
306
175
|
return {
|
|
307
176
|
...reqContext,
|
|
308
177
|
body,
|
|
309
178
|
inputItems: Array.isArray(body.input) ? body.input : reqContext.inputItems,
|
|
179
|
+
historyInputItems,
|
|
310
180
|
};
|
|
311
181
|
}
|
|
312
182
|
|
|
@@ -318,54 +188,82 @@ export function isEncryptedContentVerificationError(statusCode, text) {
|
|
|
318
188
|
&& (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
|
|
319
189
|
}
|
|
320
190
|
|
|
321
|
-
export
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
191
|
+
export function isImageNamespaceCollisionError(statusCode, text) {
|
|
192
|
+
if (statusCode < 400 || !text) return false;
|
|
193
|
+
const lower = String(text).toLowerCase();
|
|
194
|
+
return lower.includes("namespace")
|
|
195
|
+
&& lower.includes("image_gen")
|
|
196
|
+
&& lower.includes("collid");
|
|
197
|
+
}
|
|
329
198
|
|
|
330
|
-
|
|
331
|
-
if (!
|
|
199
|
+
function isImageNamespaceTool(tool, { collisionFallback = false } = {}) {
|
|
200
|
+
if (!tool || typeof tool !== "object") return false;
|
|
201
|
+
const type = String(tool.type || "").toLowerCase();
|
|
202
|
+
const name = String(tool.name || tool.function?.name || "").toLowerCase();
|
|
203
|
+
const namespace = String(tool.namespace || "").toLowerCase();
|
|
204
|
+
if (["image_gen", "image_generation"].includes(type)) return true;
|
|
205
|
+
if (["image_gen", "image_generation"].includes(name)) return true;
|
|
206
|
+
if (namespace === "image_gen" || namespace === "image_generation") return true;
|
|
207
|
+
return collisionFallback && [type, name, namespace].some((value) => value.startsWith("image_gen"));
|
|
208
|
+
}
|
|
332
209
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
210
|
+
export function sanitizeImageNamespaceCollisionRequest(reqContext) {
|
|
211
|
+
if (!Array.isArray(reqContext?.body?.tools)) return null;
|
|
212
|
+
const body = cloneJson(reqContext.body);
|
|
213
|
+
const filtered = body.tools.filter((tool) => !isImageNamespaceTool(tool, { collisionFallback: true }));
|
|
214
|
+
if (filtered.length === body.tools.length) return null;
|
|
215
|
+
if (filtered.length) body.tools = filtered;
|
|
216
|
+
else delete body.tools;
|
|
217
|
+
return { ...reqContext, body };
|
|
337
218
|
}
|
|
338
219
|
|
|
339
|
-
export function
|
|
340
|
-
|
|
220
|
+
export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
|
|
221
|
+
let encryptedRetried = false;
|
|
222
|
+
let imageNamespaceRetried = false;
|
|
223
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
224
|
+
const resp = await upstream(reqContext.body, { signal: options.signal });
|
|
225
|
+
if (resp.ok) return { resp, reqContext };
|
|
226
|
+
|
|
227
|
+
const errorText = await resp.text();
|
|
228
|
+
if (!imageNamespaceRetried && isImageNamespaceCollisionError(resp.status, errorText)) {
|
|
229
|
+
const retryContext = sanitizeImageNamespaceCollisionRequest(reqContext);
|
|
230
|
+
if (retryContext) {
|
|
231
|
+
imageNamespaceRetried = true;
|
|
232
|
+
reqContext = retryContext;
|
|
233
|
+
console.warn(status("warn", "image_gen namespace rejected by upstream; retrying without the conflicting image tool"));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!encryptedRetried && isEncryptedContentVerificationError(resp.status, errorText)) {
|
|
238
|
+
const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
|
|
239
|
+
if (retryContext) {
|
|
240
|
+
encryptedRetried = true;
|
|
241
|
+
reqContext = retryContext;
|
|
242
|
+
console.warn(status("warn", "encrypted reasoning rejected by upstream; retrying without encrypted reasoning"));
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { resp, reqContext, errorText };
|
|
247
|
+
}
|
|
248
|
+
throw httpError("Responses compatibility retry limit exceeded", 502);
|
|
341
249
|
}
|
|
342
250
|
|
|
343
251
|
// The GPT (gpt-5.6) Responses backend registers a built-in `image_gen` tool
|
|
344
252
|
// namespace. When the client also sends an image generation tool, the upstream
|
|
345
253
|
// rejects the request with "User-defined namespace 'image_gen' collides ...".
|
|
346
|
-
// Strip
|
|
254
|
+
// Strip exact built-in image tools before proxying. Broader namespace variants
|
|
255
|
+
// are removed only after the upstream explicitly reports a collision.
|
|
347
256
|
function isBuiltinImageTool(tool) {
|
|
348
|
-
|
|
349
|
-
const candidates = [tool.type, tool.name, tool.namespace];
|
|
350
|
-
return candidates.some((v) => {
|
|
351
|
-
const s = String(v || "").toLowerCase();
|
|
352
|
-
return s === "image_gen" || s === "image_generation" || s.startsWith("image_gen");
|
|
353
|
-
});
|
|
257
|
+
return isImageNamespaceTool(tool);
|
|
354
258
|
}
|
|
355
259
|
|
|
356
|
-
export function prepareResponsesRequest(reqBody) {
|
|
357
|
-
const body = cloneJson(reqBody);
|
|
358
|
-
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 });
|
|
359
263
|
const previousId = body.previous_response_id;
|
|
360
264
|
|
|
361
265
|
if (previousId !== undefined && previousId !== null) {
|
|
362
|
-
|
|
363
|
-
if (!previousItems) {
|
|
364
|
-
const err = new Error(`previous_response_id is not available in local adapter history: ${previousId}`);
|
|
365
|
-
err.statusCode = 400;
|
|
366
|
-
throw err;
|
|
367
|
-
}
|
|
368
|
-
body.input = [...cloneJson(previousItems), ...currentInputItems];
|
|
266
|
+
body.input = [...materializeResponseHistory(previousId), ...currentInputItems];
|
|
369
267
|
} else {
|
|
370
268
|
body.input = currentInputItems;
|
|
371
269
|
}
|
|
@@ -377,17 +275,28 @@ export function prepareResponsesRequest(reqBody) {
|
|
|
377
275
|
if (!body.tools.length) delete body.tools;
|
|
378
276
|
}
|
|
379
277
|
stripInternalResponsesInputFields(body.input);
|
|
278
|
+
stripInternalResponsesInputFields(currentInputItems);
|
|
380
279
|
|
|
381
|
-
return {
|
|
280
|
+
return {
|
|
281
|
+
body,
|
|
282
|
+
inputItems: body.input,
|
|
283
|
+
historyParentId: previousId ?? null,
|
|
284
|
+
historyInputItems: currentInputItems,
|
|
285
|
+
takeHistoryOwnership: mutate,
|
|
286
|
+
};
|
|
382
287
|
}
|
|
383
288
|
|
|
384
289
|
export function rememberResponseHistory(reqContext, responseJson) {
|
|
385
|
-
if (!responseJson?.id || !Array.isArray(reqContext?.inputItems)) return;
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
290
|
+
if (!responseJson?.id || !Array.isArray(reqContext?.historyInputItems || reqContext?.inputItems)) return;
|
|
291
|
+
const sourceInputItems = reqContext.historyInputItems || reqContext.inputItems;
|
|
292
|
+
const sourceOutputItems = responsesOutputItems(responseJson.output);
|
|
293
|
+
rememberResponseHistoryNode({
|
|
294
|
+
id: responseJson.id,
|
|
295
|
+
parentId: reqContext.historyParentId,
|
|
296
|
+
inputItems: sourceInputItems,
|
|
297
|
+
outputItems: sourceOutputItems,
|
|
298
|
+
takeOwnership: reqContext.takeHistoryOwnership,
|
|
299
|
+
});
|
|
391
300
|
}
|
|
392
301
|
|
|
393
302
|
function storeCompletedResponseFromSse(reqContext, data) {
|
|
@@ -435,6 +344,7 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
|
|
|
435
344
|
}
|
|
436
345
|
|
|
437
346
|
if (reqContext.body.stream) {
|
|
347
|
+
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
438
348
|
res.writeHead(resp.status, {
|
|
439
349
|
"Content-Type": resp.headers.get("content-type") || "text/event-stream",
|
|
440
350
|
"Cache-Control": "no-cache",
|
|
@@ -443,17 +353,23 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
|
|
|
443
353
|
const reader = resp.body.getReader();
|
|
444
354
|
const decoder = new TextDecoder();
|
|
445
355
|
let buffer = "";
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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;
|
|
453
369
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
370
|
+
} finally {
|
|
371
|
+
await reader.cancel().catch(() => {});
|
|
372
|
+
reader.releaseLock();
|
|
457
373
|
}
|
|
458
374
|
} else {
|
|
459
375
|
const data = await resp.text();
|
|
@@ -477,22 +393,39 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
|
|
|
477
393
|
|
|
478
394
|
// Chat Completions conversion for older models.
|
|
479
395
|
|
|
480
|
-
function responsesToChat(body) {
|
|
396
|
+
export function responsesToChat(body) {
|
|
481
397
|
const messages = [];
|
|
482
398
|
if (body.instructions) messages.push({ role: "system", content: body.instructions });
|
|
483
399
|
|
|
400
|
+
const messageContent = (content) => {
|
|
401
|
+
if (typeof content === "string") return content;
|
|
402
|
+
if (!Array.isArray(content)) return JSON.stringify(content);
|
|
403
|
+
const parts = [];
|
|
404
|
+
for (const part of content) {
|
|
405
|
+
if (!part || typeof part !== "object") continue;
|
|
406
|
+
if (["input_text", "output_text", "text"].includes(part.type)) {
|
|
407
|
+
parts.push({ type: "text", text: String(part.text || "") });
|
|
408
|
+
} else if (part.type === "input_image" || part.type === "image_url") {
|
|
409
|
+
const raw = part.image_url ?? part.url;
|
|
410
|
+
const imageUrl = typeof raw === "string"
|
|
411
|
+
? { url: raw }
|
|
412
|
+
: raw && typeof raw === "object" ? cloneJson(raw) : null;
|
|
413
|
+
if (imageUrl && part.detail !== undefined && imageUrl.detail === undefined) imageUrl.detail = part.detail;
|
|
414
|
+
if (imageUrl?.url) parts.push({ type: "image_url", image_url: imageUrl });
|
|
415
|
+
} else {
|
|
416
|
+
parts.push({ type: "text", text: JSON.stringify(part) });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
|
|
420
|
+
return parts;
|
|
421
|
+
};
|
|
422
|
+
|
|
484
423
|
if (typeof body.input === "string") {
|
|
485
424
|
messages.push({ role: "user", content: body.input });
|
|
486
425
|
} else if (Array.isArray(body.input)) {
|
|
487
426
|
for (const item of body.input) {
|
|
488
427
|
if (item.type === "message") {
|
|
489
|
-
|
|
490
|
-
typeof item.content === "string"
|
|
491
|
-
? item.content
|
|
492
|
-
: Array.isArray(item.content)
|
|
493
|
-
? item.content.map((p) => (p.type === "input_text" || p.type === "text" ? p.text : JSON.stringify(p))).join("")
|
|
494
|
-
: JSON.stringify(item.content);
|
|
495
|
-
messages.push({ role: item.role, content });
|
|
428
|
+
messages.push({ role: item.role, content: messageContent(item.content) });
|
|
496
429
|
} else if (item.type === "function_call") {
|
|
497
430
|
messages.push({
|
|
498
431
|
role: "assistant",
|
|
@@ -514,10 +447,44 @@ function responsesToChat(body) {
|
|
|
514
447
|
|
|
515
448
|
if (body.tools?.length) {
|
|
516
449
|
chatReq.tools = body.tools
|
|
517
|
-
.map((
|
|
450
|
+
.map((tool) => {
|
|
451
|
+
if (tool?.type !== "function") return null;
|
|
452
|
+
if (tool.function?.name) return cloneJson(tool);
|
|
453
|
+
const fn = {
|
|
454
|
+
name: tool.name,
|
|
455
|
+
description: tool.description,
|
|
456
|
+
parameters: tool.parameters,
|
|
457
|
+
strict: tool.strict,
|
|
458
|
+
};
|
|
459
|
+
for (const key of Object.keys(fn)) if (fn[key] === undefined) delete fn[key];
|
|
460
|
+
return { type: "function", function: fn };
|
|
461
|
+
})
|
|
462
|
+
.filter(Boolean)
|
|
518
463
|
.filter((t) => t.function?.name);
|
|
519
464
|
if (!chatReq.tools.length) delete chatReq.tools;
|
|
520
465
|
}
|
|
466
|
+
if (body.tool_choice !== undefined) {
|
|
467
|
+
if (typeof body.tool_choice === "string") {
|
|
468
|
+
chatReq.tool_choice = body.tool_choice;
|
|
469
|
+
} else if (body.tool_choice?.type === "function" && body.tool_choice.name) {
|
|
470
|
+
chatReq.tool_choice = { type: "function", function: { name: body.tool_choice.name } };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (body.parallel_tool_calls !== undefined) chatReq.parallel_tool_calls = body.parallel_tool_calls;
|
|
474
|
+
const textFormat = body.text?.format;
|
|
475
|
+
if (textFormat?.type === "json_schema") {
|
|
476
|
+
chatReq.response_format = {
|
|
477
|
+
type: "json_schema",
|
|
478
|
+
json_schema: Object.fromEntries(Object.entries({
|
|
479
|
+
name: textFormat.name,
|
|
480
|
+
description: textFormat.description,
|
|
481
|
+
schema: textFormat.schema,
|
|
482
|
+
strict: textFormat.strict,
|
|
483
|
+
}).filter(([, value]) => value !== undefined)),
|
|
484
|
+
};
|
|
485
|
+
} else if (textFormat?.type === "json_object") {
|
|
486
|
+
chatReq.response_format = { type: "json_object" };
|
|
487
|
+
}
|
|
521
488
|
return chatReq;
|
|
522
489
|
}
|
|
523
490
|
|
|
@@ -530,55 +497,160 @@ function chatToResponses(chatResp, model) {
|
|
|
530
497
|
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 };
|
|
531
498
|
}
|
|
532
499
|
|
|
533
|
-
async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
|
|
500
|
+
export async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
|
|
534
501
|
delete chatReq.max_tokens;
|
|
535
502
|
let resp;
|
|
536
503
|
try {
|
|
537
|
-
|
|
504
|
+
const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
|
|
505
|
+
resp = await chatCompletionsFn({
|
|
506
|
+
...chatReq,
|
|
507
|
+
stream: true,
|
|
508
|
+
}, { signal: options.signal });
|
|
538
509
|
} catch (e) {
|
|
539
|
-
|
|
510
|
+
const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
|
|
511
|
+
onError(statusCode, e.message);
|
|
540
512
|
return;
|
|
541
513
|
}
|
|
542
514
|
if (!resp.ok) {
|
|
543
515
|
onError(resp.status, await resp.text());
|
|
544
516
|
return;
|
|
545
517
|
}
|
|
546
|
-
|
|
547
|
-
|
|
518
|
+
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
519
|
+
const respId = `resp_${uid()}`;
|
|
520
|
+
let actualModel = chatReq.model || "unknown";
|
|
521
|
+
let fullText = "";
|
|
522
|
+
let messageItem = null;
|
|
523
|
+
let nextOutputIndex = 0;
|
|
524
|
+
let usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
|
|
525
|
+
const toolCalls = new Map();
|
|
548
526
|
|
|
549
527
|
await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
|
|
550
|
-
|
|
551
|
-
|
|
528
|
+
|
|
529
|
+
const ensureMessageItem = async () => {
|
|
530
|
+
if (messageItem) return messageItem;
|
|
531
|
+
messageItem = { id: `msg_${uid()}`, outputIndex: nextOutputIndex++ };
|
|
532
|
+
await emitEvent("response.output_item.added", {
|
|
533
|
+
output_index: messageItem.outputIndex,
|
|
534
|
+
item: { type: "message", id: messageItem.id, role: "assistant", status: "in_progress", content: [] },
|
|
535
|
+
});
|
|
536
|
+
await emitEvent("response.content_part.added", {
|
|
537
|
+
output_index: messageItem.outputIndex,
|
|
538
|
+
content_index: 0,
|
|
539
|
+
part: { type: "output_text", text: "" },
|
|
540
|
+
});
|
|
541
|
+
return messageItem;
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const ensureToolCall = async (chunk) => {
|
|
545
|
+
const key = Number.isInteger(chunk.index) ? `index:${chunk.index}` : `id:${chunk.id || toolCalls.size}`;
|
|
546
|
+
if (toolCalls.has(key)) return { tool: toolCalls.get(key), created: false };
|
|
547
|
+
const id = chunk.id || `call_${uid()}`;
|
|
548
|
+
const tool = {
|
|
549
|
+
id,
|
|
550
|
+
callId: id,
|
|
551
|
+
name: chunk.function?.name || "",
|
|
552
|
+
arguments: "",
|
|
553
|
+
outputIndex: nextOutputIndex++,
|
|
554
|
+
};
|
|
555
|
+
toolCalls.set(key, tool);
|
|
556
|
+
await emitEvent("response.output_item.added", {
|
|
557
|
+
output_index: tool.outputIndex,
|
|
558
|
+
item: {
|
|
559
|
+
type: "function_call",
|
|
560
|
+
id: tool.id,
|
|
561
|
+
call_id: tool.callId,
|
|
562
|
+
name: tool.name,
|
|
563
|
+
arguments: "",
|
|
564
|
+
status: "in_progress",
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
return { tool, created: true };
|
|
568
|
+
};
|
|
552
569
|
|
|
553
570
|
const emitCompleted = async () => {
|
|
554
|
-
if (!
|
|
555
|
-
|
|
556
|
-
|
|
571
|
+
if (!messageItem && toolCalls.size === 0) await ensureMessageItem();
|
|
572
|
+
const output = [];
|
|
573
|
+
if (messageItem) {
|
|
574
|
+
const item = {
|
|
575
|
+
type: "message",
|
|
576
|
+
id: messageItem.id,
|
|
577
|
+
role: "assistant",
|
|
578
|
+
status: "completed",
|
|
579
|
+
content: [{ type: "output_text", text: fullText }],
|
|
580
|
+
};
|
|
581
|
+
await emitEvent("response.output_text.done", { output_index: messageItem.outputIndex, content_index: 0, text: fullText });
|
|
582
|
+
await emitEvent("response.content_part.done", { output_index: messageItem.outputIndex, content_index: 0, part: item.content[0] });
|
|
583
|
+
await emitEvent("response.output_item.done", { output_index: messageItem.outputIndex, item });
|
|
584
|
+
output[messageItem.outputIndex] = item;
|
|
585
|
+
}
|
|
586
|
+
for (const tool of toolCalls.values()) {
|
|
587
|
+
const item = {
|
|
588
|
+
type: "function_call",
|
|
589
|
+
id: tool.id,
|
|
590
|
+
call_id: tool.callId,
|
|
591
|
+
name: tool.name,
|
|
592
|
+
arguments: tool.arguments,
|
|
593
|
+
status: "completed",
|
|
594
|
+
};
|
|
595
|
+
await emitEvent("response.function_call_arguments.done", {
|
|
596
|
+
output_index: tool.outputIndex,
|
|
597
|
+
item_id: tool.id,
|
|
598
|
+
arguments: tool.arguments,
|
|
599
|
+
});
|
|
600
|
+
await emitEvent("response.output_item.done", { output_index: tool.outputIndex, item });
|
|
601
|
+
output[tool.outputIndex] = item;
|
|
557
602
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
603
|
+
await emitEvent("response.completed", {
|
|
604
|
+
response: {
|
|
605
|
+
id: respId,
|
|
606
|
+
object: "response",
|
|
607
|
+
status: "completed",
|
|
608
|
+
model: actualModel,
|
|
609
|
+
output: output.filter(Boolean),
|
|
610
|
+
usage,
|
|
611
|
+
},
|
|
612
|
+
});
|
|
562
613
|
};
|
|
563
614
|
|
|
564
615
|
try {
|
|
565
|
-
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
|
+
})) {
|
|
566
619
|
if (!line.startsWith("data: ")) continue;
|
|
567
620
|
const data = line.slice(6).trim();
|
|
568
621
|
if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
|
|
569
622
|
let parsed;
|
|
570
623
|
try { parsed = JSON.parse(data); } catch { continue; }
|
|
571
624
|
if (parsed.model) actualModel = parsed.model;
|
|
625
|
+
if (parsed.usage) {
|
|
626
|
+
usage = {
|
|
627
|
+
input_tokens: parsed.usage.prompt_tokens || 0,
|
|
628
|
+
output_tokens: parsed.usage.completion_tokens || 0,
|
|
629
|
+
total_tokens: parsed.usage.total_tokens || 0,
|
|
630
|
+
};
|
|
631
|
+
const cached = parsed.usage.prompt_tokens_details?.cached_tokens;
|
|
632
|
+
if (cached) usage.input_tokens_details = { cached_tokens: cached };
|
|
633
|
+
}
|
|
572
634
|
const delta = parsed.choices?.[0]?.delta;
|
|
573
635
|
if (!delta) continue;
|
|
574
|
-
if (delta.content) {
|
|
636
|
+
if (delta.content) {
|
|
637
|
+
const message = await ensureMessageItem();
|
|
638
|
+
fullText += delta.content;
|
|
639
|
+
await emitEvent("response.output_text.delta", { output_index: message.outputIndex, content_index: 0, delta: delta.content });
|
|
640
|
+
}
|
|
575
641
|
if (delta.tool_calls) {
|
|
576
|
-
hasToolCalls = true;
|
|
577
642
|
for (const tc of delta.tool_calls) {
|
|
578
|
-
const
|
|
579
|
-
if (
|
|
580
|
-
if (tc.function?.name)
|
|
581
|
-
if (tc.function?.arguments)
|
|
643
|
+
const { tool, created } = await ensureToolCall(tc);
|
|
644
|
+
if (tc.id) tool.callId = tc.id;
|
|
645
|
+
if (!created && tc.function?.name) tool.name += tc.function.name;
|
|
646
|
+
if (tc.function?.arguments) {
|
|
647
|
+
tool.arguments += tc.function.arguments;
|
|
648
|
+
await emitEvent("response.function_call_arguments.delta", {
|
|
649
|
+
output_index: tool.outputIndex,
|
|
650
|
+
item_id: tool.id,
|
|
651
|
+
delta: tc.function.arguments,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
582
654
|
}
|
|
583
655
|
}
|
|
584
656
|
}
|
|
@@ -590,18 +662,26 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {})
|
|
|
590
662
|
onDone();
|
|
591
663
|
}
|
|
592
664
|
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
export function
|
|
665
|
+
// Shared request handler. Keeping this separate from the listener makes the
|
|
666
|
+
// complete HTTP routing layer testable without opening a local port.
|
|
667
|
+
export function createAdapterHandler(options = {}) {
|
|
596
668
|
const claudeDesktopApiKey = options.claudeDesktopApiKey
|
|
597
669
|
|| process.env.CCDX_CLAUDE_DESKTOP_API_KEY
|
|
598
670
|
|| process.env.CCDX_PROXY_API_KEY
|
|
599
671
|
|| "";
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
672
|
+
const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
|
|
673
|
+
const responsesFn = options.responsesFn || copilotResponses;
|
|
674
|
+
const responsesCompactFn = options.responsesCompactFn || copilotResponsesCompact;
|
|
675
|
+
const listModelsFn = options.listModelsFn || listModels;
|
|
676
|
+
const upstreamTimeoutMs = positiveInt(options.upstreamTimeoutMs, UPSTREAM_TIMEOUT_MS);
|
|
677
|
+
const streamHandshakeTimeoutMs = positiveInt(options.streamHandshakeTimeoutMs, STREAM_HANDSHAKE_TIMEOUT_MS);
|
|
678
|
+
const streamIdleTimeoutMs = positiveInt(options.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_MS);
|
|
679
|
+
const claudeDesktopModelOptions = () => {
|
|
680
|
+
const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
|
|
681
|
+
return Array.isArray(modelDefs) ? { modelDefs } : {};
|
|
682
|
+
};
|
|
603
683
|
|
|
604
|
-
|
|
684
|
+
return (req, res) => {
|
|
605
685
|
const pathname = requestPath(req.url);
|
|
606
686
|
|
|
607
687
|
if (req.method === "GET" && pathname === ADAPTER_HEALTH_PATH) {
|
|
@@ -611,17 +691,24 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
611
691
|
}
|
|
612
692
|
|
|
613
693
|
if (req.method === "POST" && pathname === "/v1/responses") {
|
|
614
|
-
(async () => {
|
|
694
|
+
return (async () => {
|
|
615
695
|
const abort = createRequestAbort(req, res);
|
|
616
696
|
try {
|
|
617
697
|
const parsed = await readJsonBody(req);
|
|
618
|
-
|
|
619
|
-
|
|
698
|
+
abort.setTimeout(
|
|
699
|
+
parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
|
|
700
|
+
parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
|
|
701
|
+
);
|
|
702
|
+
const prepared = prepareResponsesRequest(parsed, { mutate: true });
|
|
620
703
|
prepared.surface = "responses";
|
|
621
704
|
const model = parsed.model || "unknown";
|
|
622
705
|
console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
|
|
623
706
|
if (isResponsesOnlyModel(model)) {
|
|
624
|
-
await proxyCopilotResponses(prepared, req, res,
|
|
707
|
+
await proxyCopilotResponses(prepared, req, res, responsesFn, {
|
|
708
|
+
signal: abort.signal,
|
|
709
|
+
abort,
|
|
710
|
+
streamIdleTimeoutMs,
|
|
711
|
+
});
|
|
625
712
|
} else {
|
|
626
713
|
const chatReq = responsesToChat(prepared.body);
|
|
627
714
|
if (parsed.stream) {
|
|
@@ -632,13 +719,22 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
632
719
|
rememberResponseHistory(prepared, data.response);
|
|
633
720
|
recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
|
|
634
721
|
}
|
|
635
|
-
}, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, {
|
|
722
|
+
}, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, {
|
|
723
|
+
signal: abort.signal,
|
|
724
|
+
abort,
|
|
725
|
+
streamIdleTimeoutMs,
|
|
726
|
+
chatCompletionsFn,
|
|
727
|
+
});
|
|
636
728
|
} else {
|
|
637
729
|
chatReq.stream = false;
|
|
638
730
|
delete chatReq.max_tokens;
|
|
639
731
|
try {
|
|
640
|
-
const upstream = await
|
|
732
|
+
const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
|
|
641
733
|
const data = await upstream.text();
|
|
734
|
+
if (!upstream.ok) {
|
|
735
|
+
sendUpstreamError(res, upstream, data);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
642
738
|
const resp = chatToResponses(JSON.parse(data), model);
|
|
643
739
|
rememberResponseHistory(prepared, resp);
|
|
644
740
|
recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response: resp, event: resp });
|
|
@@ -660,7 +756,6 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
660
756
|
abort.cleanup();
|
|
661
757
|
}
|
|
662
758
|
})();
|
|
663
|
-
return;
|
|
664
759
|
}
|
|
665
760
|
|
|
666
761
|
if (req.method === "POST" && pathname === "/v1/responses/compact") {
|
|
@@ -668,12 +763,19 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
668
763
|
const abort = createRequestAbort(req, res);
|
|
669
764
|
try {
|
|
670
765
|
const parsed = await readJsonBody(req);
|
|
671
|
-
|
|
672
|
-
|
|
766
|
+
abort.setTimeout(
|
|
767
|
+
parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
|
|
768
|
+
parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
|
|
769
|
+
);
|
|
770
|
+
const prepared = prepareResponsesRequest(parsed, { mutate: true });
|
|
673
771
|
prepared.surface = "responses_compact";
|
|
674
772
|
const model = parsed.model || "unknown";
|
|
675
773
|
console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
|
|
676
|
-
await proxyCopilotResponses(prepared, req, res,
|
|
774
|
+
await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
|
|
775
|
+
signal: abort.signal,
|
|
776
|
+
abort,
|
|
777
|
+
streamIdleTimeoutMs,
|
|
778
|
+
});
|
|
677
779
|
} catch (e) {
|
|
678
780
|
logRequestFailure("Responses compact", e, abort);
|
|
679
781
|
sendJsonError(res, e);
|
|
@@ -687,12 +789,12 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
687
789
|
if (req.method === "GET" && pathname === "/v1/models") {
|
|
688
790
|
if (shouldServeClaudeDesktopModels(req, claudeDesktopApiKey)) {
|
|
689
791
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
690
|
-
res.end(JSON.stringify(claudeDesktopModelsResponse(process.env, claudeDesktopModelOptions)));
|
|
792
|
+
res.end(JSON.stringify(claudeDesktopModelsResponse(process.env, claudeDesktopModelOptions())));
|
|
691
793
|
return;
|
|
692
794
|
}
|
|
693
795
|
const abort = createRequestAbort(req, res);
|
|
694
|
-
abort.setTimeout(
|
|
695
|
-
|
|
796
|
+
abort.setTimeout(upstreamTimeoutMs);
|
|
797
|
+
listModelsFn({ signal: abort.signal })
|
|
696
798
|
.then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
|
|
697
799
|
.catch((e) => {
|
|
698
800
|
logRequestFailure("Models", e, abort);
|
|
@@ -726,24 +828,30 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
726
828
|
const abort = createRequestAbort(req, res);
|
|
727
829
|
try {
|
|
728
830
|
const parsed = await readJsonBody(req);
|
|
729
|
-
|
|
730
|
-
|
|
831
|
+
abort.setTimeout(
|
|
832
|
+
parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
|
|
833
|
+
parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
|
|
834
|
+
);
|
|
835
|
+
const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown", process.env, claudeDesktopModelOptions());
|
|
731
836
|
const modelNote = upstreamModel === requestedModel ? requestedModel : `${requestedModel} -> ${upstreamModel}`;
|
|
732
837
|
console.log(status("info", `messages model=${modelNote} stream=${!!parsed.stream}`));
|
|
733
838
|
const chatReq = anthropicToChat(parsed, { upstreamModel });
|
|
734
839
|
const forceRequestedModel = upstreamModel !== requestedModel;
|
|
735
840
|
if (parsed.stream) {
|
|
736
|
-
const upstream = await
|
|
841
|
+
const upstream = await chatCompletionsFn({ ...chatReq, stream: true }, { signal: abort.signal });
|
|
737
842
|
if (!upstream.ok) {
|
|
738
843
|
if (!res.headersSent) res.writeHead(upstream.status);
|
|
739
844
|
res.end(await upstream.text());
|
|
740
845
|
return;
|
|
741
846
|
}
|
|
847
|
+
abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
|
|
742
848
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
743
849
|
let messageId;
|
|
744
850
|
let usage;
|
|
745
851
|
await streamAnthropicFromLines(
|
|
746
|
-
webStreamLines(upstream
|
|
852
|
+
webStreamLines(upstream, {
|
|
853
|
+
onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
|
|
854
|
+
}),
|
|
747
855
|
async (event, data) => {
|
|
748
856
|
if (event === "message_start") messageId = data.message?.id;
|
|
749
857
|
if (event === "message_delta") usage = data.usage;
|
|
@@ -755,8 +863,12 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
755
863
|
recordAnthropicUsage({ surface: "messages", mode: "stream", model: requestedModel, responseId: messageId, usage });
|
|
756
864
|
if (!res.writableEnded) res.end();
|
|
757
865
|
} else {
|
|
758
|
-
const upstream = await
|
|
866
|
+
const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
|
|
759
867
|
const data = await upstream.text();
|
|
868
|
+
if (!upstream.ok) {
|
|
869
|
+
sendUpstreamError(res, upstream, data);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
760
872
|
const anthropicMsg = chatToAnthropic(JSON.parse(data), requestedModel, { forceModel: forceRequestedModel });
|
|
761
873
|
recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
|
|
762
874
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -774,7 +886,13 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
774
886
|
|
|
775
887
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
776
888
|
res.end(JSON.stringify({ error: "Not found" }));
|
|
777
|
-
}
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Public server entry point.
|
|
893
|
+
|
|
894
|
+
export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
895
|
+
const server = http.createServer(createAdapterHandler(options));
|
|
778
896
|
|
|
779
897
|
server.on("upgrade", (req, socket) => {
|
|
780
898
|
// Codex Desktop 0.130+ negotiates a "responses_websockets" server-push
|