codex-copilot-dx 0.4.42 → 0.4.44
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 +28 -16
- package/bin/cli.mjs +4 -4
- package/package.json +2 -1
- package/src/adapter.mjs +17 -8
- package/src/anthropic-handler.mjs +13 -5
- package/src/anthropic.mjs +40 -5
- package/src/auth.mjs +2 -2
- package/src/bounded-result-cache.mjs +180 -0
- package/src/claude-config.mjs +1 -1
- package/src/cli-options.mjs +7 -5
- package/src/copilot.mjs +42 -8
- package/src/doctor.mjs +11 -2
- package/src/http-transport.mjs +5 -2
- package/src/image-optimization.mjs +312 -116
- package/src/observability.mjs +2 -1
- package/src/responses-bridge.mjs +76 -20
- package/src/responses-byte-budget.mjs +314 -0
- package/src/responses-chat-payload.mjs +129 -0
- package/src/responses-compaction.mjs +113 -0
- package/src/responses-content.mjs +86 -0
- package/src/responses-handler.mjs +67 -20
- package/src/responses-image-limit.mjs +15 -26
- package/src/responses-proxy.mjs +123 -23
- package/src/responses-request.mjs +43 -6
- package/src/stream-contract.mjs +102 -0
- package/src/stream-errors.mjs +3 -1
- package/src/stream-performance.mjs +177 -0
- package/src/upstream-headers.mjs +67 -0
package/src/responses-bridge.mjs
CHANGED
|
@@ -2,11 +2,42 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { chatCompletions } from "./copilot.mjs";
|
|
3
3
|
import { webStreamLines } from "./stream.mjs";
|
|
4
4
|
import { abortErrorStatusCode, isAbortLikeError } from "./http-transport.mjs";
|
|
5
|
+
import {
|
|
6
|
+
incompleteUpstreamStream,
|
|
7
|
+
invalidUpstreamStream,
|
|
8
|
+
requireUpstreamEventStream,
|
|
9
|
+
ToolArgumentDeltaGuard,
|
|
10
|
+
upstreamChatStreamError,
|
|
11
|
+
withChatStreamUsage,
|
|
12
|
+
} from "./stream-contract.mjs";
|
|
13
|
+
import { isChatOutputDelta, markFirstOutput, markOutputTokens } from "./stream-performance.mjs";
|
|
5
14
|
|
|
6
15
|
function cloneJson(value) {
|
|
7
16
|
return value === undefined ? undefined : structuredClone(value);
|
|
8
17
|
}
|
|
9
18
|
|
|
19
|
+
function chatImagePart(part) {
|
|
20
|
+
if (!part || typeof part !== "object") return null;
|
|
21
|
+
let imageUrl = null;
|
|
22
|
+
if (part.type === "input_image" || part.type === "image_url") {
|
|
23
|
+
const raw = part.image_url ?? part.url;
|
|
24
|
+
imageUrl = typeof raw === "string"
|
|
25
|
+
? { url: raw }
|
|
26
|
+
: raw && typeof raw === "object" ? cloneJson(raw) : null;
|
|
27
|
+
} else if (part.type === "image" && part.source?.type === "base64" && typeof part.source.data === "string") {
|
|
28
|
+
imageUrl = { url: `data:${part.source.media_type || "image/png"};base64,${part.source.data}` };
|
|
29
|
+
}
|
|
30
|
+
if (imageUrl && part.detail !== undefined && imageUrl.detail === undefined) imageUrl.detail = part.detail;
|
|
31
|
+
return imageUrl?.url ? { type: "image_url", image_url: imageUrl } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function responsesBodyUsesCustomTools(body) {
|
|
35
|
+
if (body?.tool_choice?.type === "custom") return true;
|
|
36
|
+
if (Array.isArray(body?.tools) && body.tools.some((tool) => tool?.type === "custom")) return true;
|
|
37
|
+
return Array.isArray(body?.input)
|
|
38
|
+
&& body.input.some((item) => item?.type === "custom_tool_call" || item?.type === "custom_tool_call_output");
|
|
39
|
+
}
|
|
40
|
+
|
|
10
41
|
export function responsesToChat(body) {
|
|
11
42
|
const messages = [];
|
|
12
43
|
if (body.instructions) messages.push({ role: "system", content: body.instructions });
|
|
@@ -19,13 +50,10 @@ export function responsesToChat(body) {
|
|
|
19
50
|
if (!part || typeof part !== "object") continue;
|
|
20
51
|
if (["input_text", "output_text", "text"].includes(part.type)) {
|
|
21
52
|
parts.push({ type: "text", text: String(part.text || "") });
|
|
22
|
-
} else if (
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
: raw && typeof raw === "object" ? cloneJson(raw) : null;
|
|
27
|
-
if (imageUrl && part.detail !== undefined && imageUrl.detail === undefined) imageUrl.detail = part.detail;
|
|
28
|
-
if (imageUrl?.url) parts.push({ type: "image_url", image_url: imageUrl });
|
|
53
|
+
} else if (["input_image", "image_url", "image"].includes(part.type)) {
|
|
54
|
+
const imagePart = chatImagePart(part);
|
|
55
|
+
if (imagePart) parts.push(imagePart);
|
|
56
|
+
else parts.push({ type: "text", text: JSON.stringify(part) });
|
|
29
57
|
} else {
|
|
30
58
|
parts.push({ type: "text", text: JSON.stringify(part) });
|
|
31
59
|
}
|
|
@@ -40,6 +68,8 @@ export function responsesToChat(body) {
|
|
|
40
68
|
for (const item of body.input) {
|
|
41
69
|
if (item.type === "message") {
|
|
42
70
|
messages.push({ role: item.role, content: messageContent(item.content) });
|
|
71
|
+
} else if (["input_image", "image_url", "image"].includes(item.type)) {
|
|
72
|
+
messages.push({ role: "user", content: messageContent([item]) });
|
|
43
73
|
} else if (item.type === "function_call") {
|
|
44
74
|
messages.push({
|
|
45
75
|
role: "assistant",
|
|
@@ -113,14 +143,13 @@ export function chatToResponses(chatResp, model) {
|
|
|
113
143
|
|
|
114
144
|
export async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
|
|
115
145
|
delete chatReq.max_tokens;
|
|
146
|
+
const upstreamReq = withChatStreamUsage(chatReq);
|
|
147
|
+
const bodyText = upstreamReq === chatReq ? options.bodyText : undefined;
|
|
116
148
|
let resp;
|
|
117
149
|
try {
|
|
118
150
|
const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
|
|
119
151
|
try {
|
|
120
|
-
resp = await chatCompletionsFn({
|
|
121
|
-
...chatReq,
|
|
122
|
-
stream: true,
|
|
123
|
-
}, { signal: options.signal });
|
|
152
|
+
resp = await chatCompletionsFn(upstreamReq, { signal: options.signal, bodyText });
|
|
124
153
|
} finally {
|
|
125
154
|
options.releaseRequest?.();
|
|
126
155
|
}
|
|
@@ -130,9 +159,16 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
130
159
|
return;
|
|
131
160
|
}
|
|
132
161
|
if (!resp.ok) {
|
|
133
|
-
await onError(resp.status, await resp.text());
|
|
162
|
+
await onError(resp.status, await resp.text(), undefined, resp);
|
|
134
163
|
return;
|
|
135
164
|
}
|
|
165
|
+
try {
|
|
166
|
+
await requireUpstreamEventStream(resp);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
await onError(error.statusCode, error.message, error);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
options.onUpstreamResponse?.(resp);
|
|
136
172
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
137
173
|
const respId = `resp_${uid()}`;
|
|
138
174
|
let actualModel = chatReq.model || "unknown";
|
|
@@ -141,6 +177,13 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
141
177
|
let nextOutputIndex = 0;
|
|
142
178
|
let usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
|
|
143
179
|
const toolCalls = new Map();
|
|
180
|
+
const toolArgumentGuard = new ToolArgumentDeltaGuard();
|
|
181
|
+
let performanceOutputSeen = false;
|
|
182
|
+
const markPerformanceOutput = () => {
|
|
183
|
+
if (performanceOutputSeen) return;
|
|
184
|
+
performanceOutputSeen = true;
|
|
185
|
+
markFirstOutput();
|
|
186
|
+
};
|
|
144
187
|
|
|
145
188
|
await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
|
|
146
189
|
|
|
@@ -238,7 +281,16 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
238
281
|
const data = line.slice(6).trim();
|
|
239
282
|
if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
|
|
240
283
|
let parsed;
|
|
241
|
-
try {
|
|
284
|
+
try {
|
|
285
|
+
parsed = JSON.parse(data);
|
|
286
|
+
} catch {
|
|
287
|
+
throw invalidUpstreamStream(
|
|
288
|
+
"Upstream Chat Completions SSE event contained invalid JSON",
|
|
289
|
+
"upstream_stream_json_invalid",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const upstreamError = upstreamChatStreamError(parsed);
|
|
293
|
+
if (upstreamError) throw upstreamError;
|
|
242
294
|
if (parsed.model) actualModel = parsed.model;
|
|
243
295
|
if (parsed.usage) {
|
|
244
296
|
usage = {
|
|
@@ -248,9 +300,11 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
248
300
|
};
|
|
249
301
|
const cached = parsed.usage.prompt_tokens_details?.cached_tokens;
|
|
250
302
|
if (cached) usage.input_tokens_details = { cached_tokens: cached };
|
|
303
|
+
markOutputTokens(usage.output_tokens);
|
|
251
304
|
}
|
|
252
305
|
const delta = parsed.choices?.[0]?.delta;
|
|
253
306
|
if (!delta) continue;
|
|
307
|
+
if (isChatOutputDelta(delta)) markPerformanceOutput();
|
|
254
308
|
if (delta.content) {
|
|
255
309
|
const message = await ensureMessageItem();
|
|
256
310
|
fullText += delta.content;
|
|
@@ -261,22 +315,24 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
261
315
|
const { tool, created } = await ensureToolCall(tc);
|
|
262
316
|
if (tc.id) tool.callId = tc.id;
|
|
263
317
|
if (!created && tc.function?.name) tool.name += tc.function.name;
|
|
264
|
-
|
|
265
|
-
|
|
318
|
+
const argumentDelta = tc.function?.arguments;
|
|
319
|
+
toolArgumentGuard.observe(tool.outputIndex, argumentDelta);
|
|
320
|
+
if (argumentDelta) {
|
|
321
|
+
tool.arguments += argumentDelta;
|
|
266
322
|
await emitEvent("response.function_call_arguments.delta", {
|
|
267
323
|
output_index: tool.outputIndex,
|
|
268
324
|
item_id: tool.id,
|
|
269
|
-
delta:
|
|
325
|
+
delta: argumentDelta,
|
|
270
326
|
});
|
|
271
327
|
}
|
|
272
328
|
}
|
|
273
329
|
}
|
|
274
330
|
}
|
|
275
331
|
} catch (e) {
|
|
276
|
-
const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
|
|
277
|
-
await onError(statusCode, e?.message || "upstream stream error");
|
|
332
|
+
const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : (e?.statusCode || 502);
|
|
333
|
+
await onError(statusCode, e?.message || "upstream stream error", e);
|
|
278
334
|
return;
|
|
279
335
|
}
|
|
280
|
-
|
|
281
|
-
|
|
336
|
+
const error = incompleteUpstreamStream("[DONE]");
|
|
337
|
+
await onError(error.statusCode, error.message, error);
|
|
282
338
|
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isResponsesToolOutputItem,
|
|
3
|
+
readResponsesImagePart,
|
|
4
|
+
readResponsesToolOutputParts,
|
|
5
|
+
} from "./responses-content.mjs";
|
|
6
|
+
|
|
7
|
+
const IMAGE_OMISSION_TEXT = "[CCDX: earlier image omitted to fit the upstream request byte budget.]";
|
|
8
|
+
|
|
9
|
+
function bodyPayload(reqBody) {
|
|
10
|
+
const bodyText = JSON.stringify(reqBody);
|
|
11
|
+
return { bodyText, bodyBytes: Buffer.byteLength(bodyText) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function inlineImageIdentity(part) {
|
|
15
|
+
const identity = readResponsesImagePart(part)?.identity;
|
|
16
|
+
return identity ? { kind: "inline:data", value: identity } : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function imageMarkerPart(record) {
|
|
20
|
+
if (record.kind === "message") {
|
|
21
|
+
return {
|
|
22
|
+
type: record.role === "assistant" ? "output_text" : "input_text",
|
|
23
|
+
text: IMAGE_OMISSION_TEXT,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return { type: record.usesAnthropicParts ? "text" : "input_text", text: IMAGE_OMISSION_TEXT };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function collectResponseImages(inputItems, currentInputStart) {
|
|
30
|
+
const records = [];
|
|
31
|
+
const refs = [];
|
|
32
|
+
const addContainer = (parts, options) => {
|
|
33
|
+
if (!Array.isArray(parts)) return;
|
|
34
|
+
const record = {
|
|
35
|
+
parts,
|
|
36
|
+
kind: options.kind,
|
|
37
|
+
role: options.role,
|
|
38
|
+
commit: options.commit,
|
|
39
|
+
stringified: options.stringified,
|
|
40
|
+
originalOutput: options.originalOutput,
|
|
41
|
+
usesAnthropicParts: false,
|
|
42
|
+
};
|
|
43
|
+
for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
|
|
44
|
+
const identity = inlineImageIdentity(parts[partIndex]);
|
|
45
|
+
if (!identity) continue;
|
|
46
|
+
if (parts[partIndex].type === "image") record.usesAnthropicParts = true;
|
|
47
|
+
refs.push({
|
|
48
|
+
record,
|
|
49
|
+
partIndex,
|
|
50
|
+
historical: options.historical,
|
|
51
|
+
identity,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (refs.some((ref) => ref.record === record)) records.push(record);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const historyBoundary = Math.min(Math.max(0, currentInputStart), inputItems.length);
|
|
58
|
+
for (let itemIndex = 0; itemIndex < inputItems.length; itemIndex += 1) {
|
|
59
|
+
const item = inputItems[itemIndex];
|
|
60
|
+
if (!item || typeof item !== "object") continue;
|
|
61
|
+
const historical = itemIndex < historyBoundary;
|
|
62
|
+
const identity = inlineImageIdentity(item);
|
|
63
|
+
if (identity) {
|
|
64
|
+
const record = {
|
|
65
|
+
parts: inputItems,
|
|
66
|
+
partIndex: itemIndex,
|
|
67
|
+
kind: "top-level",
|
|
68
|
+
role: "user",
|
|
69
|
+
commit: null,
|
|
70
|
+
usesAnthropicParts: false,
|
|
71
|
+
};
|
|
72
|
+
refs.push({ record, partIndex: itemIndex, historical, identity });
|
|
73
|
+
records.push(record);
|
|
74
|
+
}
|
|
75
|
+
if (item.type === "message") {
|
|
76
|
+
addContainer(item.content, { kind: "message", role: item.role, historical });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const toolOutput = readResponsesToolOutputParts(item);
|
|
80
|
+
if (toolOutput) {
|
|
81
|
+
addContainer(toolOutput.parts, {
|
|
82
|
+
kind: "tool-output",
|
|
83
|
+
commit: toolOutput.commit,
|
|
84
|
+
historical,
|
|
85
|
+
stringified: typeof item.output === "string",
|
|
86
|
+
originalOutput: item.output,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { records, refs };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function orderedHistoricalImageCandidates(refs) {
|
|
94
|
+
const seenByKind = new Map();
|
|
95
|
+
const duplicates = [];
|
|
96
|
+
for (let index = refs.length - 1; index >= 0; index -= 1) {
|
|
97
|
+
const { kind, value } = refs[index].identity;
|
|
98
|
+
let seen = seenByKind.get(kind);
|
|
99
|
+
if (!seen) {
|
|
100
|
+
seen = new Set();
|
|
101
|
+
seenByKind.set(kind, seen);
|
|
102
|
+
}
|
|
103
|
+
if (seen.has(value) && refs[index].historical) duplicates.push(index);
|
|
104
|
+
else seen.add(value);
|
|
105
|
+
}
|
|
106
|
+
duplicates.sort((left, right) => left - right);
|
|
107
|
+
const duplicateSet = new Set(duplicates);
|
|
108
|
+
const remaining = refs.map((_, index) => index)
|
|
109
|
+
.filter((index) => refs[index].historical && !duplicateSet.has(index));
|
|
110
|
+
return [
|
|
111
|
+
...duplicates,
|
|
112
|
+
...remaining.filter((index) => refs[index].record.kind === "tool-output"),
|
|
113
|
+
...remaining.filter((index) => refs[index].record.kind !== "tool-output"),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function serializedPartBytes(part, stringified) {
|
|
118
|
+
const serialized = JSON.stringify(part);
|
|
119
|
+
return stringified
|
|
120
|
+
? Buffer.byteLength(JSON.stringify(serialized)) - 2
|
|
121
|
+
: Buffer.byteLength(serialized);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function createRecordDropState(record) {
|
|
125
|
+
if (record.kind === "top-level") {
|
|
126
|
+
const marker = {
|
|
127
|
+
type: "message",
|
|
128
|
+
role: "user",
|
|
129
|
+
content: [{ type: "input_text", text: IMAGE_OMISSION_TEXT }],
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
currentBytes: Buffer.byteLength(JSON.stringify(record.parts[record.partIndex])),
|
|
133
|
+
markerBytes: Buffer.byteLength(JSON.stringify(marker)),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const elementBytes = record.parts.map((part) => serializedPartBytes(part, record.stringified));
|
|
138
|
+
const remainingElementBytes = elementBytes.reduce((sum, bytes) => sum + bytes, 0);
|
|
139
|
+
const remainingCount = record.parts.length;
|
|
140
|
+
const baseBytes = record.stringified ? 4 : 2;
|
|
141
|
+
const compactBytes = baseBytes + remainingElementBytes + Math.max(0, remainingCount - 1);
|
|
142
|
+
return {
|
|
143
|
+
baseBytes,
|
|
144
|
+
currentBytes: record.stringified
|
|
145
|
+
? Buffer.byteLength(JSON.stringify(record.originalOutput))
|
|
146
|
+
: compactBytes,
|
|
147
|
+
elementBytes,
|
|
148
|
+
markerElementBytes: serializedPartBytes(imageMarkerPart(record), record.stringified),
|
|
149
|
+
remainingCount,
|
|
150
|
+
remainingElementBytes,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function recordBytesAfterDrop(record, state, partIndex) {
|
|
155
|
+
if (record.kind === "top-level") return state.markerBytes;
|
|
156
|
+
const remainingCount = state.remainingCount - 1;
|
|
157
|
+
if (remainingCount === 0) return state.baseBytes + state.markerElementBytes;
|
|
158
|
+
const remainingElementBytes = state.remainingElementBytes - state.elementBytes[partIndex];
|
|
159
|
+
return state.baseBytes + remainingElementBytes + remainingCount - 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function selectImageDrops(refs, bodyBytes, targetBytes) {
|
|
163
|
+
const candidates = orderedHistoricalImageCandidates(refs);
|
|
164
|
+
const selected = new Set();
|
|
165
|
+
const byRecord = new Map();
|
|
166
|
+
let projectedBytes = bodyBytes;
|
|
167
|
+
for (const index of candidates) {
|
|
168
|
+
const ref = refs[index];
|
|
169
|
+
let state = byRecord.get(ref.record);
|
|
170
|
+
if (!state) {
|
|
171
|
+
state = createRecordDropState(ref.record);
|
|
172
|
+
byRecord.set(ref.record, state);
|
|
173
|
+
}
|
|
174
|
+
const afterBytes = recordBytesAfterDrop(ref.record, state, ref.partIndex);
|
|
175
|
+
const savings = state.currentBytes - afterBytes;
|
|
176
|
+
if (savings <= 0) continue;
|
|
177
|
+
state.currentBytes = afterBytes;
|
|
178
|
+
if (ref.record.kind !== "top-level") {
|
|
179
|
+
state.remainingCount -= 1;
|
|
180
|
+
state.remainingElementBytes -= state.elementBytes[ref.partIndex];
|
|
181
|
+
}
|
|
182
|
+
selected.add(index);
|
|
183
|
+
projectedBytes -= savings;
|
|
184
|
+
if (projectedBytes <= targetBytes) break;
|
|
185
|
+
}
|
|
186
|
+
return selected;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function applyImageDrops(records, refs, selected) {
|
|
190
|
+
const byRecord = new Map();
|
|
191
|
+
for (const index of selected) {
|
|
192
|
+
const ref = refs[index];
|
|
193
|
+
let indexes = byRecord.get(ref.record);
|
|
194
|
+
if (!indexes) {
|
|
195
|
+
indexes = new Set();
|
|
196
|
+
byRecord.set(ref.record, indexes);
|
|
197
|
+
}
|
|
198
|
+
indexes.add(ref.partIndex);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const record of records) {
|
|
202
|
+
const indexes = byRecord.get(record);
|
|
203
|
+
if (!indexes) continue;
|
|
204
|
+
if (record.kind === "top-level") {
|
|
205
|
+
for (const index of indexes) {
|
|
206
|
+
record.parts[index] = {
|
|
207
|
+
type: "message",
|
|
208
|
+
role: "user",
|
|
209
|
+
content: [{ type: "input_text", text: IMAGE_OMISSION_TEXT }],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const kept = record.parts.filter((_, index) => !indexes.has(index));
|
|
215
|
+
if (!kept.length) kept.push(imageMarkerPart(record));
|
|
216
|
+
record.parts.splice(0, record.parts.length, ...kept);
|
|
217
|
+
record.commit?.();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function omitHistoricalToolOutputs(inputItems, currentInputStart, bodyBytes, targetBytes) {
|
|
222
|
+
let omitted = 0;
|
|
223
|
+
let projectedBytes = bodyBytes;
|
|
224
|
+
const historicalCount = Math.min(Math.max(0, currentInputStart), inputItems.length);
|
|
225
|
+
for (let itemIndex = 0; itemIndex < historicalCount && projectedBytes > targetBytes; itemIndex += 1) {
|
|
226
|
+
const item = inputItems[itemIndex];
|
|
227
|
+
if (!isResponsesToolOutputItem(item) || item.output === undefined) continue;
|
|
228
|
+
if (typeof item.output === "string" && item.output.startsWith("[CCDX: earlier tool output omitted")) continue;
|
|
229
|
+
const originalBytes = Buffer.byteLength(JSON.stringify(item.output));
|
|
230
|
+
const marker = `[CCDX: earlier tool output omitted to fit the upstream request byte budget; original_bytes=${originalBytes}.]`;
|
|
231
|
+
const markerBytes = Buffer.byteLength(JSON.stringify(marker));
|
|
232
|
+
if (markerBytes >= originalBytes) continue;
|
|
233
|
+
item.output = marker;
|
|
234
|
+
projectedBytes -= originalBytes - markerBytes;
|
|
235
|
+
omitted += 1;
|
|
236
|
+
}
|
|
237
|
+
return omitted;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function trimResponsesHistoryToByteBudget(reqBody, {
|
|
241
|
+
currentInputStart = 0,
|
|
242
|
+
targetBytes,
|
|
243
|
+
initialBodyText,
|
|
244
|
+
initialBodyBytes,
|
|
245
|
+
} = {}) {
|
|
246
|
+
let bodyText = initialBodyText ?? JSON.stringify(reqBody);
|
|
247
|
+
let bodyBytes = initialBodyBytes ?? Buffer.byteLength(bodyText);
|
|
248
|
+
const limit = Number.isFinite(targetBytes) && targetBytes > 0 ? Math.floor(targetBytes) : bodyBytes;
|
|
249
|
+
const inputItems = Array.isArray(reqBody?.input) ? reqBody.input : [];
|
|
250
|
+
let imagesOmitted = 0;
|
|
251
|
+
let toolOutputsOmitted = 0;
|
|
252
|
+
|
|
253
|
+
while (bodyBytes > limit) {
|
|
254
|
+
const { records, refs } = collectResponseImages(inputItems, currentInputStart);
|
|
255
|
+
if (!refs.length) break;
|
|
256
|
+
const selected = selectImageDrops(refs, bodyBytes, limit);
|
|
257
|
+
if (!selected.size) break;
|
|
258
|
+
applyImageDrops(records, refs, selected);
|
|
259
|
+
imagesOmitted += selected.size;
|
|
260
|
+
({ bodyText, bodyBytes } = bodyPayload(reqBody));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
while (bodyBytes > limit) {
|
|
264
|
+
const omitted = omitHistoricalToolOutputs(inputItems, currentInputStart, bodyBytes, limit);
|
|
265
|
+
if (omitted === 0) break;
|
|
266
|
+
toolOutputsOmitted += omitted;
|
|
267
|
+
({ bodyText, bodyBytes } = bodyPayload(reqBody));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
bodyText,
|
|
272
|
+
bodyBytes,
|
|
273
|
+
overBudget: bodyBytes > limit,
|
|
274
|
+
targetBytes: limit,
|
|
275
|
+
imagesOmitted,
|
|
276
|
+
toolOutputsOmitted,
|
|
277
|
+
adapted: imagesOmitted > 0 || toolOutputsOmitted > 0,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function enforceResponsesPayloadByteBudget(reqBody, payload) {
|
|
282
|
+
let finalized = payload;
|
|
283
|
+
if (payload?.overBudget) {
|
|
284
|
+
const trimmed = trimResponsesHistoryToByteBudget(reqBody, {
|
|
285
|
+
currentInputStart: payload.currentInputStart,
|
|
286
|
+
targetBytes: payload.targetBytes,
|
|
287
|
+
initialBodyText: payload.bodyText,
|
|
288
|
+
initialBodyBytes: payload.bodyBytes,
|
|
289
|
+
});
|
|
290
|
+
finalized = {
|
|
291
|
+
...payload,
|
|
292
|
+
...trimmed,
|
|
293
|
+
adapted: Boolean(payload.adapted || trimmed.adapted),
|
|
294
|
+
stage: trimmed.adapted ? `${payload.stage}+history` : payload.stage,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (!finalized?.overBudget) return finalized;
|
|
298
|
+
|
|
299
|
+
const message = `Responses request body is ${finalized.bodyBytes} bytes after ${finalized.stage}; configured upstream limit is ${finalized.targetBytes} bytes`;
|
|
300
|
+
const error = new Error(message);
|
|
301
|
+
error.statusCode = 413;
|
|
302
|
+
error.code = "ccdx_request_body_too_large";
|
|
303
|
+
error.jsonBody = {
|
|
304
|
+
error: {
|
|
305
|
+
message,
|
|
306
|
+
type: "invalid_request_error",
|
|
307
|
+
code: error.code,
|
|
308
|
+
actual_bytes: finalized.bodyBytes,
|
|
309
|
+
limit_bytes: finalized.targetBytes,
|
|
310
|
+
stage: finalized.stage,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { prepareResponsesPayload } from "./image-optimization.mjs";
|
|
2
|
+
import {
|
|
3
|
+
enforceResponsesPayloadByteBudget,
|
|
4
|
+
trimResponsesHistoryToByteBudget,
|
|
5
|
+
} from "./responses-byte-budget.mjs";
|
|
6
|
+
import { responsesToChat } from "./responses-bridge.mjs";
|
|
7
|
+
import { readResponsesImagePart, readResponsesToolOutputParts } from "./responses-content.mjs";
|
|
8
|
+
import { withChatStreamUsage } from "./stream-contract.mjs";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_UPSTREAM_BODY_BYTES = 30 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function positiveInt(value, fallback) {
|
|
13
|
+
const parsed = Number.parseInt(value, 10);
|
|
14
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, Number.MAX_SAFE_INTEGER) : fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function configuredBodyLimit(payloadOptions) {
|
|
18
|
+
const environmentLimit = positiveInt(
|
|
19
|
+
process.env.CCDX_MAX_UPSTREAM_BODY_BYTES,
|
|
20
|
+
DEFAULT_MAX_UPSTREAM_BODY_BYTES,
|
|
21
|
+
);
|
|
22
|
+
return positiveInt(payloadOptions?.maxBytes, environmentLimit);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function jsonPayload(value) {
|
|
26
|
+
const bodyText = JSON.stringify(value);
|
|
27
|
+
return { bodyText, bodyBytes: Buffer.byteLength(bodyText) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function chatRequest(responsesBody, stream) {
|
|
31
|
+
const chatReq = responsesToChat(responsesBody);
|
|
32
|
+
chatReq.stream = Boolean(stream);
|
|
33
|
+
delete chatReq.max_tokens;
|
|
34
|
+
return stream ? withChatStreamUsage(chatReq) : chatReq;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function adjustedResponsesTarget(limit, responsesBytes, chatBytes) {
|
|
38
|
+
return Math.max(1, limit - (chatBytes - responsesBytes));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function partsHaveInlineImage(parts) {
|
|
42
|
+
return Array.isArray(parts) && parts.some((part) => Boolean(readResponsesImagePart(part)?.identity));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function bodyHasInlineImage(body) {
|
|
46
|
+
if (!Array.isArray(body?.input)) return false;
|
|
47
|
+
for (const item of body.input) {
|
|
48
|
+
if (readResponsesImagePart(item)?.identity) return true;
|
|
49
|
+
if (item?.type === "message" && partsHaveInlineImage(item.content)) return true;
|
|
50
|
+
const toolOutput = readResponsesToolOutputParts(item);
|
|
51
|
+
if (toolOutput && partsHaveInlineImage(toolOutput.parts)) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function checkedChatPayload(chatReq, payload, limit, stage, adapted) {
|
|
57
|
+
enforceResponsesPayloadByteBudget(chatReq, {
|
|
58
|
+
...payload,
|
|
59
|
+
adapted,
|
|
60
|
+
currentInputStart: 0,
|
|
61
|
+
overBudget: payload.bodyBytes > limit,
|
|
62
|
+
stage: stage === "chat" ? stage : `${stage}+chat`,
|
|
63
|
+
targetBytes: limit,
|
|
64
|
+
});
|
|
65
|
+
return { chatReq, ...payload, adapted, stage };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function prepareResponsesChatPayload(reqContext, {
|
|
69
|
+
payloadOptions = {},
|
|
70
|
+
signal,
|
|
71
|
+
stream = false,
|
|
72
|
+
} = {}) {
|
|
73
|
+
const limit = configuredBodyLimit(payloadOptions);
|
|
74
|
+
let finalChatReq = chatRequest(reqContext.body, stream);
|
|
75
|
+
let finalChat = jsonPayload(finalChatReq);
|
|
76
|
+
const hasInlineImage = bodyHasInlineImage(reqContext.body);
|
|
77
|
+
if (!hasInlineImage && (finalChat.bodyBytes <= limit || !(reqContext.currentInputStart > 0))) {
|
|
78
|
+
return checkedChatPayload(finalChatReq, finalChat, limit, "chat", false);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let responsesPayload = jsonPayload(reqContext.body);
|
|
82
|
+
let adapted = false;
|
|
83
|
+
let stage = "chat";
|
|
84
|
+
if (hasInlineImage) {
|
|
85
|
+
const prepared = await prepareResponsesPayload(reqContext.body, {
|
|
86
|
+
...payloadOptions,
|
|
87
|
+
currentInputStart: reqContext.currentInputStart,
|
|
88
|
+
maxBytes: adjustedResponsesTarget(limit, responsesPayload.bodyBytes, finalChat.bodyBytes),
|
|
89
|
+
signal,
|
|
90
|
+
});
|
|
91
|
+
responsesPayload = { bodyText: prepared.bodyText, bodyBytes: prepared.bodyBytes };
|
|
92
|
+
adapted = prepared.adapted;
|
|
93
|
+
stage = prepared.stage;
|
|
94
|
+
finalChatReq = chatRequest(reqContext.body, stream);
|
|
95
|
+
finalChat = jsonPayload(finalChatReq);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
while (finalChat.bodyBytes > limit) {
|
|
99
|
+
if (!(reqContext.currentInputStart > 0)) break;
|
|
100
|
+
const targetBytes = adjustedResponsesTarget(
|
|
101
|
+
limit,
|
|
102
|
+
responsesPayload.bodyBytes,
|
|
103
|
+
finalChat.bodyBytes,
|
|
104
|
+
);
|
|
105
|
+
const bodyBeforeTrim = structuredClone(reqContext.body);
|
|
106
|
+
const responsesBeforeTrim = responsesPayload;
|
|
107
|
+
const trimmed = trimResponsesHistoryToByteBudget(reqContext.body, {
|
|
108
|
+
currentInputStart: reqContext.currentInputStart,
|
|
109
|
+
targetBytes,
|
|
110
|
+
initialBodyText: responsesPayload.bodyText,
|
|
111
|
+
initialBodyBytes: responsesPayload.bodyBytes,
|
|
112
|
+
});
|
|
113
|
+
if (!trimmed.adapted) break;
|
|
114
|
+
const candidateChatReq = chatRequest(reqContext.body, stream);
|
|
115
|
+
const candidateChat = jsonPayload(candidateChatReq);
|
|
116
|
+
if (candidateChat.bodyBytes >= finalChat.bodyBytes) {
|
|
117
|
+
reqContext.body = bodyBeforeTrim;
|
|
118
|
+
responsesPayload = responsesBeforeTrim;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
responsesPayload = trimmed;
|
|
122
|
+
finalChatReq = candidateChatReq;
|
|
123
|
+
finalChat = candidateChat;
|
|
124
|
+
adapted = true;
|
|
125
|
+
if (!stage.endsWith("+history")) stage += "+history";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return checkedChatPayload(finalChatReq, finalChat, limit, stage, adapted);
|
|
129
|
+
}
|