codex-copilot-dx 0.7.10 → 0.8.0
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 +5 -3
- package/package.json +1 -1
- package/src/cli-image.mjs +12 -3
- package/src/codex-config-doctor.mjs +8 -3
- package/src/config-toml.mjs +147 -0
- package/src/config.mjs +74 -89
- package/src/copilot-responses-compat.mjs +4 -0
- package/src/image-mcp.mjs +11 -0
- package/src/image-provider-config.mjs +4 -1
- package/src/image-provider.mjs +45 -37
- package/src/responses-handler.mjs +38 -0
- package/src/responses-proxy.mjs +55 -22
- package/src/responses-request.mjs +3 -1
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ Normal startup and reuse of an existing adapter check `~/.codex/config.toml` onc
|
|
|
76
76
|
| `[features]` `context_management` | Add `true` when missing; preserve explicit values and alternative declarations as described above | `true` |
|
|
77
77
|
| `[mcp_servers.ccdx_image]` | Added in a marked CCDX-owned block only after `ccdx enable-image`; removed by `ccdx disable-image` | Absent by default |
|
|
78
78
|
|
|
79
|
-
The two token limits are fixed defaults, not detected model capabilities. Existing custom values are retained. Writes target the listed keys with
|
|
79
|
+
The two token limits are fixed defaults, not detected model capabilities. Existing custom values are retained. Writes target the listed keys with format-preserving edits and atomic replacement; this is not a general TOML formatter. Quoted keys, quoted table headers, and explicit dotted shell-environment declarations are recognized; inline shell-environment tables are left intact. Startup and image configuration commands validate UTF-8/TOML and verify that unrelated settings retain their values before writing. An unsafe edit leaves the original file unchanged. Use `ccdx doctor config` to inspect parsed values and preview what this writer would do with the current file. No actual change means no file rewrite.
|
|
80
80
|
|
|
81
81
|
## Version 0.7.0 migration
|
|
82
82
|
|
|
@@ -145,7 +145,7 @@ Image generation is disabled by default. Configure it interactively only when yo
|
|
|
145
145
|
ccdx enable-image
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
-
The command asks for an HTTPS `/images/generations` endpoint and an API key without echoing the key. It reads the endpoint's `/models` catalog
|
|
148
|
+
The command asks for an HTTPS `/images/generations` endpoint and an API key without echoing the key. It reads the endpoint's `/models` catalog and asks for a numeric model selection only when multiple image models are available. It then detects either the standard OpenAI Images request format or the supported `input.messages` format for the selected model only. Its single validation request omits the prompt and does not generate an image.
|
|
149
149
|
|
|
150
150
|
The provider credential is stored in `~/.config/codex-copilot-dx/image-provider.json`, or under `XDG_CONFIG_HOME`, with mode `0600`. The API key is never written to `~/.codex/config.toml`; that file receives only a marked local MCP entry pointing to the running CCDX adapter with a 210-second tool timeout. Enabling also installs CCDX-owned `ccdx-image` guidance under `~/.codex/skills`, with a self-contained Node helper. Codex uses the MCP tool directly, including through deferred tool search; an already-open task without that tool can use the helper to call the same local service and display the saved image. Neither route requires Python, an OpenAI SDK, or copying credentials. The original built-in imagegen skill remains available for capabilities such as editing uploaded images and other reference-image workflows.
|
|
151
151
|
|
|
@@ -160,7 +160,7 @@ ccdx disable-image
|
|
|
160
160
|
|
|
161
161
|
`image-status` never prints the API key and performs only a local, non-generating tool-readiness check. Disabling removes the stored provider credential, the CCDX-owned MCP block, and unmodified CCDX image guidance; user-modified skill files are preserved. Cached tool calls then fail closed. The Node helper creates a new file under the task's `output/imagegen` directory by default and reports its absolute path; it never overwrites an existing file or automatically repeats a failed generation. The image endpoint is contacted only by `enable-image` validation and by explicit `generate_image` tool calls. Ordinary Responses, models, authentication, Fast, Auto-review, compaction, and image-input paths remain on their existing routes.
|
|
162
162
|
|
|
163
|
-
Generated image downloads must use public HTTPS addresses, do not follow redirects, are DNS-checked against private and reserved addresses, and are capped at 32 MiB. The local MCP route accepts calls only from the same device and permits at most two concurrent image operations. The tool supports new-image generation at `1024x1024`, `1536x1024`, or `1024x1536`.
|
|
163
|
+
Generated image downloads must use public HTTPS addresses, do not follow redirects, are DNS-checked against private and reserved addresses, and are capped at 32 MiB. DNS resolution and downloading share one deadline and respond to cancellation; a late DNS result cannot start a cancelled download. The local MCP route accepts calls only from the same device and permits at most two concurrent image operations. It requires `application/json` and rejects browser `Origin` headers; native Codex and Node clients do not send an Origin. The tool supports new-image generation at `1024x1024`, `1536x1024`, or `1024x1536`.
|
|
164
164
|
|
|
165
165
|
With `qwen-image-3.0-pro` or `qwen-image-3.0` using either supported protocol, CCDX also exposes `edit_image`. Ask Codex to modify a specific CCDX-generated image; it passes that image's returned `image_id` and the edit instruction to the same configured provider. Each edit returns a new image and ID while preserving the original; omitting `size` retains the source's requested dimensions. The Qwen request contains the source image again on every edit, as required by its [single-turn image editing API](https://help.aliyun.com/zh/model-studio/qwen-image-generation-and-editing-api-reference).
|
|
166
166
|
|
|
@@ -353,6 +353,8 @@ For `/v1/responses/compact`, CCDX sends one terminal `compaction_trigger`, valid
|
|
|
353
353
|
|
|
354
354
|
Changing the model or transport of a continuation can require removing incompatible encrypted history from the forwarded request. A specific upstream encrypted-state verification failure can also trigger one sanitized retry, including when a nominally successful HTTP response ends with `response.failed`. CCDX withholds only the pre-output prelude of an encrypted continuation while evaluating that terminal event; it never retries after visible model output, so it cannot duplicate an already-started answer. Unrelated failures pass through unchanged. CCDX does not decrypt that state locally.
|
|
355
355
|
|
|
356
|
+
After the stream handshake succeeds, stream liveness uses the idle timeout rather than the expired handshake deadline. Eligible HTTP-200 failure recovery acquires a bounded preparation budget and history admission before rebuilding its payload, then releases those reservations when the retry opens upstream or fails. Normal streams still release preparation admission after their response headers arrive.
|
|
357
|
+
|
|
356
358
|
Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Allowlisted rate-limit, retry, model, trace, and upstream request-ID metadata are forwarded; cookies, authorization, body-length, and encoding headers are not.
|
|
357
359
|
|
|
358
360
|
CCDX keeps each native Responses message on its first upstream ID when Copilot changes that ID during streaming. Message deltas, completion events, and cached history stay consistent, addressing this cause of duplicate live messages in Codex App. Tool identities, reasoning/encrypted content, message phases, and speed-tier routing are unchanged by this ID normalization. Stable events retain their original SSE bytes; changed events are rewritten with bounded, backpressured streaming.
|
package/package.json
CHANGED
package/src/cli-image.mjs
CHANGED
|
@@ -64,9 +64,12 @@ export function hiddenQuestion(input, output, question) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function fileContent(filePath) {
|
|
67
|
-
try { return fs.readFileSync(filePath
|
|
67
|
+
try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(filePath)); }
|
|
68
68
|
catch (error) {
|
|
69
69
|
if (error?.code === "ENOENT") return "";
|
|
70
|
+
if (error?.code === "ERR_ENCODING_INVALID_ENCODED_DATA") {
|
|
71
|
+
throw new Error("Codex configuration is not valid UTF-8 TOML; no configuration changes were written");
|
|
72
|
+
}
|
|
70
73
|
throw error;
|
|
71
74
|
}
|
|
72
75
|
}
|
|
@@ -206,8 +209,14 @@ export async function runImageCommand({
|
|
|
206
209
|
if (!apiKey) throw new Error("Image API key is required");
|
|
207
210
|
|
|
208
211
|
output.write("Checking image API...\n");
|
|
209
|
-
|
|
210
|
-
const
|
|
212
|
+
let model;
|
|
213
|
+
const inspected = await inspectImageProvider({
|
|
214
|
+
endpoint, apiKey, fetchImpl,
|
|
215
|
+
selectModel: async (modelIds) => {
|
|
216
|
+
model = await chooseModel(modelIds, current?.model, ask, output);
|
|
217
|
+
return model;
|
|
218
|
+
},
|
|
219
|
+
});
|
|
211
220
|
const result = enableImageProvider({
|
|
212
221
|
providerConfig: {
|
|
213
222
|
endpoint: inspected.endpoint,
|
|
@@ -51,9 +51,14 @@ function unmanagedSettings(config) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function startupPreview(content, config, host, port) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
let updated;
|
|
55
|
+
try {
|
|
56
|
+
updated = content === null
|
|
57
|
+
? { content: initialCodexConfig(port, host), changed: true }
|
|
58
|
+
: computeUpdatedCodexConfig(content, port, host);
|
|
59
|
+
} catch {
|
|
60
|
+
return { kind: "err", message: "Startup configuration cannot be edited safely; review the file before starting ccdx" };
|
|
61
|
+
}
|
|
57
62
|
if (!updated.changed) return { kind: "ok", message: "No startup configuration changes required" };
|
|
58
63
|
let next;
|
|
59
64
|
try {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { parse } from "smol-toml";
|
|
3
|
+
|
|
4
|
+
export function parseCodexToml(content) {
|
|
5
|
+
try {
|
|
6
|
+
return parse(content, { integersAsBigInt: true });
|
|
7
|
+
} catch {
|
|
8
|
+
// TOML parser errors include source snippets, which may contain credentials.
|
|
9
|
+
throw new Error("Codex configuration is not valid TOML; no configuration changes were written");
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function configValue(config, keys) {
|
|
14
|
+
return keys.reduce((value, key) => (
|
|
15
|
+
value && Object.hasOwn(value, key) ? value[key] : undefined
|
|
16
|
+
), config);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function keyParts(source) {
|
|
20
|
+
let value = parseCodexToml(`${source} = 0`);
|
|
21
|
+
const keys = [];
|
|
22
|
+
while (value && typeof value === "object") {
|
|
23
|
+
const [key] = Object.keys(value);
|
|
24
|
+
keys.push(key);
|
|
25
|
+
value = value[key];
|
|
26
|
+
}
|
|
27
|
+
return keys;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Locate complete declarations without interpreting value syntax. The TOML parser
|
|
31
|
+
// remains authoritative; this scanner only supplies spans for surgical edits.
|
|
32
|
+
export function codexTomlStatements(content) {
|
|
33
|
+
const statements = [];
|
|
34
|
+
let section = [];
|
|
35
|
+
let offset = 0;
|
|
36
|
+
let line = 0;
|
|
37
|
+
while (offset < content.length) {
|
|
38
|
+
const char = content[offset];
|
|
39
|
+
if (/\s/.test(char)) {
|
|
40
|
+
if (char === "\n") line += 1;
|
|
41
|
+
offset += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const start = offset;
|
|
45
|
+
const startLine = line;
|
|
46
|
+
if (char === "#" || char === "[") {
|
|
47
|
+
const end = content.indexOf("\n", offset);
|
|
48
|
+
offset = end < 0 ? content.length : end;
|
|
49
|
+
const source = content.slice(start, offset).trimEnd();
|
|
50
|
+
if (char === "#") statements.push({ kind: "comment", start, end: offset, line: startLine, source });
|
|
51
|
+
else {
|
|
52
|
+
const brackets = source.startsWith("[[") ? 2 : 1;
|
|
53
|
+
let quote = "";
|
|
54
|
+
let close = brackets;
|
|
55
|
+
for (; close < source.length; close += 1) {
|
|
56
|
+
const next = source[close];
|
|
57
|
+
if (quote === '"' && next === "\\") { close += 1; continue; }
|
|
58
|
+
if (quote) { if (next === quote) quote = ""; continue; }
|
|
59
|
+
if (next === '"' || next === "'") quote = next;
|
|
60
|
+
else if (next === "]") break;
|
|
61
|
+
}
|
|
62
|
+
section = keyParts(source.slice(brackets, close));
|
|
63
|
+
statements.push({ kind: "table", keys: section, start, end: offset, line: startLine });
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let quote = "";
|
|
69
|
+
for (; offset < content.length; offset += 1) {
|
|
70
|
+
const next = content[offset];
|
|
71
|
+
if (quote === '"' && next === "\\") { offset += 1; continue; }
|
|
72
|
+
if (quote) { if (next === quote) quote = ""; continue; }
|
|
73
|
+
if (next === '"' || next === "'") quote = next;
|
|
74
|
+
else if (next === "=") break;
|
|
75
|
+
}
|
|
76
|
+
const keys = [...section, ...keyParts(content.slice(start, offset).trim())];
|
|
77
|
+
offset += 1;
|
|
78
|
+
while (content[offset] === " " || content[offset] === "\t") offset += 1;
|
|
79
|
+
const valueStart = offset;
|
|
80
|
+
let depth = 0;
|
|
81
|
+
let multiline = false;
|
|
82
|
+
for (; offset < content.length; offset += 1) {
|
|
83
|
+
const next = content[offset];
|
|
84
|
+
if (next === "\n") line += 1;
|
|
85
|
+
if (quote) {
|
|
86
|
+
if (quote === '"' && next === "\\") {
|
|
87
|
+
if (content[offset + 1] === "\n") line += 1;
|
|
88
|
+
offset += 1;
|
|
89
|
+
} else if (next === quote) {
|
|
90
|
+
if (!multiline) quote = "";
|
|
91
|
+
else if (content.startsWith(quote.repeat(3), offset)) {
|
|
92
|
+
while (content[offset + 1] === quote) offset += 1;
|
|
93
|
+
quote = "";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (next === '"' || next === "'") {
|
|
99
|
+
quote = next;
|
|
100
|
+
multiline = content.startsWith(next.repeat(3), offset);
|
|
101
|
+
if (multiline) offset += 2;
|
|
102
|
+
} else if (next === "[" || next === "{") depth += 1;
|
|
103
|
+
else if (next === "]" || next === "}") depth -= 1;
|
|
104
|
+
else if (next === "#") {
|
|
105
|
+
if (depth === 0) break;
|
|
106
|
+
const end = content.indexOf("\n", offset);
|
|
107
|
+
offset = end < 0 ? content.length : end - 1;
|
|
108
|
+
} else if (next === "\n" && depth === 0) {
|
|
109
|
+
line -= 1;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const valueEnd = valueStart + content.slice(valueStart, offset).trimEnd().length;
|
|
114
|
+
statements.push({ kind: "value", keys, section, start, end: offset, line: startLine, valueStart, valueEnd });
|
|
115
|
+
}
|
|
116
|
+
return statements;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function omitManaged(config, paths) {
|
|
120
|
+
const result = structuredClone(config);
|
|
121
|
+
for (const keys of paths) {
|
|
122
|
+
const parents = [];
|
|
123
|
+
let value = result;
|
|
124
|
+
for (const key of keys) {
|
|
125
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, key)) break;
|
|
126
|
+
parents.push([value, key]);
|
|
127
|
+
value = value[key];
|
|
128
|
+
}
|
|
129
|
+
if (parents.length === keys.length) {
|
|
130
|
+
const [parent, leaf] = parents.pop();
|
|
131
|
+
delete parent[leaf];
|
|
132
|
+
}
|
|
133
|
+
while (parents.length) {
|
|
134
|
+
const [owner, key] = parents.pop();
|
|
135
|
+
if (!owner[key] || typeof owner[key] !== "object" || Array.isArray(owner[key]) || Object.keys(owner[key]).length) break;
|
|
136
|
+
delete owner[key];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function validateManagedConfigEdit(before, content, managedPaths) {
|
|
143
|
+
const after = parseCodexToml(content);
|
|
144
|
+
if (!isDeepStrictEqual(omitManaged(before, managedPaths), omitManaged(after, managedPaths))) {
|
|
145
|
+
throw new Error("Unsafe Codex configuration edit outside CCDX's managed keys; no configuration changes were written");
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import { status } from "./status.mjs";
|
|
5
5
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
6
6
|
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
7
|
+
import { codexTomlStatements, configValue, parseCodexToml, validateManagedConfigEdit } from "./config-toml.mjs";
|
|
7
8
|
|
|
8
9
|
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
|
9
10
|
const MODEL_CONTEXT_WINDOW = 1_000_000;
|
|
@@ -11,99 +12,75 @@ const MODEL_AUTO_COMPACT_TOKEN_LIMIT = 900_000;
|
|
|
11
12
|
const IMAGE_MCP_START = "# ccdx:image-mcp:start";
|
|
12
13
|
const IMAGE_MCP_END = "# ccdx:image-mcp:end";
|
|
13
14
|
const IMAGE_MCP_SECTION = "mcp_servers.ccdx_image";
|
|
15
|
+
const STARTUP_MANAGED_PATHS = [
|
|
16
|
+
"openai_base_url",
|
|
17
|
+
"model_context_window",
|
|
18
|
+
"model_auto_compact_token_limit",
|
|
19
|
+
"shell_environment_policy.set.OPENAI_BASE_URL",
|
|
20
|
+
"shell_environment_policy.set.OPENAI_API_KEY",
|
|
21
|
+
"features.context_management",
|
|
22
|
+
].map((key) => key.split("."));
|
|
23
|
+
const IMAGE_MANAGED_PATH = ["mcp_servers", "ccdx_image"];
|
|
24
|
+
|
|
25
|
+
function sameKeys(left, right) {
|
|
26
|
+
return left.length === right.length && left.every((key, index) => key === right[index]);
|
|
27
|
+
}
|
|
14
28
|
|
|
15
|
-
function
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const char = source[index];
|
|
24
|
-
if (quote) {
|
|
25
|
-
if (quote === '"' && char === "\\" && !escaped) {
|
|
26
|
-
escaped = true;
|
|
27
|
-
continue;
|
|
28
|
-
}
|
|
29
|
-
if (char === quote && !escaped) quote = "";
|
|
30
|
-
escaped = false;
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
if (char === '"' || char === "'") {
|
|
34
|
-
quote = char;
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
if (char !== "]" || (openingBrackets === 2 && source[index + 1] !== "]")) continue;
|
|
38
|
-
|
|
39
|
-
if (!source.slice(openingBrackets, index).trim()) return false;
|
|
40
|
-
const tail = source.slice(index + openingBrackets).trim();
|
|
41
|
-
return tail === "" || tail.startsWith("#");
|
|
42
|
-
}
|
|
43
|
-
return false;
|
|
29
|
+
function setExistingTomlValue(lines, keys, value) {
|
|
30
|
+
const content = lines.join("\n");
|
|
31
|
+
if (configValue(parseCodexToml(content), keys) === value) return false;
|
|
32
|
+
const entry = codexTomlStatements(content).find((item) => item.kind === "value" && sameKeys(item.keys, keys));
|
|
33
|
+
if (!entry) return false;
|
|
34
|
+
const updated = content.slice(0, entry.valueStart) + JSON.stringify(value) + content.slice(entry.valueEnd);
|
|
35
|
+
lines.splice(0, lines.length, ...updated.split("\n"));
|
|
36
|
+
return true;
|
|
44
37
|
}
|
|
45
38
|
|
|
46
39
|
function setTopLevelTomlDefault(lines, key, value) {
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
const keyRe = new RegExp(`^\\s*${key}\\s*=`);
|
|
51
|
-
if (lines.slice(0, end).some((line) => keyRe.test(line))) return false;
|
|
52
|
-
|
|
40
|
+
const content = lines.join("\n");
|
|
41
|
+
if (Object.hasOwn(parseCodexToml(content), key)) return false;
|
|
42
|
+
let end = codexTomlStatements(content).find((item) => item.kind === "table")?.line ?? lines.length;
|
|
53
43
|
while (end > 0 && lines[end - 1].trim() === "") end--;
|
|
54
44
|
lines.splice(end, 0, `${key} = ${value}`);
|
|
55
45
|
return true;
|
|
56
46
|
}
|
|
57
47
|
|
|
58
48
|
function setTomlKey(lines, sectionName, key, value) {
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
let end = lines.length;
|
|
64
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
65
|
-
if (isTomlTableHeader(lines[i])) {
|
|
66
|
-
end = i;
|
|
67
|
-
break;
|
|
68
|
-
}
|
|
49
|
+
const keys = [...sectionName.split("."), key];
|
|
50
|
+
const content = lines.join("\n");
|
|
51
|
+
if (configValue(parseCodexToml(content), keys) !== undefined) {
|
|
52
|
+
return setExistingTomlValue(lines, keys, value);
|
|
69
53
|
}
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
54
|
+
const statements = codexTomlStatements(content);
|
|
55
|
+
const parent = keys.slice(0, -1);
|
|
56
|
+
const table = statements.find((item) => item.kind === "table" && sameKeys(item.keys, parent));
|
|
57
|
+
const dotted = statements.find((item) => item.kind === "value"
|
|
58
|
+
&& item.keys.length > parent.length && sameKeys(item.keys.slice(0, parent.length), parent));
|
|
59
|
+
const anchor = table || dotted;
|
|
60
|
+
// Preserve absent and inline environment tables; only edit declarations whose
|
|
61
|
+
// exact value or insertion scope can be identified without rewriting them.
|
|
62
|
+
if (!anchor) return false;
|
|
63
|
+
const scope = table ? table.keys : dotted.section;
|
|
64
|
+
const nextKey = keys.slice(scope.length).join(".");
|
|
65
|
+
const end = statements.find((item) => item.kind === "table" && item.start > anchor.start)?.line ?? lines.length;
|
|
66
|
+
lines.splice(end, 0, `${nextKey} = ${JSON.stringify(value)}`);
|
|
82
67
|
return true;
|
|
83
68
|
}
|
|
84
69
|
|
|
85
70
|
function ensureContextManagementDefault(lines) {
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
const start = lines.findIndex((line) => /^\s*\[\s*(?:features|"features"|'features')\s*\]\s*(?:#.*)?$/.test(line));
|
|
95
|
-
if (start === -1) {
|
|
71
|
+
const content = lines.join("\n");
|
|
72
|
+
const config = parseCodexToml(content);
|
|
73
|
+
if (configValue(config, ["features", "context_management"]) !== undefined) return false;
|
|
74
|
+
const statements = codexTomlStatements(content);
|
|
75
|
+
const table = statements.find((item) => item.kind === "table" && sameKeys(item.keys, ["features"]));
|
|
76
|
+
if (!table) {
|
|
77
|
+
if (Object.hasOwn(config, "features")) return false;
|
|
96
78
|
if (lines.length && lines.at(-1).trim() !== "") lines.push("");
|
|
97
79
|
lines.push("[features]", "context_management = true");
|
|
98
80
|
return true;
|
|
99
81
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
while (end < lines.length && !isTomlTableHeader(lines[end])) end += 1;
|
|
103
|
-
if (lines.slice(start + 1, end).some((line) => /^\s*(?:context_management|"context_management"|'context_management')\s*(?:=|\.)/.test(line))) {
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
while (end > start + 1 && lines[end - 1].trim() === "") end -= 1;
|
|
82
|
+
let end = statements.find((item) => item.kind === "table" && item.start > table.start)?.line ?? lines.length;
|
|
83
|
+
while (end > table.line + 1 && lines[end - 1].trim() === "") end -= 1;
|
|
107
84
|
lines.splice(end, 0, "context_management = true");
|
|
108
85
|
return true;
|
|
109
86
|
}
|
|
@@ -116,8 +93,9 @@ function imageMcpHost(adapterHost) {
|
|
|
116
93
|
}
|
|
117
94
|
|
|
118
95
|
function removeManagedImageMcp(lines) {
|
|
119
|
-
const
|
|
120
|
-
const
|
|
96
|
+
const comments = codexTomlStatements(lines.join("\n")).filter((item) => item.kind === "comment");
|
|
97
|
+
const starts = comments.filter((item) => lines[item.line] === IMAGE_MCP_START).map((item) => item.line);
|
|
98
|
+
const ends = comments.filter((item) => lines[item.line] === IMAGE_MCP_END).map((item) => item.line);
|
|
121
99
|
let start = starts[0] ?? -1;
|
|
122
100
|
const end = ends[0] ?? -1;
|
|
123
101
|
if (start === -1 && end === -1) return false;
|
|
@@ -136,7 +114,7 @@ function removeManagedImageMcp(lines) {
|
|
|
136
114
|
}
|
|
137
115
|
|
|
138
116
|
function hasUnmanagedImageMcp(lines) {
|
|
139
|
-
return lines.
|
|
117
|
+
return configValue(parseCodexToml(lines.join("\n")), IMAGE_MANAGED_PATH) !== undefined;
|
|
140
118
|
}
|
|
141
119
|
|
|
142
120
|
function ensureImageMcp(lines, enabled, adapterPort, adapterHost) {
|
|
@@ -167,6 +145,7 @@ export function computeUpdatedCodexConfig(
|
|
|
167
145
|
adapterHost = "127.0.0.1",
|
|
168
146
|
{ imageProviderEnabled } = {},
|
|
169
147
|
) {
|
|
148
|
+
const config = parseCodexToml(content);
|
|
170
149
|
const baseUrl = `${adapterBaseUrl(adapterHost, adapterPort)}/v1`;
|
|
171
150
|
const hadTrailingNewline = content.endsWith("\n");
|
|
172
151
|
const lines = content.split("\n");
|
|
@@ -174,17 +153,11 @@ export function computeUpdatedCodexConfig(
|
|
|
174
153
|
|
|
175
154
|
let changed = false;
|
|
176
155
|
const openaiLine = `openai_base_url = "${baseUrl}"`;
|
|
177
|
-
|
|
178
|
-
const topLevelEnd = firstSection === -1 ? lines.length : firstSection;
|
|
179
|
-
const openaiIndex = lines.findIndex((line, index) => (
|
|
180
|
-
index < topLevelEnd && /^\s*openai_base_url\s*=/.test(line)
|
|
181
|
-
));
|
|
182
|
-
if (openaiIndex === -1) {
|
|
156
|
+
if (!Object.hasOwn(config, "openai_base_url")) {
|
|
183
157
|
lines.unshift(openaiLine);
|
|
184
158
|
changed = true;
|
|
185
|
-
} else
|
|
186
|
-
lines[
|
|
187
|
-
changed = true;
|
|
159
|
+
} else {
|
|
160
|
+
changed = setExistingTomlValue(lines, ["openai_base_url"], baseUrl);
|
|
188
161
|
}
|
|
189
162
|
|
|
190
163
|
changed = setTopLevelTomlDefault(lines, "model_context_window", MODEL_CONTEXT_WINDOW) || changed;
|
|
@@ -196,7 +169,10 @@ export function computeUpdatedCodexConfig(
|
|
|
196
169
|
changed = ensureImageMcp(lines, imageProviderEnabled, adapterPort, adapterHost) || changed;
|
|
197
170
|
}
|
|
198
171
|
|
|
199
|
-
|
|
172
|
+
const updated = lines.join("\n") + (hadTrailingNewline ? "\n" : "");
|
|
173
|
+
validateManagedConfigEdit(config, updated, typeof imageProviderEnabled === "boolean"
|
|
174
|
+
? [...STARTUP_MANAGED_PATHS, IMAGE_MANAGED_PATH] : STARTUP_MANAGED_PATHS);
|
|
175
|
+
return { content: updated, changed };
|
|
200
176
|
}
|
|
201
177
|
|
|
202
178
|
export function initialCodexConfig(adapterPort, adapterHost, { imageProviderEnabled = false } = {}) {
|
|
@@ -224,11 +200,14 @@ export function computeImageMcpCodexConfig(
|
|
|
224
200
|
content,
|
|
225
201
|
{ enabled, adapterPort = 2026, adapterHost = "127.0.0.1" } = {},
|
|
226
202
|
) {
|
|
203
|
+
const config = parseCodexToml(content);
|
|
227
204
|
const hadTrailingNewline = content.endsWith("\n");
|
|
228
205
|
const lines = content.split("\n");
|
|
229
206
|
if (hadTrailingNewline) lines.pop();
|
|
230
207
|
const changed = ensureImageMcp(lines, Boolean(enabled), adapterPort, adapterHost);
|
|
231
|
-
|
|
208
|
+
const updated = lines.join("\n") + (hadTrailingNewline || lines.length ? "\n" : "");
|
|
209
|
+
validateManagedConfigEdit(config, updated, [IMAGE_MANAGED_PATH]);
|
|
210
|
+
return { content: updated, changed };
|
|
232
211
|
}
|
|
233
212
|
|
|
234
213
|
export function ensureCodexConfig(adapterPort = 2026, {
|
|
@@ -245,7 +224,13 @@ export function ensureCodexConfig(adapterPort = 2026, {
|
|
|
245
224
|
return;
|
|
246
225
|
}
|
|
247
226
|
|
|
248
|
-
|
|
227
|
+
let content;
|
|
228
|
+
try {
|
|
229
|
+
content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(filePath));
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error?.code !== "ERR_ENCODING_INVALID_ENCODED_DATA") throw error;
|
|
232
|
+
throw new Error("Codex configuration is not valid UTF-8 TOML; no configuration changes were written");
|
|
233
|
+
}
|
|
249
234
|
const updated = computeUpdatedCodexConfig(content, adapterPort, host, { imageProviderEnabled });
|
|
250
235
|
|
|
251
236
|
if (!updated.changed) {
|
|
@@ -33,6 +33,10 @@ const RETRY_POLICIES = Object.freeze([
|
|
|
33
33
|
}),
|
|
34
34
|
]);
|
|
35
35
|
|
|
36
|
+
export function hasCopilotResponseRetryPolicy(statusCode, errorText, usedPolicies = new Set()) {
|
|
37
|
+
return RETRY_POLICIES.some((policy) => !usedPolicies.has(policy.id) && policy.matches(statusCode, errorText));
|
|
38
|
+
}
|
|
39
|
+
|
|
36
40
|
export function selectCopilotResponseRetry(
|
|
37
41
|
reqContext,
|
|
38
42
|
statusCode,
|
package/src/image-mcp.mjs
CHANGED
|
@@ -113,6 +113,17 @@ export function createImageMcpHandler({
|
|
|
113
113
|
writeJson(res, 405, jsonRpcError(null, -32600, "Only POST is supported"));
|
|
114
114
|
return;
|
|
115
115
|
}
|
|
116
|
+
// This endpoint serves native Codex/Node clients, not browser pages. A
|
|
117
|
+
// loopback socket alone does not make a browser-supplied Origin trusted.
|
|
118
|
+
if (Object.hasOwn(req.headers || {}, "origin")) {
|
|
119
|
+
writeJson(res, 403, jsonRpcError(null, -32001, "Browser origins are not allowed for Image MCP"));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const mediaType = String(req.headers?.["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
|
|
123
|
+
if (mediaType !== "application/json") {
|
|
124
|
+
writeJson(res, 415, jsonRpcError(null, -32600, "Image MCP requires application/json"));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
116
127
|
|
|
117
128
|
let request;
|
|
118
129
|
try { request = await readBody(req); }
|
|
@@ -170,6 +170,7 @@ export async function inspectImageProvider({
|
|
|
170
170
|
apiKey,
|
|
171
171
|
fetchImpl = fetch,
|
|
172
172
|
timeoutMs = IMAGE_PROVIDER_TIMEOUT_MS,
|
|
173
|
+
selectModel,
|
|
173
174
|
} = {}) {
|
|
174
175
|
const normalizedEndpoint = normalizeImageEndpoint(endpoint);
|
|
175
176
|
const normalizedKey = checkedApiKey(apiKey);
|
|
@@ -178,11 +179,13 @@ export async function inspectImageProvider({
|
|
|
178
179
|
if (!models.response.ok) throw new Error(`Image API model lookup failed with HTTP ${models.response.status}`);
|
|
179
180
|
const modelIds = imageModelIds(models.body);
|
|
180
181
|
if (!modelIds.length) throw new Error("Image API model lookup returned no image models");
|
|
182
|
+
const model = selectModel ? await selectModel([...modelIds]) : modelIds[0];
|
|
183
|
+
if (!modelIds.includes(model)) throw new Error("Selected image model is not in the provider catalog");
|
|
181
184
|
|
|
182
185
|
const probe = await fetchJson(normalizedEndpoint, {
|
|
183
186
|
method: "POST",
|
|
184
187
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
185
|
-
body: JSON.stringify({ model
|
|
188
|
+
body: JSON.stringify({ model }),
|
|
186
189
|
}, { fetchImpl, timeoutMs });
|
|
187
190
|
const protocol = detectProtocol(probe.response.status, probe.body);
|
|
188
191
|
if (!protocol) throw new Error("Image API protocol is unsupported or could not be detected safely");
|
package/src/image-provider.mjs
CHANGED
|
@@ -197,58 +197,66 @@ export async function downloadPublicImage(urlValue, {
|
|
|
197
197
|
if (url.protocol !== "https:" || url.username || url.password) {
|
|
198
198
|
throw providerError("Image API returned an unsafe image URL", "ccdx_image_url_unsafe");
|
|
199
199
|
}
|
|
200
|
-
const addresses = await lookup(url.hostname, { all: true, verbatim: true });
|
|
201
|
-
if (!Array.isArray(addresses) || !addresses.length || addresses.some(({ address }) => !publicAddress(address))) {
|
|
202
|
-
throw providerError("Image API returned a non-public image URL", "ccdx_image_url_unsafe");
|
|
203
|
-
}
|
|
204
200
|
return new Promise((resolve, reject) => {
|
|
205
201
|
let settled = false;
|
|
202
|
+
let request;
|
|
206
203
|
const finish = (error, value) => {
|
|
207
204
|
if (settled) return;
|
|
208
205
|
settled = true;
|
|
206
|
+
clearTimeout(timer);
|
|
209
207
|
signal?.removeEventListener("abort", onAbort);
|
|
210
|
-
if (error)
|
|
208
|
+
if (error) {
|
|
209
|
+
request?.destroy(error);
|
|
210
|
+
reject(error);
|
|
211
|
+
}
|
|
211
212
|
else resolve(value);
|
|
212
213
|
};
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
finish(providerError(
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
const declared = Number(response.headers["content-length"]);
|
|
226
|
-
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
227
|
-
response.destroy();
|
|
228
|
-
finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
|
|
214
|
+
const timer = setTimeout(() => finish(providerError("Image download timed out", "ccdx_image_timeout")), timeoutMs);
|
|
215
|
+
timer.unref?.();
|
|
216
|
+
const onAbort = () => finish(signal.reason instanceof Error ? signal.reason : providerError("Image request was cancelled"));
|
|
217
|
+
if (signal?.aborted) { onAbort(); return; }
|
|
218
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
219
|
+
// DNS and transfer share one deadline; a late lookup cannot start a cancelled download.
|
|
220
|
+
Promise.resolve().then(() => settled ? undefined : lookup(url.hostname, { all: true, verbatim: true })).then((addresses) => {
|
|
221
|
+
if (settled) return;
|
|
222
|
+
if (!Array.isArray(addresses) || !addresses.length || addresses.some(({ address }) => !publicAddress(address))) {
|
|
223
|
+
finish(providerError("Image API returned a non-public image URL", "ccdx_image_url_unsafe"));
|
|
229
224
|
return;
|
|
230
225
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
226
|
+
request = https.get(url, {
|
|
227
|
+
headers: { Accept: "image/*,application/octet-stream;q=0.5" },
|
|
228
|
+
lookup: (_hostname, options, callback) => {
|
|
229
|
+
if (options?.all) callback(null, addresses);
|
|
230
|
+
else callback(null, addresses[0].address, addresses[0].family);
|
|
231
|
+
},
|
|
232
|
+
}, (response) => {
|
|
233
|
+
response.on("error", (error) => finish(error));
|
|
234
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
235
|
+
response.resume();
|
|
236
|
+
finish(providerError(`Image download failed with HTTP ${response.statusCode}`));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const declared = Number(response.headers["content-length"]);
|
|
240
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
236
241
|
response.destroy();
|
|
237
242
|
finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
|
|
238
243
|
return;
|
|
239
244
|
}
|
|
240
|
-
chunks
|
|
245
|
+
const chunks = [];
|
|
246
|
+
let total = 0;
|
|
247
|
+
response.on("data", (chunk) => {
|
|
248
|
+
total += chunk.length;
|
|
249
|
+
if (total > maxBytes) {
|
|
250
|
+
response.destroy();
|
|
251
|
+
finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
chunks.push(chunk);
|
|
255
|
+
});
|
|
256
|
+
response.on("end", () => finish(null, Buffer.concat(chunks, total)));
|
|
241
257
|
});
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
});
|
|
245
|
-
const timer = setTimeout(() => request.destroy(providerError("Image download timed out", "ccdx_image_timeout")), timeoutMs);
|
|
246
|
-
timer.unref?.();
|
|
247
|
-
request.on("close", () => clearTimeout(timer));
|
|
248
|
-
request.on("error", (error) => finish(error));
|
|
249
|
-
const onAbort = () => request.destroy(signal.reason instanceof Error ? signal.reason : providerError("Image request was cancelled"));
|
|
250
|
-
if (signal?.aborted) onAbort();
|
|
251
|
-
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
258
|
+
request.on("error", (error) => finish(error));
|
|
259
|
+
}).catch((error) => finish(error));
|
|
252
260
|
});
|
|
253
261
|
}
|
|
254
262
|
|
|
@@ -144,6 +144,42 @@ export function createResponsesHandler(options) {
|
|
|
144
144
|
activeDeadline = now() + timeoutMs;
|
|
145
145
|
abort.setTimeout(timeoutMs, activeTimeoutReason);
|
|
146
146
|
};
|
|
147
|
+
const startStream = () => {
|
|
148
|
+
assertPrepareActive();
|
|
149
|
+
// Headers completed this phase. Stream liveness is now governed by its
|
|
150
|
+
// idle timer, not the original handshake deadline.
|
|
151
|
+
activeDeadline = Number.POSITIVE_INFINITY;
|
|
152
|
+
};
|
|
153
|
+
const acquireRetryPreparation = async (reqContext) => {
|
|
154
|
+
assertPrepareActive();
|
|
155
|
+
activeDeadline = now() + upstreamTimeoutMs;
|
|
156
|
+
activeTimeoutReason = "responses_prepare_timeout";
|
|
157
|
+
abort.setTimeout(upstreamTimeoutMs, activeTimeoutReason);
|
|
158
|
+
let releaseRetryRequest = () => {};
|
|
159
|
+
let retrySnapshot = null;
|
|
160
|
+
const release = () => {
|
|
161
|
+
retrySnapshot?.release();
|
|
162
|
+
releaseRetryRequest();
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
releaseRetryRequest = await measureRequestStageAsync("admission", () => acquireRequest(req, { signal: abort.signal }));
|
|
166
|
+
assertPrepareActive();
|
|
167
|
+
if (reqContext.historyParentId) {
|
|
168
|
+
retrySnapshot = measureRequestStage("history", () => acquireResponseHistorySnapshot(reqContext.historyParentId, {
|
|
169
|
+
assertActive: assertPrepareActive,
|
|
170
|
+
signal: abort.signal,
|
|
171
|
+
}));
|
|
172
|
+
if (retrySnapshot.bytes > 0) {
|
|
173
|
+
await measureRequestStageAsync("admission", () => releaseRetryRequest.reserveResponseHistory?.(retrySnapshot.bytes, { signal: abort.signal }));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
assertPrepareActive();
|
|
177
|
+
return { historySnapshot: retrySnapshot, release };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
release();
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
147
183
|
const applyAdaptiveTimeout = (error) => {
|
|
148
184
|
if (!["responses_prepare_timeout", "stream_handshake_timeout", "upstream_timeout"].includes(abort.reason)) {
|
|
149
185
|
return error;
|
|
@@ -251,10 +287,12 @@ export function createResponsesHandler(options) {
|
|
|
251
287
|
applyCopilotResponsesRequestPolicies(prepared.body);
|
|
252
288
|
if (routePlan.protocol === "openai-responses") {
|
|
253
289
|
const result = await proxyCopilotResponses(prepared, req, res, responsesFn, {
|
|
290
|
+
acquireRetryPreparation,
|
|
254
291
|
assertPrepareActive,
|
|
255
292
|
signal: abort.signal,
|
|
256
293
|
abort,
|
|
257
294
|
onUpstreamStart: startUpstreamTimeout,
|
|
295
|
+
onStreamStart: startStream,
|
|
258
296
|
releaseRequest: releaseUpstreamPayload,
|
|
259
297
|
responseFailures,
|
|
260
298
|
streamIdleTimeoutMs,
|
package/src/responses-proxy.mjs
CHANGED
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
sendUpstreamError,
|
|
9
9
|
writeOrDrain,
|
|
10
10
|
} from "./http-transport.mjs";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
hasCopilotResponseRetryPolicy,
|
|
13
|
+
openCopilotResponse,
|
|
14
|
+
selectCopilotResponseRetry,
|
|
15
|
+
} from "./copilot-responses-compat.mjs";
|
|
12
16
|
import {
|
|
13
17
|
dropMaterializedResponseHistory,
|
|
14
18
|
hasEncryptedResponseState,
|
|
@@ -314,21 +318,29 @@ function streamHeaders(resp) {
|
|
|
314
318
|
};
|
|
315
319
|
}
|
|
316
320
|
|
|
317
|
-
function selectStreamFailureRetry(reqContext, failure, usedRetryPolicies) {
|
|
321
|
+
async function selectStreamFailureRetry(reqContext, failure, usedRetryPolicies, options) {
|
|
318
322
|
if (!failure) return null;
|
|
323
|
+
const errorText = responseFailureText(failure.event, failure.eventType);
|
|
324
|
+
if (!hasCopilotResponseRetryPolicy(400, errorText, usedRetryPolicies)) return null;
|
|
325
|
+
const preparation = await options.acquireRetryPreparation?.(reqContext);
|
|
326
|
+
let keepReservation = false;
|
|
319
327
|
try {
|
|
320
|
-
const restored = restoreMaterializedResponseHistoryForRetry(reqContext
|
|
328
|
+
const restored = restoreMaterializedResponseHistoryForRetry(reqContext, {
|
|
329
|
+
assertActive: options.assertPrepareActive,
|
|
330
|
+
historySnapshot: preparation?.historySnapshot,
|
|
331
|
+
});
|
|
332
|
+
const retry = selectCopilotResponseRetry(restored, 400, errorText, usedRetryPolicies);
|
|
333
|
+
options.assertPrepareActive?.();
|
|
334
|
+
keepReservation = Boolean(retry);
|
|
321
335
|
return {
|
|
322
|
-
retry:
|
|
323
|
-
restored,
|
|
324
|
-
400,
|
|
325
|
-
responseFailureText(failure.event, failure.eventType),
|
|
326
|
-
usedRetryPolicies,
|
|
327
|
-
),
|
|
336
|
+
retry: retry ? { ...retry, releasePreparation: preparation?.release } : null,
|
|
328
337
|
retrySkipped: null,
|
|
329
338
|
};
|
|
330
339
|
} catch {
|
|
340
|
+
options.assertPrepareActive?.();
|
|
331
341
|
return { retry: null, retrySkipped: "history_unavailable" };
|
|
342
|
+
} finally {
|
|
343
|
+
if (!keepReservation) preparation?.release();
|
|
332
344
|
}
|
|
333
345
|
}
|
|
334
346
|
|
|
@@ -362,6 +374,7 @@ async function proxyStreamingResponses(opened, res, upstream, options) {
|
|
|
362
374
|
return { successful: false, compacted: false, upstreamStatus: resp.status };
|
|
363
375
|
}
|
|
364
376
|
await requireUpstreamEventStream(resp);
|
|
377
|
+
options.onStreamStart?.();
|
|
365
378
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
366
379
|
const headers = streamHeaders(resp);
|
|
367
380
|
let headersSent = false;
|
|
@@ -424,7 +437,13 @@ async function proxyStreamingResponses(opened, res, upstream, options) {
|
|
|
424
437
|
|
|
425
438
|
let retry = null;
|
|
426
439
|
if (streamState.failure && holdPrelude && !streamState.sawOutput) {
|
|
427
|
-
|
|
440
|
+
let selected;
|
|
441
|
+
try {
|
|
442
|
+
selected = await selectStreamFailureRetry(reqContext, streamState.failure, usedRetryPolicies, options);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
recordResponseFailure(options, reqContext, resp, streamState.failure, { retrySkipped: "retry_preparation_failed" });
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
428
447
|
retry = selected?.retry || null;
|
|
429
448
|
if (!retry) retryDisabledReason = selected?.retrySkipped || "not_applicable";
|
|
430
449
|
}
|
|
@@ -436,12 +455,16 @@ async function proxyStreamingResponses(opened, res, upstream, options) {
|
|
|
436
455
|
}
|
|
437
456
|
if (retry) {
|
|
438
457
|
console.warn(status("warn", retry.warning));
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
458
|
+
try {
|
|
459
|
+
opened = await openCopilotResponse(retry.reqContext, upstream, {
|
|
460
|
+
...options,
|
|
461
|
+
usedRetryPolicies: retry.usedPolicies,
|
|
462
|
+
});
|
|
463
|
+
opened.streamRetryEligible = hasEncryptedResponseState(opened.reqContext.body?.input);
|
|
464
|
+
dropMaterializedResponseHistory(opened.reqContext);
|
|
465
|
+
} finally {
|
|
466
|
+
retry.releasePreparation?.();
|
|
467
|
+
}
|
|
445
468
|
continue;
|
|
446
469
|
}
|
|
447
470
|
if (streamState.failure) markStreamFailure();
|
|
@@ -515,7 +538,13 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
515
538
|
const response = parseSuccessfulResponsesResult(data);
|
|
516
539
|
if (response.status === "failed") {
|
|
517
540
|
const failure = { event: { type: "response.failed", response }, eventType: "response.failed" };
|
|
518
|
-
|
|
541
|
+
let selected;
|
|
542
|
+
try {
|
|
543
|
+
selected = await selectStreamFailureRetry(reqContext, failure, usedRetryPolicies, options);
|
|
544
|
+
} catch (error) {
|
|
545
|
+
recordResponseFailure(options, reqContext, resp, failure, { retrySkipped: "retry_preparation_failed" });
|
|
546
|
+
throw error;
|
|
547
|
+
}
|
|
519
548
|
const retry = selected?.retry || null;
|
|
520
549
|
recordResponseFailure(options, reqContext, resp, failure, {
|
|
521
550
|
retry,
|
|
@@ -523,11 +552,15 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
523
552
|
});
|
|
524
553
|
if (retry) {
|
|
525
554
|
console.warn(status("warn", retry.warning));
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
555
|
+
try {
|
|
556
|
+
opened = await openCopilotResponse(retry.reqContext, upstream, {
|
|
557
|
+
...options,
|
|
558
|
+
usedRetryPolicies: retry.usedPolicies,
|
|
559
|
+
});
|
|
560
|
+
dropMaterializedResponseHistory(opened.reqContext);
|
|
561
|
+
} finally {
|
|
562
|
+
retry.releasePreparation?.();
|
|
563
|
+
}
|
|
531
564
|
continue;
|
|
532
565
|
}
|
|
533
566
|
}
|
|
@@ -389,7 +389,8 @@ export function dropMaterializedResponseHistory(reqContext) {
|
|
|
389
389
|
return true;
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
-
export function restoreMaterializedResponseHistoryForRetry(reqContext, { assertActive } = {}) {
|
|
392
|
+
export function restoreMaterializedResponseHistoryForRetry(reqContext, { assertActive, historySnapshot } = {}) {
|
|
393
|
+
assertActive?.();
|
|
393
394
|
if (!reqContext?.[RELEASED_MATERIALIZED_HISTORY] || !reqContext.historyParentId) return reqContext;
|
|
394
395
|
const body = cloneJson(reqContext.body);
|
|
395
396
|
body.previous_response_id = reqContext.historyParentId;
|
|
@@ -397,6 +398,7 @@ export function restoreMaterializedResponseHistoryForRetry(reqContext, { assertA
|
|
|
397
398
|
let restored = prepareResponsesRequest(body, {
|
|
398
399
|
assertActive,
|
|
399
400
|
copilotBoundary: false,
|
|
401
|
+
historySnapshot,
|
|
400
402
|
mutate: true,
|
|
401
403
|
});
|
|
402
404
|
restored.surface = reqContext.surface;
|