codex-copilot-dx 0.4.34 → 0.4.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/bin/cli.mjs +11 -7
- package/package.json +3 -2
- package/scripts/benchmark.mjs +87 -11
- package/src/adapter.mjs +38 -6
- package/src/anthropic.mjs +4 -4
- package/src/claude-config.mjs +7 -6
- package/src/config.mjs +11 -10
- package/src/copilot.mjs +15 -3
- package/src/doctor.mjs +18 -8
- package/src/http-transport.mjs +109 -0
- package/src/image-optimization.mjs +8 -5
- package/src/models.mjs +13 -0
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ To actively verify the protocol path through an already-running adapter, run:
|
|
|
70
70
|
codex-copilot-dx doctor --compat
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
The compatibility doctor sends a few minimal Copilot requests to check native Responses, streaming history, compaction, image tool namespace handling, and Anthropic streaming. It consumes a small amount of Copilot usage, never starts the adapter or device flow, and does not change client configuration. Combine it with `--online` when both the saved-token entitlement check and the adapter protocol checks are needed.
|
|
73
|
+
The compatibility doctor sends a few minimal Copilot requests to check Codex Auto-review, native Responses, streaming history, compaction, image tool namespace handling, and Anthropic streaming. It consumes a small amount of Copilot usage, never starts the adapter or device flow, and does not change client configuration. Combine it with `--online` when both the saved-token entitlement check and the adapter protocol checks are needed.
|
|
74
74
|
|
|
75
75
|
When the saved token is missing, `codex-copilot-dx` first looks for compatible local Copilot GitHub tokens, validates them with GitHub and Copilot, and imports a valid one before starting device login. It checks explicit token sources (`CCDX_GITHUB_TOKEN`, `CCDX_GITHUB_TOKEN_PATH`, `CCDX_GITHUB_TOKEN_PATHS`) plus common local `auth.json` layouts under application config directories. It does not rely on a specific app name. Generic discovery refuses to choose silently when valid tokens for multiple GitHub accounts are found. After an account is selected, automatic `401`/`403` recovery and in-process token rotation accept only the same GitHub account. Concurrent callers share token refresh work without sharing cancellation, and a still-valid Copilot token may be used briefly after a transient refresh failure. Explicit token variables remain the intentional way to switch accounts.
|
|
76
76
|
|
|
@@ -110,12 +110,15 @@ Environment variables:
|
|
|
110
110
|
| `CCDX_ALLOW_LAN` | unset | Set to `1` to allow non-loopback `ADAPTER_HOST` values such as `0.0.0.0`; exposes your Copilot-backed adapter beyond this machine |
|
|
111
111
|
| `CCDX_MAX_BODY_BYTES` | `67108864` | Maximum compressed/raw request body size |
|
|
112
112
|
| `CCDX_MAX_DECODED_BODY_BYTES` | `134217728` | Maximum decoded request body size after decompression |
|
|
113
|
+
| `CCDX_MAX_INFLIGHT_BODY_BYTES` | `33554432` | Shared byte budget for admitted request bodies; a larger single request runs exclusively |
|
|
114
|
+
| `CCDX_MAX_QUEUED_REQUESTS` | `16` | Maximum body requests waiting for the shared byte budget |
|
|
115
|
+
| `CCDX_REQUEST_QUEUE_TIMEOUT_MS` | `120000` | Maximum wait for request-body admission before returning `503` |
|
|
113
116
|
| `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Responses body target; larger image payloads are compressed more aggressively before forwarding |
|
|
114
117
|
| `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
|
|
115
118
|
| `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
|
|
116
119
|
| `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
|
|
117
120
|
| `CCDX_STREAM_IDLE_TIMEOUT_MS` | `120000` | Maximum idle time between upstream streaming body chunks |
|
|
118
|
-
| `CCDX_UPSTREAM_RETRIES` | `2` | Retries for
|
|
121
|
+
| `CCDX_UPSTREAM_RETRIES` | `2` | Retries for safe requests and clearly pre-connect POST failures; capped at `5` |
|
|
119
122
|
| `CCDX_UPSTREAM_RETRY_DELAY_MS` | `300` | Initial upstream retry backoff in milliseconds; capped at `5000` |
|
|
120
123
|
| `CCDX_AUTO_REVIEW_MODEL` | `gpt-5.5` | Copilot Responses model used for Codex Auto-review requests |
|
|
121
124
|
| `CCDX_LOG_PATH` | unset | Mirror terminal logs to a file; set to `1` for `~/.local/share/codex-copilot-dx/debug.log` |
|
|
@@ -178,4 +181,4 @@ npm run verify
|
|
|
178
181
|
npm run bench:payload
|
|
179
182
|
```
|
|
180
183
|
|
|
181
|
-
`npm test` runs the unit and handler-level suite. `npm run test:smoke` starts a real local HTTP adapter with fully injected offline upstreams. `npm run pack:check` verifies the npm tarball contents without publishing. `npm run bench:payload`
|
|
184
|
+
`npm test` runs the unit and handler-level suite. `npm run test:smoke` starts a real local HTTP adapter with fully injected offline upstreams. `npm run bench:check` enforces relative token-counting performance and request-admission resource limits. `npm run pack:check` verifies the npm tarball contents without publishing. `npm run bench:payload` remains a report-only, isolated-process benchmark for 5-60 MiB image payloads and does not contact Copilot. The CI workflow runs the verification checks on supported Node.js release lines.
|
package/bin/cli.mjs
CHANGED
|
@@ -6,13 +6,13 @@ import { ensureClaudeConfig } from "../src/claude-config.mjs";
|
|
|
6
6
|
import { applyClaudeDesktopConfig, formatClaudeDesktopApplyResult, generatedClaudeDesktopApiKey, loadManagedClaudeDesktopApiKey } from "../src/claude-desktop-config.mjs";
|
|
7
7
|
import { startAdapter } from "../src/adapter.mjs";
|
|
8
8
|
import { cacheModelEndpoints, listModels, refreshVSCodeVersion } from "../src/copilot.mjs";
|
|
9
|
-
import { claudeDesktopModelDefsFromCopilotModels, claudeDesktopModelIds, gptModelIdsFromCopilotModels, parseModelAliasEnv } from "../src/models.mjs";
|
|
9
|
+
import { claudeDesktopModelDefsFromCopilotModels, claudeDesktopModelIds, codexAutoReviewModelStatus, gptModelIdsFromCopilotModels, parseModelAliasEnv } from "../src/models.mjs";
|
|
10
10
|
import { status } from "../src/status.mjs";
|
|
11
11
|
import { configureLogging } from "../src/log.mjs";
|
|
12
12
|
import { flushUsageWrites, printUsageSummary } from "../src/usage.mjs";
|
|
13
13
|
import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
|
|
14
14
|
import { runDoctor } from "../src/doctor.mjs";
|
|
15
|
-
import { checkRunningAdapter } from "../src/running-adapter.mjs";
|
|
15
|
+
import { adapterBaseUrl, checkRunningAdapter } from "../src/running-adapter.mjs";
|
|
16
16
|
import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
|
|
17
17
|
import { isValidModelList, loadModelCache, saveModelCache } from "../src/model-cache.mjs";
|
|
18
18
|
import { initializeModelRegistry, runInBackground } from "../src/startup.mjs";
|
|
@@ -105,6 +105,10 @@ async function refreshClaudeDesktopModelDefs() {
|
|
|
105
105
|
throw new Error(`Copilot models returned HTTP ${httpStatus}`);
|
|
106
106
|
}
|
|
107
107
|
const models = JSON.parse(body);
|
|
108
|
+
const autoReview = codexAutoReviewModelStatus(models);
|
|
109
|
+
if (!autoReview.available) {
|
|
110
|
+
console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run codex-copilot-dx doctor --compat to verify the live path.`));
|
|
111
|
+
}
|
|
108
112
|
const gptModelIds = gptModelIdsFromCopilotModels(models);
|
|
109
113
|
if (gptModelIds.length) {
|
|
110
114
|
console.log(status("ok", `Refreshed GPT models from GitHub Copilot: ${gptModelIds.join(", ")}`));
|
|
@@ -152,8 +156,8 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
152
156
|
if (!running.ok) return false;
|
|
153
157
|
|
|
154
158
|
console.log(status("ok", `Using existing adapter at ${running.baseUrl}`));
|
|
155
|
-
ensureCodexConfig(ADAPTER_PORT);
|
|
156
|
-
ensureClaudeConfig(ADAPTER_PORT);
|
|
159
|
+
ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
|
|
160
|
+
ensureClaudeConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
|
|
157
161
|
|
|
158
162
|
if (CONFIGURE_CLAUDE_DESKTOP) {
|
|
159
163
|
const claudeDesktopApiKey = currentClaudeDesktopApiKey();
|
|
@@ -252,8 +256,8 @@ try {
|
|
|
252
256
|
});
|
|
253
257
|
|
|
254
258
|
// Point Codex and Claude Code at the adapter.
|
|
255
|
-
ensureCodexConfig(ADAPTER_PORT);
|
|
256
|
-
ensureClaudeConfig(ADAPTER_PORT);
|
|
259
|
+
ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
|
|
260
|
+
ensureClaudeConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
|
|
257
261
|
if (CONFIGURE_CLAUDE_DESKTOP) {
|
|
258
262
|
const result = applyClaudeDesktopConfig({
|
|
259
263
|
port: ADAPTER_PORT,
|
|
@@ -283,7 +287,7 @@ try {
|
|
|
283
287
|
console.log(`
|
|
284
288
|
${status("ok", "Ready, Claude Code, Claude App and Codex App are now ready to use")}
|
|
285
289
|
|
|
286
|
-
Adapter:
|
|
290
|
+
Adapter: ${adapterBaseUrl(ADAPTER_HOST, ADAPTER_PORT)}
|
|
287
291
|
|
|
288
292
|
Press Ctrl+C to stop.
|
|
289
293
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-copilot-dx",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.35",
|
|
4
4
|
"description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
|
|
5
5
|
"author": "Dale Xiao",
|
|
6
6
|
"type": "module",
|
|
@@ -44,8 +44,9 @@
|
|
|
44
44
|
"test:e2e": "bash test/e2e.sh",
|
|
45
45
|
"bench": "node scripts/benchmark.mjs",
|
|
46
46
|
"bench:payload": "node scripts/benchmark.mjs --large-payload",
|
|
47
|
+
"bench:check": "node scripts/benchmark.mjs --check",
|
|
47
48
|
"pack:check": "npm pack --dry-run --json",
|
|
48
|
-
"verify": "npm test && npm run test:smoke && npm run pack:check",
|
|
49
|
+
"verify": "npm test && npm run test:smoke && npm run bench:check && npm run pack:check",
|
|
49
50
|
"prepublishOnly": "npm run verify"
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
package/scripts/benchmark.mjs
CHANGED
|
@@ -72,12 +72,14 @@ function largePayloadProbe(targetMiB, concurrency) {
|
|
|
72
72
|
const result = runProbe(`
|
|
73
73
|
import fs from "node:fs";
|
|
74
74
|
import { prepareResponsesRequest } from "./src/adapter.mjs";
|
|
75
|
-
import { readJsonBody } from "./src/http-transport.mjs";
|
|
75
|
+
import { createRequestAdmission, readJsonBody } from "./src/http-transport.mjs";
|
|
76
76
|
import { prepareResponsesPayload } from "./src/image-optimization.mjs";
|
|
77
77
|
|
|
78
78
|
const fixtureFiles = ${JSON.stringify(fixtureFiles)};
|
|
79
|
+
const acquireRequest = createRequestAdmission();
|
|
79
80
|
const memoryFields = ["rss", "heapUsed", "external", "arrayBuffers"];
|
|
80
81
|
const peak = {};
|
|
82
|
+
let admissionMaxActiveBytes = 0;
|
|
81
83
|
const sample = () => {
|
|
82
84
|
const memory = process.memoryUsage();
|
|
83
85
|
for (const field of memoryFields) peak[field] = Math.max(peak[field] || 0, memory[field] || 0);
|
|
@@ -90,15 +92,23 @@ function largePayloadProbe(targetMiB, concurrency) {
|
|
|
90
92
|
|
|
91
93
|
async function processRequest(filePath) {
|
|
92
94
|
const inputBytes = fs.statSync(filePath).size;
|
|
95
|
+
const headers = { "content-length": String(inputBytes), "content-encoding": "identity" };
|
|
96
|
+
const releaseRequest = await acquireRequest({ headers });
|
|
97
|
+
admissionMaxActiveBytes = Math.max(admissionMaxActiveBytes, acquireRequest.stats().activeBytes);
|
|
93
98
|
const req = fs.createReadStream(filePath);
|
|
94
|
-
req.headers =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
req.headers = headers;
|
|
100
|
+
try {
|
|
101
|
+
const parsed = await readJsonBody(req);
|
|
102
|
+
req.destroy();
|
|
103
|
+
sample();
|
|
104
|
+
const prepared = prepareResponsesRequest(parsed, { mutate: true });
|
|
105
|
+
const result = await prepareResponsesPayload(prepared.body);
|
|
106
|
+
sample();
|
|
107
|
+
return { inputBytes, outputBytes: result.bodyBytes };
|
|
108
|
+
} finally {
|
|
109
|
+
req.destroy();
|
|
110
|
+
releaseRequest();
|
|
111
|
+
}
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
const originalLog = console.log;
|
|
@@ -130,6 +140,8 @@ function largePayloadProbe(targetMiB, concurrency) {
|
|
|
130
140
|
input_body_mib: results.map((entry) => mib(entry.inputBytes)),
|
|
131
141
|
output_body_mib: results.map((entry) => mib(entry.outputBytes)),
|
|
132
142
|
output_body_kib: results.map((entry) => +(entry.outputBytes / 1024).toFixed(1)),
|
|
143
|
+
admission_max_active_mib: mib(admissionMaxActiveBytes),
|
|
144
|
+
admission_budget_mib: mib(acquireRequest.stats().maxBytes),
|
|
133
145
|
memory,
|
|
134
146
|
}));
|
|
135
147
|
`);
|
|
@@ -140,6 +152,30 @@ function largePayloadProbe(targetMiB, concurrency) {
|
|
|
140
152
|
}
|
|
141
153
|
}
|
|
142
154
|
|
|
155
|
+
function largeTokenCountProbe(mode) {
|
|
156
|
+
return runProbe(`
|
|
157
|
+
const text = "a ".repeat(2 * 1024 * 1024);
|
|
158
|
+
const started = performance.now();
|
|
159
|
+
let tokens;
|
|
160
|
+
if (${JSON.stringify(mode)} === "proxy") {
|
|
161
|
+
const { countTokens } = await import("./src/anthropic.mjs");
|
|
162
|
+
tokens = (await countTokens({ messages: [{ role: "user", content: text }] })).input_tokens;
|
|
163
|
+
} else {
|
|
164
|
+
const { encode } = await import("gpt-tokenizer");
|
|
165
|
+
const encoded = encode(text);
|
|
166
|
+
tokens = encoded.length;
|
|
167
|
+
}
|
|
168
|
+
const memory = process.memoryUsage();
|
|
169
|
+
process.stdout.write(JSON.stringify({
|
|
170
|
+
input_mib: +(Buffer.byteLength(text) / 1048576).toFixed(1),
|
|
171
|
+
tokens,
|
|
172
|
+
elapsed_ms: +(performance.now() - started).toFixed(1),
|
|
173
|
+
rss_mib: +(memory.rss / 1048576).toFixed(1),
|
|
174
|
+
heap_used_mib: +(memory.heapUsed / 1048576).toFixed(1),
|
|
175
|
+
}));
|
|
176
|
+
`);
|
|
177
|
+
}
|
|
178
|
+
|
|
143
179
|
const adapterImport = runProbe(`
|
|
144
180
|
const started = performance.now();
|
|
145
181
|
await import("./src/adapter.mjs");
|
|
@@ -237,15 +273,28 @@ try {
|
|
|
237
273
|
console.warn = originalWarn;
|
|
238
274
|
}
|
|
239
275
|
|
|
276
|
+
const checkMode = process.argv.includes("--check");
|
|
240
277
|
const report = {
|
|
241
|
-
note:
|
|
278
|
+
note: checkMode
|
|
279
|
+
? "Relative performance and resource checks; absolute timings vary by machine."
|
|
280
|
+
: "Report-only benchmark; timings vary by machine and are not pass/fail thresholds.",
|
|
242
281
|
adapter_import: adapterImport,
|
|
243
282
|
token_count_100kb: tokenCount,
|
|
244
283
|
duplicate_images: duplicateImages,
|
|
245
284
|
oversized_payload: oversizedPayload,
|
|
246
285
|
};
|
|
247
286
|
|
|
248
|
-
if (
|
|
287
|
+
if (checkMode) {
|
|
288
|
+
report.large_token_count = {
|
|
289
|
+
proxy: largeTokenCountProbe("proxy"),
|
|
290
|
+
materialized_array: largeTokenCountProbe("array"),
|
|
291
|
+
};
|
|
292
|
+
report.large_payload_peak = [
|
|
293
|
+
largePayloadProbe(5, 4),
|
|
294
|
+
largePayloadProbe(30, 1),
|
|
295
|
+
largePayloadProbe(30, 2),
|
|
296
|
+
];
|
|
297
|
+
} else if (process.argv.includes("--large-payload")) {
|
|
249
298
|
report.large_payload_peak = [
|
|
250
299
|
largePayloadProbe(5, 1),
|
|
251
300
|
largePayloadProbe(5, 4),
|
|
@@ -256,3 +305,30 @@ if (process.argv.includes("--large-payload")) {
|
|
|
256
305
|
}
|
|
257
306
|
|
|
258
307
|
console.log(JSON.stringify(report, null, 2));
|
|
308
|
+
|
|
309
|
+
if (checkMode) {
|
|
310
|
+
const failures = [];
|
|
311
|
+
const proxy = report.large_token_count.proxy;
|
|
312
|
+
const array = report.large_token_count.materialized_array;
|
|
313
|
+
if (proxy.tokens !== array.tokens) failures.push(`token counts differ: proxy=${proxy.tokens} array=${array.tokens}`);
|
|
314
|
+
if (proxy.elapsed_ms > array.elapsed_ms * 1.25) {
|
|
315
|
+
failures.push(`token counter is materially slower: proxy=${proxy.elapsed_ms}ms array=${array.elapsed_ms}ms`);
|
|
316
|
+
}
|
|
317
|
+
if (proxy.rss_mib > array.rss_mib * 0.9) {
|
|
318
|
+
failures.push(`token counter RSS regression: proxy=${proxy.rss_mib}MiB array=${array.rss_mib}MiB`);
|
|
319
|
+
}
|
|
320
|
+
if (proxy.heap_used_mib > array.heap_used_mib * 0.75) {
|
|
321
|
+
failures.push(`token counter heap regression: proxy=${proxy.heap_used_mib}MiB array=${array.heap_used_mib}MiB`);
|
|
322
|
+
}
|
|
323
|
+
for (const payload of report.large_payload_peak) {
|
|
324
|
+
if (payload.admission_max_active_mib > payload.admission_budget_mib) {
|
|
325
|
+
failures.push(`request admission exceeded its budget at ${payload.target_request_mib}MiB x${payload.concurrency}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (failures.length) {
|
|
329
|
+
for (const failure of failures) console.error(`[FAIL] ${failure}`);
|
|
330
|
+
process.exitCode = 1;
|
|
331
|
+
} else {
|
|
332
|
+
console.error("[OK] Performance and resource checks passed");
|
|
333
|
+
}
|
|
334
|
+
}
|
package/src/adapter.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
|
|
|
10
10
|
import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
|
|
11
11
|
import {
|
|
12
12
|
abortErrorStatusCode,
|
|
13
|
+
createRequestAdmission,
|
|
13
14
|
createRequestAbort,
|
|
14
15
|
httpError,
|
|
15
16
|
isAbortLikeError,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
|
|
30
31
|
export {
|
|
31
32
|
abortErrorStatusCode,
|
|
33
|
+
createRequestAdmission,
|
|
32
34
|
createRequestAbort,
|
|
33
35
|
isAbortLikeError,
|
|
34
36
|
readJsonBody,
|
|
@@ -371,7 +373,12 @@ async function endStreamWithError(res, protocol, error, abort) {
|
|
|
371
373
|
}
|
|
372
374
|
|
|
373
375
|
async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
|
|
374
|
-
|
|
376
|
+
let opened;
|
|
377
|
+
try {
|
|
378
|
+
opened = await openCopilotResponse(reqContext, upstream, options);
|
|
379
|
+
} finally {
|
|
380
|
+
options.releaseRequest?.();
|
|
381
|
+
}
|
|
375
382
|
const { resp, errorText } = opened;
|
|
376
383
|
reqContext = opened.reqContext;
|
|
377
384
|
if (errorText !== undefined) {
|
|
@@ -547,10 +554,14 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
547
554
|
let resp;
|
|
548
555
|
try {
|
|
549
556
|
const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
557
|
+
try {
|
|
558
|
+
resp = await chatCompletionsFn({
|
|
559
|
+
...chatReq,
|
|
560
|
+
stream: true,
|
|
561
|
+
}, { signal: options.signal });
|
|
562
|
+
} finally {
|
|
563
|
+
options.releaseRequest?.();
|
|
564
|
+
}
|
|
554
565
|
} catch (e) {
|
|
555
566
|
const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
|
|
556
567
|
await onError(statusCode, e.message);
|
|
@@ -723,6 +734,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
723
734
|
const upstreamTimeoutMs = positiveInt(options.upstreamTimeoutMs, UPSTREAM_TIMEOUT_MS);
|
|
724
735
|
const streamHandshakeTimeoutMs = positiveInt(options.streamHandshakeTimeoutMs, STREAM_HANDSHAKE_TIMEOUT_MS);
|
|
725
736
|
const streamIdleTimeoutMs = positiveInt(options.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_MS);
|
|
737
|
+
const acquireRequest = options.acquireRequest || createRequestAdmission();
|
|
726
738
|
const claudeDesktopModelOptions = () => {
|
|
727
739
|
const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
|
|
728
740
|
return Array.isArray(modelDefs) ? { modelDefs } : {};
|
|
@@ -740,7 +752,9 @@ export function createAdapterHandler(options = {}) {
|
|
|
740
752
|
if (req.method === "POST" && pathname === "/v1/responses") {
|
|
741
753
|
return (async () => {
|
|
742
754
|
const abort = createRequestAbort(req, res);
|
|
755
|
+
let releaseRequest = () => {};
|
|
743
756
|
try {
|
|
757
|
+
releaseRequest = await acquireRequest(req, { signal: abort.signal });
|
|
744
758
|
const parsed = await readJsonBody(req);
|
|
745
759
|
abort.setTimeout(
|
|
746
760
|
parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
|
|
@@ -757,6 +771,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
757
771
|
await proxyCopilotResponses(prepared, req, res, responsesFn, {
|
|
758
772
|
signal: abort.signal,
|
|
759
773
|
abort,
|
|
774
|
+
releaseRequest,
|
|
760
775
|
streamIdleTimeoutMs,
|
|
761
776
|
});
|
|
762
777
|
} else {
|
|
@@ -781,12 +796,14 @@ export function createAdapterHandler(options = {}) {
|
|
|
781
796
|
abort,
|
|
782
797
|
streamIdleTimeoutMs,
|
|
783
798
|
chatCompletionsFn,
|
|
799
|
+
releaseRequest,
|
|
784
800
|
});
|
|
785
801
|
} else {
|
|
786
802
|
chatReq.stream = false;
|
|
787
803
|
delete chatReq.max_tokens;
|
|
788
804
|
try {
|
|
789
805
|
const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
|
|
806
|
+
releaseRequest();
|
|
790
807
|
const data = await upstream.text();
|
|
791
808
|
if (!upstream.ok) {
|
|
792
809
|
sendUpstreamError(res, upstream, data);
|
|
@@ -807,6 +824,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
807
824
|
logRequestFailure("Responses", e, abort);
|
|
808
825
|
sendJsonError(res, e, 502);
|
|
809
826
|
} finally {
|
|
827
|
+
releaseRequest();
|
|
810
828
|
abort.cleanup();
|
|
811
829
|
}
|
|
812
830
|
})();
|
|
@@ -815,7 +833,9 @@ export function createAdapterHandler(options = {}) {
|
|
|
815
833
|
if (req.method === "POST" && pathname === "/v1/responses/compact") {
|
|
816
834
|
(async () => {
|
|
817
835
|
const abort = createRequestAbort(req, res);
|
|
836
|
+
let releaseRequest = () => {};
|
|
818
837
|
try {
|
|
838
|
+
releaseRequest = await acquireRequest(req, { signal: abort.signal });
|
|
819
839
|
const parsed = await readJsonBody(req);
|
|
820
840
|
abort.setTimeout(
|
|
821
841
|
parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
|
|
@@ -831,12 +851,14 @@ export function createAdapterHandler(options = {}) {
|
|
|
831
851
|
await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
|
|
832
852
|
signal: abort.signal,
|
|
833
853
|
abort,
|
|
854
|
+
releaseRequest,
|
|
834
855
|
streamIdleTimeoutMs,
|
|
835
856
|
});
|
|
836
857
|
} catch (e) {
|
|
837
858
|
logRequestFailure("Responses compact", e, abort);
|
|
838
859
|
sendJsonError(res, e, 502);
|
|
839
860
|
} finally {
|
|
861
|
+
releaseRequest();
|
|
840
862
|
abort.cleanup();
|
|
841
863
|
}
|
|
842
864
|
})();
|
|
@@ -887,13 +909,18 @@ export function createAdapterHandler(options = {}) {
|
|
|
887
909
|
if (req.method === "POST" && pathname === "/v1/messages/count_tokens") {
|
|
888
910
|
(async () => {
|
|
889
911
|
const abort = createRequestAbort(req, res);
|
|
912
|
+
let releaseRequest = () => {};
|
|
890
913
|
try {
|
|
914
|
+
releaseRequest = await acquireRequest(req, { signal: abort.signal });
|
|
891
915
|
const parsed = await readJsonBody(req);
|
|
916
|
+
const result = await countTokens(parsed);
|
|
917
|
+
releaseRequest();
|
|
892
918
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
893
|
-
res.end(JSON.stringify(
|
|
919
|
+
res.end(JSON.stringify(result));
|
|
894
920
|
} catch (e) {
|
|
895
921
|
sendJsonError(res, e);
|
|
896
922
|
} finally {
|
|
923
|
+
releaseRequest();
|
|
897
924
|
abort.cleanup();
|
|
898
925
|
}
|
|
899
926
|
})();
|
|
@@ -903,7 +930,9 @@ export function createAdapterHandler(options = {}) {
|
|
|
903
930
|
if (req.method === "POST" && pathname === "/v1/messages") {
|
|
904
931
|
(async () => {
|
|
905
932
|
const abort = createRequestAbort(req, res);
|
|
933
|
+
let releaseRequest = () => {};
|
|
906
934
|
try {
|
|
935
|
+
releaseRequest = await acquireRequest(req, { signal: abort.signal });
|
|
907
936
|
const parsed = await readJsonBody(req);
|
|
908
937
|
abort.setTimeout(
|
|
909
938
|
parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
|
|
@@ -916,6 +945,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
916
945
|
const forceRequestedModel = upstreamModel !== requestedModel;
|
|
917
946
|
if (parsed.stream) {
|
|
918
947
|
const upstream = await chatCompletionsFn({ ...chatReq, stream: true }, { signal: abort.signal });
|
|
948
|
+
releaseRequest();
|
|
919
949
|
if (!upstream.ok) {
|
|
920
950
|
if (!res.headersSent) res.writeHead(upstream.status);
|
|
921
951
|
res.end(await upstream.text());
|
|
@@ -947,6 +977,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
947
977
|
if (!res.writableEnded) res.end();
|
|
948
978
|
} else {
|
|
949
979
|
const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
|
|
980
|
+
releaseRequest();
|
|
950
981
|
const data = await upstream.text();
|
|
951
982
|
if (!upstream.ok) {
|
|
952
983
|
sendUpstreamError(res, upstream, data);
|
|
@@ -961,6 +992,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
961
992
|
logRequestFailure("Messages", e, abort);
|
|
962
993
|
sendJsonError(res, e, 502);
|
|
963
994
|
} finally {
|
|
995
|
+
releaseRequest();
|
|
964
996
|
abort.cleanup();
|
|
965
997
|
}
|
|
966
998
|
})();
|
package/src/anthropic.mjs
CHANGED
|
@@ -2,9 +2,9 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
let tokenizerModulePromise = null;
|
|
4
4
|
|
|
5
|
-
async function
|
|
5
|
+
async function tokenCounter() {
|
|
6
6
|
tokenizerModulePromise ||= import("gpt-tokenizer");
|
|
7
|
-
return (await tokenizerModulePromise).
|
|
7
|
+
return (await tokenizerModulePromise).countTokens;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
// Anthropic Messages API to OpenAI chat/completions translation.
|
|
@@ -282,6 +282,6 @@ export async function countTokens(body) {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
const text = parts.join("\n");
|
|
285
|
-
const
|
|
286
|
-
return { input_tokens:
|
|
285
|
+
const count = await tokenCounter();
|
|
286
|
+
return { input_tokens: count(text) };
|
|
287
287
|
}
|
package/src/claude-config.mjs
CHANGED
|
@@ -3,12 +3,13 @@ import path from "node:path";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { status } from "./status.mjs";
|
|
5
5
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
6
|
+
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
6
7
|
|
|
7
8
|
const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
8
9
|
|
|
9
10
|
// Return an updated settings object without mutating the input.
|
|
10
|
-
export function computeUpdatedSettings(settings, port) {
|
|
11
|
-
const target =
|
|
11
|
+
export function computeUpdatedSettings(settings, port, host = "127.0.0.1") {
|
|
12
|
+
const target = adapterBaseUrl(host, port);
|
|
12
13
|
const current = settings?.env?.ANTHROPIC_BASE_URL;
|
|
13
14
|
const currentToken = settings?.env?.ANTHROPIC_AUTH_TOKEN;
|
|
14
15
|
const changed = current !== target || currentToken !== "dummy";
|
|
@@ -24,11 +25,11 @@ export function computeUpdatedSettings(settings, port) {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
// Point Claude Code at the adapter by updating ~/.claude/settings.json.
|
|
27
|
-
export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH } = {}) {
|
|
28
|
-
const target =
|
|
28
|
+
export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH, host = "127.0.0.1" } = {}) {
|
|
29
|
+
const target = adapterBaseUrl(host, port);
|
|
29
30
|
|
|
30
31
|
if (!fs.existsSync(filePath)) {
|
|
31
|
-
const { json } = computeUpdatedSettings({}, port);
|
|
32
|
+
const { json } = computeUpdatedSettings({}, port, host);
|
|
32
33
|
atomicWriteFileIfChangedSync(filePath, JSON.stringify(json, null, 2) + "\n");
|
|
33
34
|
console.log(status("ok", `Created ~/.claude/settings.json for Claude Code at ${target}`));
|
|
34
35
|
return;
|
|
@@ -44,7 +45,7 @@ export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH } = {
|
|
|
44
45
|
return;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const { json, changed } = computeUpdatedSettings(settings, port);
|
|
48
|
+
const { json, changed } = computeUpdatedSettings(settings, port, host);
|
|
48
49
|
if (!changed) {
|
|
49
50
|
console.log(status("ok", `Claude Code already points to ${target}`));
|
|
50
51
|
return;
|
package/src/config.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { status } from "./status.mjs";
|
|
5
5
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
6
|
+
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
6
7
|
|
|
7
8
|
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
|
8
9
|
|
|
@@ -33,9 +34,9 @@ function setTomlKey(lines, sectionName, key, value) {
|
|
|
33
34
|
return true;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
37
|
+
export function computeUpdatedCodexConfig(content, adapterPort = 2026, adapterHost = "127.0.0.1") {
|
|
38
|
+
const anthropicBaseUrl = adapterBaseUrl(adapterHost, adapterPort);
|
|
39
|
+
const baseUrl = `${anthropicBaseUrl}/v1`;
|
|
39
40
|
const hadTrailingNewline = content.endsWith("\n");
|
|
40
41
|
const lines = content.split("\n");
|
|
41
42
|
if (hadTrailingNewline) lines.pop();
|
|
@@ -59,9 +60,9 @@ export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
|
|
|
59
60
|
return { content: lines.join("\n") + (hadTrailingNewline ? "\n" : ""), changed };
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
function initialCodexConfig(adapterPort) {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
63
|
+
function initialCodexConfig(adapterPort, adapterHost) {
|
|
64
|
+
const anthropicBaseUrl = adapterBaseUrl(adapterHost, adapterPort);
|
|
65
|
+
const baseUrl = `${anthropicBaseUrl}/v1`;
|
|
65
66
|
return `openai_base_url = "${baseUrl}"
|
|
66
67
|
|
|
67
68
|
[shell_environment_policy]
|
|
@@ -75,18 +76,18 @@ OPENAI_API_KEY = "dummy"
|
|
|
75
76
|
`;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
export function ensureCodexConfig(adapterPort = 2026, { filePath = CONFIG_PATH } = {}) {
|
|
79
|
-
const baseUrl =
|
|
79
|
+
export function ensureCodexConfig(adapterPort = 2026, { filePath = CONFIG_PATH, host = "127.0.0.1" } = {}) {
|
|
80
|
+
const baseUrl = `${adapterBaseUrl(host, adapterPort)}/v1`;
|
|
80
81
|
|
|
81
82
|
if (!fs.existsSync(filePath)) {
|
|
82
83
|
// Codex config does not exist yet; create the local proxy defaults.
|
|
83
|
-
atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort));
|
|
84
|
+
atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort, host));
|
|
84
85
|
console.log(status("ok", "Created ~/.codex/config.toml"));
|
|
85
86
|
return;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
89
|
-
const updated = computeUpdatedCodexConfig(content, adapterPort);
|
|
90
|
+
const updated = computeUpdatedCodexConfig(content, adapterPort, host);
|
|
90
91
|
|
|
91
92
|
if (!updated.changed) {
|
|
92
93
|
console.log(status("ok", `Codex already points to ${baseUrl}`));
|
package/src/copilot.mjs
CHANGED
|
@@ -95,6 +95,12 @@ const RETRYABLE_UPSTREAM_ERROR_CODES = new Set([
|
|
|
95
95
|
"EAI_AGAIN",
|
|
96
96
|
"ENOTFOUND",
|
|
97
97
|
]);
|
|
98
|
+
const RETRYABLE_POST_CONNECT_ERROR_CODES = new Set([
|
|
99
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
100
|
+
"ECONNREFUSED",
|
|
101
|
+
"EAI_AGAIN",
|
|
102
|
+
"ENOTFOUND",
|
|
103
|
+
]);
|
|
98
104
|
const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 502, 503, 504]);
|
|
99
105
|
|
|
100
106
|
function upstreamTarget(url) {
|
|
@@ -146,9 +152,15 @@ function requestMethod(init = {}) {
|
|
|
146
152
|
return String(init.method || "GET").toUpperCase();
|
|
147
153
|
}
|
|
148
154
|
|
|
149
|
-
export function isRetryableUpstreamError(err, { signal } = {}) {
|
|
155
|
+
export function isRetryableUpstreamError(err, { signal, method = "GET" } = {}) {
|
|
150
156
|
if (isAbortError(err, signal)) return false;
|
|
151
|
-
|
|
157
|
+
const code = upstreamErrorCode(err);
|
|
158
|
+
if (!["GET", "HEAD", "OPTIONS"].includes(String(method || "GET").toUpperCase())) {
|
|
159
|
+
const syscall = err?.cause?.syscall || err?.syscall || "";
|
|
160
|
+
return RETRYABLE_POST_CONNECT_ERROR_CODES.has(code)
|
|
161
|
+
|| (syscall === "connect" && ["ETIMEDOUT", "ECONNRESET"].includes(code));
|
|
162
|
+
}
|
|
163
|
+
return RETRYABLE_UPSTREAM_ERROR_CODES.has(code);
|
|
152
164
|
}
|
|
153
165
|
|
|
154
166
|
export function isRetryableUpstreamStatus(status, method = "GET") {
|
|
@@ -197,7 +209,7 @@ export async function fetchCopilotUpstream(
|
|
|
197
209
|
return resp;
|
|
198
210
|
} catch (e) {
|
|
199
211
|
debugLog(`upstream ${method} ${target} error=${describeUpstreamError(e)} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
|
|
200
|
-
if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal })) throw e;
|
|
212
|
+
if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal, method })) throw e;
|
|
201
213
|
const delay = upstreamRetryDelay(attempt, baseDelay);
|
|
202
214
|
console.warn(status("warn", `upstream ${target} ${describeUpstreamError(e)}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
|
|
203
215
|
await sleep(delay, { signal });
|
package/src/doctor.mjs
CHANGED
|
@@ -6,7 +6,8 @@ import { githubTokenPath, readGithubTokenMetadata, validateGithubToken } from ".
|
|
|
6
6
|
import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
|
|
7
7
|
import { status } from "./status.mjs";
|
|
8
8
|
import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
|
|
9
|
-
import { checkRunningAdapter } from "./running-adapter.mjs";
|
|
9
|
+
import { adapterBaseUrl, checkRunningAdapter } from "./running-adapter.mjs";
|
|
10
|
+
import { CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
|
|
10
11
|
|
|
11
12
|
function localGatewayBaseUrl(host, port) {
|
|
12
13
|
const safeHost = String(host || "127.0.0.1");
|
|
@@ -146,6 +147,15 @@ export async function inspectAdapterCompatibility({
|
|
|
146
147
|
return checks;
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
await runCompatibilityCheck(checks, "Codex Auto-review", async () => {
|
|
151
|
+
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
|
|
152
|
+
model: CODEX_AUTO_REVIEW_MODEL,
|
|
153
|
+
stream: false,
|
|
154
|
+
input: "Reply with OK only.",
|
|
155
|
+
}, timeoutMs);
|
|
156
|
+
parseResponseObject(text);
|
|
157
|
+
});
|
|
158
|
+
|
|
149
159
|
const firstResponse = await runCompatibilityCheck(checks, `Native Responses (${responsesModel})`, async () => {
|
|
150
160
|
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
|
|
151
161
|
model: responsesModel,
|
|
@@ -259,10 +269,10 @@ export async function inspectGitHubTokenOnline({
|
|
|
259
269
|
}
|
|
260
270
|
}
|
|
261
271
|
|
|
262
|
-
export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
272
|
+
export function inspectCodexConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
|
|
263
273
|
const filePath = path.join(home, ".codex", "config.toml");
|
|
264
|
-
const
|
|
265
|
-
const
|
|
274
|
+
const expectedAnthropicBaseUrl = adapterBaseUrl(host, port);
|
|
275
|
+
const expectedBaseUrl = `${expectedAnthropicBaseUrl}/v1`;
|
|
266
276
|
const config = readText(filePath);
|
|
267
277
|
if (!config.ok) {
|
|
268
278
|
return [{ kind: "warn", message: `Codex config not found at ${displayPath(home, filePath)}` }];
|
|
@@ -285,9 +295,9 @@ export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
|
285
295
|
return checks;
|
|
286
296
|
}
|
|
287
297
|
|
|
288
|
-
export function inspectClaudeCodeConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
298
|
+
export function inspectClaudeCodeConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
|
|
289
299
|
const filePath = path.join(home, ".claude", "settings.json");
|
|
290
|
-
const expectedBaseUrl =
|
|
300
|
+
const expectedBaseUrl = adapterBaseUrl(host, port);
|
|
291
301
|
const settings = readJson(filePath);
|
|
292
302
|
if (!settings.ok) {
|
|
293
303
|
const reason = settings.parseError ? `could not parse: ${settings.error.message}` : `not found at ${displayPath(home, filePath)}`;
|
|
@@ -391,8 +401,8 @@ export async function collectDoctorChecks({
|
|
|
391
401
|
} = {}) {
|
|
392
402
|
const checks = [
|
|
393
403
|
...inspectGitHubToken({ home }),
|
|
394
|
-
...inspectCodexConfig({ home, port }),
|
|
395
|
-
...inspectClaudeCodeConfig({ home, port }),
|
|
404
|
+
...inspectCodexConfig({ home, host, port }),
|
|
405
|
+
...inspectClaudeCodeConfig({ home, host, port }),
|
|
396
406
|
...inspectClaudeAppConfig({ home, platform, env, host, port }),
|
|
397
407
|
];
|
|
398
408
|
|
package/src/http-transport.mjs
CHANGED
|
@@ -4,6 +4,10 @@ import { status } from "./status.mjs";
|
|
|
4
4
|
|
|
5
5
|
const DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
6
6
|
const DEFAULT_MAX_DECODED_BODY_BYTES = 128 * 1024 * 1024;
|
|
7
|
+
const DEFAULT_MAX_INFLIGHT_BODY_BYTES = 32 * 1024 * 1024;
|
|
8
|
+
const DEFAULT_MAX_QUEUED_REQUESTS = 16;
|
|
9
|
+
const DEFAULT_REQUEST_QUEUE_TIMEOUT_MS = 120 * 1000;
|
|
10
|
+
const COMPRESSED_BODY_WEIGHT_MULTIPLIER = 4;
|
|
7
11
|
const gunzipAsync = promisify(zlib.gunzip);
|
|
8
12
|
const inflateAsync = promisify(zlib.inflate);
|
|
9
13
|
const brotliDecompressAsync = promisify(zlib.brotliDecompress);
|
|
@@ -16,6 +20,9 @@ function positiveInt(value, fallback) {
|
|
|
16
20
|
|
|
17
21
|
const MAX_BODY_BYTES = positiveInt(process.env.CCDX_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
|
|
18
22
|
const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
|
|
23
|
+
const MAX_INFLIGHT_BODY_BYTES = positiveInt(process.env.CCDX_MAX_INFLIGHT_BODY_BYTES, DEFAULT_MAX_INFLIGHT_BODY_BYTES);
|
|
24
|
+
const MAX_QUEUED_REQUESTS = positiveInt(process.env.CCDX_MAX_QUEUED_REQUESTS, DEFAULT_MAX_QUEUED_REQUESTS);
|
|
25
|
+
const REQUEST_QUEUE_TIMEOUT_MS = positiveInt(process.env.CCDX_REQUEST_QUEUE_TIMEOUT_MS, DEFAULT_REQUEST_QUEUE_TIMEOUT_MS);
|
|
19
26
|
|
|
20
27
|
export function httpError(message, statusCode) {
|
|
21
28
|
const err = new Error(message);
|
|
@@ -31,6 +38,108 @@ function requestContentLength(req) {
|
|
|
31
38
|
return Number.parseInt(req.headers?.["content-length"] || "", 10);
|
|
32
39
|
}
|
|
33
40
|
|
|
41
|
+
function requestAdmissionWeight(req, maxBytes) {
|
|
42
|
+
const encodings = contentEncodings(req.headers?.["content-encoding"]);
|
|
43
|
+
const contentLength = requestContentLength(req);
|
|
44
|
+
if (!Number.isFinite(contentLength) || contentLength < 0) return maxBytes;
|
|
45
|
+
const compressed = encodings.some((encoding) => encoding !== "identity");
|
|
46
|
+
const weightedLength = compressed ? contentLength * COMPRESSED_BODY_WEIGHT_MULTIPLIER : contentLength;
|
|
47
|
+
return Math.max(1, Math.min(weightedLength, maxBytes));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function admissionAbortError(signal) {
|
|
51
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
52
|
+
const error = new Error("The operation was aborted");
|
|
53
|
+
error.name = "AbortError";
|
|
54
|
+
return error;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createRequestAdmission({
|
|
58
|
+
maxBytes = MAX_INFLIGHT_BODY_BYTES,
|
|
59
|
+
maxQueued = MAX_QUEUED_REQUESTS,
|
|
60
|
+
waitTimeoutMs = REQUEST_QUEUE_TIMEOUT_MS,
|
|
61
|
+
} = {}) {
|
|
62
|
+
const byteLimit = positiveInt(maxBytes, MAX_INFLIGHT_BODY_BYTES);
|
|
63
|
+
const queueLimit = positiveInt(maxQueued, MAX_QUEUED_REQUESTS);
|
|
64
|
+
const timeoutMs = positiveInt(waitTimeoutMs, REQUEST_QUEUE_TIMEOUT_MS);
|
|
65
|
+
const queue = [];
|
|
66
|
+
let activeBytes = 0;
|
|
67
|
+
|
|
68
|
+
const remove = (entry) => {
|
|
69
|
+
const index = queue.indexOf(entry);
|
|
70
|
+
if (index >= 0) queue.splice(index, 1);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const activate = (entry) => {
|
|
74
|
+
entry.cleanup();
|
|
75
|
+
activeBytes += entry.weight;
|
|
76
|
+
let released = false;
|
|
77
|
+
entry.resolve(() => {
|
|
78
|
+
if (released) return;
|
|
79
|
+
released = true;
|
|
80
|
+
activeBytes = Math.max(0, activeBytes - entry.weight);
|
|
81
|
+
drain();
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const drain = () => {
|
|
86
|
+
for (let index = 0; index < queue.length;) {
|
|
87
|
+
const entry = queue[index];
|
|
88
|
+
if (entry.cancelled) {
|
|
89
|
+
queue.splice(index, 1);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (activeBytes + entry.weight > byteLimit) {
|
|
93
|
+
index += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
queue.splice(index, 1);
|
|
97
|
+
activate(entry);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const acquire = (req, { signal } = {}) => {
|
|
102
|
+
if (signal?.aborted) return Promise.reject(admissionAbortError(signal));
|
|
103
|
+
const weight = requestAdmissionWeight(req, byteLimit);
|
|
104
|
+
if (queue.length >= queueLimit && activeBytes + weight > byteLimit) {
|
|
105
|
+
return Promise.reject(httpError(`Request queue is full (${queueLimit} waiting)`, 503));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
let timer;
|
|
110
|
+
const entry = {
|
|
111
|
+
cancelled: false,
|
|
112
|
+
weight,
|
|
113
|
+
resolve,
|
|
114
|
+
cleanup: () => {
|
|
115
|
+
if (timer) clearTimeout(timer);
|
|
116
|
+
signal?.removeEventListener("abort", onAbort);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const cancel = (error) => {
|
|
120
|
+
if (entry.cancelled) return;
|
|
121
|
+
entry.cancelled = true;
|
|
122
|
+
entry.cleanup();
|
|
123
|
+
remove(entry);
|
|
124
|
+
reject(error);
|
|
125
|
+
drain();
|
|
126
|
+
};
|
|
127
|
+
const onAbort = () => cancel(admissionAbortError(signal));
|
|
128
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
129
|
+
timer = setTimeout(() => {
|
|
130
|
+
cancel(httpError(`Request admission timed out after ${timeoutMs}ms`, 503));
|
|
131
|
+
}, timeoutMs);
|
|
132
|
+
timer.unref?.();
|
|
133
|
+
queue.push(entry);
|
|
134
|
+
if (signal?.aborted) onAbort();
|
|
135
|
+
else drain();
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
acquire.stats = () => ({ activeBytes, queued: queue.length, maxBytes: byteLimit });
|
|
140
|
+
return acquire;
|
|
141
|
+
}
|
|
142
|
+
|
|
34
143
|
async function readRequestBuffer(req, maxBytes) {
|
|
35
144
|
const contentLength = requestContentLength(req);
|
|
36
145
|
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
@@ -130,13 +130,16 @@ export async function optimizeImageDataUrl(dataUrl, {
|
|
|
130
130
|
const match = /^data:(image\/[a-z+.-]+);base64,(.+)$/i.exec(dataUrl);
|
|
131
131
|
if (!match) return dataUrl;
|
|
132
132
|
const mime = match[1].toLowerCase();
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (!force && touchOptimizedImage(digest)) return dataUrl;
|
|
133
|
+
const encoded = match[2];
|
|
134
|
+
const inputBytes = Buffer.byteLength(encoded, "base64");
|
|
135
|
+
if (inputBytes < IMG_MIN_BYTES || mime.includes("gif")) return dataUrl;
|
|
137
136
|
|
|
138
137
|
try {
|
|
139
138
|
return await runGlobalImageTask(async () => {
|
|
139
|
+
const raw = Buffer.from(encoded, "base64");
|
|
140
|
+
if (raw.length < IMG_MIN_BYTES) return dataUrl;
|
|
141
|
+
const digest = imageDigest(raw);
|
|
142
|
+
if (!force && touchOptimizedImage(digest)) return dataUrl;
|
|
140
143
|
const resize = await sharp();
|
|
141
144
|
const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
|
|
142
145
|
const out = await image
|
|
@@ -156,7 +159,7 @@ export async function optimizeImageDataUrl(dataUrl, {
|
|
|
156
159
|
}, { signal });
|
|
157
160
|
} catch (e) {
|
|
158
161
|
if (signal?.aborted || e?.name === "AbortError") throw e;
|
|
159
|
-
console.warn(status("warn", `image optimize failed (${mime}, ${
|
|
162
|
+
console.warn(status("warn", `image optimize failed (${mime}, ${inputBytes}b): ${e.message}`));
|
|
160
163
|
return dataUrl;
|
|
161
164
|
}
|
|
162
165
|
}
|
package/src/models.mjs
CHANGED
|
@@ -225,6 +225,19 @@ export function resolveOpenAIModel(model, env = process.env) {
|
|
|
225
225
|
};
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
export function codexAutoReviewModelStatus(models, env = process.env) {
|
|
229
|
+
const { upstreamModel } = resolveOpenAIModel(CODEX_AUTO_REVIEW_MODEL, env);
|
|
230
|
+
const model = copilotModelData(models).find((entry) => String(entry?.id || "").trim() === upstreamModel);
|
|
231
|
+
if (!model) return { available: false, upstreamModel, reason: "model is not advertised" };
|
|
232
|
+
const endpoints = modelEndpoints(model);
|
|
233
|
+
const available = endpoints.includes("/responses") || endpoints.includes("/v1/responses");
|
|
234
|
+
return {
|
|
235
|
+
available,
|
|
236
|
+
upstreamModel,
|
|
237
|
+
reason: available ? "" : "model does not advertise a Responses endpoint",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
228
241
|
export function anthropicModelInfo(model) {
|
|
229
242
|
return {
|
|
230
243
|
id: model.id,
|