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.
@@ -0,0 +1,122 @@
1
+ import { responses as copilotResponses } from "./copilot.mjs";
2
+ import {
3
+ httpError,
4
+ logRequestFailure,
5
+ sendUpstreamError,
6
+ writeOrDrain,
7
+ } from "./http-transport.mjs";
8
+ import { openCopilotResponse, rememberResponseHistory } from "./responses-request.mjs";
9
+ import { loadRuntimeConfig } from "./runtime-config.mjs";
10
+ import { endStreamWithError } from "./stream-errors.mjs";
11
+ import { recordResponsesUsage } from "./usage.mjs";
12
+
13
+ const MAX_SSE_BUFFER_BYTES = loadRuntimeConfig().maxSseBufferBytes;
14
+
15
+ function storeCompletedResponseFromSse(reqContext, data) {
16
+ if (!data || data === "[DONE]") return;
17
+ try {
18
+ const event = JSON.parse(data);
19
+ const response = event.response;
20
+ if (response?.id && response.status === "completed") {
21
+ rememberResponseHistory(reqContext, response);
22
+ recordResponsesUsage({
23
+ surface: reqContext.surface,
24
+ mode: "stream",
25
+ model: reqContext.body?.model,
26
+ response,
27
+ event,
28
+ });
29
+ }
30
+ } catch {
31
+ // Ignore non-JSON stream fragments.
32
+ }
33
+ }
34
+
35
+ function readSseEvents(buffer, onData) {
36
+ while (true) {
37
+ const match = buffer.match(/\r?\n\r?\n/);
38
+ if (!match) return buffer;
39
+ const chunk = buffer.slice(0, match.index);
40
+ if (Buffer.byteLength(chunk) > MAX_SSE_BUFFER_BYTES) {
41
+ throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
42
+ }
43
+ buffer = buffer.slice(match.index + match[0].length);
44
+ const data = chunk
45
+ .split(/\r?\n/)
46
+ .filter((line) => line.startsWith("data:"))
47
+ .map((line) => line.slice(5).trimStart())
48
+ .join("\n");
49
+ if (data) onData(data);
50
+ }
51
+ }
52
+
53
+ export async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
54
+ let opened;
55
+ try {
56
+ opened = await openCopilotResponse(reqContext, upstream, options);
57
+ } finally {
58
+ options.releaseRequest?.();
59
+ }
60
+ const { resp, errorText } = opened;
61
+ reqContext = opened.reqContext;
62
+ if (errorText !== undefined) {
63
+ sendUpstreamError(res, resp, errorText);
64
+ return;
65
+ }
66
+
67
+ if (reqContext.body.stream) {
68
+ options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
69
+ res.writeHead(resp.status, {
70
+ "Content-Type": resp.headers.get("content-type") || "text/event-stream",
71
+ "Cache-Control": "no-cache",
72
+ Connection: "keep-alive",
73
+ });
74
+ const reader = resp.body.getReader();
75
+ const decoder = new TextDecoder();
76
+ let buffer = "";
77
+ try {
78
+ while (true) {
79
+ const { done, value } = await reader.read();
80
+ if (done) {
81
+ buffer += decoder.decode();
82
+ if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
83
+ throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
84
+ }
85
+ readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
86
+ res.end();
87
+ return;
88
+ }
89
+ options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
90
+ buffer += decoder.decode(value, { stream: true });
91
+ buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
92
+ if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
93
+ throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
94
+ }
95
+ if (!await writeOrDrain(res, value)) return;
96
+ }
97
+ } catch (e) {
98
+ logRequestFailure("Responses", e, options.abort);
99
+ await endStreamWithError(res, "responses", e, options.abort);
100
+ } finally {
101
+ await reader.cancel().catch(() => {});
102
+ reader.releaseLock();
103
+ }
104
+ } else {
105
+ const data = await resp.text();
106
+ res.writeHead(resp.status, { "Content-Type": "application/json" });
107
+ res.end(data);
108
+ if (resp.ok) {
109
+ try {
110
+ const response = JSON.parse(data);
111
+ rememberResponseHistory(reqContext, response);
112
+ recordResponsesUsage({
113
+ surface: reqContext.surface,
114
+ mode: "json",
115
+ model: reqContext.body?.model,
116
+ response,
117
+ event: response,
118
+ });
119
+ } catch {}
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,202 @@
1
+ import { responses as copilotResponses } from "./copilot.mjs";
2
+ import { httpError } from "./http-transport.mjs";
3
+ import { materializeResponseHistory, rememberResponseHistoryNode } from "./response-history.mjs";
4
+ import { status } from "./status.mjs";
5
+
6
+ function cloneJson(value) {
7
+ return value === undefined ? undefined : structuredClone(value);
8
+ }
9
+
10
+ function responsesInputItems(input, { clone = true } = {}) {
11
+ if (input === undefined || input === null) return [];
12
+ if (typeof input === "string") {
13
+ return [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }];
14
+ }
15
+ if (Array.isArray(input)) return clone ? cloneJson(input) : input;
16
+ return [clone ? cloneJson(input) : input];
17
+ }
18
+
19
+ export function stripInternalResponsesInputFields(inputItems) {
20
+ if (!Array.isArray(inputItems)) return inputItems;
21
+ for (const item of inputItems) {
22
+ if (!item || typeof item !== "object") continue;
23
+ for (const key of Object.keys(item)) {
24
+ if (key.startsWith("internal_")) delete item[key];
25
+ }
26
+ }
27
+ return inputItems;
28
+ }
29
+
30
+ function responsesOutputItems(output) {
31
+ if (!Array.isArray(output)) return [];
32
+ return output.filter((item) => item?.type === "message" || item?.type === "function_call");
33
+ }
34
+
35
+ function stripEncryptedReasoningValue(value, state) {
36
+ if (Array.isArray(value)) {
37
+ return value.map((item) => stripEncryptedReasoningValue(item, state));
38
+ }
39
+
40
+ if (value && typeof value === "object") {
41
+ const out = {};
42
+ for (const [key, child] of Object.entries(value)) {
43
+ if (key === "encrypted_content") {
44
+ state.changed = true;
45
+ continue;
46
+ }
47
+ out[key] = stripEncryptedReasoningValue(child, state);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ return value;
53
+ }
54
+
55
+ function isEncryptedReasoningInputItem(item) {
56
+ return item && typeof item === "object"
57
+ && (item.type === "reasoning" || Object.prototype.hasOwnProperty.call(item, "encrypted_content"));
58
+ }
59
+
60
+ export function sanitizeEncryptedReasoningRequest(reqContext) {
61
+ const state = { changed: false };
62
+ let body = cloneJson(reqContext.body);
63
+ if (Array.isArray(body.input)) {
64
+ const input = [];
65
+ for (const item of body.input) {
66
+ if (isEncryptedReasoningInputItem(item)) {
67
+ state.changed = true;
68
+ continue;
69
+ }
70
+ input.push(stripEncryptedReasoningValue(item, state));
71
+ }
72
+ body.input = input;
73
+ } else {
74
+ body = stripEncryptedReasoningValue(body, state);
75
+ }
76
+ if (!state.changed) return null;
77
+ const historyInputItems = Array.isArray(reqContext.historyInputItems)
78
+ ? reqContext.historyInputItems
79
+ .filter((item) => !isEncryptedReasoningInputItem(item))
80
+ .map((item) => stripEncryptedReasoningValue(item, { changed: false }))
81
+ : reqContext.historyInputItems;
82
+ return {
83
+ ...reqContext,
84
+ body,
85
+ inputItems: Array.isArray(body.input) ? body.input : reqContext.inputItems,
86
+ historyInputItems,
87
+ };
88
+ }
89
+
90
+ export function isEncryptedContentVerificationError(statusCode, text) {
91
+ if (statusCode < 400 || !text) return false;
92
+ const lower = String(text).toLowerCase();
93
+ return lower.includes("encrypted content")
94
+ && lower.includes("could not be verified")
95
+ && (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
96
+ }
97
+
98
+ export function isImageNamespaceCollisionError(statusCode, text) {
99
+ if (statusCode < 400 || !text) return false;
100
+ const lower = String(text).toLowerCase();
101
+ return lower.includes("namespace")
102
+ && lower.includes("image_gen")
103
+ && lower.includes("collid");
104
+ }
105
+
106
+ function isImageNamespaceTool(tool, { collisionFallback = false } = {}) {
107
+ if (!tool || typeof tool !== "object") return false;
108
+ const type = String(tool.type || "").toLowerCase();
109
+ const name = String(tool.name || tool.function?.name || "").toLowerCase();
110
+ const namespace = String(tool.namespace || "").toLowerCase();
111
+ if (["image_gen", "image_generation"].includes(type)) return true;
112
+ if (["image_gen", "image_generation"].includes(name)) return true;
113
+ if (namespace === "image_gen" || namespace === "image_generation") return true;
114
+ return collisionFallback && [type, name, namespace].some((value) => value.startsWith("image_gen"));
115
+ }
116
+
117
+ export function sanitizeImageNamespaceCollisionRequest(reqContext) {
118
+ if (!Array.isArray(reqContext?.body?.tools)) return null;
119
+ const body = cloneJson(reqContext.body);
120
+ const filtered = body.tools.filter((tool) => !isImageNamespaceTool(tool, { collisionFallback: true }));
121
+ if (filtered.length === body.tools.length) return null;
122
+ if (filtered.length) body.tools = filtered;
123
+ else delete body.tools;
124
+ return { ...reqContext, body };
125
+ }
126
+
127
+ export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
128
+ let encryptedRetried = false;
129
+ let imageNamespaceRetried = false;
130
+ for (let attempt = 0; attempt < 3; attempt += 1) {
131
+ const resp = await upstream(reqContext.body, { signal: options.signal });
132
+ if (resp.ok) return { resp, reqContext };
133
+
134
+ const errorText = await resp.text();
135
+ if (!imageNamespaceRetried && isImageNamespaceCollisionError(resp.status, errorText)) {
136
+ const retryContext = sanitizeImageNamespaceCollisionRequest(reqContext);
137
+ if (retryContext) {
138
+ imageNamespaceRetried = true;
139
+ reqContext = retryContext;
140
+ console.warn(status("warn", "image_gen namespace rejected by upstream; retrying without the conflicting image tool"));
141
+ continue;
142
+ }
143
+ }
144
+ if (!encryptedRetried && isEncryptedContentVerificationError(resp.status, errorText)) {
145
+ const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
146
+ if (retryContext) {
147
+ encryptedRetried = true;
148
+ reqContext = retryContext;
149
+ console.warn(status("warn", "encrypted reasoning rejected by upstream; retrying without encrypted reasoning"));
150
+ continue;
151
+ }
152
+ }
153
+ return { resp, reqContext, errorText };
154
+ }
155
+ throw httpError("Responses compatibility retry limit exceeded", 502);
156
+ }
157
+
158
+ function isBuiltinImageTool(tool) {
159
+ return isImageNamespaceTool(tool);
160
+ }
161
+
162
+ export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
163
+ const body = mutate ? reqBody : cloneJson(reqBody);
164
+ const currentInputItems = responsesInputItems(body.input, { clone: !mutate });
165
+ const previousId = body.previous_response_id;
166
+
167
+ if (previousId !== undefined && previousId !== null) {
168
+ body.input = [...materializeResponseHistory(previousId), ...currentInputItems];
169
+ } else {
170
+ body.input = currentInputItems;
171
+ }
172
+
173
+ delete body.previous_response_id;
174
+ delete body.store;
175
+ if (Array.isArray(body.tools)) {
176
+ body.tools = body.tools.filter((tool) => !isBuiltinImageTool(tool));
177
+ if (!body.tools.length) delete body.tools;
178
+ }
179
+ stripInternalResponsesInputFields(body.input);
180
+ stripInternalResponsesInputFields(currentInputItems);
181
+
182
+ return {
183
+ body,
184
+ inputItems: body.input,
185
+ historyParentId: previousId ?? null,
186
+ historyInputItems: currentInputItems,
187
+ takeHistoryOwnership: mutate,
188
+ };
189
+ }
190
+
191
+ export function rememberResponseHistory(reqContext, responseJson) {
192
+ if (!responseJson?.id || !Array.isArray(reqContext?.historyInputItems || reqContext?.inputItems)) return;
193
+ const sourceInputItems = reqContext.historyInputItems || reqContext.inputItems;
194
+ const sourceOutputItems = responsesOutputItems(responseJson.output);
195
+ rememberResponseHistoryNode({
196
+ id: responseJson.id,
197
+ parentId: reqContext.historyParentId,
198
+ inputItems: sourceInputItems,
199
+ outputItems: sourceOutputItems,
200
+ takeOwnership: reqContext.takeHistoryOwnership,
201
+ });
202
+ }
@@ -0,0 +1,38 @@
1
+ export const RUNTIME_DEFAULTS = Object.freeze({
2
+ upstreamTimeoutMs: 120 * 1000,
3
+ streamHandshakeTimeoutMs: 120 * 1000,
4
+ streamIdleTimeoutMs: 120 * 1000,
5
+ maxSseBufferBytes: 8 * 1024 * 1024,
6
+ maxBodyBytes: 64 * 1024 * 1024,
7
+ maxDecodedBodyBytes: 128 * 1024 * 1024,
8
+ maxInflightBodyBytes: 32 * 1024 * 1024,
9
+ maxQueuedRequests: 16,
10
+ requestQueueTimeoutMs: 120 * 1000,
11
+ responseHistoryMaxBytes: 64 * 1024 * 1024,
12
+ responseHistoryMaxEntries: 4096,
13
+ tokenLockTimeoutMs: 10 * 60 * 1000,
14
+ tokenLockStaleMs: 15 * 60 * 1000,
15
+ });
16
+
17
+ export function parsePositiveInteger(value, fallback, max = Number.POSITIVE_INFINITY) {
18
+ const parsed = Number.parseInt(value, 10);
19
+ return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, max) : fallback;
20
+ }
21
+
22
+ export function loadRuntimeConfig(env = process.env) {
23
+ return Object.freeze({
24
+ upstreamTimeoutMs: parsePositiveInteger(env.CCDX_UPSTREAM_TIMEOUT_MS, RUNTIME_DEFAULTS.upstreamTimeoutMs),
25
+ streamHandshakeTimeoutMs: parsePositiveInteger(env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamHandshakeTimeoutMs),
26
+ streamIdleTimeoutMs: parsePositiveInteger(env.CCDX_STREAM_IDLE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamIdleTimeoutMs),
27
+ maxSseBufferBytes: parsePositiveInteger(env.CCDX_MAX_SSE_BUFFER_BYTES, RUNTIME_DEFAULTS.maxSseBufferBytes),
28
+ maxBodyBytes: parsePositiveInteger(env.CCDX_MAX_BODY_BYTES, RUNTIME_DEFAULTS.maxBodyBytes),
29
+ maxDecodedBodyBytes: parsePositiveInteger(env.CCDX_MAX_DECODED_BODY_BYTES, RUNTIME_DEFAULTS.maxDecodedBodyBytes),
30
+ maxInflightBodyBytes: parsePositiveInteger(env.CCDX_MAX_INFLIGHT_BODY_BYTES, RUNTIME_DEFAULTS.maxInflightBodyBytes),
31
+ maxQueuedRequests: parsePositiveInteger(env.CCDX_MAX_QUEUED_REQUESTS, RUNTIME_DEFAULTS.maxQueuedRequests),
32
+ requestQueueTimeoutMs: parsePositiveInteger(env.CCDX_REQUEST_QUEUE_TIMEOUT_MS, RUNTIME_DEFAULTS.requestQueueTimeoutMs),
33
+ responseHistoryMaxBytes: parsePositiveInteger(env.CCDX_RESPONSE_HISTORY_MAX_BYTES, RUNTIME_DEFAULTS.responseHistoryMaxBytes),
34
+ responseHistoryMaxEntries: parsePositiveInteger(env.CCDX_RESPONSE_HISTORY_MAX_ENTRIES, RUNTIME_DEFAULTS.responseHistoryMaxEntries),
35
+ tokenLockTimeoutMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
36
+ tokenLockStaleMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
37
+ });
38
+ }
package/src/status.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { currentRequestContext } from "./request-context.mjs";
2
+
1
3
  const LABELS = {
2
4
  info: "[INFO]",
3
5
  ok: "[OK]",
@@ -8,5 +10,8 @@ const LABELS = {
8
10
  };
9
11
 
10
12
  export function status(kind, message) {
11
- return `${LABELS[kind] || LABELS.info} ${message}`;
13
+ const requestContext = currentRequestContext();
14
+ const requestId = requestContext?.showRequestId ? requestContext.requestId : "";
15
+ const context = requestId ? ` request_id=${requestId}` : "";
16
+ return `${LABELS[kind] || LABELS.info}${context} ${message}`;
12
17
  }
@@ -0,0 +1,17 @@
1
+ import { writeOrDrain } from "./http-transport.mjs";
2
+
3
+ function streamErrorData(protocol, error, abort) {
4
+ const code = abort?.reason || "upstream_stream_error";
5
+ const message = `${code}: ${error?.message || "Upstream stream failed"}`;
6
+ if (protocol === "anthropic") {
7
+ return { type: "error", error: { type: "api_error", message } };
8
+ }
9
+ return { type: "error", code, message, param: null };
10
+ }
11
+
12
+ export async function endStreamWithError(res, protocol, error, abort) {
13
+ if (res.destroyed || res.writableEnded) return;
14
+ const data = streamErrorData(protocol, error, abort);
15
+ await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`).catch(() => false);
16
+ if (!res.destroyed && !res.writableEnded) res.end();
17
+ }
package/src/stream.mjs CHANGED
@@ -1,13 +1,8 @@
1
1
  // Yield fetch Response body lines while preserving SSE semantics for callers.
2
2
  // Strip CRLF tails and release the reader if iteration stops early.
3
- const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024;
3
+ import { loadRuntimeConfig } from "./runtime-config.mjs";
4
4
 
5
- function positiveInt(value, fallback) {
6
- const n = Number.parseInt(value, 10);
7
- return Number.isFinite(n) && n > 0 ? n : fallback;
8
- }
9
-
10
- const MAX_SSE_BUFFER_BYTES = positiveInt(process.env.CCDX_MAX_SSE_BUFFER_BYTES, DEFAULT_MAX_SSE_BUFFER_BYTES);
5
+ const MAX_SSE_BUFFER_BYTES = loadRuntimeConfig().maxSseBufferBytes;
11
6
 
12
7
  function assertSseBufferLimit(buffer, maxBufferBytes) {
13
8
  if (Buffer.byteLength(buffer) > maxBufferBytes) {