codex-copilot-dx 0.4.41 → 0.4.43

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.
@@ -7,6 +7,28 @@ function cloneJson(value) {
7
7
  return value === undefined ? undefined : structuredClone(value);
8
8
  }
9
9
 
10
+ function chatImagePart(part) {
11
+ if (!part || typeof part !== "object") return null;
12
+ let imageUrl = null;
13
+ if (part.type === "input_image" || part.type === "image_url") {
14
+ const raw = part.image_url ?? part.url;
15
+ imageUrl = typeof raw === "string"
16
+ ? { url: raw }
17
+ : raw && typeof raw === "object" ? cloneJson(raw) : null;
18
+ } else if (part.type === "image" && part.source?.type === "base64" && typeof part.source.data === "string") {
19
+ imageUrl = { url: `data:${part.source.media_type || "image/png"};base64,${part.source.data}` };
20
+ }
21
+ if (imageUrl && part.detail !== undefined && imageUrl.detail === undefined) imageUrl.detail = part.detail;
22
+ return imageUrl?.url ? { type: "image_url", image_url: imageUrl } : null;
23
+ }
24
+
25
+ export function responsesBodyUsesCustomTools(body) {
26
+ if (body?.tool_choice?.type === "custom") return true;
27
+ if (Array.isArray(body?.tools) && body.tools.some((tool) => tool?.type === "custom")) return true;
28
+ return Array.isArray(body?.input)
29
+ && body.input.some((item) => item?.type === "custom_tool_call" || item?.type === "custom_tool_call_output");
30
+ }
31
+
10
32
  export function responsesToChat(body) {
11
33
  const messages = [];
12
34
  if (body.instructions) messages.push({ role: "system", content: body.instructions });
@@ -19,13 +41,10 @@ export function responsesToChat(body) {
19
41
  if (!part || typeof part !== "object") continue;
20
42
  if (["input_text", "output_text", "text"].includes(part.type)) {
21
43
  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 });
44
+ } else if (["input_image", "image_url", "image"].includes(part.type)) {
45
+ const imagePart = chatImagePart(part);
46
+ if (imagePart) parts.push(imagePart);
47
+ else parts.push({ type: "text", text: JSON.stringify(part) });
29
48
  } else {
30
49
  parts.push({ type: "text", text: JSON.stringify(part) });
31
50
  }
@@ -40,6 +59,8 @@ export function responsesToChat(body) {
40
59
  for (const item of body.input) {
41
60
  if (item.type === "message") {
42
61
  messages.push({ role: item.role, content: messageContent(item.content) });
62
+ } else if (["input_image", "image_url", "image"].includes(item.type)) {
63
+ messages.push({ role: "user", content: messageContent([item]) });
43
64
  } else if (item.type === "function_call") {
44
65
  messages.push({
45
66
  role: "assistant",
@@ -113,14 +134,13 @@ export function chatToResponses(chatResp, model) {
113
134
 
114
135
  export async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
115
136
  delete chatReq.max_tokens;
137
+ const upstreamReq = chatReq.stream === true ? chatReq : { ...chatReq, stream: true };
138
+ const bodyText = upstreamReq === chatReq ? options.bodyText : undefined;
116
139
  let resp;
117
140
  try {
118
141
  const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
119
142
  try {
120
- resp = await chatCompletionsFn({
121
- ...chatReq,
122
- stream: true,
123
- }, { signal: options.signal });
143
+ resp = await chatCompletionsFn(upstreamReq, { signal: options.signal, bodyText });
124
144
  } finally {
125
145
  options.releaseRequest?.();
126
146
  }
@@ -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,128 @@
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
+
9
+ const DEFAULT_MAX_UPSTREAM_BODY_BYTES = 30 * 1024 * 1024;
10
+
11
+ function positiveInt(value, fallback) {
12
+ const parsed = Number.parseInt(value, 10);
13
+ return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, Number.MAX_SAFE_INTEGER) : fallback;
14
+ }
15
+
16
+ function configuredBodyLimit(payloadOptions) {
17
+ const environmentLimit = positiveInt(
18
+ process.env.CCDX_MAX_UPSTREAM_BODY_BYTES,
19
+ DEFAULT_MAX_UPSTREAM_BODY_BYTES,
20
+ );
21
+ return positiveInt(payloadOptions?.maxBytes, environmentLimit);
22
+ }
23
+
24
+ function jsonPayload(value) {
25
+ const bodyText = JSON.stringify(value);
26
+ return { bodyText, bodyBytes: Buffer.byteLength(bodyText) };
27
+ }
28
+
29
+ function chatRequest(responsesBody, stream) {
30
+ const chatReq = responsesToChat(responsesBody);
31
+ chatReq.stream = Boolean(stream);
32
+ delete chatReq.max_tokens;
33
+ return chatReq;
34
+ }
35
+
36
+ function adjustedResponsesTarget(limit, responsesBytes, chatBytes) {
37
+ return Math.max(1, limit - (chatBytes - responsesBytes));
38
+ }
39
+
40
+ function partsHaveInlineImage(parts) {
41
+ return Array.isArray(parts) && parts.some((part) => Boolean(readResponsesImagePart(part)?.identity));
42
+ }
43
+
44
+ function bodyHasInlineImage(body) {
45
+ if (!Array.isArray(body?.input)) return false;
46
+ for (const item of body.input) {
47
+ if (readResponsesImagePart(item)?.identity) return true;
48
+ if (item?.type === "message" && partsHaveInlineImage(item.content)) return true;
49
+ const toolOutput = readResponsesToolOutputParts(item);
50
+ if (toolOutput && partsHaveInlineImage(toolOutput.parts)) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ function checkedChatPayload(chatReq, payload, limit, stage, adapted) {
56
+ enforceResponsesPayloadByteBudget(chatReq, {
57
+ ...payload,
58
+ adapted,
59
+ currentInputStart: 0,
60
+ overBudget: payload.bodyBytes > limit,
61
+ stage: stage === "chat" ? stage : `${stage}+chat`,
62
+ targetBytes: limit,
63
+ });
64
+ return { chatReq, ...payload, adapted, stage };
65
+ }
66
+
67
+ export async function prepareResponsesChatPayload(reqContext, {
68
+ payloadOptions = {},
69
+ signal,
70
+ stream = false,
71
+ } = {}) {
72
+ const limit = configuredBodyLimit(payloadOptions);
73
+ let finalChatReq = chatRequest(reqContext.body, stream);
74
+ let finalChat = jsonPayload(finalChatReq);
75
+ const hasInlineImage = bodyHasInlineImage(reqContext.body);
76
+ if (!hasInlineImage && (finalChat.bodyBytes <= limit || !(reqContext.currentInputStart > 0))) {
77
+ return checkedChatPayload(finalChatReq, finalChat, limit, "chat", false);
78
+ }
79
+
80
+ let responsesPayload = jsonPayload(reqContext.body);
81
+ let adapted = false;
82
+ let stage = "chat";
83
+ if (hasInlineImage) {
84
+ const prepared = await prepareResponsesPayload(reqContext.body, {
85
+ ...payloadOptions,
86
+ currentInputStart: reqContext.currentInputStart,
87
+ maxBytes: adjustedResponsesTarget(limit, responsesPayload.bodyBytes, finalChat.bodyBytes),
88
+ signal,
89
+ });
90
+ responsesPayload = { bodyText: prepared.bodyText, bodyBytes: prepared.bodyBytes };
91
+ adapted = prepared.adapted;
92
+ stage = prepared.stage;
93
+ finalChatReq = chatRequest(reqContext.body, stream);
94
+ finalChat = jsonPayload(finalChatReq);
95
+ }
96
+
97
+ while (finalChat.bodyBytes > limit) {
98
+ if (!(reqContext.currentInputStart > 0)) break;
99
+ const targetBytes = adjustedResponsesTarget(
100
+ limit,
101
+ responsesPayload.bodyBytes,
102
+ finalChat.bodyBytes,
103
+ );
104
+ const bodyBeforeTrim = structuredClone(reqContext.body);
105
+ const responsesBeforeTrim = responsesPayload;
106
+ const trimmed = trimResponsesHistoryToByteBudget(reqContext.body, {
107
+ currentInputStart: reqContext.currentInputStart,
108
+ targetBytes,
109
+ initialBodyText: responsesPayload.bodyText,
110
+ initialBodyBytes: responsesPayload.bodyBytes,
111
+ });
112
+ if (!trimmed.adapted) break;
113
+ const candidateChatReq = chatRequest(reqContext.body, stream);
114
+ const candidateChat = jsonPayload(candidateChatReq);
115
+ if (candidateChat.bodyBytes >= finalChat.bodyBytes) {
116
+ reqContext.body = bodyBeforeTrim;
117
+ responsesPayload = responsesBeforeTrim;
118
+ break;
119
+ }
120
+ responsesPayload = trimmed;
121
+ finalChatReq = candidateChatReq;
122
+ finalChat = candidateChat;
123
+ adapted = true;
124
+ if (!stage.endsWith("+history")) stage += "+history";
125
+ }
126
+
127
+ return checkedChatPayload(finalChatReq, finalChat, limit, stage, adapted);
128
+ }
@@ -0,0 +1,86 @@
1
+ export function isResponsesToolOutputItem(item) {
2
+ return item?.type === "function_call_output" || item?.type === "custom_tool_call_output";
3
+ }
4
+
5
+ const INLINE_IMAGE_DATA_URL = /^data:(image\/[a-z+.-]+);base64,(.+)$/i;
6
+
7
+ export function canonicalInlineImageIdentity(dataUrl) {
8
+ if (typeof dataUrl !== "string") return null;
9
+ const match = INLINE_IMAGE_DATA_URL.exec(dataUrl);
10
+ if (!match) return null;
11
+ const canonicalMime = match[1].toLowerCase();
12
+ return match[1] === canonicalMime
13
+ ? dataUrl
14
+ : `data:${canonicalMime};base64,${match[2]}`;
15
+ }
16
+
17
+ function imageUrlAccessor(part) {
18
+ if (!part || !["input_image", "image_url"].includes(part.type)) return null;
19
+ if (typeof part.image_url === "string") return { owner: part, key: "image_url" };
20
+ if (part.image_url && typeof part.image_url === "object" && typeof part.image_url.url === "string") {
21
+ return { owner: part.image_url, key: "url" };
22
+ }
23
+ if (part.type === "image_url" && typeof part.url === "string") return { owner: part, key: "url" };
24
+ if (part.type === "image_url" && part.url && typeof part.url === "object" && typeof part.url.url === "string") {
25
+ return { owner: part.url, key: "url" };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function readResponsesImagePart(part) {
31
+ const accessor = imageUrlAccessor(part);
32
+ if (accessor) {
33
+ return {
34
+ get dataUrl() {
35
+ return accessor.owner[accessor.key];
36
+ },
37
+ get identity() {
38
+ return canonicalInlineImageIdentity(accessor.owner[accessor.key]);
39
+ },
40
+ setDataUrl(value) {
41
+ if (typeof value !== "string" || accessor.owner[accessor.key] === value) return false;
42
+ accessor.owner[accessor.key] = value;
43
+ return true;
44
+ },
45
+ };
46
+ }
47
+
48
+ if (part?.type !== "image" || part.source?.type !== "base64" || typeof part.source.data !== "string") {
49
+ return null;
50
+ }
51
+ return {
52
+ get dataUrl() {
53
+ return `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
54
+ },
55
+ get identity() {
56
+ return canonicalInlineImageIdentity(`data:${part.source.media_type || "image/png"};base64,${part.source.data}`);
57
+ },
58
+ setDataUrl(value) {
59
+ const match = typeof value === "string" ? INLINE_IMAGE_DATA_URL.exec(value) : null;
60
+ if (!match) return false;
61
+ const mediaType = match[1].toLowerCase();
62
+ const data = match[2];
63
+ if (part.source.media_type === mediaType && part.source.data === data) return false;
64
+ part.source.media_type = mediaType;
65
+ part.source.data = data;
66
+ return true;
67
+ },
68
+ };
69
+ }
70
+
71
+ export function readResponsesToolOutputParts(item) {
72
+ if (!isResponsesToolOutputItem(item)) return null;
73
+ if (Array.isArray(item.output)) return { parts: item.output, commit: null };
74
+ if (typeof item.output !== "string" || !item.output.trim().startsWith("[")) return null;
75
+
76
+ try {
77
+ const parts = JSON.parse(item.output);
78
+ if (!Array.isArray(parts)) return null;
79
+ return {
80
+ parts,
81
+ commit: () => { item.output = JSON.stringify(parts); },
82
+ };
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
@@ -13,7 +13,8 @@ import {
13
13
  modelSupportsChatCompletions,
14
14
  resolveOpenAIModel,
15
15
  } from "./models.mjs";
16
- import { chatToResponses, forwardToChat, responsesToChat } from "./responses-bridge.mjs";
16
+ import { chatToResponses, forwardToChat, responsesBodyUsesCustomTools } from "./responses-bridge.mjs";
17
+ import { prepareResponsesChatPayload } from "./responses-chat-payload.mjs";
17
18
  import { proxyCopilotResponses } from "./responses-proxy.mjs";
18
19
  import { prepareResponsesRequest, rememberResponseHistory } from "./responses-request.mjs";
19
20
  import { status } from "./status.mjs";
@@ -41,11 +42,34 @@ function isResponsesOnlyModel(model) {
41
42
  return RESPONSES_ONLY_FALLBACK.has(model);
42
43
  }
43
44
 
45
+ function cachedModelSupportsResponses(model) {
46
+ const endpoints = getCachedModelEndpoints(model);
47
+ return Array.isArray(endpoints)
48
+ && (endpoints.includes("/responses") || endpoints.includes("/v1/responses"));
49
+ }
50
+
51
+ function unsupportedCustomToolsError(model) {
52
+ const message = `Model ${model} has custom tool items that cannot be represented by Chat Completions, and cached endpoint metadata does not confirm Responses support`;
53
+ const error = new Error(message);
54
+ error.statusCode = 400;
55
+ error.code = "ccdx_custom_tools_require_responses";
56
+ error.jsonBody = {
57
+ error: {
58
+ message,
59
+ type: "invalid_request_error",
60
+ code: error.code,
61
+ model,
62
+ },
63
+ };
64
+ return error;
65
+ }
66
+
44
67
  export function createResponsesHandler(options) {
45
68
  const {
46
69
  acquireRequest,
47
70
  chatCompletionsFn,
48
71
  openAIModelEnv,
72
+ responsesPayloadOptions,
49
73
  responsesFn,
50
74
  streamHandshakeTimeoutMs,
51
75
  streamIdleTimeoutMs,
@@ -69,7 +93,12 @@ export function createResponsesHandler(options) {
69
93
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
70
94
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
71
95
  console.log(status("info", `responses model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
72
- if (requestedModel === CODEX_AUTO_REVIEW_MODEL || isResponsesOnlyModel(upstreamModel)) {
96
+ const usesCustomTools = responsesBodyUsesCustomTools(prepared.body);
97
+ const useNativeResponses = requestedModel === CODEX_AUTO_REVIEW_MODEL
98
+ || isResponsesOnlyModel(upstreamModel)
99
+ || (usesCustomTools && cachedModelSupportsResponses(upstreamModel));
100
+ if (usesCustomTools && !useNativeResponses) throw unsupportedCustomToolsError(upstreamModel);
101
+ if (useNativeResponses) {
73
102
  await proxyCopilotResponses(prepared, req, res, responsesFn, {
74
103
  signal: abort.signal,
75
104
  abort,
@@ -77,7 +106,12 @@ export function createResponsesHandler(options) {
77
106
  streamIdleTimeoutMs,
78
107
  });
79
108
  } else {
80
- const chatReq = responsesToChat(prepared.body);
109
+ const chatPayload = await prepareResponsesChatPayload(prepared, {
110
+ payloadOptions: responsesPayloadOptions,
111
+ signal: abort.signal,
112
+ stream: parsed.stream,
113
+ });
114
+ const { chatReq, bodyText } = chatPayload;
81
115
  if (parsed.stream) {
82
116
  await forwardToChat(chatReq, async (event, data) => {
83
117
  if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
@@ -99,12 +133,11 @@ export function createResponsesHandler(options) {
99
133
  streamIdleTimeoutMs,
100
134
  chatCompletionsFn,
101
135
  releaseRequest,
136
+ bodyText,
102
137
  });
103
138
  } else {
104
- chatReq.stream = false;
105
- delete chatReq.max_tokens;
106
139
  try {
107
- const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
140
+ const upstream = await chatCompletionsFn(chatReq, { signal: abort.signal, bodyText });
108
141
  releaseRequest();
109
142
  const data = await upstream.text();
110
143
  if (!upstream.ok) {