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.
@@ -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 (part.type === "input_image" || part.type === "image_url") {
23
- const raw = part.image_url ?? part.url;
24
- const imageUrl = typeof raw === "string"
25
- ? { url: raw }
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 { parsed = JSON.parse(data); } catch { continue; }
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
- if (tc.function?.arguments) {
265
- tool.arguments += tc.function.arguments;
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: tc.function.arguments,
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
- await emitCompleted();
281
- onDone();
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
+ }