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 +9 -6
- package/package.json +1 -1
- package/scripts/benchmark.mjs +74 -0
- package/src/adapter.mjs +6 -2
- package/src/anthropic-handler.mjs +38 -2
- package/src/anthropic.mjs +77 -24
- package/src/config.mjs +38 -3
- package/src/copilot-responses-compat.mjs +73 -0
- package/src/copilot-responses-policy.mjs +78 -0
- package/src/doctor.mjs +1 -2
- package/src/model-cache.mjs +36 -6
- package/src/observability.mjs +13 -1
- package/src/pm-studio-relay.mjs +6 -3
- package/src/profile-model-runtime.mjs +87 -13
- package/src/profile-routing.mjs +45 -7
- package/src/response-history.mjs +20 -3
- package/src/responses-bridge.mjs +44 -7
- package/src/responses-compaction.mjs +1 -50
- package/src/responses-handler.mjs +42 -9
- package/src/responses-proxy.mjs +118 -36
- package/src/responses-request.mjs +98 -105
- package/src/route-plan.mjs +51 -0
- package/src/startup.mjs +8 -2
- package/src/stream.mjs +27 -13
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
|
|
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.
|
|
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,
|
|
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
|
|
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
package/scripts/benchmark.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
67
|
+
return m;
|
|
84
68
|
} else if (textImageParts.length > 0) {
|
|
85
69
|
if (textImageParts.length === 1 && textImageParts[0].type === "text") {
|
|
86
|
-
|
|
70
|
+
return { role, content: textImageParts[0].text };
|
|
87
71
|
} else {
|
|
88
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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(
|
|
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 (
|
|
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
|
|
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
|
|
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
|
});
|