codex-copilot-dx 0.6.13 → 0.6.15

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
@@ -7,7 +7,7 @@ Use [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/
7
7
  A single in-process adapter (port `2026`) exposes both APIs over one Copilot
8
8
  account by default, or over isolated Codex and Claude accounts when configured:
9
9
 
10
- - **Codex** -> OpenAI Responses API (`/v1/responses`, `/v1/responses/compact`); Responses-only models and compaction proxy directly, chat models convert to Chat Completions.
10
+ - **Codex** -> OpenAI Responses API (`/v1/responses`, `/v1/responses/compact`); Responses-only models and compaction proxy directly, while chat models convert the text, image, and function-tool subset and explicitly reject incompatible top-level items, tools, and tool choices.
11
11
  - **Claude Code** -> Anthropic Messages API (`/v1/messages`, `/v1/messages/count_tokens`), translated to/from Chat Completions.
12
12
  - **Claude App** -> optional Claude Desktop App gateway profile using the same local Messages API plus local model discovery for the configured gateway key.
13
13
  - **PM Studio** -> optional macOS app patch that keeps the active PM profile for GPT models while routing enabled Claude models through an isolated CCDX Claude account.
@@ -91,7 +91,10 @@ Once enabled, routing is fixed by API surface rather than by model name:
91
91
 
92
92
  The two profiles keep separate service tokens, API bases, refresh/backoff state,
93
93
  model endpoint metadata, and credential-bound model caches. Switching either
94
- GitHub account invalidates that profile's old cache instead of borrowing it. If the isolated Claude profile later
94
+ GitHub account invalidates that profile's old cache instead of borrowing it.
95
+ Cached catalogs are fresh for two hours; older catalogs remain immediately
96
+ available for up to seven days while CCDX refreshes them in the background.
97
+ If the isolated Claude profile later
95
98
  becomes invalid, Claude requests fail with a reauthentication instruction while
96
99
  Codex remains available; CCDX never silently falls back to the enterprise
97
100
  account. Reauthorize it explicitly with:
@@ -301,7 +304,7 @@ To actively verify the protocol path through an already-running adapter, run:
301
304
  ccdx doctor --compat
302
305
  ```
303
306
 
304
- The compatibility doctor sends a few minimal Copilot requests to check Codex Auto-review, native Responses, streaming history, compaction, image tool namespace handling, and Anthropic streaming. It consumes a small amount of Copilot usage, never starts the adapter or device flow, and does not change client configuration. Combine it with `--online` when both the saved-token entitlement check and the adapter protocol checks are needed.
307
+ The compatibility doctor sends a few minimal Copilot requests to check Codex Auto-review, native Responses, streaming history, compaction, and Anthropic streaming. It consumes a small amount of Copilot usage, never starts the adapter or device flow, and does not change client configuration. Combine it with `--online` when both the saved-token entitlement check and the adapter protocol checks are needed.
305
308
 
306
309
  For a read-only summary of the running adapter, use:
307
310
 
@@ -438,9 +441,9 @@ Before image encoding, CCDX leaves ordinary visual history byte-for-byte unchang
438
441
 
439
442
  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.
440
443
 
441
- For `/v1/responses/compact`, the adapter sends one terminal `compaction_trigger` in unary mode, validates that Copilot returned completed compaction state, and rebuilds a replayable snapshot from recent system, developer, user, and assistant messages plus every compaction item. Only a validated snapshot becomes a new local history root. Later turns continue from it without re-expanding pre-compaction history; existing older response branches remain available until normal history eviction.
444
+ For `/v1/responses/compact`, the adapter sends one terminal `compaction_trigger` in unary mode, validates that Copilot returned completed compaction state, and preserves the complete upstream compacted window as the canonical next context. Only a validated window becomes a new local history root. Later turns continue from it without re-expanding pre-compaction history; existing older response branches remain available until normal history eviction.
442
445
 
443
- Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Inference responses forward safe quota, retry, model, trace, and upstream request-ID metadata; cookies, authorization, body-length, and encoding headers are not forwarded.
446
+ Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event; bytes after that terminal are not forwarded. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Inference responses forward safe quota, retry, model, trace, and upstream request-ID metadata; cookies, authorization, body-length, and encoding headers are not forwarded.
444
447
 
445
448
  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.
446
449
 
@@ -456,4 +459,4 @@ npm run verify
456
459
  npm run bench:payload
457
460
  ```
458
461
 
459
- `npm test` runs the unit and handler-level suite. `npm run test:smoke` starts a real local HTTP adapter with fully injected offline upstreams. `npm run bench:check` enforces relative token-counting performance and request-admission resource limits. `npm run pack:check` verifies the npm tarball contents without publishing. `npm run bench:payload` remains a report-only, isolated-process benchmark for 5-60 MiB image payloads and does not contact Copilot. The CI workflow runs the verification checks on supported Node.js release lines.
462
+ `npm test` runs the unit and handler-level suite. `npm run test:smoke` starts a real local HTTP adapter with fully injected offline upstreams. `npm run bench:check` enforces relative token-counting performance, linear SSE scanning, and request-admission resource limits. `npm run pack:check` verifies the npm tarball contents without publishing. `npm run bench:payload` remains a report-only, isolated-process benchmark for 5-60 MiB image payloads and does not contact Copilot. The CI workflow runs the verification checks on supported Node.js release lines.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -31,6 +31,65 @@ function median(values) {
31
31
  : ordered[middle];
32
32
  }
33
33
 
34
+ function sseLinearityProbe() {
35
+ return runProbe(`
36
+ import { webStreamLines } from "./src/stream.mjs";
37
+
38
+ const chunkBytes = 16 * 1024;
39
+ const maxBufferBytes = 8 * 1024 * 1024;
40
+ const chunk = new TextEncoder().encode("x".repeat(chunkBytes));
41
+ const results = [];
42
+ for (const inputMiB of [1, 2, 4, 8]) {
43
+ const inputBytes = inputMiB * 1024 * 1024;
44
+ let remaining = inputBytes;
45
+ const response = {
46
+ body: new ReadableStream({
47
+ pull(controller) {
48
+ if (remaining === 0) {
49
+ controller.close();
50
+ return;
51
+ }
52
+ const nextBytes = Math.min(chunk.byteLength, remaining);
53
+ controller.enqueue(nextBytes === chunk.byteLength ? chunk : chunk.subarray(0, nextBytes));
54
+ remaining -= nextBytes;
55
+ },
56
+ }, { highWaterMark: 0 }),
57
+ };
58
+ const originalByteLength = Buffer.byteLength;
59
+ let byteLengthCalls = 0;
60
+ let scannedBytes = 0;
61
+ Buffer.byteLength = function trackedByteLength(value, ...args) {
62
+ const bytes = originalByteLength(value, ...args);
63
+ byteLengthCalls += 1;
64
+ scannedBytes += bytes;
65
+ return bytes;
66
+ };
67
+ let lines = 0;
68
+ let outputBytes = 0;
69
+ const started = performance.now();
70
+ try {
71
+ for await (const line of webStreamLines(response, { maxBufferBytes })) {
72
+ lines += 1;
73
+ outputBytes += originalByteLength(line);
74
+ }
75
+ } finally {
76
+ Buffer.byteLength = originalByteLength;
77
+ }
78
+ results.push({
79
+ input_mib: inputMiB,
80
+ chunks: Math.ceil(inputBytes / chunkBytes),
81
+ lines,
82
+ output_bytes: outputBytes,
83
+ byte_length_calls: byteLengthCalls,
84
+ scanned_bytes: scannedBytes,
85
+ scan_ratio: +(scannedBytes / inputBytes).toFixed(3),
86
+ elapsed_ms: +(performance.now() - started).toFixed(1),
87
+ });
88
+ }
89
+ process.stdout.write(JSON.stringify({ chunk_kib: chunkBytes / 1024, max_buffer_mib: 8, results }));
90
+ `);
91
+ }
92
+
34
93
  function deterministicPixels(byteLength, initialSeed) {
35
94
  const pixels = Buffer.alloc(byteLength);
36
95
  let seed = initialSeed;
@@ -422,6 +481,7 @@ const report = {
422
481
  };
423
482
 
424
483
  if (checkMode) {
484
+ report.sse_linearity = sseLinearityProbe();
425
485
  report.large_token_count = {
426
486
  proxy: largeTokenCountProbe("proxy"),
427
487
  materialized_array: largeTokenCountProbe("array"),
@@ -466,6 +526,20 @@ if (checkMode) {
466
526
  if (report.tool_output_parse_cache.parse_calls !== 1) {
467
527
  failures.push(`stringified tool output parsed ${report.tool_output_parse_cache.parse_calls} times instead of once`);
468
528
  }
529
+ const expectedSseSizes = [1, 2, 4, 8];
530
+ if (report.sse_linearity.results.length !== expectedSseSizes.length
531
+ || report.sse_linearity.results.some((sample, index) => sample.input_mib !== expectedSseSizes[index])) {
532
+ failures.push("SSE linearity probe did not cover 1/2/4/8 MiB inputs");
533
+ }
534
+ for (const sample of report.sse_linearity.results) {
535
+ const inputBytes = sample.input_mib * 1024 * 1024;
536
+ if (sample.lines !== 1 || sample.output_bytes !== inputBytes) {
537
+ failures.push(`SSE parser changed the ${sample.input_mib}MiB fragmented line`);
538
+ }
539
+ if (sample.scanned_bytes > inputBytes * 1.01 || sample.byte_length_calls > sample.chunks + 1) {
540
+ failures.push(`SSE parser scan complexity is not linear at ${sample.input_mib}MiB`);
541
+ }
542
+ }
469
543
  for (const payload of report.large_payload_peak) {
470
544
  if (payload.admission_max_active_mib > payload.admission_budget_mib) {
471
545
  failures.push(`request admission exceeded its budget at ${payload.target_request_mib}MiB x${payload.concurrency}`);
package/src/adapter.mjs CHANGED
@@ -41,14 +41,18 @@ export {
41
41
  responseHistoryStats,
42
42
  } from "./response-history.mjs";
43
43
  export { forwardToChat, responsesToChat } from "./responses-bridge.mjs";
44
+ export {
45
+ openCopilotResponse,
46
+ } from "./copilot-responses-compat.mjs";
44
47
  export {
45
48
  isEncryptedContentVerificationError,
46
49
  isImageNamespaceCollisionError,
47
- openCopilotResponse,
50
+ sanitizeImageNamespaceCollisionRequest,
51
+ } from "./copilot-responses-policy.mjs";
52
+ export {
48
53
  prepareResponsesRequest,
49
54
  rememberResponseHistory,
50
55
  sanitizeEncryptedReasoningRequest,
51
- sanitizeImageNamespaceCollisionRequest,
52
56
  stripInternalResponsesInputFields,
53
57
  } from "./responses-request.mjs";
54
58
 
@@ -20,6 +20,42 @@ import { requireUpstreamEventStream, withChatStreamUsage } from "./stream-contra
20
20
  import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
21
21
  import { recordAnthropicUsage } from "./usage.mjs";
22
22
 
23
+ const PROMPT_TOO_LONG_MESSAGE = "prompt is too long: your prompt is too long. Please reduce the number of messages or use a model with a larger context window.";
24
+
25
+ function contextWindowErrorBody(text) {
26
+ let error;
27
+ try {
28
+ const parsed = JSON.parse(text);
29
+ error = parsed && typeof parsed === "object" ? parsed.error : null;
30
+ } catch {
31
+ error = { message: text };
32
+ }
33
+ if (!error || typeof error !== "object") return null;
34
+ const code = error.code;
35
+ const message = typeof error.message === "string" ? error.message.toLowerCase() : "";
36
+ const matches = code === "context_length_exceeded"
37
+ || code === "model_max_prompt_tokens_exceeded"
38
+ || message.includes("exceeds the context window of this model")
39
+ || message.includes("maximum context length is")
40
+ || message.includes("request body is too large for model context window");
41
+ if (!matches) return null;
42
+ return JSON.stringify({
43
+ type: "error",
44
+ error: { type: "invalid_request_error", message: PROMPT_TOO_LONG_MESSAGE },
45
+ });
46
+ }
47
+
48
+ function sendAnthropicUpstreamError(res, response, text) {
49
+ const rewritten = contextWindowErrorBody(text);
50
+ if (!rewritten) {
51
+ sendUpstreamError(res, response, text);
52
+ return;
53
+ }
54
+ const headers = new Headers(response.headers);
55
+ headers.set("content-type", "application/json");
56
+ sendUpstreamError(res, { status: response.status, headers }, rewritten);
57
+ }
58
+
23
59
  export function createAnthropicCountTokensHandler(options) {
24
60
  const { acquireRequest, requestBodyTimeoutMs } = options;
25
61
 
@@ -76,7 +112,7 @@ export function createAnthropicMessagesHandler(options) {
76
112
  const upstream = await chatCompletionsFn(withChatStreamUsage(chatReq), { signal: abort.signal });
77
113
  releaseRequest();
78
114
  if (!upstream.ok) {
79
- sendUpstreamError(res, upstream, await upstream.text());
115
+ sendAnthropicUpstreamError(res, upstream, await upstream.text());
80
116
  return;
81
117
  }
82
118
  await requireUpstreamEventStream(upstream);
@@ -113,7 +149,7 @@ export function createAnthropicMessagesHandler(options) {
113
149
  releaseRequest();
114
150
  const data = await upstream.text();
115
151
  if (!upstream.ok) {
116
- sendUpstreamError(res, upstream, data);
152
+ sendAnthropicUpstreamError(res, upstream, data);
117
153
  return;
118
154
  }
119
155
  const anthropicMessage = chatToAnthropic(JSON.parse(data), requestedModel, { forceModel: forceRequestedModel });
package/src/anthropic.mjs CHANGED
@@ -44,26 +44,10 @@ function toolResultContent(content) {
44
44
  }
45
45
 
46
46
  // Convert one Anthropic message into zero or more OpenAI messages.
47
- function convertMessage(msg) {
48
- const out = [];
49
- const role = msg.role; // "user" | "assistant"
50
-
51
- if (typeof msg.content === "string") {
52
- out.push({ role, content: msg.content });
53
- return out;
54
- }
55
- if (!Array.isArray(msg.content)) return out;
56
-
57
- // tool_result blocks must become independent role:"tool" messages.
58
- const toolResults = msg.content.filter((b) => b.type === "tool_result");
59
- for (const tr of toolResults) {
60
- out.push({ role: "tool", tool_call_id: tr.tool_use_id, content: toolResultContent(tr.content) });
61
- }
62
-
63
- // Convert remaining text, image, and tool_use blocks.
47
+ function convertContentBlocks(role, blocks) {
64
48
  const textImageParts = [];
65
49
  const toolCalls = [];
66
- for (const b of msg.content) {
50
+ for (const b of blocks) {
67
51
  if (b.type === "text") {
68
52
  textImageParts.push({ type: "text", text: b.text });
69
53
  } else if (b.type === "image") {
@@ -80,17 +64,66 @@ function convertMessage(msg) {
80
64
  const m = { role, content: null, tool_calls: toolCalls };
81
65
  if (textImageParts.length === 1 && textImageParts[0].type === "text") m.content = textImageParts[0].text;
82
66
  else if (textImageParts.length > 0) m.content = textImageParts;
83
- out.push(m);
67
+ return m;
84
68
  } else if (textImageParts.length > 0) {
85
69
  if (textImageParts.length === 1 && textImageParts[0].type === "text") {
86
- out.push({ role, content: textImageParts[0].text });
70
+ return { role, content: textImageParts[0].text };
87
71
  } else {
88
- out.push({ role, content: textImageParts });
72
+ return { role, content: textImageParts };
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ function convertMessage(msg) {
79
+ const out = [];
80
+ const role = msg.role; // "user" | "assistant"
81
+
82
+ if (typeof msg.content === "string") {
83
+ out.push({ role, content: msg.content });
84
+ return out;
85
+ }
86
+ if (!Array.isArray(msg.content)) return out;
87
+
88
+ if (!msg.content.some((block) => block.type === "tool_result")) {
89
+ const converted = convertContentBlocks(role, msg.content);
90
+ if (converted) out.push(converted);
91
+ return out;
92
+ }
93
+
94
+ let contentBlocks = [];
95
+ const flushContent = () => {
96
+ const converted = convertContentBlocks(role, contentBlocks);
97
+ if (converted) out.push(converted);
98
+ contentBlocks = [];
99
+ };
100
+ for (const block of msg.content) {
101
+ if (block.type !== "tool_result") {
102
+ contentBlocks.push(block);
103
+ continue;
89
104
  }
105
+ flushContent();
106
+ out.push({
107
+ role: "tool",
108
+ tool_call_id: block.tool_use_id,
109
+ content: toolResultContent(block.content),
110
+ });
90
111
  }
112
+ flushContent();
91
113
  return out;
92
114
  }
93
115
 
116
+ function toolInputSchema(inputSchema) {
117
+ if (inputSchema
118
+ && typeof inputSchema === "object"
119
+ && !Array.isArray(inputSchema)
120
+ && inputSchema.type === "object"
121
+ && Object.keys(inputSchema).length === 1) {
122
+ return { type: "object", properties: {} };
123
+ }
124
+ return inputSchema;
125
+ }
126
+
94
127
  function mapToolChoice(tc) {
95
128
  if (!tc) return undefined;
96
129
  switch (tc.type) {
@@ -119,7 +152,7 @@ export function anthropicToChat(body, options = {}) {
119
152
  if (Array.isArray(body.tools) && body.tools.length) {
120
153
  chatReq.tools = body.tools.map((t) => ({
121
154
  type: "function",
122
- function: { name: t.name, description: t.description, parameters: t.input_schema },
155
+ function: { name: t.name, description: t.description, parameters: toolInputSchema(t.input_schema) },
123
156
  }));
124
157
  }
125
158
  const tc = mapToolChoice(body.tool_choice);
@@ -130,6 +163,14 @@ export function anthropicToChat(body, options = {}) {
130
163
 
131
164
  function uid() { return randomUUID().replace(/-/g, ""); }
132
165
 
166
+ function invalidToolArguments(toolName) {
167
+ const name = String(toolName || "unknown").slice(0, 100);
168
+ return invalidUpstreamStream(
169
+ `Upstream Chat Completions tool "${name}" did not return valid JSON object arguments`,
170
+ "upstream_tool_arguments_json_invalid",
171
+ );
172
+ }
173
+
133
174
  export function chatToAnthropic(openaiResp, model, options = {}) {
134
175
  const choice = openaiResp.choices?.[0];
135
176
  const msg = choice?.message || {};
@@ -138,8 +179,20 @@ export function chatToAnthropic(openaiResp, model, options = {}) {
138
179
  if (msg.content) content.push({ type: "text", text: msg.content });
139
180
  if (Array.isArray(msg.tool_calls)) {
140
181
  for (const tc of msg.tool_calls) {
141
- let input = {};
142
- try { input = JSON.parse(tc.function.arguments || "{}"); } catch { input = {}; }
182
+ const rawArguments = tc.function?.arguments;
183
+ let input;
184
+ if (rawArguments === undefined || rawArguments === null || rawArguments === "") {
185
+ input = {};
186
+ } else {
187
+ try {
188
+ input = JSON.parse(rawArguments);
189
+ } catch {
190
+ throw invalidToolArguments(tc.function?.name);
191
+ }
192
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
193
+ throw invalidToolArguments(tc.function?.name);
194
+ }
195
+ }
143
196
  content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input });
144
197
  }
145
198
  }
package/src/config.mjs CHANGED
@@ -9,8 +9,39 @@ const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
9
9
  const MODEL_CONTEXT_WINDOW = 1_000_000;
10
10
  const MODEL_AUTO_COMPACT_TOKEN_LIMIT = 900_000;
11
11
 
12
+ function isTomlTableHeader(line) {
13
+ const source = line.trimStart();
14
+ const openingBrackets = source.startsWith("[[") ? 2 : source.startsWith("[") ? 1 : 0;
15
+ if (!openingBrackets) return false;
16
+
17
+ let quote = "";
18
+ let escaped = false;
19
+ for (let index = openingBrackets; index < source.length; index += 1) {
20
+ const char = source[index];
21
+ if (quote) {
22
+ if (quote === '"' && char === "\\" && !escaped) {
23
+ escaped = true;
24
+ continue;
25
+ }
26
+ if (char === quote && !escaped) quote = "";
27
+ escaped = false;
28
+ continue;
29
+ }
30
+ if (char === '"' || char === "'") {
31
+ quote = char;
32
+ continue;
33
+ }
34
+ if (char !== "]" || (openingBrackets === 2 && source[index + 1] !== "]")) continue;
35
+
36
+ if (!source.slice(openingBrackets, index).trim()) return false;
37
+ const tail = source.slice(index + openingBrackets).trim();
38
+ return tail === "" || tail.startsWith("#");
39
+ }
40
+ return false;
41
+ }
42
+
12
43
  function setTopLevelTomlDefault(lines, key, value) {
13
- let end = lines.findIndex((line) => /^\s*\[[^\]]+\]\s*$/.test(line));
44
+ let end = lines.findIndex(isTomlTableHeader);
14
45
  if (end === -1) end = lines.length;
15
46
 
16
47
  const keyRe = new RegExp(`^\\s*${key}\\s*=`);
@@ -28,7 +59,7 @@ function setTomlKey(lines, sectionName, key, value) {
28
59
 
29
60
  let end = lines.length;
30
61
  for (let i = start + 1; i < lines.length; i++) {
31
- if (/^\s*\[[^\]]+\]\s*$/.test(lines[i])) {
62
+ if (isTomlTableHeader(lines[i])) {
32
63
  end = i;
33
64
  break;
34
65
  }
@@ -57,7 +88,11 @@ export function computeUpdatedCodexConfig(content, adapterPort = 2026, adapterHo
57
88
 
58
89
  let changed = false;
59
90
  const openaiLine = `openai_base_url = "${baseUrl}"`;
60
- const openaiIndex = lines.findIndex((line) => /^openai_base_url\s*=/.test(line));
91
+ const firstSection = lines.findIndex(isTomlTableHeader);
92
+ const topLevelEnd = firstSection === -1 ? lines.length : firstSection;
93
+ const openaiIndex = lines.findIndex((line, index) => (
94
+ index < topLevelEnd && /^\s*openai_base_url\s*=/.test(line)
95
+ ));
61
96
  if (openaiIndex === -1) {
62
97
  lines.unshift(openaiLine);
63
98
  changed = true;
@@ -0,0 +1,73 @@
1
+ import { responses as copilotResponses } from "./copilot.mjs";
2
+ import {
3
+ isEncryptedContentVerificationError,
4
+ isImageNamespaceCollisionError,
5
+ sanitizeImageNamespaceCollisionRequest,
6
+ } from "./copilot-responses-policy.mjs";
7
+ import { httpError } from "./http-transport.mjs";
8
+ import {
9
+ finalizeEncryptedHistoryRebase,
10
+ sanitizeEncryptedReasoningRequest,
11
+ } from "./responses-request.mjs";
12
+ import { status } from "./status.mjs";
13
+
14
+ // Ordered Copilot-only fallbacks. Each policy is single-use and must be gated
15
+ // by an exact upstream failure plus a request transformation that changes the
16
+ // payload. Generic Responses preparation does not decide whether to retry.
17
+ const RETRY_POLICIES = Object.freeze([
18
+ Object.freeze({
19
+ id: "image-namespace-collision",
20
+ matches: isImageNamespaceCollisionError,
21
+ apply: sanitizeImageNamespaceCollisionRequest,
22
+ warning: "image_gen namespace rejected by upstream; retrying without the conflicting image tool",
23
+ }),
24
+ Object.freeze({
25
+ id: "encrypted-replay-rejected",
26
+ matches: isEncryptedContentVerificationError,
27
+ apply: sanitizeEncryptedReasoningRequest,
28
+ warning: "encrypted replay content rejected by upstream; retrying without unavailable encrypted content",
29
+ }),
30
+ ]);
31
+
32
+ export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
33
+ const usedPolicies = new Set();
34
+ let payloadPrepared = false;
35
+ for (let attempt = 0; attempt <= RETRY_POLICIES.length; attempt += 1) {
36
+ options.assertPrepareActive?.();
37
+ const resp = await upstream(reqContext.body, {
38
+ assertActive: options.assertPrepareActive,
39
+ signal: options.signal,
40
+ currentInputStart: reqContext.currentInputStart,
41
+ onUpstreamStart: options.onUpstreamStart,
42
+ payloadPrepared,
43
+ });
44
+ options.assertPrepareActive?.();
45
+ payloadPrepared = true;
46
+ if (resp.ok) {
47
+ reqContext = finalizeEncryptedHistoryRebase(reqContext);
48
+ return { resp, reqContext };
49
+ }
50
+
51
+ const errorText = await resp.text();
52
+ options.assertPrepareActive?.();
53
+ let retryPolicy = null;
54
+ let retryContext = null;
55
+ for (const policy of RETRY_POLICIES) {
56
+ if (usedPolicies.has(policy.id) || !policy.matches(resp.status, errorText)) continue;
57
+ const candidate = policy.apply(reqContext);
58
+ if (candidate) {
59
+ retryPolicy = policy;
60
+ retryContext = candidate;
61
+ break;
62
+ }
63
+ }
64
+ if (retryPolicy && retryContext) {
65
+ usedPolicies.add(retryPolicy.id);
66
+ reqContext = retryContext;
67
+ console.warn(status("warn", retryPolicy.warning));
68
+ continue;
69
+ }
70
+ return { resp, reqContext, errorText };
71
+ }
72
+ throw httpError("Responses compatibility retry limit exceeded", 502);
73
+ }
@@ -0,0 +1,78 @@
1
+ function cloneJson(value) {
2
+ return value === undefined ? undefined : structuredClone(value);
3
+ }
4
+
5
+ export function isEncryptedContentVerificationError(statusCode, text) {
6
+ if (statusCode < 400 || !text) return false;
7
+ const lower = String(text).toLowerCase();
8
+ const reasoningFailure = lower.includes("encrypted content")
9
+ && lower.includes("could not be verified")
10
+ && (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
11
+ const functionOutputFailure = lower.includes("encrypted function output content")
12
+ && lower.includes("could not be decrypted or decoded");
13
+ const missingEncryptedContent = statusCode < 500
14
+ && /missing required parameter:\s*(['"]?)input\[\d+\](?:\.[a-z0-9_]+|\[\d+\])*\.encrypted_content\1(?=\.?(?:\s|$|["},\]]))/.test(lower);
15
+ return reasoningFailure || functionOutputFailure || missingEncryptedContent;
16
+ }
17
+
18
+ export function isImageNamespaceCollisionError(statusCode, text) {
19
+ if (statusCode < 400 || !text) return false;
20
+ const lower = String(text).toLowerCase();
21
+ return lower.includes("namespace")
22
+ && lower.includes("image_gen")
23
+ && lower.includes("collid");
24
+ }
25
+
26
+ export function isCopilotImageNamespaceTool(tool, { collisionFallback = false } = {}) {
27
+ if (!tool || typeof tool !== "object") return false;
28
+ const type = String(tool.type || "").toLowerCase();
29
+ if (["image_gen", "image_generation"].includes(type)) return true;
30
+ if (!collisionFallback) return false;
31
+ const name = String(tool.name || tool.function?.name || "").toLowerCase();
32
+ const namespace = String(tool.namespace || "").toLowerCase();
33
+ if (["image_gen", "image_generation"].includes(name)) return true;
34
+ if (namespace === "image_gen" || namespace === "image_generation") return true;
35
+ return [type, name, namespace].some((value) => value.startsWith("image_gen"));
36
+ }
37
+
38
+ function toolName(tool) {
39
+ return String(tool?.name || tool?.function?.name || "").trim();
40
+ }
41
+
42
+ // Copilot currently rejects the public image-generation tool. Keep this
43
+ // provider-owned policy paired with tool_choice so request invariants survive
44
+ // the removal. Revalidate against Copilot before deleting or broadening it.
45
+ // Reference: https://github.com/Menci/Floway/blob/ac2bbb04352033a7d9d74574a9d465d40395ef06/packages/provider-copilot/src/interceptors/openai-responses/strip-image-generation.ts
46
+ export function applyCopilotResponsesRequestPolicies(body, options = {}) {
47
+ if (!Array.isArray(body?.tools)) return false;
48
+ const removedNames = new Set();
49
+ const filtered = body.tools.filter((tool) => {
50
+ const removed = isCopilotImageNamespaceTool(tool, options);
51
+ if (removed) {
52
+ const name = toolName(tool);
53
+ if (name) removedNames.add(name);
54
+ }
55
+ return !removed;
56
+ });
57
+ if (filtered.length === body.tools.length) return false;
58
+ if (filtered.length) body.tools = filtered;
59
+ else delete body.tools;
60
+
61
+ const choiceName = toolName(body.tool_choice);
62
+ const choiceTargetsRemovedTool = choiceName
63
+ && removedNames.has(choiceName)
64
+ && !filtered.some((tool) => toolName(tool) === choiceName);
65
+ if (isCopilotImageNamespaceTool(body.tool_choice, options)
66
+ || choiceTargetsRemovedTool
67
+ || (body.tool_choice === "required" && !body.tools)) {
68
+ delete body.tool_choice;
69
+ }
70
+ return true;
71
+ }
72
+
73
+ export function sanitizeImageNamespaceCollisionRequest(reqContext) {
74
+ if (!Array.isArray(reqContext?.body?.tools)) return null;
75
+ const body = cloneJson(reqContext.body);
76
+ if (!applyCopilotResponsesRequestPolicies(body, { collisionFallback: true })) return null;
77
+ return { ...reqContext, body };
78
+ }
package/src/doctor.mjs CHANGED
@@ -181,13 +181,12 @@ export async function inspectAdapterCompatibility({
181
181
  });
182
182
 
183
183
  if (firstResponse) {
184
- await runCompatibilityCheck(checks, "Responses stream, history, and image tool compatibility", async () => {
184
+ await runCompatibilityCheck(checks, "Responses stream and history compatibility", async () => {
185
185
  const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
186
186
  model: responsesModel,
187
187
  stream: true,
188
188
  previous_response_id: firstResponse.id,
189
189
  input: "Reply with OK again.",
190
- tools: [{ type: "image_generation" }],
191
190
  }, timeoutMs);
192
191
  if (!/^event:\s*response\.completed\s*$/m.test(text)) throw new Error("stream did not contain response.completed");
193
192
  });