codex-copilot-dx 0.4.41 → 0.4.42

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 CHANGED
@@ -179,6 +179,8 @@ Long computer-use sessions can accumulate screenshots inside the conversation hi
179
179
 
180
180
  The adapter automatically downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`. It keeps an existing in-bounds WebP unchanged, never replaces an image with a larger encoding, and applies one global concurrency limit across direct and tool-output images.
181
181
 
182
+ When expanded Responses history exceeds Copilot's 50-image request limit, the adapter removes older duplicate image occurrences first, then keeps the 50 most recent images. This applies only to the forwarded request: local history remains complete, current images are preferred over older history, and requests at or below the limit are unchanged.
183
+
182
184
  Newer ChatGPT/Codex clients can advertise an `image_gen` namespace that already exists upstream. The adapter removes that exact conflicting client tool before forwarding and retries once only when Copilot explicitly reports an image namespace collision. Image inputs and screenshot optimization remain enabled.
183
185
 
184
186
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.41",
3
+ "version": "0.4.42",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -0,0 +1,220 @@
1
+ export const MAX_UPSTREAM_IMAGES = 50;
2
+
3
+ function isImagePart(part) {
4
+ return part && typeof part === "object"
5
+ && ["input_image", "image", "image_url"].includes(part.type);
6
+ }
7
+
8
+ function imageIdentity(part) {
9
+ if (!isImagePart(part)) return null;
10
+
11
+ if (part.type === "image") {
12
+ const source = part.source;
13
+ if (typeof source?.data === "string") {
14
+ return { kind: `image:${source.type || "base64"}:${source.media_type || ""}`, value: source.data };
15
+ }
16
+ if (typeof source?.url === "string") {
17
+ return { kind: `image:url:${part.detail || ""}`, value: source.url };
18
+ }
19
+ }
20
+
21
+ const raw = part.image_url ?? part.url;
22
+ const url = typeof raw === "string" ? raw : raw?.url;
23
+ if (typeof url === "string") {
24
+ const detail = part.detail ?? raw?.detail ?? "";
25
+ return { kind: `${part.type}:url:${detail}`, value: url };
26
+ }
27
+ if (typeof part.file_id === "string") {
28
+ return { kind: `${part.type}:file`, value: part.file_id };
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function markerPart(record, maxImages) {
34
+ const text = `[Earlier image omitted to stay within the upstream ${maxImages}-image request limit.]`;
35
+ if (record.kind === "message") {
36
+ return {
37
+ type: record.role === "assistant" ? "output_text" : "input_text",
38
+ text,
39
+ };
40
+ }
41
+ return { type: record.usesAnthropicParts ? "text" : "input_text", text };
42
+ }
43
+
44
+ function addPartContainer(records, refs, parts, options) {
45
+ if (!Array.isArray(parts)) return;
46
+ const record = {
47
+ parts,
48
+ kind: options.kind,
49
+ role: options.role,
50
+ commit: options.commit,
51
+ usesAnthropicParts: false,
52
+ };
53
+ let found = false;
54
+ for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
55
+ const part = parts[partIndex];
56
+ if (!isImagePart(part)) continue;
57
+ found = true;
58
+ if (part.type === "image") record.usesAnthropicParts = true;
59
+ refs.push({
60
+ record,
61
+ partIndex,
62
+ itemIndex: options.itemIndex,
63
+ identity: imageIdentity(part),
64
+ });
65
+ }
66
+ if (found) records.push(record);
67
+ }
68
+
69
+ function collectImages(inputItems) {
70
+ const records = [];
71
+ const refs = [];
72
+ const topLevelRecord = {
73
+ parts: inputItems,
74
+ kind: "top-level",
75
+ role: "user",
76
+ commit: null,
77
+ usesAnthropicParts: false,
78
+ };
79
+ let hasTopLevelImages = false;
80
+
81
+ for (let itemIndex = 0; itemIndex < inputItems.length; itemIndex += 1) {
82
+ const item = inputItems[itemIndex];
83
+ if (!item || typeof item !== "object") continue;
84
+
85
+ if (isImagePart(item)) {
86
+ hasTopLevelImages = true;
87
+ refs.push({
88
+ record: topLevelRecord,
89
+ partIndex: itemIndex,
90
+ itemIndex,
91
+ identity: imageIdentity(item),
92
+ });
93
+ }
94
+
95
+ if (item.type === "message") {
96
+ addPartContainer(records, refs, item.content, {
97
+ kind: "message",
98
+ role: item.role,
99
+ itemIndex,
100
+ });
101
+ }
102
+
103
+ if (item.type !== "function_call_output") continue;
104
+ if (Array.isArray(item.output)) {
105
+ addPartContainer(records, refs, item.output, {
106
+ kind: "tool-output",
107
+ itemIndex,
108
+ });
109
+ continue;
110
+ }
111
+ if (typeof item.output !== "string" || !item.output.trim().startsWith("[")) continue;
112
+ try {
113
+ const parsed = JSON.parse(item.output);
114
+ if (!Array.isArray(parsed)) continue;
115
+ addPartContainer(records, refs, parsed, {
116
+ kind: "tool-output",
117
+ itemIndex,
118
+ commit: () => { item.output = JSON.stringify(parsed); },
119
+ });
120
+ } catch {
121
+ // Preserve non-JSON tool output unchanged.
122
+ }
123
+ }
124
+
125
+ if (hasTopLevelImages) records.push(topLevelRecord);
126
+ return { records, refs };
127
+ }
128
+
129
+ function applyDrops(records, refs, dropped, maxImages) {
130
+ const droppedByRecord = new Map();
131
+ for (const refIndex of dropped) {
132
+ const ref = refs[refIndex];
133
+ let indexes = droppedByRecord.get(ref.record);
134
+ if (!indexes) {
135
+ indexes = new Set();
136
+ droppedByRecord.set(ref.record, indexes);
137
+ }
138
+ indexes.add(ref.partIndex);
139
+ }
140
+
141
+ for (const record of records) {
142
+ const indexes = droppedByRecord.get(record);
143
+ if (!indexes) continue;
144
+ const kept = record.parts.filter((_, index) => !indexes.has(index));
145
+ if (!kept.length && record.kind !== "top-level") kept.push(markerPart(record, maxImages));
146
+ record.parts.splice(0, record.parts.length, ...kept);
147
+ record.commit?.();
148
+ }
149
+ }
150
+
151
+ export function enforceResponsesImageLimit(inputItems, {
152
+ currentInputStart = 0,
153
+ maxImages = MAX_UPSTREAM_IMAGES,
154
+ beforeMutate = () => {},
155
+ } = {}) {
156
+ const limit = Number.isFinite(maxImages) && maxImages > 0
157
+ ? Math.floor(maxImages)
158
+ : MAX_UPSTREAM_IMAGES;
159
+ if (!Array.isArray(inputItems)) {
160
+ return { total: 0, kept: 0, omitted: 0, duplicates: 0, historicalOmitted: 0, currentOmitted: 0 };
161
+ }
162
+
163
+ const { records, refs } = collectImages(inputItems);
164
+ if (refs.length <= limit) {
165
+ return { total: refs.length, kept: refs.length, omitted: 0, duplicates: 0, historicalOmitted: 0, currentOmitted: 0 };
166
+ }
167
+
168
+ const dropped = new Set();
169
+ const seenByKind = new Map();
170
+ const duplicateCandidates = [];
171
+ let kept = refs.length;
172
+ let duplicates = 0;
173
+
174
+ for (let index = refs.length - 1; index >= 0; index -= 1) {
175
+ const identity = refs[index].identity;
176
+ if (!identity) continue;
177
+ let seen = seenByKind.get(identity.kind);
178
+ if (!seen) {
179
+ seen = new Set();
180
+ seenByKind.set(identity.kind, seen);
181
+ }
182
+ if (seen.has(identity.value)) {
183
+ duplicateCandidates.push(index);
184
+ } else {
185
+ seen.add(identity.value);
186
+ }
187
+ }
188
+
189
+ duplicateCandidates.sort((left, right) => left - right);
190
+ for (const index of duplicateCandidates) {
191
+ if (kept <= limit) break;
192
+ dropped.add(index);
193
+ kept -= 1;
194
+ duplicates += 1;
195
+ }
196
+
197
+ for (let index = 0; index < refs.length && kept > limit; index += 1) {
198
+ if (dropped.has(index)) continue;
199
+ dropped.add(index);
200
+ kept -= 1;
201
+ }
202
+
203
+ let historicalOmitted = 0;
204
+ let currentOmitted = 0;
205
+ for (const index of dropped) {
206
+ if (refs[index].itemIndex >= currentInputStart) currentOmitted += 1;
207
+ else historicalOmitted += 1;
208
+ }
209
+ beforeMutate({ historicalOmitted, currentOmitted });
210
+ applyDrops(records, refs, dropped, limit);
211
+
212
+ return {
213
+ total: refs.length,
214
+ kept,
215
+ omitted: dropped.size,
216
+ duplicates,
217
+ historicalOmitted,
218
+ currentOmitted,
219
+ };
220
+ }
@@ -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) {
@@ -163,13 +165,26 @@ export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
163
165
  const body = mutate ? reqBody : cloneJson(reqBody);
164
166
  const currentInputItems = responsesInputItems(body.input, { clone: !mutate });
165
167
  const previousId = body.previous_response_id;
168
+ let historyItems = [];
166
169
 
167
170
  if (previousId !== undefined && previousId !== null) {
168
- body.input = [...materializeResponseHistory(previousId), ...currentInputItems];
171
+ historyItems = materializeResponseHistory(previousId);
172
+ body.input = [...historyItems, ...currentInputItems];
169
173
  } else {
170
174
  body.input = currentInputItems;
171
175
  }
172
176
 
177
+ let historyInputItems = currentInputItems;
178
+ const imageLimit = enforceResponsesImageLimit(body.input, {
179
+ currentInputStart: historyItems.length,
180
+ beforeMutate: ({ currentOmitted }) => {
181
+ if (currentOmitted > 0) historyInputItems = cloneJson(currentInputItems);
182
+ },
183
+ });
184
+ if (imageLimit.omitted > 0) {
185
+ debugLog(`responses image window total=${imageLimit.total} kept=${imageLimit.kept} omitted=${imageLimit.omitted} duplicates=${imageLimit.duplicates} historical=${imageLimit.historicalOmitted} current=${imageLimit.currentOmitted}`);
186
+ }
187
+
173
188
  delete body.previous_response_id;
174
189
  delete body.store;
175
190
  if (Array.isArray(body.tools)) {
@@ -177,13 +192,13 @@ export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
177
192
  if (!body.tools.length) delete body.tools;
178
193
  }
179
194
  stripInternalResponsesInputFields(body.input);
180
- stripInternalResponsesInputFields(currentInputItems);
195
+ stripInternalResponsesInputFields(historyInputItems);
181
196
 
182
197
  return {
183
198
  body,
184
199
  inputItems: body.input,
185
200
  historyParentId: previousId ?? null,
186
- historyInputItems: currentInputItems,
201
+ historyInputItems,
187
202
  takeHistoryOwnership: mutate,
188
203
  };
189
204
  }