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.
@@ -0,0 +1,209 @@
1
+ import { readResponsesImagePart, readResponsesToolOutputParts } from "./responses-content.mjs";
2
+
3
+ export const MAX_UPSTREAM_IMAGES = 50;
4
+
5
+ function isImagePart(part) {
6
+ return part && typeof part === "object"
7
+ && ["input_image", "image", "image_url"].includes(part.type);
8
+ }
9
+
10
+ function imageIdentity(part) {
11
+ if (!isImagePart(part)) return null;
12
+
13
+ const inlineIdentity = readResponsesImagePart(part)?.identity;
14
+ if (inlineIdentity) return { kind: "inline:data", value: inlineIdentity };
15
+
16
+ if (part.type === "image") {
17
+ const source = part.source;
18
+ if (typeof source?.url === "string") {
19
+ return { kind: `url:${part.detail || ""}`, value: source.url };
20
+ }
21
+ }
22
+
23
+ const raw = part.image_url ?? part.url;
24
+ const url = typeof raw === "string" ? raw : raw?.url;
25
+ if (typeof url === "string") {
26
+ const detail = part.detail ?? raw?.detail ?? "";
27
+ return { kind: `url:${detail}`, value: url };
28
+ }
29
+ if (typeof part.file_id === "string") {
30
+ return { kind: "file", value: part.file_id };
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function markerPart(record, maxImages) {
36
+ const text = `[Earlier image omitted to stay within the upstream ${maxImages}-image request limit.]`;
37
+ if (record.kind === "message") {
38
+ return {
39
+ type: record.role === "assistant" ? "output_text" : "input_text",
40
+ text,
41
+ };
42
+ }
43
+ return { type: record.usesAnthropicParts ? "text" : "input_text", text };
44
+ }
45
+
46
+ function addPartContainer(records, refs, parts, options) {
47
+ if (!Array.isArray(parts)) return;
48
+ const record = {
49
+ parts,
50
+ kind: options.kind,
51
+ role: options.role,
52
+ commit: options.commit,
53
+ usesAnthropicParts: false,
54
+ };
55
+ let found = false;
56
+ for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
57
+ const part = parts[partIndex];
58
+ if (!isImagePart(part)) continue;
59
+ found = true;
60
+ if (part.type === "image") record.usesAnthropicParts = true;
61
+ refs.push({
62
+ record,
63
+ partIndex,
64
+ itemIndex: options.itemIndex,
65
+ identity: imageIdentity(part),
66
+ });
67
+ }
68
+ if (found) records.push(record);
69
+ }
70
+
71
+ function collectImages(inputItems) {
72
+ const records = [];
73
+ const refs = [];
74
+ const topLevelRecord = {
75
+ parts: inputItems,
76
+ kind: "top-level",
77
+ role: "user",
78
+ commit: null,
79
+ usesAnthropicParts: false,
80
+ };
81
+ let hasTopLevelImages = false;
82
+
83
+ for (let itemIndex = 0; itemIndex < inputItems.length; itemIndex += 1) {
84
+ const item = inputItems[itemIndex];
85
+ if (!item || typeof item !== "object") continue;
86
+
87
+ if (isImagePart(item)) {
88
+ hasTopLevelImages = true;
89
+ refs.push({
90
+ record: topLevelRecord,
91
+ partIndex: itemIndex,
92
+ itemIndex,
93
+ identity: imageIdentity(item),
94
+ });
95
+ }
96
+
97
+ if (item.type === "message") {
98
+ addPartContainer(records, refs, item.content, {
99
+ kind: "message",
100
+ role: item.role,
101
+ itemIndex,
102
+ });
103
+ }
104
+
105
+ const toolOutput = readResponsesToolOutputParts(item);
106
+ if (!toolOutput) continue;
107
+ addPartContainer(records, refs, toolOutput.parts, {
108
+ kind: "tool-output",
109
+ itemIndex,
110
+ commit: toolOutput.commit,
111
+ });
112
+ }
113
+
114
+ if (hasTopLevelImages) records.push(topLevelRecord);
115
+ return { records, refs };
116
+ }
117
+
118
+ function applyDrops(records, refs, dropped, maxImages) {
119
+ const droppedByRecord = new Map();
120
+ for (const refIndex of dropped) {
121
+ const ref = refs[refIndex];
122
+ let indexes = droppedByRecord.get(ref.record);
123
+ if (!indexes) {
124
+ indexes = new Set();
125
+ droppedByRecord.set(ref.record, indexes);
126
+ }
127
+ indexes.add(ref.partIndex);
128
+ }
129
+
130
+ for (const record of records) {
131
+ const indexes = droppedByRecord.get(record);
132
+ if (!indexes) continue;
133
+ const kept = record.parts.filter((_, index) => !indexes.has(index));
134
+ if (!kept.length && record.kind !== "top-level") kept.push(markerPart(record, maxImages));
135
+ record.parts.splice(0, record.parts.length, ...kept);
136
+ record.commit?.();
137
+ }
138
+ }
139
+
140
+ export function enforceResponsesImageLimit(inputItems, {
141
+ currentInputStart = 0,
142
+ maxImages = MAX_UPSTREAM_IMAGES,
143
+ beforeMutate = () => {},
144
+ } = {}) {
145
+ const limit = Number.isFinite(maxImages) && maxImages > 0
146
+ ? Math.floor(maxImages)
147
+ : MAX_UPSTREAM_IMAGES;
148
+ if (!Array.isArray(inputItems)) {
149
+ return { total: 0, kept: 0, omitted: 0, duplicates: 0, historicalOmitted: 0, currentOmitted: 0 };
150
+ }
151
+
152
+ const { records, refs } = collectImages(inputItems);
153
+ if (refs.length <= limit) {
154
+ return { total: refs.length, kept: refs.length, omitted: 0, duplicates: 0, historicalOmitted: 0, currentOmitted: 0 };
155
+ }
156
+
157
+ const dropped = new Set();
158
+ const seenByKind = new Map();
159
+ const duplicateCandidates = [];
160
+ let kept = refs.length;
161
+ let duplicates = 0;
162
+
163
+ for (let index = refs.length - 1; index >= 0; index -= 1) {
164
+ const identity = refs[index].identity;
165
+ if (!identity) continue;
166
+ let seen = seenByKind.get(identity.kind);
167
+ if (!seen) {
168
+ seen = new Set();
169
+ seenByKind.set(identity.kind, seen);
170
+ }
171
+ if (seen.has(identity.value)) {
172
+ duplicateCandidates.push(index);
173
+ } else {
174
+ seen.add(identity.value);
175
+ }
176
+ }
177
+
178
+ duplicateCandidates.sort((left, right) => left - right);
179
+ for (const index of duplicateCandidates) {
180
+ if (kept <= limit) break;
181
+ dropped.add(index);
182
+ kept -= 1;
183
+ duplicates += 1;
184
+ }
185
+
186
+ for (let index = 0; index < refs.length && kept > limit; index += 1) {
187
+ if (dropped.has(index)) continue;
188
+ dropped.add(index);
189
+ kept -= 1;
190
+ }
191
+
192
+ let historicalOmitted = 0;
193
+ let currentOmitted = 0;
194
+ for (const index of dropped) {
195
+ if (refs[index].itemIndex >= currentInputStart) currentOmitted += 1;
196
+ else historicalOmitted += 1;
197
+ }
198
+ beforeMutate({ historicalOmitted, currentOmitted });
199
+ applyDrops(records, refs, dropped, limit);
200
+
201
+ return {
202
+ total: refs.length,
203
+ kept,
204
+ omitted: dropped.size,
205
+ duplicates,
206
+ historicalOmitted,
207
+ currentOmitted,
208
+ };
209
+ }
@@ -1,6 +1,8 @@
1
1
  import { responses as copilotResponses } from "./copilot.mjs";
2
2
  import { httpError } from "./http-transport.mjs";
3
+ import { debugLog } from "./log.mjs";
3
4
  import { materializeResponseHistory, rememberResponseHistoryNode } from "./response-history.mjs";
5
+ import { enforceResponsesImageLimit } from "./responses-image-limit.mjs";
4
6
  import { status } from "./status.mjs";
5
7
 
6
8
  function cloneJson(value) {
@@ -29,7 +31,24 @@ export function stripInternalResponsesInputFields(inputItems) {
29
31
 
30
32
  function responsesOutputItems(output) {
31
33
  if (!Array.isArray(output)) return [];
32
- return output.filter((item) => item?.type === "message" || item?.type === "function_call");
34
+ return output.filter((item) => ["message", "function_call", "custom_tool_call"].includes(item?.type));
35
+ }
36
+
37
+ function isCompactedResponsesOutput(output) {
38
+ return Array.isArray(output) && output.some((item) => item?.type === "compaction");
39
+ }
40
+
41
+ function isSuccessfulCompactionResponse(response) {
42
+ if (!isCompactedResponsesOutput(response?.output)) return false;
43
+ if (Object.prototype.hasOwnProperty.call(response, "status") && response.status !== "completed") {
44
+ return false;
45
+ }
46
+ return response.object === "response.compaction" || response.status === "completed";
47
+ }
48
+
49
+ function replayableResponsesOutputItems(output) {
50
+ if (!Array.isArray(output)) return [];
51
+ return output.filter((item) => item && typeof item === "object");
33
52
  }
34
53
 
35
54
  function stripEncryptedReasoningValue(value, state) {
@@ -60,16 +79,21 @@ function isEncryptedReasoningInputItem(item) {
60
79
  export function sanitizeEncryptedReasoningRequest(reqContext) {
61
80
  const state = { changed: false };
62
81
  let body = cloneJson(reqContext.body);
82
+ let currentInputStart = reqContext.currentInputStart;
63
83
  if (Array.isArray(body.input)) {
64
84
  const input = [];
65
- for (const item of body.input) {
85
+ let retainedBeforeCurrent = 0;
86
+ for (let index = 0; index < body.input.length; index += 1) {
87
+ const item = body.input[index];
66
88
  if (isEncryptedReasoningInputItem(item)) {
67
89
  state.changed = true;
68
90
  continue;
69
91
  }
70
92
  input.push(stripEncryptedReasoningValue(item, state));
93
+ if (Number.isFinite(currentInputStart) && index < currentInputStart) retainedBeforeCurrent += 1;
71
94
  }
72
95
  body.input = input;
96
+ if (Number.isFinite(currentInputStart)) currentInputStart = retainedBeforeCurrent;
73
97
  } else {
74
98
  body = stripEncryptedReasoningValue(body, state);
75
99
  }
@@ -82,6 +106,7 @@ export function sanitizeEncryptedReasoningRequest(reqContext) {
82
106
  return {
83
107
  ...reqContext,
84
108
  body,
109
+ currentInputStart,
85
110
  inputItems: Array.isArray(body.input) ? body.input : reqContext.inputItems,
86
111
  historyInputItems,
87
112
  };
@@ -127,8 +152,14 @@ export function sanitizeImageNamespaceCollisionRequest(reqContext) {
127
152
  export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
128
153
  let encryptedRetried = false;
129
154
  let imageNamespaceRetried = false;
155
+ let payloadPrepared = false;
130
156
  for (let attempt = 0; attempt < 3; attempt += 1) {
131
- const resp = await upstream(reqContext.body, { signal: options.signal });
157
+ const resp = await upstream(reqContext.body, {
158
+ signal: options.signal,
159
+ currentInputStart: reqContext.currentInputStart,
160
+ payloadPrepared,
161
+ });
162
+ payloadPrepared = true;
132
163
  if (resp.ok) return { resp, reqContext };
133
164
 
134
165
  const errorText = await resp.text();
@@ -162,14 +193,30 @@ function isBuiltinImageTool(tool) {
162
193
  export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
163
194
  const body = mutate ? reqBody : cloneJson(reqBody);
164
195
  const currentInputItems = responsesInputItems(body.input, { clone: !mutate });
196
+ const currentInputRefs = new Set(
197
+ currentInputItems.filter((item) => item && typeof item === "object"),
198
+ );
165
199
  const previousId = body.previous_response_id;
200
+ let historyItems = [];
166
201
 
167
202
  if (previousId !== undefined && previousId !== null) {
168
- body.input = [...materializeResponseHistory(previousId), ...currentInputItems];
203
+ historyItems = materializeResponseHistory(previousId);
204
+ body.input = [...historyItems, ...currentInputItems];
169
205
  } else {
170
206
  body.input = currentInputItems;
171
207
  }
172
208
 
209
+ let historyInputItems = currentInputItems;
210
+ const imageLimit = enforceResponsesImageLimit(body.input, {
211
+ currentInputStart: historyItems.length,
212
+ beforeMutate: ({ currentOmitted }) => {
213
+ if (currentOmitted > 0) historyInputItems = cloneJson(currentInputItems);
214
+ },
215
+ });
216
+ if (imageLimit.omitted > 0) {
217
+ debugLog(`responses image window total=${imageLimit.total} kept=${imageLimit.kept} omitted=${imageLimit.omitted} duplicates=${imageLimit.duplicates} historical=${imageLimit.historicalOmitted} current=${imageLimit.currentOmitted}`);
218
+ }
219
+
173
220
  delete body.previous_response_id;
174
221
  delete body.store;
175
222
  if (Array.isArray(body.tools)) {
@@ -177,24 +224,29 @@ export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
177
224
  if (!body.tools.length) delete body.tools;
178
225
  }
179
226
  stripInternalResponsesInputFields(body.input);
180
- stripInternalResponsesInputFields(currentInputItems);
227
+ stripInternalResponsesInputFields(historyInputItems);
228
+ const retainedCurrentInputStart = body.input.findIndex((item) => currentInputRefs.has(item));
181
229
 
182
230
  return {
183
231
  body,
184
232
  inputItems: body.input,
233
+ currentInputStart: retainedCurrentInputStart < 0 ? body.input.length : retainedCurrentInputStart,
185
234
  historyParentId: previousId ?? null,
186
- historyInputItems: currentInputItems,
235
+ historyInputItems,
187
236
  takeHistoryOwnership: mutate,
188
237
  };
189
238
  }
190
239
 
191
240
  export function rememberResponseHistory(reqContext, responseJson) {
192
241
  if (!responseJson?.id || !Array.isArray(reqContext?.historyInputItems || reqContext?.inputItems)) return;
193
- const sourceInputItems = reqContext.historyInputItems || reqContext.inputItems;
194
- const sourceOutputItems = responsesOutputItems(responseJson.output);
242
+ const compacted = isSuccessfulCompactionResponse(responseJson);
243
+ const sourceInputItems = compacted ? [] : reqContext.historyInputItems || reqContext.inputItems;
244
+ const sourceOutputItems = compacted
245
+ ? replayableResponsesOutputItems(responseJson.output)
246
+ : responsesOutputItems(responseJson.output);
195
247
  rememberResponseHistoryNode({
196
248
  id: responseJson.id,
197
- parentId: reqContext.historyParentId,
249
+ parentId: compacted ? null : reqContext.historyParentId,
198
250
  inputItems: sourceInputItems,
199
251
  outputItems: sourceOutputItems,
200
252
  takeOwnership: reqContext.takeHistoryOwnership,