codex-copilot-dx 0.6.2 → 0.6.3
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 +15 -17
- package/bin/codex-copilot-dx.mjs +2 -1
- package/package.json +1 -1
- package/scripts/benchmark.mjs +61 -0
- package/src/adapter.mjs +5 -0
- package/src/cli-options.mjs +5 -11
- package/src/cli-status.mjs +5 -1
- package/src/copilot-client.mjs +5 -0
- package/src/http-transport.mjs +1 -1
- package/src/observability.mjs +2 -0
- package/src/response-history.mjs +4 -0
- package/src/responses-bridge.mjs +12 -6
- package/src/responses-byte-budget.mjs +63 -5
- package/src/responses-handler.mjs +60 -16
- package/src/responses-image-pressure.mjs +163 -0
- package/src/responses-proxy.mjs +7 -4
- package/src/responses-request.mjs +9 -1
package/README.md
CHANGED
|
@@ -29,11 +29,11 @@ npm install -g codex-copilot-dx@latest
|
|
|
29
29
|
ccdx
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
`ccdx` is the only documented command and primary launcher. The old
|
|
33
|
+
`codex-copilot-dx` executable remains temporarily as a compatibility shim: it
|
|
34
|
+
prints a deprecation warning and runs `ccdx`, and will be removed in a future
|
|
35
|
+
breaking release. For a one-off run without a global install, use
|
|
36
|
+
`npm exec --yes --package=codex-copilot-dx@latest -- ccdx`.
|
|
37
37
|
Run `ccdx <command> --help` (including `auth login claude --help` and
|
|
38
38
|
`pms status --help`) for command-specific behavior and side-effect details.
|
|
39
39
|
|
|
@@ -49,8 +49,7 @@ starting the adapter, opening device login, reading the local model cache, or
|
|
|
49
49
|
consuming an inference request. It lists model IDs by provider with their
|
|
50
50
|
advertised API capabilities and reports the live Claude/Anthropic count. A model
|
|
51
51
|
being advertised does not guarantee that a later inference request will avoid
|
|
52
|
-
quota, rate-limit, or policy enforcement.
|
|
53
|
-
equivalent.
|
|
52
|
+
quota, rate-limit, or policy enforcement.
|
|
54
53
|
|
|
55
54
|
### Separate GitHub account for Claude
|
|
56
55
|
|
|
@@ -193,7 +192,7 @@ It saves the selection in `~/.config/codex-copilot-dx/config.json` (or under
|
|
|
193
192
|
`XDG_CONFIG_HOME` when set); choosing `gpt-5.5` clears the override and restores
|
|
194
193
|
the package default. A running 0.5.1+
|
|
195
194
|
adapter reads the setting on the next Auto-review request, so it does not need to
|
|
196
|
-
be restarted.
|
|
195
|
+
be restarted.
|
|
197
196
|
|
|
198
197
|
To update the globally installed package, choose a source interactively:
|
|
199
198
|
|
|
@@ -216,8 +215,7 @@ latest commit from `DaleXiao/codex-copilot-dx` `main` and opts in to Git fetchin
|
|
|
216
215
|
for that command, as required by npm 12. It does not change npm's persistent
|
|
217
216
|
`allow-git` setting. Both paths use npm's global installer without shell
|
|
218
217
|
interpolation. A currently running adapter keeps its loaded version until it is
|
|
219
|
-
stopped and started again.
|
|
220
|
-
`codex-copilot-dx update` compatibility command behaves identically.
|
|
218
|
+
stopped and started again.
|
|
221
219
|
If a configured npm mirror has not synchronized the current release yet, use
|
|
222
220
|
the GitHub source to install the current `main` revision.
|
|
223
221
|
|
|
@@ -278,15 +276,13 @@ For a read-only summary of the running adapter, use:
|
|
|
278
276
|
ccdx status
|
|
279
277
|
```
|
|
280
278
|
|
|
281
|
-
|
|
282
|
-
`codex-copilot-dx status`. For the complete machine-readable payload, query the
|
|
283
|
-
loopback endpoint directly:
|
|
279
|
+
For the complete machine-readable payload, query the loopback endpoint directly:
|
|
284
280
|
|
|
285
281
|
```bash
|
|
286
282
|
curl -s http://127.0.0.1:2026/_ccdx/status | jq
|
|
287
283
|
```
|
|
288
284
|
|
|
289
|
-
The status endpoint is restricted to the socket's real loopback address, even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms for streaming routes, admission pressure, memory use, response-history size, image queue/cache state, profile model cache counts, PM relay route totals, and token expiry state. It never retains metric samples or includes prompts, completions, tool arguments, image content, account names, or token values. API responses always include a safe `X-Request-Id` for correlation. If the adapter is not running, the CLI prints the exact start-and-retry command. To include the same ID as `request_id` in related terminal and file log lines, start the adapter with:
|
|
285
|
+
The status endpoint is restricted to the socket's real loopback address, even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms for streaming routes, admission pressure, memory use, response-history size, image queue/cache and adaptive-history state, profile model cache counts, PM relay route totals, and token expiry state. It never retains metric samples or includes prompts, completions, tool arguments, image content, account names, or token values. API responses always include a safe `X-Request-Id` for correlation. If the adapter is not running, the CLI prints the exact start-and-retry command. To include the same ID as `request_id` in related terminal and file log lines, start the adapter with:
|
|
290
286
|
|
|
291
287
|
```bash
|
|
292
288
|
ccdx --show-request-id
|
|
@@ -306,7 +302,7 @@ ccdx
|
|
|
306
302
|
Claude App support is opt-in so the default Codex Desktop and Claude Code setup stays unchanged:
|
|
307
303
|
|
|
308
304
|
```bash
|
|
309
|
-
|
|
305
|
+
npm exec --yes --package=codex-copilot-dx@latest -- ccdx start --configure-claude-app
|
|
310
306
|
```
|
|
311
307
|
|
|
312
308
|
The previous `--configure-claude-desktop` spelling remains a compatibility
|
|
@@ -316,7 +312,7 @@ key is restored or the profile is configured in that run.
|
|
|
316
312
|
Or set:
|
|
317
313
|
|
|
318
314
|
```bash
|
|
319
|
-
CCDX_CONFIGURE_CLAUDE_DESKTOP=1
|
|
315
|
+
CCDX_CONFIGURE_CLAUDE_DESKTOP=1 npm exec --yes --package=codex-copilot-dx@latest -- ccdx
|
|
320
316
|
```
|
|
321
317
|
|
|
322
318
|
This writes a local Claude App 3P gateway profile that points to the adapter root URL, such as `http://127.0.0.1:2026`. The profile uses a generated local bearer key unless `CCDX_CLAUDE_DESKTOP_API_KEY` is set. Later starts restore that key only from the active managed profile when its gateway URL matches the adapter. Once that managed profile is active, successful Copilot model refreshes automatically keep its `inferenceModels` list current without changing the gateway key or other profile settings. Restart Claude App after the initial setup. Later model changes require no profile reconfiguration, although a running Claude App may need to be reopened if it has not reloaded the updated profile yet.
|
|
@@ -392,7 +388,7 @@ Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-d
|
|
|
392
388
|
|
|
393
389
|
### Image optimization
|
|
394
390
|
|
|
395
|
-
Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns,
|
|
391
|
+
Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns, which can increase local preparation time, upstream handshake latency, and the chance of a `413 Payload Too Large` response.
|
|
396
392
|
|
|
397
393
|
The adapter automatically downsamples embedded screenshots to model-appropriate pixel bounds (with a conservative 2048 px long-edge fallback) and initially re-encodes them as WebP quality 82 before forwarding `/v1/responses`. It never replaces an image with a larger encoding and applies one global concurrency limit across direct, function-tool, and custom-tool output images.
|
|
398
394
|
|
|
@@ -400,6 +396,8 @@ The final serialized UTF-8 request body is measured before forwarding. Above the
|
|
|
400
396
|
|
|
401
397
|
Completed image transforms are reused from a byte-bounded process-local LRU cache, and concurrent requests for the same transform share one encode. The cache key includes source content, MIME type, model-aware dimensions, WebP quality, and encoder settings; failures are not cached and request cancellation remains isolated.
|
|
402
398
|
|
|
399
|
+
Before image encoding, CCDX leaves ordinary visual history byte-for-byte unchanged while it has at most 24 historical images and the expanded body is at most 18 MiB. Above either threshold, the temporary upstream view keeps every current-turn image and at most 16 recent historical images, targeting 16 MiB. If that history tree then times out during local preparation or upstream handshake, the next retry automatically uses an 8-image, 10 MiB recovery window for ten minutes. Two successful requests or a successful compaction clear recovery mode. CCDX never transparently repeats the ambiguous timed-out POST, and no local history is deleted; retrying does not require restarting the service.
|
|
400
|
+
|
|
403
401
|
When expanded Responses history exceeds Copilot's 50-image request limit, the adapter removes older duplicate image occurrences first, then keeps the 50 most recent images. This applies only to the forwarded request: local history remains complete, current images are preferred over older history, and requests at or below the limit are unchanged.
|
|
404
402
|
|
|
405
403
|
For `/v1/responses/compact`, the adapter sends one terminal `compaction_trigger` in unary mode, validates that Copilot returned completed compaction state, and rebuilds a replayable snapshot from recent system, developer, user, and assistant messages plus every compaction item. Only a validated snapshot becomes a new local history root. Later turns continue from it without re-expanding pre-compaction history; existing older response branches remain available until normal history eviction.
|
package/bin/codex-copilot-dx.mjs
CHANGED
package/package.json
CHANGED
package/scripts/benchmark.mjs
CHANGED
|
@@ -177,6 +177,60 @@ function largeTokenCountProbe(mode) {
|
|
|
177
177
|
`);
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
function visualHistoryPressureProbe() {
|
|
181
|
+
return runProbe(`
|
|
182
|
+
import { applyResponsesImagePressure } from "./src/responses-image-pressure.mjs";
|
|
183
|
+
|
|
184
|
+
const concurrency = 4;
|
|
185
|
+
const historicalImages = 36;
|
|
186
|
+
const imageBytes = 256 * 1024;
|
|
187
|
+
const dataUrls = Array.from({ length: historicalImages }, (_, index) => (
|
|
188
|
+
\`data:image/png;base64,\${Buffer.alloc(imageBytes, index + 1).toString("base64")}\`
|
|
189
|
+
));
|
|
190
|
+
const contexts = Array.from({ length: concurrency }, (_, requestIndex) => ({
|
|
191
|
+
historyRootId: \`resp_benchmark_\${requestIndex}\`,
|
|
192
|
+
currentInputStart: historicalImages,
|
|
193
|
+
body: {
|
|
194
|
+
model: "gpt-5.6-sol",
|
|
195
|
+
stream: true,
|
|
196
|
+
input: [
|
|
197
|
+
...dataUrls.map((imageUrl) => ({
|
|
198
|
+
type: "message",
|
|
199
|
+
role: "user",
|
|
200
|
+
content: [{ type: "input_image", image_url: imageUrl }],
|
|
201
|
+
})),
|
|
202
|
+
{ type: "message", role: "user", content: "continue" },
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
}));
|
|
206
|
+
globalThis.gc?.();
|
|
207
|
+
const before = process.memoryUsage();
|
|
208
|
+
let peakRss = before.rss;
|
|
209
|
+
let peakHeap = before.heapUsed;
|
|
210
|
+
const started = performance.now();
|
|
211
|
+
const results = await Promise.all(contexts.map(async (context) => {
|
|
212
|
+
await Promise.resolve();
|
|
213
|
+
const result = applyResponsesImagePressure(context);
|
|
214
|
+
const memory = process.memoryUsage();
|
|
215
|
+
peakRss = Math.max(peakRss, memory.rss);
|
|
216
|
+
peakHeap = Math.max(peakHeap, memory.heapUsed);
|
|
217
|
+
return result;
|
|
218
|
+
}));
|
|
219
|
+
const mib = (bytes) => +(bytes / 1048576).toFixed(1);
|
|
220
|
+
process.stdout.write(JSON.stringify({
|
|
221
|
+
concurrency,
|
|
222
|
+
historical_images_per_request: historicalImages,
|
|
223
|
+
elapsed_ms: +(performance.now() - started).toFixed(1),
|
|
224
|
+
omitted_images: results.map((result) => result.imagesOmitted),
|
|
225
|
+
retained_historical_images: results.map((result) => result.historicalImages),
|
|
226
|
+
input_body_mib: results.map((result) => mib(result.initialBodyBytes)),
|
|
227
|
+
output_body_mib: results.map((result) => mib(result.bodyBytes)),
|
|
228
|
+
rss_peak_delta_mib: mib(peakRss - before.rss),
|
|
229
|
+
heap_peak_delta_mib: mib(peakHeap - before.heapUsed),
|
|
230
|
+
}));
|
|
231
|
+
`);
|
|
232
|
+
}
|
|
233
|
+
|
|
180
234
|
const adapterImport = runProbe(`
|
|
181
235
|
const started = performance.now();
|
|
182
236
|
await import("./src/adapter.mjs");
|
|
@@ -324,6 +378,7 @@ if (checkMode) {
|
|
|
324
378
|
largePayloadProbe(30, 1),
|
|
325
379
|
largePayloadProbe(30, 2),
|
|
326
380
|
];
|
|
381
|
+
report.visual_history_pressure_36x4 = visualHistoryPressureProbe();
|
|
327
382
|
} else if (process.argv.includes("--large-payload")) {
|
|
328
383
|
report.large_payload_peak = [
|
|
329
384
|
largePayloadProbe(5, 1),
|
|
@@ -359,6 +414,12 @@ if (checkMode) {
|
|
|
359
414
|
failures.push(`request admission exceeded its budget at ${payload.target_request_mib}MiB x${payload.concurrency}`);
|
|
360
415
|
}
|
|
361
416
|
}
|
|
417
|
+
const visualHistory = report.visual_history_pressure_36x4;
|
|
418
|
+
if (!visualHistory.omitted_images.every((count) => count === 20)
|
|
419
|
+
|| !visualHistory.retained_historical_images.every((count) => count === 16)
|
|
420
|
+
|| !visualHistory.output_body_mib.every((size, index) => size < visualHistory.input_body_mib[index])) {
|
|
421
|
+
failures.push("36-image x4 visual history pressure did not preserve the expected 16-image upstream window");
|
|
422
|
+
}
|
|
362
423
|
if (failures.length) {
|
|
363
424
|
for (const failure of failures) console.error(`[FAIL] ${failure}`);
|
|
364
425
|
process.exitCode = 1;
|
package/src/adapter.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { status } from "./status.mjs";
|
|
|
17
17
|
import { createTerminalActivityIndicator } from "./terminal-activity.mjs";
|
|
18
18
|
import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
|
|
19
19
|
import { createResponsesCompactHandler, createResponsesHandler } from "./responses-handler.mjs";
|
|
20
|
+
import { createResponsesImagePressureController } from "./responses-image-pressure.mjs";
|
|
20
21
|
import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
|
|
21
22
|
import {
|
|
22
23
|
createRequestAdmission,
|
|
@@ -138,6 +139,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
138
139
|
const acquireRequest = options.acquireRequest || createRequestAdmission();
|
|
139
140
|
const requestMetrics = options.requestMetrics || createRequestMetrics();
|
|
140
141
|
const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
|
|
142
|
+
const imagePressure = options.imagePressure || createResponsesImagePressureController();
|
|
141
143
|
const claudeDesktopModelOptions = () => {
|
|
142
144
|
const modelDefs = claudeModelRegistry?.modelDefs || options.claudeDesktopModelDefs;
|
|
143
145
|
return Array.isArray(modelDefs) ? { modelDefs } : {};
|
|
@@ -147,6 +149,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
147
149
|
autoReviewModelResolver: options.autoReviewModelResolver,
|
|
148
150
|
chatCompletionsFn: codexChatCompletionsFn,
|
|
149
151
|
getCachedModelEndpointsFn,
|
|
152
|
+
imagePressure,
|
|
150
153
|
openAIModelEnv,
|
|
151
154
|
responsesPayloadOptions: options.responsesPayloadOptions,
|
|
152
155
|
responsesFn,
|
|
@@ -157,6 +160,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
157
160
|
const responsesCompactHandler = createResponsesCompactHandler({
|
|
158
161
|
acquireRequest,
|
|
159
162
|
autoReviewModelResolver: options.autoReviewModelResolver,
|
|
163
|
+
imagePressure,
|
|
160
164
|
openAIModelEnv,
|
|
161
165
|
responsesCompactFn,
|
|
162
166
|
streamHandshakeTimeoutMs,
|
|
@@ -197,6 +201,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
197
201
|
metrics: requestMetrics,
|
|
198
202
|
streamPerformance: streamPerformanceMetrics,
|
|
199
203
|
admission: acquireRequest,
|
|
204
|
+
imagePressure,
|
|
200
205
|
modelRegistry: codexModelRegistry,
|
|
201
206
|
codexClient,
|
|
202
207
|
claudeClient,
|
package/src/cli-options.mjs
CHANGED
|
@@ -5,7 +5,6 @@ const START_OPTIONS = new Map([
|
|
|
5
5
|
["--configure-claude-app", "configure-claude-app"],
|
|
6
6
|
["--show-request-id", "show-request-id"],
|
|
7
7
|
]);
|
|
8
|
-
const CLI_COMMANDS = new Set(["ccdx", "codex-copilot-dx"]);
|
|
9
8
|
const PM_COMMANDS = new Set(["pms", "pm-studio"]);
|
|
10
9
|
const HELP_TOPICS = new Set([
|
|
11
10
|
"",
|
|
@@ -253,10 +252,8 @@ export function parseAdapterProbeOptions(env = process.env) {
|
|
|
253
252
|
};
|
|
254
253
|
}
|
|
255
254
|
|
|
256
|
-
export function cliCommandName(
|
|
257
|
-
|
|
258
|
-
if (name === "codex-copilot-dx.mjs") return "codex-copilot-dx";
|
|
259
|
-
return CLI_COMMANDS.has(name) ? name : "ccdx";
|
|
255
|
+
export function cliCommandName() {
|
|
256
|
+
return "ccdx";
|
|
260
257
|
}
|
|
261
258
|
|
|
262
259
|
function topicHelp(name, topic) {
|
|
@@ -280,10 +277,9 @@ function topicHelp(name, topic) {
|
|
|
280
277
|
}
|
|
281
278
|
|
|
282
279
|
export function cliHelp(commandName = "ccdx", topic = "") {
|
|
283
|
-
const name =
|
|
284
|
-
const alias = name === "ccdx" ? "codex-copilot-dx" : "ccdx";
|
|
280
|
+
const name = "ccdx";
|
|
285
281
|
const normalizedTopic = checkedHelpTopic(topic ? topic.split(" ") : []);
|
|
286
|
-
if (normalizedTopic) return
|
|
282
|
+
if (normalizedTopic) return topicHelp(name, normalizedTopic);
|
|
287
283
|
return `Usage:
|
|
288
284
|
${name} [start] [--configure-claude-app] [--show-request-id]
|
|
289
285
|
${name} auth status [--online]
|
|
@@ -310,7 +306,5 @@ Commands:
|
|
|
310
306
|
auto-review-model Select the Codex Auto-review Responses model
|
|
311
307
|
update Update the global package from npm or GitHub
|
|
312
308
|
|
|
313
|
-
Run ${name} <command> --help for command details
|
|
314
|
-
|
|
315
|
-
Equivalent command: ${alias}`;
|
|
309
|
+
Run ${name} <command> --help for command details.`;
|
|
316
310
|
}
|
package/src/cli-status.mjs
CHANGED
|
@@ -149,6 +149,7 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
|
|
|
149
149
|
const admission = data.admission || {};
|
|
150
150
|
const history = data.response_history || {};
|
|
151
151
|
const images = data.image_optimization || {};
|
|
152
|
+
const imageHistory = data.image_history_pressure;
|
|
152
153
|
const models = data.models || {};
|
|
153
154
|
const copilot = data.copilot || {};
|
|
154
155
|
const limits = data.limits || {};
|
|
@@ -166,8 +167,11 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
|
|
|
166
167
|
status("info", `Memory: RSS ${mebibytes(processStats.rss_bytes)}, heap ${mebibytes(processStats.heap_used_bytes)}`),
|
|
167
168
|
status("info", `History: ${mebibytes(history.bytes)} / ${mebibytes(limits.response_history_max_bytes)}, ${count(history.entries)} entries, ${count(history.evicted)} evicted`),
|
|
168
169
|
status("info", `Image cache: ${count(images.cache_entries)} entries, ${cacheHitRate(images)} hits, ${mebibytes(images.cache_bytes)} / ${mebibytes(images.cache_max_bytes)}`),
|
|
169
|
-
status("info", `Models: ${count(models.models)} total, ${count(models.claude_models)} Claude; Copilot token ${copilot.token_cached ? `cached (${duration(copilot.token_expires_in_ms)} remaining)` : "not cached"}`),
|
|
170
170
|
];
|
|
171
|
+
if (imageHistory && typeof imageHistory === "object") {
|
|
172
|
+
lines.push(status("info", `Visual history: ${count(imageHistory.active_recovery_trees)} recovery trees, ${count(imageHistory.adapted_requests)} adapted requests, ${count(imageHistory.historical_images_omitted)} older images omitted, ${count(imageHistory.timeouts_recorded)} timeouts`));
|
|
173
|
+
}
|
|
174
|
+
lines.push(status("info", `Models: ${count(models.models)} total, ${count(models.claude_models)} Claude; Copilot token ${copilot.token_cached ? `cached (${duration(copilot.token_expires_in_ms)} remaining)` : "not cached"}`));
|
|
171
175
|
if (data.profiles?.codex || data.profiles?.claude) {
|
|
172
176
|
lines.push(status("info", `Profiles: ${profileSummary("Codex", data.profiles?.codex)}; ${profileSummary("Claude", data.profiles?.claude)}`));
|
|
173
177
|
}
|
package/src/copilot-client.mjs
CHANGED
|
@@ -85,6 +85,7 @@ export function createCopilotClientRuntime({
|
|
|
85
85
|
fetchImpl,
|
|
86
86
|
retryOptions,
|
|
87
87
|
bodyText,
|
|
88
|
+
onUpstreamStart,
|
|
88
89
|
} = {}) {
|
|
89
90
|
const token = await tokenSession.getToken({ signal });
|
|
90
91
|
const upstreamReq = chatReq.stream === true ? withChatStreamUsage(chatReq) : chatReq;
|
|
@@ -99,6 +100,7 @@ export function createCopilotClientRuntime({
|
|
|
99
100
|
vision: computeVision(messages),
|
|
100
101
|
});
|
|
101
102
|
headers["Content-Length"] = String(Buffer.byteLength(serializedBody));
|
|
103
|
+
onUpstreamStart?.();
|
|
102
104
|
if (upstreamReq.stream === true) markUpstreamStarted();
|
|
103
105
|
return fetchCopilotUpstream(`${tokenSession.getApiBase()}/chat/completions`, {
|
|
104
106
|
method: "POST",
|
|
@@ -152,6 +154,7 @@ export function createCopilotClientRuntime({
|
|
|
152
154
|
fetchImpl,
|
|
153
155
|
retryOptions,
|
|
154
156
|
currentInputStart = 0,
|
|
157
|
+
onUpstreamStart,
|
|
155
158
|
payloadPrepared = false,
|
|
156
159
|
payloadOptions = {},
|
|
157
160
|
} = {}) {
|
|
@@ -175,6 +178,7 @@ export function createCopilotClientRuntime({
|
|
|
175
178
|
headers["Content-Length"] = String(bodyBytes);
|
|
176
179
|
headers.Accept = reqBody.stream ? "text/event-stream" : "application/json";
|
|
177
180
|
try {
|
|
181
|
+
onUpstreamStart?.();
|
|
178
182
|
if (reqBody.stream === true) markUpstreamStarted();
|
|
179
183
|
return await fetchCopilotUpstream(`${tokenSession.getApiBase()}${responsesEndpointPath()}`, {
|
|
180
184
|
method: "POST",
|
|
@@ -183,6 +187,7 @@ export function createCopilotClientRuntime({
|
|
|
183
187
|
signal,
|
|
184
188
|
}, { fetchImpl, ...retryOptions });
|
|
185
189
|
} catch (error) {
|
|
190
|
+
if (signal?.aborted) throw abortError(signal);
|
|
186
191
|
const cause = error?.cause;
|
|
187
192
|
const causeText = cause ? ` (${[cause.code, cause.message].filter(Boolean).join(": ")})` : "";
|
|
188
193
|
throw new Error(`Copilot responses fetch failed: ${error.message}${causeText}`);
|
package/src/http-transport.mjs
CHANGED
|
@@ -327,7 +327,7 @@ export function isAbortLikeError(err) {
|
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
export function abortErrorStatusCode(reason) {
|
|
330
|
-
if (["upstream_timeout", "stream_handshake_timeout", "stream_idle_timeout"].includes(reason)) return 504;
|
|
330
|
+
if (["responses_prepare_timeout", "upstream_timeout", "stream_handshake_timeout", "stream_idle_timeout"].includes(reason)) return 504;
|
|
331
331
|
if (reason === "client_aborted" || reason === "client_closed") return 499;
|
|
332
332
|
return 502;
|
|
333
333
|
}
|
package/src/observability.mjs
CHANGED
|
@@ -154,6 +154,7 @@ export function runtimeStatusPayload({
|
|
|
154
154
|
metrics,
|
|
155
155
|
streamPerformance,
|
|
156
156
|
admission,
|
|
157
|
+
imagePressure,
|
|
157
158
|
modelRegistry,
|
|
158
159
|
codexClient,
|
|
159
160
|
claudeClient,
|
|
@@ -186,6 +187,7 @@ export function runtimeStatusPayload({
|
|
|
186
187
|
admission: admission?.diagnostics?.() || admission?.stats?.() || null,
|
|
187
188
|
response_history: responseHistoryStats(),
|
|
188
189
|
image_optimization: imageOptimizationStats(),
|
|
190
|
+
image_history_pressure: imagePressure?.snapshot?.() || null,
|
|
189
191
|
copilot: codexRuntime,
|
|
190
192
|
models: codexModels,
|
|
191
193
|
profiles: {
|
package/src/response-history.mjs
CHANGED
|
@@ -174,6 +174,10 @@ export function responseHistoryStats() {
|
|
|
174
174
|
return { entries: histories.size, bytes: totalBytes, evicted: evictedIds.size };
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
export function responseHistoryRootId(responseId) {
|
|
178
|
+
return histories.get(responseId)?.rootId || null;
|
|
179
|
+
}
|
|
180
|
+
|
|
177
181
|
export function materializeResponseHistory(responseId) {
|
|
178
182
|
const chain = [];
|
|
179
183
|
const seen = new Set();
|
package/src/responses-bridge.mjs
CHANGED
|
@@ -149,24 +149,29 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
149
149
|
try {
|
|
150
150
|
const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
|
|
151
151
|
try {
|
|
152
|
-
|
|
152
|
+
options.onUpstreamStart?.();
|
|
153
|
+
resp = await chatCompletionsFn(upstreamReq, {
|
|
154
|
+
signal: options.signal,
|
|
155
|
+
bodyText,
|
|
156
|
+
onUpstreamStart: options.onUpstreamStart,
|
|
157
|
+
});
|
|
153
158
|
} finally {
|
|
154
159
|
options.releaseRequest?.();
|
|
155
160
|
}
|
|
156
161
|
} catch (e) {
|
|
157
162
|
const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
|
|
158
163
|
await onError(statusCode, e.message);
|
|
159
|
-
return;
|
|
164
|
+
return false;
|
|
160
165
|
}
|
|
161
166
|
if (!resp.ok) {
|
|
162
167
|
await onError(resp.status, await resp.text(), undefined, resp);
|
|
163
|
-
return;
|
|
168
|
+
return false;
|
|
164
169
|
}
|
|
165
170
|
try {
|
|
166
171
|
await requireUpstreamEventStream(resp);
|
|
167
172
|
} catch (error) {
|
|
168
173
|
await onError(error.statusCode, error.message, error);
|
|
169
|
-
return;
|
|
174
|
+
return false;
|
|
170
175
|
}
|
|
171
176
|
options.onUpstreamResponse?.(resp);
|
|
172
177
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
@@ -279,7 +284,7 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
279
284
|
})) {
|
|
280
285
|
if (!line.startsWith("data: ")) continue;
|
|
281
286
|
const data = line.slice(6).trim();
|
|
282
|
-
if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
|
|
287
|
+
if (data === "[DONE]") { await emitCompleted(); onDone(); return true; }
|
|
283
288
|
let parsed;
|
|
284
289
|
try {
|
|
285
290
|
parsed = JSON.parse(data);
|
|
@@ -331,8 +336,9 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
331
336
|
} catch (e) {
|
|
332
337
|
const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : (e?.statusCode || 502);
|
|
333
338
|
await onError(statusCode, e?.message || "upstream stream error", e);
|
|
334
|
-
return;
|
|
339
|
+
return false;
|
|
335
340
|
}
|
|
336
341
|
const error = incompleteUpstreamStream("[DONE]");
|
|
337
342
|
await onError(error.statusCode, error.message, error);
|
|
343
|
+
return false;
|
|
338
344
|
}
|
|
@@ -159,12 +159,14 @@ function recordBytesAfterDrop(record, state, partIndex) {
|
|
|
159
159
|
return state.baseBytes + remainingElementBytes + remainingCount - 1;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
function selectImageDrops(refs, bodyBytes, targetBytes) {
|
|
162
|
+
function selectImageDrops(refs, bodyBytes, targetBytes, maxHistoricalImages = Number.POSITIVE_INFINITY) {
|
|
163
163
|
const candidates = orderedHistoricalImageCandidates(refs);
|
|
164
164
|
const selected = new Set();
|
|
165
165
|
const byRecord = new Map();
|
|
166
166
|
let projectedBytes = bodyBytes;
|
|
167
|
+
let historicalImages = refs.reduce((count, ref) => count + (ref.historical ? 1 : 0), 0);
|
|
167
168
|
for (const index of candidates) {
|
|
169
|
+
if (projectedBytes <= targetBytes && historicalImages <= maxHistoricalImages) break;
|
|
168
170
|
const ref = refs[index];
|
|
169
171
|
let state = byRecord.get(ref.record);
|
|
170
172
|
if (!state) {
|
|
@@ -173,7 +175,7 @@ function selectImageDrops(refs, bodyBytes, targetBytes) {
|
|
|
173
175
|
}
|
|
174
176
|
const afterBytes = recordBytesAfterDrop(ref.record, state, ref.partIndex);
|
|
175
177
|
const savings = state.currentBytes - afterBytes;
|
|
176
|
-
if (savings <= 0) continue;
|
|
178
|
+
if (savings <= 0 && historicalImages <= maxHistoricalImages) continue;
|
|
177
179
|
state.currentBytes = afterBytes;
|
|
178
180
|
if (ref.record.kind !== "top-level") {
|
|
179
181
|
state.remainingCount -= 1;
|
|
@@ -181,9 +183,9 @@ function selectImageDrops(refs, bodyBytes, targetBytes) {
|
|
|
181
183
|
}
|
|
182
184
|
selected.add(index);
|
|
183
185
|
projectedBytes -= savings;
|
|
184
|
-
|
|
186
|
+
historicalImages -= 1;
|
|
185
187
|
}
|
|
186
|
-
return selected;
|
|
188
|
+
return { selected, historicalImages, projectedBytes };
|
|
187
189
|
}
|
|
188
190
|
|
|
189
191
|
function applyImageDrops(records, refs, selected) {
|
|
@@ -253,7 +255,7 @@ export function trimResponsesHistoryToByteBudget(reqBody, {
|
|
|
253
255
|
while (bodyBytes > limit) {
|
|
254
256
|
const { records, refs } = collectResponseImages(inputItems, currentInputStart);
|
|
255
257
|
if (!refs.length) break;
|
|
256
|
-
const selected = selectImageDrops(refs, bodyBytes, limit);
|
|
258
|
+
const { selected } = selectImageDrops(refs, bodyBytes, limit);
|
|
257
259
|
if (!selected.size) break;
|
|
258
260
|
applyImageDrops(records, refs, selected);
|
|
259
261
|
imagesOmitted += selected.size;
|
|
@@ -278,6 +280,62 @@ export function trimResponsesHistoryToByteBudget(reqBody, {
|
|
|
278
280
|
};
|
|
279
281
|
}
|
|
280
282
|
|
|
283
|
+
export function responsesHistoricalImageStats(inputItems, currentInputStart = 0) {
|
|
284
|
+
const items = Array.isArray(inputItems) ? inputItems : [];
|
|
285
|
+
const { refs } = collectResponseImages(items, currentInputStart);
|
|
286
|
+
const historicalImages = refs.reduce((count, ref) => count + (ref.historical ? 1 : 0), 0);
|
|
287
|
+
return {
|
|
288
|
+
totalImages: refs.length,
|
|
289
|
+
historicalImages,
|
|
290
|
+
currentImages: refs.length - historicalImages,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function trimResponsesHistoricalImages(reqBody, {
|
|
295
|
+
currentInputStart = 0,
|
|
296
|
+
maxHistoricalImages = Number.POSITIVE_INFINITY,
|
|
297
|
+
targetBytes = Number.POSITIVE_INFINITY,
|
|
298
|
+
initialBodyText,
|
|
299
|
+
initialBodyBytes,
|
|
300
|
+
} = {}) {
|
|
301
|
+
let bodyText = initialBodyText ?? JSON.stringify(reqBody);
|
|
302
|
+
let bodyBytes = initialBodyBytes ?? Buffer.byteLength(bodyText);
|
|
303
|
+
const byteLimit = Number.isFinite(targetBytes) && targetBytes > 0
|
|
304
|
+
? Math.floor(targetBytes)
|
|
305
|
+
: Number.POSITIVE_INFINITY;
|
|
306
|
+
const imageLimit = Number.isFinite(maxHistoricalImages) && maxHistoricalImages >= 0
|
|
307
|
+
? Math.floor(maxHistoricalImages)
|
|
308
|
+
: Number.POSITIVE_INFINITY;
|
|
309
|
+
const inputItems = Array.isArray(reqBody?.input) ? reqBody.input : [];
|
|
310
|
+
let imagesOmitted = 0;
|
|
311
|
+
|
|
312
|
+
while (true) {
|
|
313
|
+
const { records, refs } = collectResponseImages(inputItems, currentInputStart);
|
|
314
|
+
const historicalImages = refs.reduce((count, ref) => count + (ref.historical ? 1 : 0), 0);
|
|
315
|
+
if (bodyBytes <= byteLimit && historicalImages <= imageLimit) break;
|
|
316
|
+
if (!historicalImages) break;
|
|
317
|
+
const { selected } = selectImageDrops(refs, bodyBytes, byteLimit, imageLimit);
|
|
318
|
+
if (!selected.size) break;
|
|
319
|
+
applyImageDrops(records, refs, selected);
|
|
320
|
+
imagesOmitted += selected.size;
|
|
321
|
+
({ bodyText, bodyBytes } = bodyPayload(reqBody));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const stats = responsesHistoricalImageStats(inputItems, currentInputStart);
|
|
325
|
+
return {
|
|
326
|
+
bodyText,
|
|
327
|
+
bodyBytes,
|
|
328
|
+
targetBytes: byteLimit,
|
|
329
|
+
maxHistoricalImages: imageLimit,
|
|
330
|
+
historicalImages: stats.historicalImages,
|
|
331
|
+
currentImages: stats.currentImages,
|
|
332
|
+
imagesOmitted,
|
|
333
|
+
overBudget: bodyBytes > byteLimit,
|
|
334
|
+
overImageBudget: stats.historicalImages > imageLimit,
|
|
335
|
+
adapted: imagesOmitted > 0,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
281
339
|
export function enforceResponsesPayloadByteBudget(reqBody, payload) {
|
|
282
340
|
let finalized = payload;
|
|
283
341
|
if (payload?.overBudget) {
|
|
@@ -79,6 +79,7 @@ export function createResponsesHandler(options) {
|
|
|
79
79
|
autoReviewModelResolver,
|
|
80
80
|
chatCompletionsFn,
|
|
81
81
|
getCachedModelEndpointsFn = getCachedModelEndpoints,
|
|
82
|
+
imagePressure,
|
|
82
83
|
openAIModelEnv,
|
|
83
84
|
responsesPayloadOptions,
|
|
84
85
|
responsesFn,
|
|
@@ -90,14 +91,42 @@ export function createResponsesHandler(options) {
|
|
|
90
91
|
return async function handleResponses(req, res) {
|
|
91
92
|
const abort = createRequestAbort(req, res);
|
|
92
93
|
let releaseRequest = () => {};
|
|
94
|
+
let prepared = null;
|
|
95
|
+
let imagePressureResult = null;
|
|
96
|
+
let parsed = null;
|
|
97
|
+
const startUpstreamTimeout = () => abort.setTimeout(
|
|
98
|
+
parsed?.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
|
|
99
|
+
parsed?.stream ? "stream_handshake_timeout" : "upstream_timeout",
|
|
100
|
+
);
|
|
101
|
+
const applyAdaptiveTimeout = (error) => {
|
|
102
|
+
if (!["responses_prepare_timeout", "stream_handshake_timeout", "upstream_timeout"].includes(abort.reason)) {
|
|
103
|
+
return error;
|
|
104
|
+
}
|
|
105
|
+
const activated = imagePressure?.markTimeout?.(prepared?.historyRootId, {
|
|
106
|
+
eligible: imagePressureResult?.pressureEligible === true,
|
|
107
|
+
});
|
|
108
|
+
if (!activated) return error;
|
|
109
|
+
const message = "Responses timed out while processing a large visual history. Retry the request; ccdx will keep fewer earlier images automatically. Restart is not required.";
|
|
110
|
+
error.statusCode = 504;
|
|
111
|
+
error.jsonBody = {
|
|
112
|
+
error: {
|
|
113
|
+
message,
|
|
114
|
+
type: "timeout_error",
|
|
115
|
+
code: "ccdx_visual_history_timeout",
|
|
116
|
+
retryable: true,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
return error;
|
|
120
|
+
};
|
|
93
121
|
try {
|
|
94
122
|
releaseRequest = await acquireRequest(req, { signal: abort.signal });
|
|
95
|
-
|
|
96
|
-
abort.setTimeout(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
|
|
123
|
+
parsed = await readJsonBody(req);
|
|
124
|
+
abort.setTimeout(upstreamTimeoutMs, "responses_prepare_timeout");
|
|
125
|
+
prepared = prepareResponsesRequest(parsed, { mutate: true });
|
|
126
|
+
imagePressureResult = imagePressure?.apply?.(prepared) || null;
|
|
127
|
+
if (imagePressureResult?.adapted) {
|
|
128
|
+
console.warn(status("warn", `responses visual history mode=${imagePressureResult.mode} historical_images=${imagePressureResult.initialHistoricalImages}->${imagePressureResult.historicalImages} omitted=${imagePressureResult.imagesOmitted} bytes=${imagePressureResult.initialBodyBytes}->${imagePressureResult.bodyBytes}`));
|
|
129
|
+
}
|
|
101
130
|
prepared.surface = "responses";
|
|
102
131
|
const model = parsed.model || "unknown";
|
|
103
132
|
const { requestedModel, upstreamModel } = resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver);
|
|
@@ -110,12 +139,14 @@ export function createResponsesHandler(options) {
|
|
|
110
139
|
|| (usesCustomTools && cachedModelSupportsResponses(upstreamModel, getCachedModelEndpointsFn));
|
|
111
140
|
if (usesCustomTools && !useNativeResponses) throw unsupportedCustomToolsError(upstreamModel);
|
|
112
141
|
if (useNativeResponses) {
|
|
113
|
-
await proxyCopilotResponses(prepared, req, res, responsesFn, {
|
|
142
|
+
const result = await proxyCopilotResponses(prepared, req, res, responsesFn, {
|
|
114
143
|
signal: abort.signal,
|
|
115
144
|
abort,
|
|
145
|
+
onUpstreamStart: startUpstreamTimeout,
|
|
116
146
|
releaseRequest,
|
|
117
147
|
streamIdleTimeoutMs,
|
|
118
148
|
});
|
|
149
|
+
if (result?.successful) imagePressure?.markSuccess?.(prepared.historyRootId);
|
|
119
150
|
} else {
|
|
120
151
|
const chatPayload = await prepareResponsesChatPayload(prepared, {
|
|
121
152
|
payloadOptions: responsesPayloadOptions,
|
|
@@ -125,7 +156,7 @@ export function createResponsesHandler(options) {
|
|
|
125
156
|
const { chatReq, bodyText } = chatPayload;
|
|
126
157
|
if (parsed.stream) {
|
|
127
158
|
let streamResponseHeaders = null;
|
|
128
|
-
await forwardToChat(chatReq, async (event, data) => {
|
|
159
|
+
const successful = await forwardToChat(chatReq, async (event, data) => {
|
|
129
160
|
if (!res.headersSent) res.writeHead(200, {
|
|
130
161
|
...streamResponseHeaders,
|
|
131
162
|
"Cache-Control": "no-cache",
|
|
@@ -142,7 +173,7 @@ export function createResponsesHandler(options) {
|
|
|
142
173
|
sendUpstreamError(res, upstreamResponse, errMsg);
|
|
143
174
|
return;
|
|
144
175
|
}
|
|
145
|
-
const responseError = error || Object.assign(new Error(errMsg), { statusCode });
|
|
176
|
+
const responseError = applyAdaptiveTimeout(error || Object.assign(new Error(errMsg), { statusCode }));
|
|
146
177
|
sendJsonError(res, responseError, statusCode || 500);
|
|
147
178
|
return;
|
|
148
179
|
}
|
|
@@ -150,6 +181,7 @@ export function createResponsesHandler(options) {
|
|
|
150
181
|
}, {
|
|
151
182
|
signal: abort.signal,
|
|
152
183
|
abort,
|
|
184
|
+
onUpstreamStart: startUpstreamTimeout,
|
|
153
185
|
streamIdleTimeoutMs,
|
|
154
186
|
chatCompletionsFn,
|
|
155
187
|
releaseRequest,
|
|
@@ -160,9 +192,15 @@ export function createResponsesHandler(options) {
|
|
|
160
192
|
});
|
|
161
193
|
},
|
|
162
194
|
});
|
|
195
|
+
if (successful) imagePressure?.markSuccess?.(prepared.historyRootId);
|
|
163
196
|
} else {
|
|
164
197
|
try {
|
|
165
|
-
|
|
198
|
+
startUpstreamTimeout();
|
|
199
|
+
const upstream = await chatCompletionsFn(chatReq, {
|
|
200
|
+
signal: abort.signal,
|
|
201
|
+
bodyText,
|
|
202
|
+
onUpstreamStart: startUpstreamTimeout,
|
|
203
|
+
});
|
|
166
204
|
releaseRequest();
|
|
167
205
|
const data = await upstream.text();
|
|
168
206
|
if (!upstream.ok) {
|
|
@@ -171,20 +209,23 @@ export function createResponsesHandler(options) {
|
|
|
171
209
|
}
|
|
172
210
|
const response = chatToResponses(JSON.parse(data), model);
|
|
173
211
|
rememberResponseHistory(prepared, response);
|
|
212
|
+
imagePressure?.markSuccess?.(prepared.historyRootId);
|
|
174
213
|
recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response, event: response });
|
|
175
214
|
res.writeHead(200, safeUpstreamResponseHeaders(upstream.headers, {
|
|
176
215
|
contentType: "application/json",
|
|
177
216
|
}));
|
|
178
217
|
res.end(JSON.stringify(response));
|
|
179
218
|
} catch (error) {
|
|
180
|
-
|
|
181
|
-
|
|
219
|
+
const responseError = applyAdaptiveTimeout(error);
|
|
220
|
+
logRequestFailure("Responses", responseError, abort);
|
|
221
|
+
sendJsonError(res, responseError, 502);
|
|
182
222
|
}
|
|
183
223
|
}
|
|
184
224
|
}
|
|
185
225
|
} catch (error) {
|
|
186
|
-
|
|
187
|
-
|
|
226
|
+
const responseError = applyAdaptiveTimeout(error);
|
|
227
|
+
logRequestFailure("Responses", responseError, abort);
|
|
228
|
+
sendJsonError(res, responseError, 502);
|
|
188
229
|
} finally {
|
|
189
230
|
releaseRequest();
|
|
190
231
|
abort.cleanup();
|
|
@@ -196,6 +237,7 @@ export function createResponsesCompactHandler(options) {
|
|
|
196
237
|
const {
|
|
197
238
|
acquireRequest,
|
|
198
239
|
autoReviewModelResolver,
|
|
240
|
+
imagePressure,
|
|
199
241
|
openAIModelEnv,
|
|
200
242
|
responsesCompactFn,
|
|
201
243
|
upstreamTimeoutMs,
|
|
@@ -207,7 +249,7 @@ export function createResponsesCompactHandler(options) {
|
|
|
207
249
|
try {
|
|
208
250
|
releaseRequest = await acquireRequest(req, { signal: abort.signal });
|
|
209
251
|
const parsed = await readJsonBody(req);
|
|
210
|
-
abort.setTimeout(upstreamTimeoutMs, "
|
|
252
|
+
abort.setTimeout(upstreamTimeoutMs, "responses_prepare_timeout");
|
|
211
253
|
const prepared = prepareResponsesCompactionRequest(
|
|
212
254
|
prepareResponsesRequest(parsed, { mutate: true }),
|
|
213
255
|
);
|
|
@@ -217,11 +259,13 @@ export function createResponsesCompactHandler(options) {
|
|
|
217
259
|
if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
|
|
218
260
|
const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
|
|
219
261
|
console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=false`));
|
|
220
|
-
await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
|
|
262
|
+
const result = await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
|
|
221
263
|
signal: abort.signal,
|
|
222
264
|
abort,
|
|
265
|
+
onUpstreamStart: () => abort.setTimeout(upstreamTimeoutMs, "upstream_timeout"),
|
|
223
266
|
releaseRequest,
|
|
224
267
|
});
|
|
268
|
+
if (result?.compacted) imagePressure?.clear?.(prepared.historyRootId);
|
|
225
269
|
} catch (error) {
|
|
226
270
|
logRequestFailure("Responses compact", error, abort);
|
|
227
271
|
sendJsonError(res, error, 502);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
responsesHistoricalImageStats,
|
|
3
|
+
trimResponsesHistoricalImages,
|
|
4
|
+
} from "./responses-byte-budget.mjs";
|
|
5
|
+
|
|
6
|
+
const MIB = 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_RESPONSES_IMAGE_PRESSURE_POLICY = Object.freeze({
|
|
9
|
+
triggerHistoricalImages: 24,
|
|
10
|
+
triggerBodyBytes: 18 * MIB,
|
|
11
|
+
pressureHistoricalImages: 16,
|
|
12
|
+
pressureBodyBytes: 16 * MIB,
|
|
13
|
+
recoveryHistoricalImages: 8,
|
|
14
|
+
recoveryBodyBytes: 10 * MIB,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function normalizedPolicy(policy = {}) {
|
|
18
|
+
return { ...DEFAULT_RESPONSES_IMAGE_PRESSURE_POLICY, ...policy };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function bodyPayload(body) {
|
|
22
|
+
const bodyText = JSON.stringify(body);
|
|
23
|
+
return { bodyText, bodyBytes: Buffer.byteLength(bodyText) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function applyResponsesImagePressure(reqContext, {
|
|
27
|
+
mode = "normal",
|
|
28
|
+
policy: policyOverrides,
|
|
29
|
+
} = {}) {
|
|
30
|
+
const policy = normalizedPolicy(policyOverrides);
|
|
31
|
+
const body = reqContext?.body;
|
|
32
|
+
const currentInputStart = Number.isFinite(reqContext?.currentInputStart)
|
|
33
|
+
? reqContext.currentInputStart
|
|
34
|
+
: 0;
|
|
35
|
+
const initial = bodyPayload(body);
|
|
36
|
+
const initialImages = responsesHistoricalImageStats(body?.input, currentInputStart);
|
|
37
|
+
const pressureEligible = initialImages.historicalImages > 0 && (
|
|
38
|
+
initialImages.historicalImages > policy.triggerHistoricalImages
|
|
39
|
+
|| initial.bodyBytes > policy.triggerBodyBytes
|
|
40
|
+
);
|
|
41
|
+
const recovery = mode === "recovery";
|
|
42
|
+
const maxHistoricalImages = recovery
|
|
43
|
+
? policy.recoveryHistoricalImages
|
|
44
|
+
: policy.pressureHistoricalImages;
|
|
45
|
+
const targetBytes = recovery ? policy.recoveryBodyBytes : policy.pressureBodyBytes;
|
|
46
|
+
const shouldAdapt = initialImages.historicalImages > 0 && (recovery
|
|
47
|
+
? initialImages.historicalImages > maxHistoricalImages || initial.bodyBytes > targetBytes
|
|
48
|
+
: pressureEligible);
|
|
49
|
+
|
|
50
|
+
if (!shouldAdapt) {
|
|
51
|
+
return {
|
|
52
|
+
mode: "normal",
|
|
53
|
+
pressureEligible,
|
|
54
|
+
adapted: false,
|
|
55
|
+
imagesOmitted: 0,
|
|
56
|
+
initialBodyBytes: initial.bodyBytes,
|
|
57
|
+
bodyBytes: initial.bodyBytes,
|
|
58
|
+
initialHistoricalImages: initialImages.historicalImages,
|
|
59
|
+
historicalImages: initialImages.historicalImages,
|
|
60
|
+
currentImages: initialImages.currentImages,
|
|
61
|
+
overBudget: false,
|
|
62
|
+
overImageBudget: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const trimmed = trimResponsesHistoricalImages(body, {
|
|
67
|
+
currentInputStart,
|
|
68
|
+
maxHistoricalImages,
|
|
69
|
+
targetBytes,
|
|
70
|
+
initialBodyText: initial.bodyText,
|
|
71
|
+
initialBodyBytes: initial.bodyBytes,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
mode: recovery ? "recovery" : "pressure",
|
|
75
|
+
pressureEligible: pressureEligible || recovery,
|
|
76
|
+
initialBodyBytes: initial.bodyBytes,
|
|
77
|
+
initialHistoricalImages: initialImages.historicalImages,
|
|
78
|
+
...trimmed,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createResponsesImagePressureController({
|
|
83
|
+
now = Date.now,
|
|
84
|
+
maxEntries = 256,
|
|
85
|
+
recoveryTtlMs = 10 * 60 * 1000,
|
|
86
|
+
successesToClear = 2,
|
|
87
|
+
policy: policyOverrides,
|
|
88
|
+
} = {}) {
|
|
89
|
+
const policy = normalizedPolicy(policyOverrides);
|
|
90
|
+
const recovery = new Map();
|
|
91
|
+
const counters = {
|
|
92
|
+
adaptedRequests: 0,
|
|
93
|
+
imagesOmitted: 0,
|
|
94
|
+
recoveryActivations: 0,
|
|
95
|
+
timeoutsRecorded: 0,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const prune = () => {
|
|
99
|
+
const timestamp = now();
|
|
100
|
+
for (const [rootId, state] of recovery) {
|
|
101
|
+
if (state.expiresAt <= timestamp) recovery.delete(rootId);
|
|
102
|
+
}
|
|
103
|
+
while (recovery.size > maxEntries) recovery.delete(recovery.keys().next().value);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const modeFor = (rootId) => {
|
|
107
|
+
prune();
|
|
108
|
+
return rootId && recovery.has(rootId) ? "recovery" : "normal";
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
apply(reqContext) {
|
|
113
|
+
const result = applyResponsesImagePressure(reqContext, {
|
|
114
|
+
mode: modeFor(reqContext?.historyRootId),
|
|
115
|
+
policy,
|
|
116
|
+
});
|
|
117
|
+
if (result.adapted) {
|
|
118
|
+
counters.adaptedRequests += 1;
|
|
119
|
+
counters.imagesOmitted += result.imagesOmitted;
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
},
|
|
123
|
+
markTimeout(rootId, { eligible = false } = {}) {
|
|
124
|
+
if (!rootId || !eligible) return false;
|
|
125
|
+
prune();
|
|
126
|
+
const existing = recovery.get(rootId);
|
|
127
|
+
recovery.delete(rootId);
|
|
128
|
+
recovery.set(rootId, { expiresAt: now() + recoveryTtlMs, successes: 0 });
|
|
129
|
+
counters.timeoutsRecorded += 1;
|
|
130
|
+
if (!existing) counters.recoveryActivations += 1;
|
|
131
|
+
prune();
|
|
132
|
+
return true;
|
|
133
|
+
},
|
|
134
|
+
markSuccess(rootId) {
|
|
135
|
+
if (!rootId) return false;
|
|
136
|
+
prune();
|
|
137
|
+
const state = recovery.get(rootId);
|
|
138
|
+
if (!state) return false;
|
|
139
|
+
state.successes += 1;
|
|
140
|
+
if (state.successes >= successesToClear) {
|
|
141
|
+
recovery.delete(rootId);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
},
|
|
146
|
+
clear(rootId) {
|
|
147
|
+
return rootId ? recovery.delete(rootId) : false;
|
|
148
|
+
},
|
|
149
|
+
snapshot() {
|
|
150
|
+
prune();
|
|
151
|
+
return {
|
|
152
|
+
active_recovery_trees: recovery.size,
|
|
153
|
+
adapted_requests: counters.adaptedRequests,
|
|
154
|
+
historical_images_omitted: counters.imagesOmitted,
|
|
155
|
+
recovery_activations: counters.recoveryActivations,
|
|
156
|
+
timeouts_recorded: counters.timeoutsRecorded,
|
|
157
|
+
recovery_ttl_ms: recoveryTtlMs,
|
|
158
|
+
pressure_image_limit: policy.pressureHistoricalImages,
|
|
159
|
+
recovery_image_limit: policy.recoveryHistoricalImages,
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
package/src/responses-proxy.mjs
CHANGED
|
@@ -126,7 +126,7 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
126
126
|
reqContext = opened.reqContext;
|
|
127
127
|
if (errorText !== undefined) {
|
|
128
128
|
sendUpstreamError(res, resp, errorText);
|
|
129
|
-
return;
|
|
129
|
+
return { successful: false, compacted: false };
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
if (reqContext.body.stream) {
|
|
@@ -158,7 +158,7 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
158
158
|
if (!streamState.sawTerminal) throw incompleteUpstreamStream("a terminal Responses event");
|
|
159
159
|
storeCompletedResponse(reqContext, streamState.completed);
|
|
160
160
|
res.end();
|
|
161
|
-
return;
|
|
161
|
+
return { successful: Boolean(streamState.completed), compacted: false };
|
|
162
162
|
}
|
|
163
163
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
164
164
|
buffer += decoder.decode(value, { stream: true });
|
|
@@ -170,12 +170,13 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
170
170
|
if (streamState.sawTerminal) {
|
|
171
171
|
storeCompletedResponse(reqContext, streamState.completed);
|
|
172
172
|
res.end();
|
|
173
|
-
return;
|
|
173
|
+
return { successful: Boolean(streamState.completed), compacted: false };
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
} catch (e) {
|
|
177
177
|
logRequestFailure("Responses", e, options.abort);
|
|
178
178
|
await endStreamWithError(res, "responses", e, options.abort);
|
|
179
|
+
return { successful: false, compacted: false };
|
|
179
180
|
} finally {
|
|
180
181
|
await reader.cancel().catch(() => {});
|
|
181
182
|
reader.releaseLock();
|
|
@@ -199,7 +200,7 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
199
200
|
contentType: "application/json",
|
|
200
201
|
}));
|
|
201
202
|
res.end(JSON.stringify(response));
|
|
202
|
-
return;
|
|
203
|
+
return { successful: true, compacted: true };
|
|
203
204
|
}
|
|
204
205
|
res.writeHead(resp.status, safeUpstreamResponseHeaders(resp.headers, {
|
|
205
206
|
contentType: "application/json",
|
|
@@ -216,7 +217,9 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
216
217
|
response,
|
|
217
218
|
event: response,
|
|
218
219
|
});
|
|
220
|
+
return { successful: true, compacted: false };
|
|
219
221
|
} catch {}
|
|
220
222
|
}
|
|
223
|
+
return { successful: false, compacted: false };
|
|
221
224
|
}
|
|
222
225
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { responses as copilotResponses } from "./copilot.mjs";
|
|
2
2
|
import { httpError } from "./http-transport.mjs";
|
|
3
3
|
import { debugLog } from "./log.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
materializeResponseHistory,
|
|
6
|
+
rememberResponseHistoryNode,
|
|
7
|
+
responseHistoryRootId,
|
|
8
|
+
} from "./response-history.mjs";
|
|
5
9
|
import { enforceResponsesImageLimit } from "./responses-image-limit.mjs";
|
|
6
10
|
import { status } from "./status.mjs";
|
|
7
11
|
|
|
@@ -157,6 +161,7 @@ export async function openCopilotResponse(reqContext, upstream = copilotResponse
|
|
|
157
161
|
const resp = await upstream(reqContext.body, {
|
|
158
162
|
signal: options.signal,
|
|
159
163
|
currentInputStart: reqContext.currentInputStart,
|
|
164
|
+
onUpstreamStart: options.onUpstreamStart,
|
|
160
165
|
payloadPrepared,
|
|
161
166
|
});
|
|
162
167
|
payloadPrepared = true;
|
|
@@ -198,9 +203,11 @@ export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
|
|
|
198
203
|
);
|
|
199
204
|
const previousId = body.previous_response_id;
|
|
200
205
|
let historyItems = [];
|
|
206
|
+
let historyRootId = null;
|
|
201
207
|
|
|
202
208
|
if (previousId !== undefined && previousId !== null) {
|
|
203
209
|
historyItems = materializeResponseHistory(previousId);
|
|
210
|
+
historyRootId = responseHistoryRootId(previousId);
|
|
204
211
|
body.input = [...historyItems, ...currentInputItems];
|
|
205
212
|
} else {
|
|
206
213
|
body.input = currentInputItems;
|
|
@@ -232,6 +239,7 @@ export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
|
|
|
232
239
|
inputItems: body.input,
|
|
233
240
|
currentInputStart: retainedCurrentInputStart < 0 ? body.input.length : retainedCurrentInputStart,
|
|
234
241
|
historyParentId: previousId ?? null,
|
|
242
|
+
historyRootId,
|
|
235
243
|
historyInputItems,
|
|
236
244
|
takeHistoryOwnership: mutate,
|
|
237
245
|
};
|