codex-copilot-dx 0.6.14 → 0.6.18

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,6 +2,7 @@ import {
2
2
  isClaudeCopilotCatalogEntry,
3
3
  isClaudeCopilotModel,
4
4
  } from "./models.mjs";
5
+ import { createRoutePlan } from "./route-plan.mjs";
5
6
 
6
7
  function catalogData(catalog) {
7
8
  if (Array.isArray(catalog)) return catalog;
@@ -47,14 +48,51 @@ export function createPmStudioModelRouter({ getCatalog, isClaudeEnabled } = {})
47
48
  return { allowed, known };
48
49
  }
49
50
 
51
+ function classify(modelId) {
52
+ const id = String(modelId || "").trim();
53
+ const { allowed, known } = availability();
54
+ if (allowed.has(id)) return "claude";
55
+ if (known.has(id) || id.toLowerCase().startsWith("claude-")) return "unsupported_claude";
56
+ return "enterprise";
57
+ }
58
+
59
+ function plan(modelId) {
60
+ const id = String(modelId || "").trim();
61
+ const modelType = classify(id);
62
+ const protocol = "openai-chat-completions";
63
+ if (modelType === "claude") {
64
+ return createRoutePlan({
65
+ disposition: "relay",
66
+ origin: "ccdx",
67
+ profile: "claude",
68
+ protocol,
69
+ model: id,
70
+ surface: "pm-studio",
71
+ });
72
+ }
73
+ if (modelType === "unsupported_claude") {
74
+ return createRoutePlan({
75
+ disposition: "reject",
76
+ origin: "ccdx",
77
+ profile: "claude",
78
+ protocol,
79
+ model: id || "unknown",
80
+ surface: "pm-studio",
81
+ });
82
+ }
83
+ return createRoutePlan({
84
+ disposition: "native",
85
+ origin: "pm-studio-native",
86
+ profile: "enterprise",
87
+ protocol,
88
+ model: id || "unknown",
89
+ surface: "pm-studio",
90
+ });
91
+ }
92
+
50
93
  return Object.freeze({
51
- classify(modelId) {
52
- const id = String(modelId || "").trim();
53
- const { allowed, known } = availability();
54
- if (allowed.has(id)) return "claude";
55
- if (known.has(id) || id.toLowerCase().startsWith("claude-")) return "unsupported_claude";
56
- return "enterprise";
57
- },
94
+ plan,
95
+ classify,
58
96
  allowedModels() {
59
97
  return [...availability().allowed.values()];
60
98
  },
@@ -71,6 +71,7 @@ function jsonByteLength(value, seen = new Set()) {
71
71
  const histories = new Map();
72
72
  const childrenById = new Map();
73
73
  const treeLru = new Map();
74
+ const pinnedTrees = new Map();
74
75
  const evictedIds = new Set();
75
76
  let totalBytes = 0;
76
77
  const HISTORY_RUNTIME_CONFIG = loadRuntimeConfig();
@@ -88,6 +89,21 @@ function touchTree(rootId) {
88
89
  treeLru.set(rootId, true);
89
90
  }
90
91
 
92
+ function pinTree(rootId) {
93
+ if (!rootId) return;
94
+ pinnedTrees.set(rootId, (pinnedTrees.get(rootId) || 0) + 1);
95
+ }
96
+
97
+ function unpinTree(rootId) {
98
+ const count = pinnedTrees.get(rootId) || 0;
99
+ if (count <= 1) pinnedTrees.delete(rootId);
100
+ else pinnedTrees.set(rootId, count - 1);
101
+ }
102
+
103
+ function treeIsPinned(rootId) {
104
+ return (pinnedTrees.get(rootId) || 0) > 0;
105
+ }
106
+
91
107
  function linkChild(parentId, id) {
92
108
  if (!parentId) return;
93
109
  let children = childrenById.get(parentId);
@@ -148,18 +164,50 @@ function assignSubtreeRoot(id, rootId) {
148
164
  }
149
165
  }
150
166
 
151
- function enforceLimits() {
152
- while (histories.size > maxEntries || totalBytes > maxBytes) {
153
- const oldestRootId = treeLru.keys().next().value;
154
- if (!oldestRootId) break;
155
- removeSubtree(oldestRootId);
167
+ function subtreeUsage(rootId) {
168
+ const pending = [rootId];
169
+ const seen = new Set();
170
+ let bytes = 0;
171
+ let entries = 0;
172
+ while (pending.length) {
173
+ const id = pending.pop();
174
+ if (seen.has(id)) continue;
175
+ seen.add(id);
176
+ const entry = histories.get(id);
177
+ if (entry) {
178
+ entries += 1;
179
+ bytes += entry.bytes;
180
+ }
181
+ const children = childrenById.get(id);
182
+ if (children) pending.push(...children);
183
+ }
184
+ return { bytes, entries };
185
+ }
186
+
187
+ function makeRoomFor({ additionalBytes, additionalEntries, protectedRoots }) {
188
+ let projectedBytes = totalBytes + additionalBytes;
189
+ let projectedEntries = histories.size + additionalEntries;
190
+ if (projectedEntries <= maxEntries && projectedBytes <= maxBytes) return true;
191
+
192
+ const candidates = [];
193
+ for (const rootId of treeLru.keys()) {
194
+ if (protectedRoots.has(rootId) || treeIsPinned(rootId)) continue;
195
+ const usage = subtreeUsage(rootId);
196
+ candidates.push(rootId);
197
+ projectedBytes -= usage.bytes;
198
+ projectedEntries -= usage.entries;
199
+ if (projectedEntries <= maxEntries && projectedBytes <= maxBytes) break;
156
200
  }
201
+ if (projectedEntries > maxEntries || projectedBytes > maxBytes) return false;
202
+ for (const rootId of candidates) removeSubtree(rootId);
203
+ return true;
157
204
  }
158
205
 
159
206
  export function clearResponseHistoryForTests() {
160
207
  histories.clear();
161
208
  childrenById.clear();
162
209
  treeLru.clear();
210
+ pinnedTrees.clear();
163
211
  evictedIds.clear();
164
212
  totalBytes = 0;
165
213
  maxBytes = DEFAULT_MAX_BYTES;
@@ -202,13 +250,61 @@ export function responseHistoryMaterializedBytes(responseId) {
202
250
  return chain.reduce((bytes, entry) => bytes + entry.bytes, 0);
203
251
  }
204
252
 
205
- export function materializeResponseHistory(responseId, { assertActive } = {}) {
253
+ export function acquireResponseHistorySnapshot(responseId, { assertActive, signal } = {}) {
254
+ assertActive?.();
255
+ if (signal?.aborted) throw signal.reason || new DOMException("The operation was aborted", "AbortError");
256
+ const { chain, rootId } = responseHistoryChain(responseId);
257
+ const entries = chain.slice().reverse();
258
+ const bytes = chain.reduce((total, entry) => total + entry.bytes, 0);
259
+ pinTree(rootId);
260
+
261
+ let released = false;
262
+ const release = () => {
263
+ if (released) return;
264
+ released = true;
265
+ signal?.removeEventListener("abort", release);
266
+ unpinTree(rootId);
267
+ };
268
+ signal?.addEventListener("abort", release, { once: true });
269
+
270
+ return Object.freeze({
271
+ responseId,
272
+ rootId,
273
+ bytes,
274
+ materialize({ assertActive: assertSnapshotActive, routeMetadata } = {}) {
275
+ assertSnapshotActive?.();
276
+ if (released) throw new Error("Response history snapshot has been released");
277
+ const items = [];
278
+ for (let index = 0; index < entries.length; index += 1) {
279
+ if ((index & 63) === 0) assertSnapshotActive?.();
280
+ const entry = entries[index];
281
+ routeMetadata?.push({
282
+ affinity: entry.routeAffinity || null,
283
+ hasOpaque: entry.hasOpaque === true,
284
+ });
285
+ items.push(...entry.inputItems, ...entry.outputItems);
286
+ }
287
+ assertSnapshotActive?.();
288
+ const materialized = cloneJson(items);
289
+ assertSnapshotActive?.();
290
+ touchTree(rootId);
291
+ return materialized;
292
+ },
293
+ release,
294
+ });
295
+ }
296
+
297
+ export function materializeResponseHistory(responseId, { assertActive, routeMetadata } = {}) {
206
298
  assertActive?.();
207
299
  const { chain, rootId } = responseHistoryChain(responseId);
208
300
  const items = [];
209
301
  for (let index = 0; index < chain.length; index += 1) {
210
302
  if ((index & 63) === 0) assertActive?.();
211
303
  const entry = chain[chain.length - index - 1];
304
+ routeMetadata?.push({
305
+ affinity: entry.routeAffinity || null,
306
+ hasOpaque: entry.hasOpaque === true,
307
+ });
212
308
  items.push(...entry.inputItems, ...entry.outputItems);
213
309
  }
214
310
  assertActive?.();
@@ -218,27 +314,57 @@ export function materializeResponseHistory(responseId, { assertActive } = {}) {
218
314
  return materialized;
219
315
  }
220
316
 
221
- export function rememberResponseHistoryNode({ id, parentId, inputItems, outputItems, takeOwnership = false }) {
222
- if (!id || !Array.isArray(inputItems)) return;
317
+ export function rememberResponseHistoryNode({
318
+ id,
319
+ parentId,
320
+ inputItems,
321
+ outputItems,
322
+ hasOpaque = false,
323
+ routeAffinity = null,
324
+ takeOwnership = false,
325
+ }) {
326
+ if (!id || !Array.isArray(inputItems)) return false;
327
+ const existing = histories.get(id);
328
+ const parentEntry = parentId ? histories.get(parentId) : null;
329
+ if (parentId && !parentEntry) {
330
+ if (!existing) rememberEvictedId(id);
331
+ return false;
332
+ }
223
333
  clearResponsesToolOutputPartsCache(inputItems);
224
334
  clearResponsesToolOutputPartsCache(outputItems);
225
- const bytes = jsonByteLength([inputItems, outputItems]);
335
+ const historyValue = routeAffinity
336
+ ? [inputItems, outputItems, { routeAffinity, hasOpaque: hasOpaque === true }]
337
+ : [inputItems, outputItems];
338
+ const bytes = jsonByteLength(historyValue);
226
339
  if (bytes > maxBytes) {
227
- if (histories.has(id)) removeSubtree(id);
228
- rememberEvictedId(id);
229
- return;
340
+ if (!existing) rememberEvictedId(id);
341
+ else if (!treeIsPinned(existing.rootId)) removeSubtree(id);
342
+ return false;
230
343
  }
231
- const existing = histories.get(id);
232
- const parentEntry = parentId ? histories.get(parentId) : null;
233
- const rootId = parentEntry?.rootId || (parentId ? existing?.rootId : id) || id;
344
+ const rootId = parentEntry?.rootId || id;
234
345
  const entry = {
235
346
  parentId: parentId || null,
236
347
  rootId,
237
348
  inputItems: takeOwnership ? inputItems : cloneJson(inputItems),
238
349
  outputItems: takeOwnership ? outputItems : cloneJson(outputItems),
350
+ hasOpaque: hasOpaque === true,
351
+ routeAffinity: routeAffinity ? cloneJson(routeAffinity) : null,
239
352
  bytes,
240
353
  };
241
354
  const oldRootId = existing?.rootId;
355
+ const protectedRoots = new Set([rootId]);
356
+ if (oldRootId) protectedRoots.add(oldRootId);
357
+ if (!makeRoomFor({
358
+ additionalBytes: bytes - (existing?.bytes || 0),
359
+ additionalEntries: existing ? 0 : 1,
360
+ protectedRoots,
361
+ })) {
362
+ if (!existing) {
363
+ if (parentEntry && !treeIsPinned(rootId)) removeSubtree(rootId);
364
+ rememberEvictedId(id);
365
+ }
366
+ return false;
367
+ }
242
368
  if (existing) {
243
369
  totalBytes -= existing.bytes;
244
370
  unlinkChild(existing.parentId, id);
@@ -253,5 +379,5 @@ export function rememberResponseHistoryNode({ id, parentId, inputItems, outputIt
253
379
  totalBytes += entry.bytes;
254
380
  evictedIds.delete(id);
255
381
  touchTree(rootId);
256
- enforceLimits();
382
+ return true;
257
383
  }
@@ -1,7 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { chatCompletions } from "./copilot.mjs";
3
3
  import { webStreamLines } from "./stream.mjs";
4
- import { abortErrorStatusCode, isAbortLikeError } from "./http-transport.mjs";
4
+ import {
5
+ abortErrorStatusCode,
6
+ isAbortLikeError,
7
+ MAX_UPSTREAM_ERROR_BODY_BYTES,
8
+ readBoundedResponseText,
9
+ } from "./http-transport.mjs";
5
10
  import {
6
11
  incompleteUpstreamStream,
7
12
  invalidUpstreamStream,
@@ -216,7 +221,10 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
216
221
  return false;
217
222
  }
218
223
  if (!resp.ok) {
219
- await onError(resp.status, await resp.text(), undefined, resp);
224
+ await onError(resp.status, await readBoundedResponseText(resp, {
225
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
226
+ label: "Copilot Chat error body",
227
+ }), undefined, resp);
220
228
  return false;
221
229
  }
222
230
  try {
@@ -235,6 +243,13 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
235
243
  let usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
236
244
  const toolCalls = new Map();
237
245
  const toolArgumentGuard = new ToolArgumentDeltaGuard();
246
+ const downstreamClosed = Symbol("downstream-closed");
247
+ const configuredEmitEvent = emitEvent;
248
+ emitEvent = async (...args) => {
249
+ const written = await configuredEmitEvent(...args);
250
+ if (written === false) throw downstreamClosed;
251
+ return written;
252
+ };
238
253
  let performanceOutputSeen = false;
239
254
  const markPerformanceOutput = () => {
240
255
  if (performanceOutputSeen) return;
@@ -242,8 +257,6 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
242
257
  markFirstOutput();
243
258
  };
244
259
 
245
- await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
246
-
247
260
  const ensureMessageItem = async () => {
248
261
  if (messageItem) return messageItem;
249
262
  messageItem = { id: `msg_${uid()}`, outputIndex: nextOutputIndex++ };
@@ -331,6 +344,7 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
331
344
  };
332
345
 
333
346
  try {
347
+ await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
334
348
  for await (const line of webStreamLines(resp, {
335
349
  onChunk: () => abort?.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
336
350
  })) {
@@ -386,6 +400,10 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
386
400
  }
387
401
  }
388
402
  } catch (e) {
403
+ if (e === downstreamClosed) {
404
+ await resp.body?.cancel?.().catch?.(() => {});
405
+ return false;
406
+ }
389
407
  const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(abort?.reason) : (e?.statusCode || 502);
390
408
  await onError(statusCode, e?.message || "upstream stream error", e);
391
409
  return false;