codex-copilot-dx 0.4.35 → 0.4.37

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/src/adapter.mjs CHANGED
@@ -1,32 +1,27 @@
1
1
  import http from "node:http";
2
- import { randomUUID } from "node:crypto";
3
- import { chatCompletions, listModels, responses as copilotResponses, responsesCompact as copilotResponsesCompact, getCachedModelEndpoints } from "./copilot.mjs";
4
- import { webStreamLines } from "./stream.mjs";
5
- import { anthropicToChat, chatToAnthropic, streamAnthropicFromLines, countTokens } from "./anthropic.mjs";
6
- import { CODEX_AUTO_REVIEW_MODEL, claudeDesktopModelsResponse, resolveAnthropicModel, resolveOpenAIModel, modelIsResponsesOnly, modelSupportsChatCompletions } from "./models.mjs";
2
+ import { createAnthropicCountTokensHandler, createAnthropicMessagesHandler } from "./anthropic-handler.mjs";
3
+ import { chatCompletions, listModels, responses as copilotResponses, responsesCompact as copilotResponsesCompact } from "./copilot.mjs";
7
4
  import { isValidModelList } from "./model-cache.mjs";
5
+ import { claudeDesktopModelsResponse } from "./models.mjs";
6
+ import {
7
+ ADAPTER_STATUS_PATH,
8
+ classifyAdapterRoute,
9
+ createRequestMetrics,
10
+ isLoopbackAddress,
11
+ runtimeStatusPayload,
12
+ } from "./observability.mjs";
13
+ import { createRequestId, runWithRequestContext } from "./request-context.mjs";
8
14
  import { status } from "./status.mjs";
9
- import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
10
15
  import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
16
+ import { createResponsesCompactHandler, createResponsesHandler } from "./responses-handler.mjs";
17
+ import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
11
18
  import {
12
- abortErrorStatusCode,
13
19
  createRequestAdmission,
14
20
  createRequestAbort,
15
21
  httpError,
16
- isAbortLikeError,
17
22
  logRequestFailure,
18
- readJsonBody,
19
23
  sendJsonError,
20
- sendUpstreamError,
21
- writeOrDrain,
22
24
  } from "./http-transport.mjs";
23
- import {
24
- clearResponseHistoryForTests,
25
- configureResponseHistoryForTests,
26
- materializeResponseHistory,
27
- rememberResponseHistoryNode,
28
- responseHistoryStats,
29
- } from "./response-history.mjs";
30
25
 
31
26
  export {
32
27
  abortErrorStatusCode,
@@ -41,57 +36,24 @@ export {
41
36
  configureResponseHistoryForTests,
42
37
  responseHistoryStats,
43
38
  } from "./response-history.mjs";
44
-
45
- // Fallback list for models known to only support the Responses API, used when
46
- // live model metadata (supported_endpoints) has not been cached yet.
47
- const RESPONSES_ONLY_FALLBACK = new Set([
48
- "gpt-5.6-luna",
49
- "gpt-5.6-sol",
50
- "gpt-5.6-terra",
51
- "gpt-5.5",
52
- "gpt-5.4",
53
- "gpt-5.4-mini",
54
- "gpt-5.3-codex",
55
- "gpt-5.2-codex",
56
- ]);
57
-
58
- // Decide whether a model must be proxied straight to /responses (vs converted to
59
- // /chat/completions). Prefer real endpoint metadata; fall back to the static list.
60
- function isResponsesOnlyModel(model) {
61
- const endpoints = getCachedModelEndpoints(model);
62
- if (endpoints) {
63
- const fakeModel = { supported_endpoints: endpoints };
64
- if (modelSupportsChatCompletions(fakeModel)) return false;
65
- if (modelIsResponsesOnly(fakeModel)) return true;
66
- }
67
- return RESPONSES_ONLY_FALLBACK.has(model);
68
- }
69
-
70
- // Direct Copilot Responses API proxy.
71
-
72
- const DEFAULT_UPSTREAM_TIMEOUT_MS = 120 * 1000;
73
- const DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS = 120 * 1000;
74
- const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120 * 1000;
75
- const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024;
76
-
77
- function positiveInt(value, fallback) {
78
- const n = Number.parseInt(value, 10);
79
- return Number.isFinite(n) && n > 0 ? n : fallback;
80
- }
81
-
82
- const UPSTREAM_TIMEOUT_MS = positiveInt(process.env.CCDX_UPSTREAM_TIMEOUT_MS, DEFAULT_UPSTREAM_TIMEOUT_MS);
83
- const STREAM_HANDSHAKE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS);
84
- const STREAM_IDLE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_IDLE_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS);
85
- const MAX_SSE_BUFFER_BYTES = positiveInt(process.env.CCDX_MAX_SSE_BUFFER_BYTES, DEFAULT_MAX_SSE_BUFFER_BYTES);
39
+ export { forwardToChat, responsesToChat } from "./responses-bridge.mjs";
40
+ export {
41
+ isEncryptedContentVerificationError,
42
+ isImageNamespaceCollisionError,
43
+ openCopilotResponse,
44
+ prepareResponsesRequest,
45
+ rememberResponseHistory,
46
+ sanitizeEncryptedReasoningRequest,
47
+ sanitizeImageNamespaceCollisionRequest,
48
+ stripInternalResponsesInputFields,
49
+ } from "./responses-request.mjs";
50
+
51
+ const ADAPTER_RUNTIME_CONFIG = loadRuntimeConfig();
86
52
 
87
53
  export function requestPath(reqUrl) {
88
54
  return new URL(reqUrl || "/", "http://localhost").pathname;
89
55
  }
90
56
 
91
- function cloneJson(value) {
92
- return value === undefined ? undefined : structuredClone(value);
93
- }
94
-
95
57
  function bearerToken(headers = {}) {
96
58
  const value = headers.authorization || headers.Authorization || "";
97
59
  const match = /^Bearer\s+(.+)$/i.exec(String(value));
@@ -119,606 +81,6 @@ function sendLastKnownGoodModels(res, modelRegistry) {
119
81
  return true;
120
82
  }
121
83
 
122
- function responsesInputItems(input, { clone = true } = {}) {
123
- if (input === undefined || input === null) return [];
124
- if (typeof input === "string") {
125
- return [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }];
126
- }
127
- if (Array.isArray(input)) return clone ? cloneJson(input) : input;
128
- return [clone ? cloneJson(input) : input];
129
- }
130
-
131
- export function stripInternalResponsesInputFields(inputItems) {
132
- if (!Array.isArray(inputItems)) return inputItems;
133
- for (const item of inputItems) {
134
- if (!item || typeof item !== "object") continue;
135
- for (const key of Object.keys(item)) {
136
- if (key.startsWith("internal_")) delete item[key];
137
- }
138
- }
139
- return inputItems;
140
- }
141
-
142
- function responsesOutputItems(output) {
143
- if (!Array.isArray(output)) return [];
144
- return output.filter((item) => item?.type === "message" || item?.type === "function_call");
145
- }
146
-
147
- function stripEncryptedReasoningValue(value, state) {
148
- if (Array.isArray(value)) {
149
- return value.map((item) => stripEncryptedReasoningValue(item, state));
150
- }
151
-
152
- if (value && typeof value === "object") {
153
- const out = {};
154
- for (const [key, child] of Object.entries(value)) {
155
- if (key === "encrypted_content") {
156
- state.changed = true;
157
- continue;
158
- }
159
- out[key] = stripEncryptedReasoningValue(child, state);
160
- }
161
- return out;
162
- }
163
-
164
- return value;
165
- }
166
-
167
- function isEncryptedReasoningInputItem(item) {
168
- return item && typeof item === "object"
169
- && (item.type === "reasoning" || Object.prototype.hasOwnProperty.call(item, "encrypted_content"));
170
- }
171
-
172
- export function sanitizeEncryptedReasoningRequest(reqContext) {
173
- const state = { changed: false };
174
- let body = cloneJson(reqContext.body);
175
- if (Array.isArray(body.input)) {
176
- const input = [];
177
- for (const item of body.input) {
178
- if (isEncryptedReasoningInputItem(item)) {
179
- state.changed = true;
180
- continue;
181
- }
182
- input.push(stripEncryptedReasoningValue(item, state));
183
- }
184
- body.input = input;
185
- } else {
186
- body = stripEncryptedReasoningValue(body, state);
187
- }
188
- if (!state.changed) return null;
189
- const historyInputItems = Array.isArray(reqContext.historyInputItems)
190
- ? reqContext.historyInputItems
191
- .filter((item) => !isEncryptedReasoningInputItem(item))
192
- .map((item) => stripEncryptedReasoningValue(item, { changed: false }))
193
- : reqContext.historyInputItems;
194
- return {
195
- ...reqContext,
196
- body,
197
- inputItems: Array.isArray(body.input) ? body.input : reqContext.inputItems,
198
- historyInputItems,
199
- };
200
- }
201
-
202
- export function isEncryptedContentVerificationError(statusCode, text) {
203
- if (statusCode < 400 || !text) return false;
204
- const lower = String(text).toLowerCase();
205
- return lower.includes("encrypted content")
206
- && lower.includes("could not be verified")
207
- && (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
208
- }
209
-
210
- export function isImageNamespaceCollisionError(statusCode, text) {
211
- if (statusCode < 400 || !text) return false;
212
- const lower = String(text).toLowerCase();
213
- return lower.includes("namespace")
214
- && lower.includes("image_gen")
215
- && lower.includes("collid");
216
- }
217
-
218
- function isImageNamespaceTool(tool, { collisionFallback = false } = {}) {
219
- if (!tool || typeof tool !== "object") return false;
220
- const type = String(tool.type || "").toLowerCase();
221
- const name = String(tool.name || tool.function?.name || "").toLowerCase();
222
- const namespace = String(tool.namespace || "").toLowerCase();
223
- if (["image_gen", "image_generation"].includes(type)) return true;
224
- if (["image_gen", "image_generation"].includes(name)) return true;
225
- if (namespace === "image_gen" || namespace === "image_generation") return true;
226
- return collisionFallback && [type, name, namespace].some((value) => value.startsWith("image_gen"));
227
- }
228
-
229
- export function sanitizeImageNamespaceCollisionRequest(reqContext) {
230
- if (!Array.isArray(reqContext?.body?.tools)) return null;
231
- const body = cloneJson(reqContext.body);
232
- const filtered = body.tools.filter((tool) => !isImageNamespaceTool(tool, { collisionFallback: true }));
233
- if (filtered.length === body.tools.length) return null;
234
- if (filtered.length) body.tools = filtered;
235
- else delete body.tools;
236
- return { ...reqContext, body };
237
- }
238
-
239
- export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
240
- let encryptedRetried = false;
241
- let imageNamespaceRetried = false;
242
- for (let attempt = 0; attempt < 3; attempt += 1) {
243
- const resp = await upstream(reqContext.body, { signal: options.signal });
244
- if (resp.ok) return { resp, reqContext };
245
-
246
- const errorText = await resp.text();
247
- if (!imageNamespaceRetried && isImageNamespaceCollisionError(resp.status, errorText)) {
248
- const retryContext = sanitizeImageNamespaceCollisionRequest(reqContext);
249
- if (retryContext) {
250
- imageNamespaceRetried = true;
251
- reqContext = retryContext;
252
- console.warn(status("warn", "image_gen namespace rejected by upstream; retrying without the conflicting image tool"));
253
- continue;
254
- }
255
- }
256
- if (!encryptedRetried && isEncryptedContentVerificationError(resp.status, errorText)) {
257
- const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
258
- if (retryContext) {
259
- encryptedRetried = true;
260
- reqContext = retryContext;
261
- console.warn(status("warn", "encrypted reasoning rejected by upstream; retrying without encrypted reasoning"));
262
- continue;
263
- }
264
- }
265
- return { resp, reqContext, errorText };
266
- }
267
- throw httpError("Responses compatibility retry limit exceeded", 502);
268
- }
269
-
270
- // The GPT (gpt-5.6) Responses backend registers a built-in `image_gen` tool
271
- // namespace. When the client also sends an image generation tool, the upstream
272
- // rejects the request with "User-defined namespace 'image_gen' collides ...".
273
- // Strip exact built-in image tools before proxying. Broader namespace variants
274
- // are removed only after the upstream explicitly reports a collision.
275
- function isBuiltinImageTool(tool) {
276
- return isImageNamespaceTool(tool);
277
- }
278
-
279
- export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
280
- const body = mutate ? reqBody : cloneJson(reqBody);
281
- const currentInputItems = responsesInputItems(body.input, { clone: !mutate });
282
- const previousId = body.previous_response_id;
283
-
284
- if (previousId !== undefined && previousId !== null) {
285
- body.input = [...materializeResponseHistory(previousId), ...currentInputItems];
286
- } else {
287
- body.input = currentInputItems;
288
- }
289
-
290
- delete body.previous_response_id;
291
- delete body.store;
292
- if (Array.isArray(body.tools)) {
293
- body.tools = body.tools.filter((tool) => !isBuiltinImageTool(tool));
294
- if (!body.tools.length) delete body.tools;
295
- }
296
- stripInternalResponsesInputFields(body.input);
297
- stripInternalResponsesInputFields(currentInputItems);
298
-
299
- return {
300
- body,
301
- inputItems: body.input,
302
- historyParentId: previousId ?? null,
303
- historyInputItems: currentInputItems,
304
- takeHistoryOwnership: mutate,
305
- };
306
- }
307
-
308
- export function rememberResponseHistory(reqContext, responseJson) {
309
- if (!responseJson?.id || !Array.isArray(reqContext?.historyInputItems || reqContext?.inputItems)) return;
310
- const sourceInputItems = reqContext.historyInputItems || reqContext.inputItems;
311
- const sourceOutputItems = responsesOutputItems(responseJson.output);
312
- rememberResponseHistoryNode({
313
- id: responseJson.id,
314
- parentId: reqContext.historyParentId,
315
- inputItems: sourceInputItems,
316
- outputItems: sourceOutputItems,
317
- takeOwnership: reqContext.takeHistoryOwnership,
318
- });
319
- }
320
-
321
- function storeCompletedResponseFromSse(reqContext, data) {
322
- if (!data || data === "[DONE]") return;
323
- try {
324
- const event = JSON.parse(data);
325
- const response = event.response;
326
- if (response?.id && response.status === "completed") {
327
- rememberResponseHistory(reqContext, response);
328
- recordResponsesUsage({
329
- surface: reqContext.surface,
330
- mode: "stream",
331
- model: reqContext.body?.model,
332
- response,
333
- event,
334
- });
335
- }
336
- } catch {
337
- // Ignore non-JSON stream fragments.
338
- }
339
- }
340
-
341
- function readSseEvents(buffer, onData) {
342
- while (true) {
343
- const match = buffer.match(/\r?\n\r?\n/);
344
- if (!match) return buffer;
345
- const chunk = buffer.slice(0, match.index);
346
- if (Buffer.byteLength(chunk) > MAX_SSE_BUFFER_BYTES) {
347
- throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
348
- }
349
- buffer = buffer.slice(match.index + match[0].length);
350
- const data = chunk
351
- .split(/\r?\n/)
352
- .filter((line) => line.startsWith("data:"))
353
- .map((line) => line.slice(5).trimStart())
354
- .join("\n");
355
- if (data) onData(data);
356
- }
357
- }
358
-
359
- function streamErrorData(protocol, error, abort) {
360
- const code = abort?.reason || "upstream_stream_error";
361
- const message = `${code}: ${error?.message || "Upstream stream failed"}`;
362
- if (protocol === "anthropic") {
363
- return { type: "error", error: { type: "api_error", message } };
364
- }
365
- return { type: "error", code, message, param: null };
366
- }
367
-
368
- async function endStreamWithError(res, protocol, error, abort) {
369
- if (res.destroyed || res.writableEnded) return;
370
- const data = streamErrorData(protocol, error, abort);
371
- await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`).catch(() => false);
372
- if (!res.destroyed && !res.writableEnded) res.end();
373
- }
374
-
375
- async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
376
- let opened;
377
- try {
378
- opened = await openCopilotResponse(reqContext, upstream, options);
379
- } finally {
380
- options.releaseRequest?.();
381
- }
382
- const { resp, errorText } = opened;
383
- reqContext = opened.reqContext;
384
- if (errorText !== undefined) {
385
- sendUpstreamError(res, resp, errorText);
386
- return;
387
- }
388
-
389
- if (reqContext.body.stream) {
390
- options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
391
- res.writeHead(resp.status, {
392
- "Content-Type": resp.headers.get("content-type") || "text/event-stream",
393
- "Cache-Control": "no-cache",
394
- Connection: "keep-alive",
395
- });
396
- const reader = resp.body.getReader();
397
- const decoder = new TextDecoder();
398
- let buffer = "";
399
- try {
400
- while (true) {
401
- const { done, value } = await reader.read();
402
- if (done) {
403
- buffer += decoder.decode();
404
- if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
405
- throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
406
- }
407
- readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
408
- res.end();
409
- return;
410
- }
411
- options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
412
- buffer += decoder.decode(value, { stream: true });
413
- buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
414
- if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
415
- throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
416
- }
417
- if (!await writeOrDrain(res, value)) return;
418
- }
419
- } catch (e) {
420
- logRequestFailure("Responses", e, options.abort);
421
- await endStreamWithError(res, "responses", e, options.abort);
422
- } finally {
423
- await reader.cancel().catch(() => {});
424
- reader.releaseLock();
425
- }
426
- } else {
427
- const data = await resp.text();
428
- res.writeHead(resp.status, { "Content-Type": "application/json" });
429
- res.end(data);
430
- if (resp.ok) {
431
- try {
432
- const response = JSON.parse(data);
433
- rememberResponseHistory(reqContext, response);
434
- recordResponsesUsage({
435
- surface: reqContext.surface,
436
- mode: "json",
437
- model: reqContext.body?.model,
438
- response,
439
- event: response,
440
- });
441
- } catch {}
442
- }
443
- }
444
- }
445
-
446
- // Chat Completions conversion for older models.
447
-
448
- export function responsesToChat(body) {
449
- const messages = [];
450
- if (body.instructions) messages.push({ role: "system", content: body.instructions });
451
-
452
- const messageContent = (content) => {
453
- if (typeof content === "string") return content;
454
- if (!Array.isArray(content)) return JSON.stringify(content);
455
- const parts = [];
456
- for (const part of content) {
457
- if (!part || typeof part !== "object") continue;
458
- if (["input_text", "output_text", "text"].includes(part.type)) {
459
- parts.push({ type: "text", text: String(part.text || "") });
460
- } else if (part.type === "input_image" || part.type === "image_url") {
461
- const raw = part.image_url ?? part.url;
462
- const imageUrl = typeof raw === "string"
463
- ? { url: raw }
464
- : raw && typeof raw === "object" ? cloneJson(raw) : null;
465
- if (imageUrl && part.detail !== undefined && imageUrl.detail === undefined) imageUrl.detail = part.detail;
466
- if (imageUrl?.url) parts.push({ type: "image_url", image_url: imageUrl });
467
- } else {
468
- parts.push({ type: "text", text: JSON.stringify(part) });
469
- }
470
- }
471
- if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
472
- return parts;
473
- };
474
-
475
- if (typeof body.input === "string") {
476
- messages.push({ role: "user", content: body.input });
477
- } else if (Array.isArray(body.input)) {
478
- for (const item of body.input) {
479
- if (item.type === "message") {
480
- messages.push({ role: item.role, content: messageContent(item.content) });
481
- } else if (item.type === "function_call") {
482
- messages.push({
483
- role: "assistant",
484
- content: null,
485
- tool_calls: [{ id: item.call_id || randomUUID(), type: "function", function: { name: item.name, arguments: item.arguments } }],
486
- });
487
- } else if (item.type === "function_call_output") {
488
- messages.push({ role: "tool", tool_call_id: item.call_id, content: typeof item.output === "string" ? item.output : JSON.stringify(item.output) });
489
- }
490
- }
491
- }
492
-
493
- const chatReq = { model: body.model, messages, stream: true };
494
- for (const k of ["temperature", "top_p", "stop", "presence_penalty", "frequency_penalty"]) {
495
- if (body[k] !== undefined) chatReq[k] = body[k];
496
- }
497
- const maxTok = body.max_output_tokens ?? body.max_tokens ?? body.max_completion_tokens;
498
- if (maxTok !== undefined) chatReq.max_completion_tokens = maxTok;
499
-
500
- if (body.tools?.length) {
501
- chatReq.tools = body.tools
502
- .map((tool) => {
503
- if (tool?.type !== "function") return null;
504
- if (tool.function?.name) return cloneJson(tool);
505
- const fn = {
506
- name: tool.name,
507
- description: tool.description,
508
- parameters: tool.parameters,
509
- strict: tool.strict,
510
- };
511
- for (const key of Object.keys(fn)) if (fn[key] === undefined) delete fn[key];
512
- return { type: "function", function: fn };
513
- })
514
- .filter(Boolean)
515
- .filter((t) => t.function?.name);
516
- if (!chatReq.tools.length) delete chatReq.tools;
517
- }
518
- if (body.tool_choice !== undefined) {
519
- if (typeof body.tool_choice === "string") {
520
- chatReq.tool_choice = body.tool_choice;
521
- } else if (body.tool_choice?.type === "function" && body.tool_choice.name) {
522
- chatReq.tool_choice = { type: "function", function: { name: body.tool_choice.name } };
523
- }
524
- }
525
- if (body.parallel_tool_calls !== undefined) chatReq.parallel_tool_calls = body.parallel_tool_calls;
526
- const textFormat = body.text?.format;
527
- if (textFormat?.type === "json_schema") {
528
- chatReq.response_format = {
529
- type: "json_schema",
530
- json_schema: Object.fromEntries(Object.entries({
531
- name: textFormat.name,
532
- description: textFormat.description,
533
- schema: textFormat.schema,
534
- strict: textFormat.strict,
535
- }).filter(([, value]) => value !== undefined)),
536
- };
537
- } else if (textFormat?.type === "json_object") {
538
- chatReq.response_format = { type: "json_object" };
539
- }
540
- return chatReq;
541
- }
542
-
543
- function uid() { return randomUUID().replace(/-/g, ""); }
544
-
545
- function chatToResponses(chatResp, model) {
546
- const id = `resp_${uid()}`, choice = chatResp.choices?.[0], msg = choice?.message, output = [];
547
- if (msg?.content) output.push({ type: "message", id: `msg_${uid()}`, role: "assistant", status: "completed", content: [{ type: "output_text", text: msg.content }] });
548
- if (msg?.tool_calls) for (const tc of msg.tool_calls) output.push({ type: "function_call", id: tc.id, call_id: tc.id, name: tc.function.name, arguments: tc.function.arguments, status: "completed" });
549
- 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 };
550
- }
551
-
552
- export async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
553
- delete chatReq.max_tokens;
554
- let resp;
555
- try {
556
- const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
557
- try {
558
- resp = await chatCompletionsFn({
559
- ...chatReq,
560
- stream: true,
561
- }, { signal: options.signal });
562
- } finally {
563
- options.releaseRequest?.();
564
- }
565
- } catch (e) {
566
- const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
567
- await onError(statusCode, e.message);
568
- return;
569
- }
570
- if (!resp.ok) {
571
- await onError(resp.status, await resp.text());
572
- return;
573
- }
574
- options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
575
- const respId = `resp_${uid()}`;
576
- let actualModel = chatReq.model || "unknown";
577
- let fullText = "";
578
- let messageItem = null;
579
- let nextOutputIndex = 0;
580
- let usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
581
- const toolCalls = new Map();
582
-
583
- await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
584
-
585
- const ensureMessageItem = async () => {
586
- if (messageItem) return messageItem;
587
- messageItem = { id: `msg_${uid()}`, outputIndex: nextOutputIndex++ };
588
- await emitEvent("response.output_item.added", {
589
- output_index: messageItem.outputIndex,
590
- item: { type: "message", id: messageItem.id, role: "assistant", status: "in_progress", content: [] },
591
- });
592
- await emitEvent("response.content_part.added", {
593
- output_index: messageItem.outputIndex,
594
- content_index: 0,
595
- part: { type: "output_text", text: "" },
596
- });
597
- return messageItem;
598
- };
599
-
600
- const ensureToolCall = async (chunk) => {
601
- const key = Number.isInteger(chunk.index) ? `index:${chunk.index}` : `id:${chunk.id || toolCalls.size}`;
602
- if (toolCalls.has(key)) return { tool: toolCalls.get(key), created: false };
603
- const id = chunk.id || `call_${uid()}`;
604
- const tool = {
605
- id,
606
- callId: id,
607
- name: chunk.function?.name || "",
608
- arguments: "",
609
- outputIndex: nextOutputIndex++,
610
- };
611
- toolCalls.set(key, tool);
612
- await emitEvent("response.output_item.added", {
613
- output_index: tool.outputIndex,
614
- item: {
615
- type: "function_call",
616
- id: tool.id,
617
- call_id: tool.callId,
618
- name: tool.name,
619
- arguments: "",
620
- status: "in_progress",
621
- },
622
- });
623
- return { tool, created: true };
624
- };
625
-
626
- const emitCompleted = async () => {
627
- if (!messageItem && toolCalls.size === 0) await ensureMessageItem();
628
- const output = [];
629
- if (messageItem) {
630
- const item = {
631
- type: "message",
632
- id: messageItem.id,
633
- role: "assistant",
634
- status: "completed",
635
- content: [{ type: "output_text", text: fullText }],
636
- };
637
- await emitEvent("response.output_text.done", { output_index: messageItem.outputIndex, content_index: 0, text: fullText });
638
- await emitEvent("response.content_part.done", { output_index: messageItem.outputIndex, content_index: 0, part: item.content[0] });
639
- await emitEvent("response.output_item.done", { output_index: messageItem.outputIndex, item });
640
- output[messageItem.outputIndex] = item;
641
- }
642
- for (const tool of toolCalls.values()) {
643
- const item = {
644
- type: "function_call",
645
- id: tool.id,
646
- call_id: tool.callId,
647
- name: tool.name,
648
- arguments: tool.arguments,
649
- status: "completed",
650
- };
651
- await emitEvent("response.function_call_arguments.done", {
652
- output_index: tool.outputIndex,
653
- item_id: tool.id,
654
- arguments: tool.arguments,
655
- });
656
- await emitEvent("response.output_item.done", { output_index: tool.outputIndex, item });
657
- output[tool.outputIndex] = item;
658
- }
659
- await emitEvent("response.completed", {
660
- response: {
661
- id: respId,
662
- object: "response",
663
- status: "completed",
664
- model: actualModel,
665
- output: output.filter(Boolean),
666
- usage,
667
- },
668
- });
669
- };
670
-
671
- try {
672
- for await (const line of webStreamLines(resp, {
673
- onChunk: () => options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout"),
674
- })) {
675
- if (!line.startsWith("data: ")) continue;
676
- const data = line.slice(6).trim();
677
- if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
678
- let parsed;
679
- try { parsed = JSON.parse(data); } catch { continue; }
680
- if (parsed.model) actualModel = parsed.model;
681
- if (parsed.usage) {
682
- usage = {
683
- input_tokens: parsed.usage.prompt_tokens || 0,
684
- output_tokens: parsed.usage.completion_tokens || 0,
685
- total_tokens: parsed.usage.total_tokens || 0,
686
- };
687
- const cached = parsed.usage.prompt_tokens_details?.cached_tokens;
688
- if (cached) usage.input_tokens_details = { cached_tokens: cached };
689
- }
690
- const delta = parsed.choices?.[0]?.delta;
691
- if (!delta) continue;
692
- if (delta.content) {
693
- const message = await ensureMessageItem();
694
- fullText += delta.content;
695
- await emitEvent("response.output_text.delta", { output_index: message.outputIndex, content_index: 0, delta: delta.content });
696
- }
697
- if (delta.tool_calls) {
698
- for (const tc of delta.tool_calls) {
699
- const { tool, created } = await ensureToolCall(tc);
700
- if (tc.id) tool.callId = tc.id;
701
- if (!created && tc.function?.name) tool.name += tc.function.name;
702
- if (tc.function?.arguments) {
703
- tool.arguments += tc.function.arguments;
704
- await emitEvent("response.function_call_arguments.delta", {
705
- output_index: tool.outputIndex,
706
- item_id: tool.id,
707
- delta: tc.function.arguments,
708
- });
709
- }
710
- }
711
- }
712
- }
713
- } catch (e) {
714
- const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
715
- await onError(statusCode, e?.message || "upstream stream error");
716
- return;
717
- }
718
- await emitCompleted();
719
- onDone();
720
- }
721
-
722
84
  // Shared request handler. Keeping this separate from the listener makes the
723
85
  // complete HTTP routing layer testable without opening a local port.
724
86
  export function createAdapterHandler(options = {}) {
@@ -731,17 +93,58 @@ export function createAdapterHandler(options = {}) {
731
93
  const responsesCompactFn = options.responsesCompactFn || copilotResponsesCompact;
732
94
  const listModelsFn = options.listModelsFn || listModels;
733
95
  const openAIModelEnv = options.openAIModelEnv || process.env;
734
- const upstreamTimeoutMs = positiveInt(options.upstreamTimeoutMs, UPSTREAM_TIMEOUT_MS);
735
- const streamHandshakeTimeoutMs = positiveInt(options.streamHandshakeTimeoutMs, STREAM_HANDSHAKE_TIMEOUT_MS);
736
- const streamIdleTimeoutMs = positiveInt(options.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_MS);
96
+ const upstreamTimeoutMs = parsePositiveInteger(options.upstreamTimeoutMs, ADAPTER_RUNTIME_CONFIG.upstreamTimeoutMs);
97
+ const streamHandshakeTimeoutMs = parsePositiveInteger(options.streamHandshakeTimeoutMs, ADAPTER_RUNTIME_CONFIG.streamHandshakeTimeoutMs);
98
+ const streamIdleTimeoutMs = parsePositiveInteger(options.streamIdleTimeoutMs, ADAPTER_RUNTIME_CONFIG.streamIdleTimeoutMs);
737
99
  const acquireRequest = options.acquireRequest || createRequestAdmission();
100
+ const requestMetrics = options.requestMetrics || createRequestMetrics();
738
101
  const claudeDesktopModelOptions = () => {
739
102
  const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
740
103
  return Array.isArray(modelDefs) ? { modelDefs } : {};
741
104
  };
105
+ const responsesHandler = createResponsesHandler({
106
+ acquireRequest,
107
+ chatCompletionsFn,
108
+ openAIModelEnv,
109
+ responsesFn,
110
+ streamHandshakeTimeoutMs,
111
+ streamIdleTimeoutMs,
112
+ upstreamTimeoutMs,
113
+ });
114
+ const responsesCompactHandler = createResponsesCompactHandler({
115
+ acquireRequest,
116
+ openAIModelEnv,
117
+ responsesCompactFn,
118
+ streamHandshakeTimeoutMs,
119
+ streamIdleTimeoutMs,
120
+ upstreamTimeoutMs,
121
+ });
122
+ const anthropicCountTokensHandler = createAnthropicCountTokensHandler({ acquireRequest });
123
+ const anthropicMessagesHandler = createAnthropicMessagesHandler({
124
+ acquireRequest,
125
+ chatCompletionsFn,
126
+ environment: process.env,
127
+ modelOptions: claudeDesktopModelOptions,
128
+ streamHandshakeTimeoutMs,
129
+ streamIdleTimeoutMs,
130
+ upstreamTimeoutMs,
131
+ });
742
132
 
743
- return (req, res) => {
744
- const pathname = requestPath(req.url);
133
+ const dispatch = (req, res, pathname) => {
134
+ if (req.method === "GET" && pathname === ADAPTER_STATUS_PATH) {
135
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
136
+ res.writeHead(403, { "Content-Type": "application/json" });
137
+ res.end(JSON.stringify({ error: "Runtime status is available only from loopback" }));
138
+ return;
139
+ }
140
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
141
+ res.end(JSON.stringify(runtimeStatusPayload({
142
+ metrics: requestMetrics,
143
+ admission: acquireRequest,
144
+ modelRegistry: options.modelRegistry,
145
+ })));
146
+ return;
147
+ }
745
148
 
746
149
  if (req.method === "GET" && pathname === ADAPTER_HEALTH_PATH) {
747
150
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -750,119 +153,11 @@ export function createAdapterHandler(options = {}) {
750
153
  }
751
154
 
752
155
  if (req.method === "POST" && pathname === "/v1/responses") {
753
- return (async () => {
754
- const abort = createRequestAbort(req, res);
755
- let releaseRequest = () => {};
756
- try {
757
- releaseRequest = await acquireRequest(req, { signal: abort.signal });
758
- const parsed = await readJsonBody(req);
759
- abort.setTimeout(
760
- parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
761
- parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
762
- );
763
- const prepared = prepareResponsesRequest(parsed, { mutate: true });
764
- prepared.surface = "responses";
765
- const model = parsed.model || "unknown";
766
- const { requestedModel, upstreamModel } = resolveOpenAIModel(model, openAIModelEnv);
767
- if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
768
- const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
769
- console.log(status("info", `responses model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
770
- if (requestedModel === CODEX_AUTO_REVIEW_MODEL || isResponsesOnlyModel(upstreamModel)) {
771
- await proxyCopilotResponses(prepared, req, res, responsesFn, {
772
- signal: abort.signal,
773
- abort,
774
- releaseRequest,
775
- streamIdleTimeoutMs,
776
- });
777
- } else {
778
- const chatReq = responsesToChat(prepared.body);
779
- if (parsed.stream) {
780
- await forwardToChat(chatReq, async (event, data) => {
781
- if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
782
- await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
783
- if (event === "response.completed") {
784
- rememberResponseHistory(prepared, data.response);
785
- recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
786
- }
787
- }, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg) => {
788
- if (!res.headersSent) {
789
- res.writeHead(statusCode || 500, { "Content-Type": "text/plain; charset=utf-8" });
790
- res.end(errMsg);
791
- return;
792
- }
793
- await endStreamWithError(res, "responses", new Error(errMsg), abort);
794
- }, {
795
- signal: abort.signal,
796
- abort,
797
- streamIdleTimeoutMs,
798
- chatCompletionsFn,
799
- releaseRequest,
800
- });
801
- } else {
802
- chatReq.stream = false;
803
- delete chatReq.max_tokens;
804
- try {
805
- const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
806
- releaseRequest();
807
- const data = await upstream.text();
808
- if (!upstream.ok) {
809
- sendUpstreamError(res, upstream, data);
810
- return;
811
- }
812
- const resp = chatToResponses(JSON.parse(data), model);
813
- rememberResponseHistory(prepared, resp);
814
- recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response: resp, event: resp });
815
- res.writeHead(200, { "Content-Type": "application/json" });
816
- res.end(JSON.stringify(resp));
817
- } catch (e) {
818
- logRequestFailure("Responses", e, abort);
819
- sendJsonError(res, e, 502);
820
- }
821
- }
822
- }
823
- } catch (e) {
824
- logRequestFailure("Responses", e, abort);
825
- sendJsonError(res, e, 502);
826
- } finally {
827
- releaseRequest();
828
- abort.cleanup();
829
- }
830
- })();
156
+ return responsesHandler(req, res);
831
157
  }
832
158
 
833
159
  if (req.method === "POST" && pathname === "/v1/responses/compact") {
834
- (async () => {
835
- const abort = createRequestAbort(req, res);
836
- let releaseRequest = () => {};
837
- try {
838
- releaseRequest = await acquireRequest(req, { signal: abort.signal });
839
- const parsed = await readJsonBody(req);
840
- abort.setTimeout(
841
- parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
842
- parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
843
- );
844
- const prepared = prepareResponsesRequest(parsed, { mutate: true });
845
- prepared.surface = "responses_compact";
846
- const model = parsed.model || "unknown";
847
- const { requestedModel, upstreamModel } = resolveOpenAIModel(model, openAIModelEnv);
848
- if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
849
- const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
850
- console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
851
- await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
852
- signal: abort.signal,
853
- abort,
854
- releaseRequest,
855
- streamIdleTimeoutMs,
856
- });
857
- } catch (e) {
858
- logRequestFailure("Responses compact", e, abort);
859
- sendJsonError(res, e, 502);
860
- } finally {
861
- releaseRequest();
862
- abort.cleanup();
863
- }
864
- })();
865
- return;
160
+ return responsesCompactHandler(req, res);
866
161
  }
867
162
 
868
163
  if (req.method === "GET" && pathname === "/v1/models") {
@@ -907,101 +202,56 @@ export function createAdapterHandler(options = {}) {
907
202
  }
908
203
 
909
204
  if (req.method === "POST" && pathname === "/v1/messages/count_tokens") {
910
- (async () => {
911
- const abort = createRequestAbort(req, res);
912
- let releaseRequest = () => {};
913
- try {
914
- releaseRequest = await acquireRequest(req, { signal: abort.signal });
915
- const parsed = await readJsonBody(req);
916
- const result = await countTokens(parsed);
917
- releaseRequest();
918
- res.writeHead(200, { "Content-Type": "application/json" });
919
- res.end(JSON.stringify(result));
920
- } catch (e) {
921
- sendJsonError(res, e);
922
- } finally {
923
- releaseRequest();
924
- abort.cleanup();
925
- }
926
- })();
927
- return;
205
+ return anthropicCountTokensHandler(req, res);
928
206
  }
929
207
 
930
208
  if (req.method === "POST" && pathname === "/v1/messages") {
931
- (async () => {
932
- const abort = createRequestAbort(req, res);
933
- let releaseRequest = () => {};
934
- try {
935
- releaseRequest = await acquireRequest(req, { signal: abort.signal });
936
- const parsed = await readJsonBody(req);
937
- abort.setTimeout(
938
- parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
939
- parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
940
- );
941
- const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown", process.env, claudeDesktopModelOptions());
942
- const modelNote = upstreamModel === requestedModel ? requestedModel : `${requestedModel} -> ${upstreamModel}`;
943
- console.log(status("info", `messages model=${modelNote} stream=${!!parsed.stream}`));
944
- const chatReq = anthropicToChat(parsed, { upstreamModel });
945
- const forceRequestedModel = upstreamModel !== requestedModel;
946
- if (parsed.stream) {
947
- const upstream = await chatCompletionsFn({ ...chatReq, stream: true }, { signal: abort.signal });
948
- releaseRequest();
949
- if (!upstream.ok) {
950
- if (!res.headersSent) res.writeHead(upstream.status);
951
- res.end(await upstream.text());
952
- return;
953
- }
954
- abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
955
- res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
956
- let messageId;
957
- let usage;
958
- try {
959
- await streamAnthropicFromLines(
960
- webStreamLines(upstream, {
961
- onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
962
- }),
963
- async (event, data) => {
964
- if (event === "message_start") messageId = data.message?.id;
965
- if (event === "message_delta") usage = data.usage;
966
- await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
967
- },
968
- requestedModel,
969
- { forceModel: forceRequestedModel },
970
- );
971
- } catch (e) {
972
- logRequestFailure("Messages", e, abort);
973
- await endStreamWithError(res, "anthropic", e, abort);
974
- return;
975
- }
976
- recordAnthropicUsage({ surface: "messages", mode: "stream", model: requestedModel, responseId: messageId, usage });
977
- if (!res.writableEnded) res.end();
978
- } else {
979
- const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
980
- releaseRequest();
981
- const data = await upstream.text();
982
- if (!upstream.ok) {
983
- sendUpstreamError(res, upstream, data);
984
- return;
985
- }
986
- const anthropicMsg = chatToAnthropic(JSON.parse(data), requestedModel, { forceModel: forceRequestedModel });
987
- recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
988
- res.writeHead(200, { "Content-Type": "application/json" });
989
- res.end(JSON.stringify(anthropicMsg));
990
- }
991
- } catch (e) {
992
- logRequestFailure("Messages", e, abort);
993
- sendJsonError(res, e, 502);
994
- } finally {
995
- releaseRequest();
996
- abort.cleanup();
997
- }
998
- })();
999
- return;
209
+ return anthropicMessagesHandler(req, res);
1000
210
  }
1001
211
 
1002
212
  res.writeHead(404, { "Content-Type": "application/json" });
1003
213
  res.end(JSON.stringify({ error: "Not found" }));
1004
214
  };
215
+
216
+ return (req, res) => {
217
+ const pathname = requestPath(req.url);
218
+ const requestId = createRequestId();
219
+ if (typeof res.setHeader === "function") {
220
+ res.setHeader("X-Request-Id", requestId);
221
+ } else if (res.headers && typeof res.headers === "object") {
222
+ res.headers["X-Request-Id"] = requestId;
223
+ }
224
+
225
+ const trackRequest = pathname !== ADAPTER_HEALTH_PATH && pathname !== ADAPTER_STATUS_PATH;
226
+ const complete = trackRequest
227
+ ? requestMetrics.begin(classifyAdapterRoute(req.method, pathname))
228
+ : () => {};
229
+ if (trackRequest && typeof res.once === "function") {
230
+ res.once("finish", () => complete({ statusCode: res.statusCode }));
231
+ res.once("close", () => complete({
232
+ statusCode: res.statusCode,
233
+ aborted: !res.writableFinished && !res.writableEnded,
234
+ }));
235
+ }
236
+
237
+ try {
238
+ const result = runWithRequestContext({
239
+ requestId,
240
+ pathname,
241
+ showRequestId: options.showRequestId === true,
242
+ }, () => dispatch(req, res, pathname));
243
+ if (result && typeof result.then === "function") {
244
+ return result.catch((error) => {
245
+ complete({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
246
+ throw error;
247
+ });
248
+ }
249
+ return result;
250
+ } catch (error) {
251
+ complete({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
252
+ throw error;
253
+ }
254
+ };
1005
255
  }
1006
256
 
1007
257
  // Public server entry point.