codex-copilot-dx 0.7.6 → 0.7.8
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 +33 -3
- package/bin/cli.mjs +31 -2
- package/package.json +1 -1
- package/src/adapter.mjs +12 -0
- package/src/cli-image.mjs +199 -0
- package/src/cli-options.mjs +18 -0
- package/src/cli-status.mjs +12 -0
- package/src/config.mjs +88 -6
- package/src/copilot-responses-compat.mjs +28 -18
- package/src/image-mcp.mjs +169 -0
- package/src/image-provider-config.mjs +190 -0
- package/src/image-provider.mjs +268 -0
- package/src/observability.mjs +2 -0
- package/src/response-failures.mjs +100 -0
- package/src/responses-handler.mjs +2 -0
- package/src/responses-proxy.mjs +181 -35
- package/src/responses-request.mjs +23 -0
- package/src/terminal-animation.mjs +94 -6
- package/src/user-settings.mjs +2 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import dns from "node:dns/promises";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
|
|
5
|
+
export const IMAGE_MAX_BYTES = 32 * 1024 * 1024;
|
|
6
|
+
export const IMAGE_GENERATION_TIMEOUT_MS = 180_000;
|
|
7
|
+
export const IMAGE_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
8
|
+
export const IMAGE_SIZES = new Set(["1024x1024", "1536x1024", "1024x1536"]);
|
|
9
|
+
|
|
10
|
+
function providerError(message, code = "ccdx_image_provider_error") {
|
|
11
|
+
const error = new Error(message);
|
|
12
|
+
error.code = code;
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function checkedPrompt(value) {
|
|
17
|
+
const prompt = String(value || "").trim();
|
|
18
|
+
if (!prompt) throw providerError("Image prompt is required", "ccdx_image_prompt_required");
|
|
19
|
+
if (prompt.length > 16_000) throw providerError("Image prompt exceeds 16000 characters", "ccdx_image_prompt_too_large");
|
|
20
|
+
return prompt;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function checkedSize(value) {
|
|
24
|
+
const size = String(value || "1024x1024").trim().toLowerCase();
|
|
25
|
+
if (!IMAGE_SIZES.has(size)) {
|
|
26
|
+
throw providerError(`Image size must be one of: ${[...IMAGE_SIZES].join(", ")}`, "ccdx_image_size_invalid");
|
|
27
|
+
}
|
|
28
|
+
return size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function generationBody(config, prompt, size) {
|
|
32
|
+
if (config.protocol === "qwen-messages") {
|
|
33
|
+
return {
|
|
34
|
+
model: config.model,
|
|
35
|
+
input: { messages: [{ role: "user", content: [{ text: prompt }] }] },
|
|
36
|
+
parameters: { n: 1, size: size.replace("x", "*"), watermark: false },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { model: config.model, prompt, n: 1, size };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function boundedJson(response) {
|
|
43
|
+
const maxBytes = 48 * 1024 * 1024;
|
|
44
|
+
const declared = Number(response.headers?.get?.("content-length"));
|
|
45
|
+
if (Number.isFinite(declared) && declared > maxBytes) throw providerError("Image API response was too large");
|
|
46
|
+
const reader = response.body?.getReader?.();
|
|
47
|
+
let text;
|
|
48
|
+
if (!reader) {
|
|
49
|
+
text = await response.text();
|
|
50
|
+
if (Buffer.byteLength(text) > maxBytes) throw providerError("Image API response was too large");
|
|
51
|
+
} else {
|
|
52
|
+
const chunks = [];
|
|
53
|
+
let total = 0;
|
|
54
|
+
try {
|
|
55
|
+
while (true) {
|
|
56
|
+
const { done, value } = await reader.read();
|
|
57
|
+
if (done) break;
|
|
58
|
+
total += value.byteLength;
|
|
59
|
+
if (total > maxBytes) {
|
|
60
|
+
await reader.cancel().catch(() => {});
|
|
61
|
+
throw providerError("Image API response was too large");
|
|
62
|
+
}
|
|
63
|
+
chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
reader.releaseLock();
|
|
67
|
+
}
|
|
68
|
+
text = Buffer.concat(chunks, total).toString("utf8");
|
|
69
|
+
}
|
|
70
|
+
try { return JSON.parse(text); } catch { throw providerError("Image API returned invalid JSON"); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function upstreamMessage(body) {
|
|
74
|
+
return String(body?.message || body?.error?.message || body?.error || "")
|
|
75
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
76
|
+
.trim()
|
|
77
|
+
.slice(0, 500);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function imageSource(body) {
|
|
81
|
+
const standard = body?.data?.[0];
|
|
82
|
+
if (typeof standard?.b64_json === "string" && standard.b64_json) {
|
|
83
|
+
return { type: "base64", value: standard.b64_json };
|
|
84
|
+
}
|
|
85
|
+
if (typeof standard?.url === "string" && standard.url) return { type: "url", value: standard.url };
|
|
86
|
+
const contents = body?.output?.choices?.[0]?.message?.content;
|
|
87
|
+
if (Array.isArray(contents)) {
|
|
88
|
+
const image = contents.find((part) => typeof part?.image === "string" && part.image);
|
|
89
|
+
if (image) return { type: "url", value: image.image };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function imageMetadata(bytes) {
|
|
95
|
+
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
|
96
|
+
return { mimeType: "image/png", width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
97
|
+
}
|
|
98
|
+
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") {
|
|
99
|
+
return { mimeType: "image/webp" };
|
|
100
|
+
}
|
|
101
|
+
if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
102
|
+
let offset = 2;
|
|
103
|
+
while (offset + 9 < bytes.length) {
|
|
104
|
+
if (bytes[offset] !== 0xff) { offset += 1; continue; }
|
|
105
|
+
const marker = bytes[offset + 1];
|
|
106
|
+
if (marker === 0xd8 || marker === 0xd9) { offset += 2; continue; }
|
|
107
|
+
const length = bytes.readUInt16BE(offset + 2);
|
|
108
|
+
if (length < 2 || offset + 2 + length > bytes.length) break;
|
|
109
|
+
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
|
|
110
|
+
return { mimeType: "image/jpeg", height: bytes.readUInt16BE(offset + 5), width: bytes.readUInt16BE(offset + 7) };
|
|
111
|
+
}
|
|
112
|
+
offset += 2 + length;
|
|
113
|
+
}
|
|
114
|
+
return { mimeType: "image/jpeg" };
|
|
115
|
+
}
|
|
116
|
+
throw providerError("Image API returned an unsupported image format", "ccdx_image_format_unsupported");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ipv4Public(address) {
|
|
120
|
+
const parts = address.split(".").map(Number);
|
|
121
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
122
|
+
const [a, b, c] = parts;
|
|
123
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
|
|
124
|
+
if (a === 100 && b >= 64 && b <= 127) return false;
|
|
125
|
+
if (a === 169 && b === 254) return false;
|
|
126
|
+
if (a === 172 && b >= 16 && b <= 31) return false;
|
|
127
|
+
if (a === 192 && (b === 168 || (b === 0 && [0, 2].includes(c)))) return false;
|
|
128
|
+
if (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) return false;
|
|
129
|
+
if (a === 203 && b === 0 && c === 113) return false;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function publicAddress(address) {
|
|
134
|
+
const family = net.isIP(address);
|
|
135
|
+
if (family === 4) return ipv4Public(address);
|
|
136
|
+
if (family !== 6) return false;
|
|
137
|
+
const lower = address.toLowerCase();
|
|
138
|
+
if (lower === "::" || lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd")
|
|
139
|
+
|| /^fe[89ab]/.test(lower) || lower.startsWith("ff") || lower.startsWith("2001:db8:")) return false;
|
|
140
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
|
141
|
+
return mapped ? ipv4Public(mapped[1]) : true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function downloadPublicImage(urlValue, {
|
|
145
|
+
lookup = dns.lookup,
|
|
146
|
+
timeoutMs = IMAGE_DOWNLOAD_TIMEOUT_MS,
|
|
147
|
+
maxBytes = IMAGE_MAX_BYTES,
|
|
148
|
+
signal,
|
|
149
|
+
} = {}) {
|
|
150
|
+
let url;
|
|
151
|
+
try { url = new URL(urlValue); } catch { throw providerError("Image API returned an invalid image URL"); }
|
|
152
|
+
if (url.protocol !== "https:" || url.username || url.password) {
|
|
153
|
+
throw providerError("Image API returned an unsafe image URL", "ccdx_image_url_unsafe");
|
|
154
|
+
}
|
|
155
|
+
const addresses = await lookup(url.hostname, { all: true, verbatim: true });
|
|
156
|
+
if (!Array.isArray(addresses) || !addresses.length || addresses.some(({ address }) => !publicAddress(address))) {
|
|
157
|
+
throw providerError("Image API returned a non-public image URL", "ccdx_image_url_unsafe");
|
|
158
|
+
}
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
let settled = false;
|
|
161
|
+
const finish = (error, value) => {
|
|
162
|
+
if (settled) return;
|
|
163
|
+
settled = true;
|
|
164
|
+
signal?.removeEventListener("abort", onAbort);
|
|
165
|
+
if (error) reject(error);
|
|
166
|
+
else resolve(value);
|
|
167
|
+
};
|
|
168
|
+
const request = https.get(url, {
|
|
169
|
+
headers: { Accept: "image/*,application/octet-stream;q=0.5" },
|
|
170
|
+
lookup: (_hostname, options, callback) => {
|
|
171
|
+
if (options?.all) callback(null, addresses);
|
|
172
|
+
else callback(null, addresses[0].address, addresses[0].family);
|
|
173
|
+
},
|
|
174
|
+
}, (response) => {
|
|
175
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
176
|
+
response.resume();
|
|
177
|
+
finish(providerError(`Image download failed with HTTP ${response.statusCode}`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const declared = Number(response.headers["content-length"]);
|
|
181
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
182
|
+
response.destroy();
|
|
183
|
+
finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const chunks = [];
|
|
187
|
+
let total = 0;
|
|
188
|
+
response.on("data", (chunk) => {
|
|
189
|
+
total += chunk.length;
|
|
190
|
+
if (total > maxBytes) {
|
|
191
|
+
response.destroy();
|
|
192
|
+
finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
chunks.push(chunk);
|
|
196
|
+
});
|
|
197
|
+
response.on("end", () => finish(null, Buffer.concat(chunks, total)));
|
|
198
|
+
response.on("error", (error) => finish(error));
|
|
199
|
+
});
|
|
200
|
+
const timer = setTimeout(() => request.destroy(providerError("Image download timed out", "ccdx_image_timeout")), timeoutMs);
|
|
201
|
+
timer.unref?.();
|
|
202
|
+
request.on("close", () => clearTimeout(timer));
|
|
203
|
+
request.on("error", (error) => finish(error));
|
|
204
|
+
const onAbort = () => request.destroy(signal.reason instanceof Error ? signal.reason : providerError("Image request was cancelled"));
|
|
205
|
+
if (signal?.aborted) onAbort();
|
|
206
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function generateImage(config, {
|
|
211
|
+
prompt,
|
|
212
|
+
size = "1024x1024",
|
|
213
|
+
} = {}, {
|
|
214
|
+
fetchImpl = fetch,
|
|
215
|
+
downloadImage = downloadPublicImage,
|
|
216
|
+
signal,
|
|
217
|
+
timeoutMs = IMAGE_GENERATION_TIMEOUT_MS,
|
|
218
|
+
} = {}) {
|
|
219
|
+
const normalizedPrompt = checkedPrompt(prompt);
|
|
220
|
+
const normalizedSize = checkedSize(size);
|
|
221
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
222
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
223
|
+
let response;
|
|
224
|
+
try {
|
|
225
|
+
response = await fetchImpl(config.endpoint, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: {
|
|
228
|
+
Authorization: `Bearer ${config.api_key}`,
|
|
229
|
+
"Content-Type": "application/json",
|
|
230
|
+
Accept: "application/json",
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify(generationBody(config, normalizedPrompt, normalizedSize)),
|
|
233
|
+
signal: requestSignal,
|
|
234
|
+
});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (requestSignal.aborted) throw providerError("Image generation timed out or was cancelled", "ccdx_image_timeout");
|
|
237
|
+
throw providerError("Image API could not be reached");
|
|
238
|
+
}
|
|
239
|
+
const body = await boundedJson(response);
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
const detail = upstreamMessage(body);
|
|
242
|
+
throw providerError(`Image API failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
|
|
243
|
+
}
|
|
244
|
+
const source = imageSource(body);
|
|
245
|
+
if (!source) throw providerError("Image API response did not contain an image");
|
|
246
|
+
let bytes;
|
|
247
|
+
try {
|
|
248
|
+
bytes = source.type === "base64"
|
|
249
|
+
? Buffer.from(source.value, "base64")
|
|
250
|
+
: await downloadImage(source.value, { signal: requestSignal, maxBytes: IMAGE_MAX_BYTES });
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error?.code?.startsWith?.("ccdx_")) throw error;
|
|
253
|
+
const code = String(error?.cause?.code || error?.code || "").replace(/[^A-Z0-9_-]/gi, "").slice(0, 64);
|
|
254
|
+
throw providerError(`Generated image could not be downloaded${code ? ` (${code})` : ""}`);
|
|
255
|
+
}
|
|
256
|
+
if (!bytes.length || bytes.length > IMAGE_MAX_BYTES) {
|
|
257
|
+
throw providerError("Generated image is empty or exceeds the size limit", "ccdx_image_too_large");
|
|
258
|
+
}
|
|
259
|
+
const metadata = imageMetadata(bytes);
|
|
260
|
+
return {
|
|
261
|
+
data: bytes.toString("base64"),
|
|
262
|
+
mimeType: metadata.mimeType,
|
|
263
|
+
width: metadata.width,
|
|
264
|
+
height: metadata.height,
|
|
265
|
+
model: config.model,
|
|
266
|
+
size: normalizedSize,
|
|
267
|
+
};
|
|
268
|
+
}
|
package/src/observability.mjs
CHANGED
|
@@ -154,6 +154,7 @@ export function runtimeStatusPayload({
|
|
|
154
154
|
streamPerformance,
|
|
155
155
|
admission,
|
|
156
156
|
imagePressure,
|
|
157
|
+
responseFailures,
|
|
157
158
|
modelRegistry,
|
|
158
159
|
codexClient,
|
|
159
160
|
codexModelRegistry,
|
|
@@ -177,6 +178,7 @@ export function runtimeStatusPayload({
|
|
|
177
178
|
response_history: responseHistoryStats(),
|
|
178
179
|
image_optimization: imageOptimizationStats(),
|
|
179
180
|
image_history_pressure: imagePressure?.snapshot?.() || null,
|
|
181
|
+
response_failures: responseFailures?.snapshot?.() || null,
|
|
180
182
|
copilot: codexRuntime,
|
|
181
183
|
models: codexModels,
|
|
182
184
|
profiles: {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { terminalCell } from "./cli-table.mjs";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_RECENT_FAILURES = 10;
|
|
4
|
+
|
|
5
|
+
function safeIdentifier(value, fallback = "unknown") {
|
|
6
|
+
const text = terminalCell(value, { fallback });
|
|
7
|
+
return text.slice(0, 200);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function safeFailureMessage(value) {
|
|
11
|
+
let text = terminalCell(value, { fallback: "Response failed without an error message" });
|
|
12
|
+
text = text
|
|
13
|
+
.replace(/(encrypted(?: function output)? content\s+)[A-Za-z0-9+/_=-]{24,}/gi, "$1[redacted]")
|
|
14
|
+
.replace(/\b[A-Za-z0-9+/_=-]{96,}\b/g, "[redacted]");
|
|
15
|
+
return text.slice(0, 500);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function upstreamRequestId(headers) {
|
|
19
|
+
if (!headers?.get) return null;
|
|
20
|
+
for (const name of ["x-request-id", "x-github-request-id", "x-ms-request-id", "request-id", "x-correlation-id"]) {
|
|
21
|
+
const value = headers.get(name);
|
|
22
|
+
if (value) return safeIdentifier(value, "");
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function responseFailureDetails(event, eventType = event?.type, {
|
|
28
|
+
model,
|
|
29
|
+
headers,
|
|
30
|
+
retried = false,
|
|
31
|
+
retryPolicy = null,
|
|
32
|
+
retrySkipped = null,
|
|
33
|
+
} = {}) {
|
|
34
|
+
const response = event?.response && typeof event.response === "object" ? event.response : event;
|
|
35
|
+
const error = response?.error && typeof response.error === "object"
|
|
36
|
+
? response.error
|
|
37
|
+
: event?.error && typeof event.error === "object"
|
|
38
|
+
? event.error
|
|
39
|
+
: event;
|
|
40
|
+
return {
|
|
41
|
+
at: new Date().toISOString(),
|
|
42
|
+
event_type: safeIdentifier(eventType || "response.failed"),
|
|
43
|
+
model: safeIdentifier(response?.model || model),
|
|
44
|
+
code: safeIdentifier(error?.code || "unknown_error"),
|
|
45
|
+
message: safeFailureMessage(error?.message),
|
|
46
|
+
response_id: response?.id ? safeIdentifier(response.id, "") : null,
|
|
47
|
+
upstream_request_id: upstreamRequestId(headers),
|
|
48
|
+
retried: Boolean(retried),
|
|
49
|
+
retry_policy: retryPolicy ? safeIdentifier(retryPolicy, "") : null,
|
|
50
|
+
retry_skipped: retrySkipped ? safeIdentifier(retrySkipped, "") : null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function responseFailureText(event, eventType = event?.type) {
|
|
55
|
+
const response = event?.response && typeof event.response === "object" ? event.response : event;
|
|
56
|
+
const error = response?.error && typeof response.error === "object"
|
|
57
|
+
? response.error
|
|
58
|
+
: event?.error && typeof event.error === "object"
|
|
59
|
+
? event.error
|
|
60
|
+
: event;
|
|
61
|
+
return JSON.stringify({ error: { code: error?.code, message: error?.message } });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatResponseFailureLog(details) {
|
|
65
|
+
const fields = [
|
|
66
|
+
`Responses ${details.event_type}`,
|
|
67
|
+
`model=${details.model}`,
|
|
68
|
+
`code=${details.code}`,
|
|
69
|
+
details.response_id ? `response_id=${details.response_id}` : null,
|
|
70
|
+
details.upstream_request_id ? `upstream_request_id=${details.upstream_request_id}` : null,
|
|
71
|
+
details.retried ? `retry=${details.retry_policy || "yes"}` : null,
|
|
72
|
+
details.retry_skipped ? `retry_skipped=${details.retry_skipped}` : null,
|
|
73
|
+
`message=${details.message}`,
|
|
74
|
+
].filter(Boolean);
|
|
75
|
+
return fields.join(" ");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createResponseFailureDiagnostics({
|
|
79
|
+
maxEntries = DEFAULT_RECENT_FAILURES,
|
|
80
|
+
now = () => new Date().toISOString(),
|
|
81
|
+
} = {}) {
|
|
82
|
+
const recent = [];
|
|
83
|
+
let total = 0;
|
|
84
|
+
let retried = 0;
|
|
85
|
+
return {
|
|
86
|
+
record(details) {
|
|
87
|
+
total += 1;
|
|
88
|
+
if (details.retried) retried += 1;
|
|
89
|
+
recent.push({ ...details, at: now() });
|
|
90
|
+
if (recent.length > maxEntries) recent.splice(0, recent.length - maxEntries);
|
|
91
|
+
},
|
|
92
|
+
snapshot() {
|
|
93
|
+
return {
|
|
94
|
+
total,
|
|
95
|
+
retried,
|
|
96
|
+
recent: recent.map((entry) => ({ ...entry })),
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -109,6 +109,7 @@ export function createResponsesHandler(options) {
|
|
|
109
109
|
requestBodyTimeoutMs,
|
|
110
110
|
responsesPayloadOptions,
|
|
111
111
|
responsesFn,
|
|
112
|
+
responseFailures,
|
|
112
113
|
streamHandshakeTimeoutMs,
|
|
113
114
|
streamIdleTimeoutMs,
|
|
114
115
|
upstreamTimeoutMs,
|
|
@@ -255,6 +256,7 @@ export function createResponsesHandler(options) {
|
|
|
255
256
|
abort,
|
|
256
257
|
onUpstreamStart: startUpstreamTimeout,
|
|
257
258
|
releaseRequest: releaseUpstreamPayload,
|
|
259
|
+
responseFailures,
|
|
258
260
|
streamIdleTimeoutMs,
|
|
259
261
|
});
|
|
260
262
|
if (result?.successful) imagePressure?.markSuccess?.(responseHistoryPressureRootId(prepared));
|
package/src/responses-proxy.mjs
CHANGED
|
@@ -8,8 +8,18 @@ import {
|
|
|
8
8
|
sendUpstreamError,
|
|
9
9
|
writeOrDrain,
|
|
10
10
|
} from "./http-transport.mjs";
|
|
11
|
-
import { openCopilotResponse } from "./copilot-responses-compat.mjs";
|
|
12
|
-
import {
|
|
11
|
+
import { openCopilotResponse, selectCopilotResponseRetry } from "./copilot-responses-compat.mjs";
|
|
12
|
+
import {
|
|
13
|
+
dropMaterializedResponseHistory,
|
|
14
|
+
hasEncryptedResponseState,
|
|
15
|
+
rememberResponseHistory,
|
|
16
|
+
restoreMaterializedResponseHistoryForRetry,
|
|
17
|
+
} from "./responses-request.mjs";
|
|
18
|
+
import {
|
|
19
|
+
formatResponseFailureLog,
|
|
20
|
+
responseFailureDetails,
|
|
21
|
+
responseFailureText,
|
|
22
|
+
} from "./response-failures.mjs";
|
|
13
23
|
import {
|
|
14
24
|
compactionInputWithoutTrigger,
|
|
15
25
|
parseResponsesCompactionResult,
|
|
@@ -30,9 +40,11 @@ import {
|
|
|
30
40
|
} from "./stream-performance.mjs";
|
|
31
41
|
import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
|
|
32
42
|
import { recordResponsesUsage } from "./usage.mjs";
|
|
43
|
+
import { status } from "./status.mjs";
|
|
33
44
|
|
|
34
45
|
const MAX_SSE_BUFFER_BYTES = loadRuntimeConfig().maxSseBufferBytes;
|
|
35
46
|
const SSE_WRITE_BATCH_BYTES = 64 * 1024;
|
|
47
|
+
const STREAM_RETRY_PRELUDE_MAX_BYTES = 256 * 1024;
|
|
36
48
|
const RESPONSES_TERMINAL_EVENT_TYPES = new Set([
|
|
37
49
|
"response.completed",
|
|
38
50
|
"response.incomplete",
|
|
@@ -124,7 +136,9 @@ function inspectResponseSseEvent(state, eventName, data) {
|
|
|
124
136
|
);
|
|
125
137
|
}
|
|
126
138
|
state.sawTerminal = true;
|
|
127
|
-
if (eventType === "response.failed" || eventType === "error")
|
|
139
|
+
if (eventType === "response.failed" || eventType === "error") {
|
|
140
|
+
state.failure = { event, eventType };
|
|
141
|
+
}
|
|
128
142
|
if (eventType === "response.completed") {
|
|
129
143
|
const response = event.response;
|
|
130
144
|
state.completed = { response, event };
|
|
@@ -292,68 +306,182 @@ function storeCompletedResponse(reqContext, completed) {
|
|
|
292
306
|
});
|
|
293
307
|
}
|
|
294
308
|
|
|
295
|
-
|
|
296
|
-
|
|
309
|
+
function streamHeaders(resp) {
|
|
310
|
+
return {
|
|
311
|
+
...safeUpstreamResponseHeaders(resp.headers, { defaultContentType: "text/event-stream" }),
|
|
312
|
+
"Cache-Control": "no-cache",
|
|
313
|
+
Connection: "keep-alive",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function selectStreamFailureRetry(reqContext, failure, usedRetryPolicies) {
|
|
318
|
+
if (!failure) return null;
|
|
297
319
|
try {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
320
|
+
const restored = restoreMaterializedResponseHistoryForRetry(reqContext);
|
|
321
|
+
return {
|
|
322
|
+
retry: selectCopilotResponseRetry(
|
|
323
|
+
restored,
|
|
324
|
+
400,
|
|
325
|
+
responseFailureText(failure.event, failure.eventType),
|
|
326
|
+
usedRetryPolicies,
|
|
327
|
+
),
|
|
328
|
+
retrySkipped: null,
|
|
329
|
+
};
|
|
330
|
+
} catch {
|
|
331
|
+
return { retry: null, retrySkipped: "history_unavailable" };
|
|
307
332
|
}
|
|
333
|
+
}
|
|
308
334
|
|
|
309
|
-
|
|
335
|
+
function recordResponseFailure(options, reqContext, resp, failure, {
|
|
336
|
+
retry,
|
|
337
|
+
retrySkipped,
|
|
338
|
+
} = {}) {
|
|
339
|
+
if (!failure) return;
|
|
340
|
+
const details = responseFailureDetails(failure.event, failure.eventType, {
|
|
341
|
+
model: reqContext.body?.model,
|
|
342
|
+
headers: resp.headers,
|
|
343
|
+
retried: Boolean(retry),
|
|
344
|
+
retryPolicy: retry?.policyId,
|
|
345
|
+
retrySkipped,
|
|
346
|
+
});
|
|
347
|
+
options.responseFailures?.record?.(details);
|
|
348
|
+
console.warn(status("warn", formatResponseFailureLog(details)));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function proxyStreamingResponses(opened, res, upstream, options) {
|
|
352
|
+
while (true) {
|
|
353
|
+
let {
|
|
354
|
+
resp,
|
|
355
|
+
errorText,
|
|
356
|
+
reqContext,
|
|
357
|
+
streamRetryEligible = false,
|
|
358
|
+
usedRetryPolicies = new Set(),
|
|
359
|
+
} = opened;
|
|
360
|
+
if (errorText !== undefined) {
|
|
361
|
+
sendUpstreamError(res, resp, errorText);
|
|
362
|
+
return { successful: false, compacted: false, upstreamStatus: resp.status };
|
|
363
|
+
}
|
|
310
364
|
await requireUpstreamEventStream(resp);
|
|
311
365
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
366
|
+
const headers = streamHeaders(resp);
|
|
367
|
+
let headersSent = false;
|
|
368
|
+
const writeHeaders = () => {
|
|
369
|
+
if (headersSent || res.headersSent) return;
|
|
370
|
+
headersSent = true;
|
|
371
|
+
res.writeHead(resp.status, headers);
|
|
372
|
+
};
|
|
317
373
|
const reader = resp.body.getReader();
|
|
318
374
|
const streamState = {
|
|
319
375
|
completed: null,
|
|
376
|
+
failure: null,
|
|
320
377
|
sawTerminal: false,
|
|
321
378
|
sawOutput: false,
|
|
322
379
|
toolArgumentGuard: new ToolArgumentDeltaGuard(),
|
|
323
380
|
messageIds: new Map(),
|
|
324
381
|
};
|
|
325
382
|
const transformer = createResponseSseTransformer(streamState);
|
|
383
|
+
let holdPrelude = streamRetryEligible;
|
|
384
|
+
let retryDisabledReason = holdPrelude ? null : "request_not_eligible";
|
|
385
|
+
const prelude = [];
|
|
386
|
+
let preludeBytes = 0;
|
|
387
|
+
const flushPrelude = async () => {
|
|
388
|
+
if (!preludeBytes) return true;
|
|
389
|
+
writeHeaders();
|
|
390
|
+
const body = prelude.length === 1 ? prelude[0] : Buffer.concat(prelude, preludeBytes);
|
|
391
|
+
prelude.length = 0;
|
|
392
|
+
preludeBytes = 0;
|
|
393
|
+
return writeOrDrain(res, body);
|
|
394
|
+
};
|
|
395
|
+
const forwardFrame = async (frame) => {
|
|
396
|
+
if (!frame) return true;
|
|
397
|
+
if (!holdPrelude) {
|
|
398
|
+
writeHeaders();
|
|
399
|
+
return writeOrDrain(res, frame);
|
|
400
|
+
}
|
|
401
|
+
prelude.push(frame);
|
|
402
|
+
preludeBytes += frame.byteLength;
|
|
403
|
+
if (!streamState.sawOutput && preludeBytes <= STREAM_RETRY_PRELUDE_MAX_BYTES) return true;
|
|
404
|
+
retryDisabledReason = streamState.sawOutput ? "output_started" : "prelude_limit";
|
|
405
|
+
holdPrelude = false;
|
|
406
|
+
return flushPrelude();
|
|
407
|
+
};
|
|
408
|
+
|
|
326
409
|
try {
|
|
327
410
|
while (true) {
|
|
328
411
|
const { done, value } = await reader.read();
|
|
329
412
|
if (done) {
|
|
330
413
|
if (!streamState.sawTerminal) throw incompleteUpstreamStream("a terminal Responses event");
|
|
331
|
-
|
|
332
|
-
res.end();
|
|
333
|
-
return { successful: Boolean(streamState.completed), compacted: false };
|
|
414
|
+
break;
|
|
334
415
|
}
|
|
335
416
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
336
|
-
// Batch within this read, not across reads, and await drain before processing more.
|
|
337
417
|
for (let offset = 0; offset < value.byteLength; offset += SSE_WRITE_BATCH_BYTES) {
|
|
338
418
|
const frame = transformer.push(value.subarray(offset, offset + SSE_WRITE_BATCH_BYTES));
|
|
339
|
-
if (
|
|
419
|
+
if (!await forwardFrame(frame)) return { successful: false, compacted: false };
|
|
340
420
|
if (streamState.sawTerminal) break;
|
|
341
421
|
}
|
|
342
|
-
if (streamState.sawTerminal)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
422
|
+
if (streamState.sawTerminal) break;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let retry = null;
|
|
426
|
+
if (streamState.failure && holdPrelude && !streamState.sawOutput) {
|
|
427
|
+
const selected = selectStreamFailureRetry(reqContext, streamState.failure, usedRetryPolicies);
|
|
428
|
+
retry = selected?.retry || null;
|
|
429
|
+
if (!retry) retryDisabledReason = selected?.retrySkipped || "not_applicable";
|
|
430
|
+
}
|
|
431
|
+
if (streamState.failure) {
|
|
432
|
+
recordResponseFailure(options, reqContext, resp, streamState.failure, {
|
|
433
|
+
retry,
|
|
434
|
+
retrySkipped: retry ? null : retryDisabledReason,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (retry) {
|
|
438
|
+
console.warn(status("warn", retry.warning));
|
|
439
|
+
opened = await openCopilotResponse(retry.reqContext, upstream, {
|
|
440
|
+
...options,
|
|
441
|
+
usedRetryPolicies: retry.usedPolicies,
|
|
442
|
+
});
|
|
443
|
+
opened.streamRetryEligible = hasEncryptedResponseState(opened.reqContext.body?.input);
|
|
444
|
+
dropMaterializedResponseHistory(opened.reqContext);
|
|
445
|
+
continue;
|
|
347
446
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
447
|
+
if (streamState.failure) markStreamFailure();
|
|
448
|
+
if (holdPrelude && !await flushPrelude()) return { successful: false, compacted: false };
|
|
449
|
+
storeCompletedResponse(reqContext, streamState.completed);
|
|
450
|
+
if (!res.writableEnded) res.end();
|
|
451
|
+
return { successful: Boolean(streamState.completed), compacted: false };
|
|
452
|
+
} catch (error) {
|
|
453
|
+
logRequestFailure("Responses", error, options.abort);
|
|
454
|
+
writeHeaders();
|
|
455
|
+
await endStreamWithError(res, error, options.abort);
|
|
351
456
|
return { successful: false, compacted: false };
|
|
352
457
|
} finally {
|
|
353
458
|
await reader.cancel().catch(() => {});
|
|
354
459
|
reader.releaseLock();
|
|
355
460
|
}
|
|
356
|
-
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
|
|
465
|
+
let opened;
|
|
466
|
+
try {
|
|
467
|
+
opened = await openCopilotResponse(reqContext, upstream, options);
|
|
468
|
+
opened.streamRetryEligible = opened.reqContext.body.stream === true
|
|
469
|
+
&& hasEncryptedResponseState(opened.reqContext.body?.input);
|
|
470
|
+
} finally {
|
|
471
|
+
options.releaseRequest?.(opened?.reqContext);
|
|
472
|
+
}
|
|
473
|
+
reqContext = opened.reqContext;
|
|
474
|
+
if (reqContext.body.stream) {
|
|
475
|
+
return proxyStreamingResponses(opened, res, upstream, options);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
while (true) {
|
|
479
|
+
const { resp, errorText, usedRetryPolicies = new Set() } = opened;
|
|
480
|
+
reqContext = opened.reqContext;
|
|
481
|
+
if (errorText !== undefined) {
|
|
482
|
+
sendUpstreamError(res, resp, errorText);
|
|
483
|
+
return { successful: false, compacted: false, upstreamStatus: resp.status };
|
|
484
|
+
}
|
|
357
485
|
const data = resp.ok
|
|
358
486
|
? await readBoundedResponseText(resp, {
|
|
359
487
|
maxBytes: MAX_UPSTREAM_RESPONSES_SUCCESS_BODY_BYTES,
|
|
@@ -385,6 +513,24 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
385
513
|
}
|
|
386
514
|
if (resp.ok) {
|
|
387
515
|
const response = parseSuccessfulResponsesResult(data);
|
|
516
|
+
if (response.status === "failed") {
|
|
517
|
+
const failure = { event: { type: "response.failed", response }, eventType: "response.failed" };
|
|
518
|
+
const selected = selectStreamFailureRetry(reqContext, failure, usedRetryPolicies);
|
|
519
|
+
const retry = selected?.retry || null;
|
|
520
|
+
recordResponseFailure(options, reqContext, resp, failure, {
|
|
521
|
+
retry,
|
|
522
|
+
retrySkipped: retry ? null : selected?.retrySkipped || "not_applicable",
|
|
523
|
+
});
|
|
524
|
+
if (retry) {
|
|
525
|
+
console.warn(status("warn", retry.warning));
|
|
526
|
+
opened = await openCopilotResponse(retry.reqContext, upstream, {
|
|
527
|
+
...options,
|
|
528
|
+
usedRetryPolicies: retry.usedPolicies,
|
|
529
|
+
});
|
|
530
|
+
dropMaterializedResponseHistory(opened.reqContext);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
388
534
|
rememberResponseHistory(reqContext, response);
|
|
389
535
|
recordResponsesUsage({
|
|
390
536
|
surface: reqContext.surface,
|
|
@@ -397,7 +543,7 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
397
543
|
contentType: "application/json",
|
|
398
544
|
}));
|
|
399
545
|
res.end(data);
|
|
400
|
-
return { successful:
|
|
546
|
+
return { successful: response.status !== "failed" && response.status !== "incomplete", compacted: false };
|
|
401
547
|
}
|
|
402
548
|
res.writeHead(resp.status, safeUpstreamResponseHeaders(resp.headers, {
|
|
403
549
|
contentType: "application/json",
|