codex-copilot-dx 0.4.43 → 0.4.44
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 +20 -12
- package/bin/cli.mjs +4 -4
- package/package.json +2 -1
- package/src/adapter.mjs +16 -8
- package/src/anthropic-handler.mjs +13 -5
- package/src/anthropic.mjs +40 -5
- package/src/auth.mjs +2 -2
- package/src/bounded-result-cache.mjs +180 -0
- package/src/claude-config.mjs +1 -1
- package/src/cli-options.mjs +7 -5
- package/src/copilot.mjs +11 -4
- package/src/doctor.mjs +11 -2
- package/src/http-transport.mjs +4 -1
- package/src/image-optimization.mjs +74 -65
- package/src/observability.mjs +2 -1
- package/src/responses-bridge.mjs +46 -10
- package/src/responses-chat-payload.mjs +2 -1
- package/src/responses-compaction.mjs +113 -0
- package/src/responses-handler.mjs +28 -14
- package/src/responses-proxy.mjs +123 -23
- package/src/stream-contract.mjs +102 -0
- package/src/stream-errors.mjs +3 -1
- package/src/stream-performance.mjs +177 -0
- package/src/upstream-headers.mjs +67 -0
package/README.md
CHANGED
|
@@ -23,10 +23,13 @@ Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adap
|
|
|
23
23
|
## Usage
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
|
|
26
|
+
npm install -g codex-copilot-dx@latest
|
|
27
|
+
ccdx
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
The shorter `ccdx` command is the primary launcher. The existing `codex-copilot-dx`
|
|
31
|
+
command remains an equivalent compatibility alias. For a one-off run without a
|
|
32
|
+
global install, use `npx codex-copilot-dx@latest`.
|
|
30
33
|
|
|
31
34
|
On first run, it will:
|
|
32
35
|
1. Authenticate with GitHub via device flow (if needed), after first trying compatible local Copilot token sources
|
|
@@ -42,14 +45,14 @@ If an existing `codex-copilot-dx` adapter is already running on the configured h
|
|
|
42
45
|
|
|
43
46
|
The running adapter reports its package and protocol versions. After upgrading `codex-copilot-dx`, stop the old process before starting the new version; the new CLI refuses to silently reuse an incompatible adapter.
|
|
44
47
|
|
|
45
|
-
Do not set Claude Code by manually exporting `ANTHROPIC_BASE_URL` or `ANTHROPIC_AUTH_TOKEN` in your shell. Let `
|
|
48
|
+
Do not set Claude Code by manually exporting `ANTHROPIC_BASE_URL` or `ANTHROPIC_AUTH_TOKEN` in your shell. Let `ccdx` write the local config files instead. If you previously exported those variables, remove them from shell startup files and restart the terminal before launching Claude Code.
|
|
46
49
|
|
|
47
50
|
### Diagnostics
|
|
48
51
|
|
|
49
52
|
Run a read-only config check without starting the adapter or changing files:
|
|
50
53
|
|
|
51
54
|
```bash
|
|
52
|
-
|
|
55
|
+
ccdx doctor
|
|
53
56
|
```
|
|
54
57
|
|
|
55
58
|
The command exits with status `1` when it finds an invalid configuration and `0` when checks contain only OK or warning results.
|
|
@@ -59,7 +62,7 @@ The doctor checks the GitHub token, Codex config, Claude Code settings, Claude A
|
|
|
59
62
|
For a read-only live check of the saved GitHub token, Copilot entitlement, and models endpoint, run:
|
|
60
63
|
|
|
61
64
|
```bash
|
|
62
|
-
|
|
65
|
+
ccdx doctor --online
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
The online doctor never starts device flow, scans for replacement tokens, or changes the saved token.
|
|
@@ -67,7 +70,7 @@ The online doctor never starts device flow, scans for replacement tokens, or cha
|
|
|
67
70
|
To actively verify the protocol path through an already-running adapter, run:
|
|
68
71
|
|
|
69
72
|
```bash
|
|
70
|
-
|
|
73
|
+
ccdx doctor --compat
|
|
71
74
|
```
|
|
72
75
|
|
|
73
76
|
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.
|
|
@@ -78,19 +81,19 @@ For live runtime diagnostics, query the running adapter from the same machine:
|
|
|
78
81
|
curl -s http://127.0.0.1:2026/_ccdx/status | jq
|
|
79
82
|
```
|
|
80
83
|
|
|
81
|
-
The status endpoint is restricted to the socket's real loopback address, even when LAN binding is explicitly enabled. It reports fixed-size request counters,
|
|
84
|
+
The status endpoint is restricted to the socket's real loopback address, even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms for streaming routes, admission pressure, memory use, response-history size, image queue/cache state, model cache counts, and token expiry state. It never retains metric samples or includes prompts, completions, tool arguments, image content, account names, or token values. API responses always include a safe `X-Request-Id` for correlation. To include the same ID as `request_id` in related terminal and file log lines, start the adapter with:
|
|
82
85
|
|
|
83
86
|
```bash
|
|
84
|
-
|
|
87
|
+
ccdx --show-request-id
|
|
85
88
|
```
|
|
86
89
|
|
|
87
|
-
When the saved token is missing, `
|
|
90
|
+
When the saved token is missing, `ccdx` first looks for compatible local Copilot GitHub tokens, validates them with GitHub and Copilot, and imports a valid one before starting device login. It checks explicit token sources (`CCDX_GITHUB_TOKEN`, `CCDX_GITHUB_TOKEN_PATH`, `CCDX_GITHUB_TOKEN_PATHS`) plus common local `auth.json` layouts under application config directories. It does not rely on a specific app name. Generic discovery refuses to choose silently when valid tokens for multiple GitHub accounts are found. After an account is selected, automatic `401`/`403` recovery and in-process token rotation accept only the same GitHub account. Concurrent callers share token refresh work without sharing cancellation, and a still-valid Copilot token may be used briefly after a transient refresh failure. Explicit token variables remain the intentional way to switch accounts.
|
|
88
91
|
|
|
89
92
|
If Copilot token refresh still fails with `401` or `403`, the saved GitHub token may be expired, revoked, or missing Copilot access. Delete the saved token and start the tool again to trigger GitHub device login:
|
|
90
93
|
|
|
91
94
|
```bash
|
|
92
95
|
rm ~/.local/share/copilot-api/github_token
|
|
93
|
-
|
|
96
|
+
ccdx
|
|
94
97
|
```
|
|
95
98
|
|
|
96
99
|
### Claude App opt-in
|
|
@@ -141,6 +144,7 @@ Environment variables:
|
|
|
141
144
|
| `CCDX_IMG_MIN_BYTES` | `100000` | In-bounds images smaller than this are left untouched; oversized images are still downscaled |
|
|
142
145
|
| `CCDX_IMG_CONCURRENCY` | `2` | Global concurrent image optimization tasks; values above `12` are capped at `12` |
|
|
143
146
|
| `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted by `sharp` for one image |
|
|
147
|
+
| `CCDX_IMG_CACHE_MAX_BYTES` | `67108864` | Process-local byte ceiling for cached image optimization results; set to `0` to disable result caching |
|
|
144
148
|
| `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
|
|
145
149
|
| `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
|
|
146
150
|
| `CCDX_CLAUDE_DESKTOP_API_KEY` | managed profile or generated for opt-in setup | Explicit bearer key written into the Claude App profile and recognized by the adapter for model discovery |
|
|
@@ -170,7 +174,7 @@ The adapter records token usage metadata to `~/.local/share/codex-copilot-dx/usa
|
|
|
170
174
|
Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or set `CCDX_LOG_PATH` to a custom file. Add `CCDX_LOG_LEVEL=debug` to include upstream request attempts, retry causes, status codes, and timings. Debug logs do not include prompts, completions, request bodies, or authorization tokens.
|
|
171
175
|
|
|
172
176
|
```bash
|
|
173
|
-
|
|
177
|
+
ccdx usage
|
|
174
178
|
```
|
|
175
179
|
|
|
176
180
|
### Image optimization
|
|
@@ -181,9 +185,13 @@ The adapter automatically downsamples embedded screenshots to model-appropriate
|
|
|
181
185
|
|
|
182
186
|
The final serialized UTF-8 request body is measured before forwarding. Above the configured byte limit, unique source images are processed largest-first from their originals at quality 75 / 1600 px and then quality 65 / 1280 px, stopping as soon as the body fits. If necessary, the forwarded view omits older duplicate images, historical tool images, other historical images, and finally old tool outputs while preserving current input and tool-call skeletons. A request that still cannot fit is rejected locally with a structured `413` instead of sending an oversized body upstream.
|
|
183
187
|
|
|
188
|
+
Completed image transforms are reused from a byte-bounded process-local LRU cache, and concurrent requests for the same transform share one encode. The cache key includes source content, MIME type, model-aware dimensions, WebP quality, and encoder settings; failures are not cached and request cancellation remains isolated.
|
|
189
|
+
|
|
184
190
|
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.
|
|
185
191
|
|
|
186
|
-
|
|
192
|
+
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.
|
|
193
|
+
|
|
194
|
+
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.
|
|
187
195
|
|
|
188
196
|
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.
|
|
189
197
|
|
package/bin/cli.mjs
CHANGED
|
@@ -27,7 +27,7 @@ try {
|
|
|
27
27
|
CLI = parseCliArgs(process.argv.slice(2));
|
|
28
28
|
} catch (e) {
|
|
29
29
|
console.error(e.message);
|
|
30
|
-
console.error("Run
|
|
30
|
+
console.error("Run ccdx --help for usage.");
|
|
31
31
|
process.exit(2);
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -107,7 +107,7 @@ async function refreshClaudeDesktopModelDefs() {
|
|
|
107
107
|
const models = JSON.parse(body);
|
|
108
108
|
const autoReview = codexAutoReviewModelStatus(models);
|
|
109
109
|
if (!autoReview.available) {
|
|
110
|
-
console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run
|
|
110
|
+
console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run ccdx doctor --compat to verify the live path.`));
|
|
111
111
|
}
|
|
112
112
|
const gptModelIds = gptModelIdsFromCopilotModels(models);
|
|
113
113
|
if (gptModelIds.length) {
|
|
@@ -169,7 +169,7 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
169
169
|
});
|
|
170
170
|
if (running.incompatible) {
|
|
171
171
|
const found = running.data?.version || "unknown";
|
|
172
|
-
throw new Error(`Adapter ${found} is already running at ${running.baseUrl}, but this CLI is ${LOCAL_VERSION}. Stop the existing process and run
|
|
172
|
+
throw new Error(`Adapter ${found} is already running at ${running.baseUrl}, but this CLI is ${LOCAL_VERSION}. Stop the existing process and run ccdx again.`);
|
|
173
173
|
}
|
|
174
174
|
if (!running.ok) return false;
|
|
175
175
|
|
|
@@ -197,7 +197,7 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
197
197
|
|
|
198
198
|
await openCodex();
|
|
199
199
|
console.log(`
|
|
200
|
-
${status("ok", "Ready, using the existing
|
|
200
|
+
${status("ok", "Ready, using the existing ccdx adapter")}
|
|
201
201
|
|
|
202
202
|
Adapter: ${running.baseUrl}
|
|
203
203
|
`);
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-copilot-dx",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.44",
|
|
4
4
|
"description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
|
|
5
5
|
"author": "Dale Xiao",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
+
"ccdx": "bin/cli.mjs",
|
|
8
9
|
"codex-copilot-dx": "bin/cli.mjs"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
package/src/adapter.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
runtimeStatusPayload,
|
|
12
12
|
} from "./observability.mjs";
|
|
13
13
|
import { createRequestId, runWithRequestContext } from "./request-context.mjs";
|
|
14
|
+
import { createStreamPerformanceMetrics } from "./stream-performance.mjs";
|
|
14
15
|
import { status } from "./status.mjs";
|
|
15
16
|
import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
|
|
16
17
|
import { createResponsesCompactHandler, createResponsesHandler } from "./responses-handler.mjs";
|
|
@@ -98,6 +99,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
98
99
|
const streamIdleTimeoutMs = parsePositiveInteger(options.streamIdleTimeoutMs, ADAPTER_RUNTIME_CONFIG.streamIdleTimeoutMs);
|
|
99
100
|
const acquireRequest = options.acquireRequest || createRequestAdmission();
|
|
100
101
|
const requestMetrics = options.requestMetrics || createRequestMetrics();
|
|
102
|
+
const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
|
|
101
103
|
const claudeDesktopModelOptions = () => {
|
|
102
104
|
const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
|
|
103
105
|
return Array.isArray(modelDefs) ? { modelDefs } : {};
|
|
@@ -141,6 +143,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
141
143
|
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
142
144
|
res.end(JSON.stringify(runtimeStatusPayload({
|
|
143
145
|
metrics: requestMetrics,
|
|
146
|
+
streamPerformance: streamPerformanceMetrics,
|
|
144
147
|
admission: acquireRequest,
|
|
145
148
|
modelRegistry: options.modelRegistry,
|
|
146
149
|
})));
|
|
@@ -224,12 +227,16 @@ export function createAdapterHandler(options = {}) {
|
|
|
224
227
|
}
|
|
225
228
|
|
|
226
229
|
const trackRequest = pathname !== ADAPTER_HEALTH_PATH && pathname !== ADAPTER_STATUS_PATH;
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
+
const routeName = classifyAdapterRoute(req.method, pathname);
|
|
231
|
+
const complete = trackRequest ? requestMetrics.begin(routeName) : () => {};
|
|
232
|
+
const streamPerformance = trackRequest ? streamPerformanceMetrics.begin(routeName) : null;
|
|
233
|
+
const finishRequest = ({ statusCode = 0, aborted = false } = {}) => {
|
|
234
|
+
complete({ statusCode, aborted });
|
|
235
|
+
streamPerformance?.finish({ failed: aborted || statusCode >= 400 });
|
|
236
|
+
};
|
|
230
237
|
if (trackRequest && typeof res.once === "function") {
|
|
231
|
-
res.once("finish", () =>
|
|
232
|
-
res.once("close", () =>
|
|
238
|
+
res.once("finish", () => finishRequest({ statusCode: res.statusCode }));
|
|
239
|
+
res.once("close", () => finishRequest({
|
|
233
240
|
statusCode: res.statusCode,
|
|
234
241
|
aborted: !res.writableFinished && !res.writableEnded,
|
|
235
242
|
}));
|
|
@@ -240,16 +247,17 @@ export function createAdapterHandler(options = {}) {
|
|
|
240
247
|
requestId,
|
|
241
248
|
pathname,
|
|
242
249
|
showRequestId: options.showRequestId === true,
|
|
250
|
+
streamPerformance,
|
|
243
251
|
}, () => dispatch(req, res, pathname));
|
|
244
252
|
if (result && typeof result.then === "function") {
|
|
245
253
|
return result.catch((error) => {
|
|
246
|
-
|
|
254
|
+
finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
|
|
247
255
|
throw error;
|
|
248
256
|
});
|
|
249
257
|
}
|
|
250
258
|
return result;
|
|
251
259
|
} catch (error) {
|
|
252
|
-
|
|
260
|
+
finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
|
|
253
261
|
throw error;
|
|
254
262
|
}
|
|
255
263
|
};
|
|
@@ -273,7 +281,7 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
273
281
|
return new Promise((resolve, reject) => {
|
|
274
282
|
const onListenError = (e) => {
|
|
275
283
|
if (e?.code === "EADDRINUSE") {
|
|
276
|
-
reject(new Error(`Adapter address http://${host}:${port} is already in use. Stop the existing
|
|
284
|
+
reject(new Error(`Adapter address http://${host}:${port} is already in use. Stop the existing ccdx process or set ADAPTER_PORT to another port.`));
|
|
277
285
|
return;
|
|
278
286
|
}
|
|
279
287
|
reject(e);
|
|
@@ -16,6 +16,8 @@ import { resolveAnthropicModel } from "./models.mjs";
|
|
|
16
16
|
import { status } from "./status.mjs";
|
|
17
17
|
import { webStreamLines } from "./stream.mjs";
|
|
18
18
|
import { endStreamWithError } from "./stream-errors.mjs";
|
|
19
|
+
import { requireUpstreamEventStream, withChatStreamUsage } from "./stream-contract.mjs";
|
|
20
|
+
import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
|
|
19
21
|
import { recordAnthropicUsage } from "./usage.mjs";
|
|
20
22
|
|
|
21
23
|
export function createAnthropicCountTokensHandler(options) {
|
|
@@ -67,15 +69,19 @@ export function createAnthropicMessagesHandler(options) {
|
|
|
67
69
|
const chatReq = anthropicToChat(parsed, { upstreamModel });
|
|
68
70
|
const forceRequestedModel = upstreamModel !== requestedModel;
|
|
69
71
|
if (parsed.stream) {
|
|
70
|
-
const upstream = await chatCompletionsFn(
|
|
72
|
+
const upstream = await chatCompletionsFn(withChatStreamUsage(chatReq), { signal: abort.signal });
|
|
71
73
|
releaseRequest();
|
|
72
74
|
if (!upstream.ok) {
|
|
73
|
-
|
|
74
|
-
res.end(await upstream.text());
|
|
75
|
+
sendUpstreamError(res, upstream, await upstream.text());
|
|
75
76
|
return;
|
|
76
77
|
}
|
|
78
|
+
await requireUpstreamEventStream(upstream);
|
|
77
79
|
abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
|
|
78
|
-
res.writeHead(200, {
|
|
80
|
+
res.writeHead(200, {
|
|
81
|
+
...safeUpstreamResponseHeaders(upstream.headers, { contentType: "text/event-stream" }),
|
|
82
|
+
"Cache-Control": "no-cache",
|
|
83
|
+
Connection: "keep-alive",
|
|
84
|
+
});
|
|
79
85
|
let messageId;
|
|
80
86
|
let usage;
|
|
81
87
|
try {
|
|
@@ -114,7 +120,9 @@ export function createAnthropicMessagesHandler(options) {
|
|
|
114
120
|
responseId: anthropicMessage.id,
|
|
115
121
|
usage: anthropicMessage.usage,
|
|
116
122
|
});
|
|
117
|
-
res.writeHead(200, {
|
|
123
|
+
res.writeHead(200, safeUpstreamResponseHeaders(upstream.headers, {
|
|
124
|
+
contentType: "application/json",
|
|
125
|
+
}));
|
|
118
126
|
res.end(JSON.stringify(anthropicMessage));
|
|
119
127
|
}
|
|
120
128
|
} catch (error) {
|
package/src/anthropic.mjs
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
incompleteUpstreamStream,
|
|
4
|
+
invalidUpstreamStream,
|
|
5
|
+
ToolArgumentDeltaGuard,
|
|
6
|
+
upstreamChatStreamError,
|
|
7
|
+
} from "./stream-contract.mjs";
|
|
8
|
+
import { isChatOutputDelta, markFirstOutput, markOutputTokens } from "./stream-performance.mjs";
|
|
2
9
|
|
|
3
10
|
let tokenizerModulePromise = null;
|
|
4
11
|
|
|
@@ -169,6 +176,14 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
|
|
|
169
176
|
let sawToolUse = false;
|
|
170
177
|
const trailingTextChunks = [];
|
|
171
178
|
let outputTokens = 0;
|
|
179
|
+
let sawDone = false;
|
|
180
|
+
const toolArgumentGuard = new ToolArgumentDeltaGuard();
|
|
181
|
+
let performanceOutputSeen = false;
|
|
182
|
+
const markPerformanceOutput = () => {
|
|
183
|
+
if (performanceOutputSeen) return;
|
|
184
|
+
performanceOutputSeen = true;
|
|
185
|
+
markFirstOutput();
|
|
186
|
+
};
|
|
172
187
|
|
|
173
188
|
const ensureStart = async () => {
|
|
174
189
|
if (started) return;
|
|
@@ -196,14 +211,30 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
|
|
|
196
211
|
for await (const line of lineIterator) {
|
|
197
212
|
if (!line.startsWith("data: ")) continue;
|
|
198
213
|
const data = line.slice(6).trim();
|
|
199
|
-
if (data === "[DONE]")
|
|
214
|
+
if (data === "[DONE]") {
|
|
215
|
+
sawDone = true;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
200
218
|
let parsed;
|
|
201
|
-
try {
|
|
219
|
+
try {
|
|
220
|
+
parsed = JSON.parse(data);
|
|
221
|
+
} catch {
|
|
222
|
+
throw invalidUpstreamStream(
|
|
223
|
+
"Upstream Chat Completions SSE event contained invalid JSON",
|
|
224
|
+
"upstream_stream_json_invalid",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const upstreamError = upstreamChatStreamError(parsed);
|
|
228
|
+
if (upstreamError) throw upstreamError;
|
|
202
229
|
if (!options.forceModel && parsed.model) actualModel = parsed.model;
|
|
203
|
-
if (parsed.usage?.completion_tokens != null)
|
|
230
|
+
if (parsed.usage?.completion_tokens != null) {
|
|
231
|
+
outputTokens = parsed.usage.completion_tokens;
|
|
232
|
+
markOutputTokens(outputTokens);
|
|
233
|
+
}
|
|
204
234
|
const choice = parsed.choices?.[0];
|
|
205
235
|
if (!choice) continue;
|
|
206
236
|
const delta = choice.delta || {};
|
|
237
|
+
if (isChatOutputDelta(delta)) markPerformanceOutput();
|
|
207
238
|
await ensureStart();
|
|
208
239
|
|
|
209
240
|
if (delta.content) {
|
|
@@ -227,9 +258,11 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
|
|
|
227
258
|
await emit("content_block_start", { type: "content_block_start", index: blockIndex,
|
|
228
259
|
content_block: { type: "tool_use", id: tc.id || `tu_${uid()}`, name: tc.function?.name || "", input: {} } });
|
|
229
260
|
}
|
|
230
|
-
|
|
261
|
+
const argumentDelta = tc.function?.arguments;
|
|
262
|
+
toolArgumentGuard.observe(oi, argumentDelta);
|
|
263
|
+
if (argumentDelta) {
|
|
231
264
|
await emit("content_block_delta", { type: "content_block_delta", index: toolBlocks[oi].anthropicIndex,
|
|
232
|
-
delta: { type: "input_json_delta", partial_json:
|
|
265
|
+
delta: { type: "input_json_delta", partial_json: argumentDelta } });
|
|
233
266
|
}
|
|
234
267
|
}
|
|
235
268
|
}
|
|
@@ -237,6 +270,8 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
|
|
|
237
270
|
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
238
271
|
}
|
|
239
272
|
|
|
273
|
+
if (!sawDone) throw incompleteUpstreamStream("[DONE]");
|
|
274
|
+
|
|
240
275
|
await ensureStart();
|
|
241
276
|
if (textOpen) await closeText();
|
|
242
277
|
for (const oi of Object.keys(toolBlocks)) {
|
package/src/auth.mjs
CHANGED
|
@@ -33,9 +33,9 @@ export function githubTokenMetadataPath(home = os.homedir()) {
|
|
|
33
33
|
export function githubReauthMessage(reason, home = os.homedir()) {
|
|
34
34
|
const tokenPath = githubTokenPath(home);
|
|
35
35
|
return `${reason}
|
|
36
|
-
Delete the saved GitHub token, then run
|
|
36
|
+
Delete the saved GitHub token, then run ccdx again to log in:
|
|
37
37
|
rm '${tokenPath}'
|
|
38
|
-
|
|
38
|
+
ccdx`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// Map GitHub polling responses to a small local state machine.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
function byteLength(value) {
|
|
2
|
+
return Buffer.byteLength(String(value));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function abortError(signal) {
|
|
6
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
7
|
+
const error = new Error("The operation was aborted");
|
|
8
|
+
error.name = "AbortError";
|
|
9
|
+
return error;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function throwIfAborted(signal) {
|
|
13
|
+
if (signal?.aborted) throw abortError(signal);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createByteLruCache({ maxBytes }) {
|
|
17
|
+
const limit = Math.max(0, Number.parseInt(maxBytes, 10) || 0);
|
|
18
|
+
const entries = new Map();
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
let hits = 0;
|
|
21
|
+
let misses = 0;
|
|
22
|
+
let evictions = 0;
|
|
23
|
+
|
|
24
|
+
const remove = (key) => {
|
|
25
|
+
const entry = entries.get(key);
|
|
26
|
+
if (!entry) return false;
|
|
27
|
+
entries.delete(key);
|
|
28
|
+
bytes -= entry.bytes;
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
get(key) {
|
|
34
|
+
const entry = entries.get(key);
|
|
35
|
+
if (!entry) {
|
|
36
|
+
misses += 1;
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
hits += 1;
|
|
40
|
+
entries.delete(key);
|
|
41
|
+
entries.set(key, entry);
|
|
42
|
+
return entry.value;
|
|
43
|
+
},
|
|
44
|
+
set(key, value) {
|
|
45
|
+
if (typeof key !== "string" || typeof value !== "string") return false;
|
|
46
|
+
const entry = { value, bytes: byteLength(key) + byteLength(value) };
|
|
47
|
+
if (entry.bytes > limit) return false;
|
|
48
|
+
remove(key);
|
|
49
|
+
entries.set(key, entry);
|
|
50
|
+
bytes += entry.bytes;
|
|
51
|
+
while (bytes > limit && entries.size) {
|
|
52
|
+
remove(entries.keys().next().value);
|
|
53
|
+
evictions += 1;
|
|
54
|
+
}
|
|
55
|
+
return entries.has(key);
|
|
56
|
+
},
|
|
57
|
+
clear() {
|
|
58
|
+
entries.clear();
|
|
59
|
+
bytes = 0;
|
|
60
|
+
hits = 0;
|
|
61
|
+
misses = 0;
|
|
62
|
+
evictions = 0;
|
|
63
|
+
},
|
|
64
|
+
stats() {
|
|
65
|
+
return {
|
|
66
|
+
entries: entries.size,
|
|
67
|
+
bytes,
|
|
68
|
+
max_bytes: limit,
|
|
69
|
+
hits,
|
|
70
|
+
misses,
|
|
71
|
+
evictions,
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createAbortAwareSingleflight() {
|
|
78
|
+
const flights = new Map();
|
|
79
|
+
|
|
80
|
+
const createFlight = (key, task) => {
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const flight = {
|
|
83
|
+
controller,
|
|
84
|
+
settled: false,
|
|
85
|
+
waiters: 0,
|
|
86
|
+
promise: null,
|
|
87
|
+
};
|
|
88
|
+
flight.promise = Promise.resolve()
|
|
89
|
+
.then(() => task(controller.signal))
|
|
90
|
+
.finally(() => {
|
|
91
|
+
flight.settled = true;
|
|
92
|
+
if (flights.get(key) === flight) flights.delete(key);
|
|
93
|
+
});
|
|
94
|
+
// The shared task can outlive every cancelled waiter. Keep its eventual
|
|
95
|
+
// rejection observed even when no caller remains to await it.
|
|
96
|
+
flight.promise.catch(() => {});
|
|
97
|
+
flights.set(key, flight);
|
|
98
|
+
return flight;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const waitForFlight = (key, flight, signal) => new Promise((resolve, reject) => {
|
|
102
|
+
let done = false;
|
|
103
|
+
flight.waiters += 1;
|
|
104
|
+
|
|
105
|
+
const finish = () => {
|
|
106
|
+
if (done) return false;
|
|
107
|
+
done = true;
|
|
108
|
+
signal?.removeEventListener("abort", onAbort);
|
|
109
|
+
flight.waiters -= 1;
|
|
110
|
+
if (flight.waiters === 0 && !flight.settled) {
|
|
111
|
+
if (flights.get(key) === flight) flights.delete(key);
|
|
112
|
+
flight.controller.abort(abortError());
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
116
|
+
const onAbort = () => {
|
|
117
|
+
if (!finish()) return;
|
|
118
|
+
reject(abortError(signal));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
122
|
+
if (signal?.aborted) {
|
|
123
|
+
onAbort();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
flight.promise.then(
|
|
127
|
+
(value) => {
|
|
128
|
+
if (!finish()) return;
|
|
129
|
+
resolve(value);
|
|
130
|
+
},
|
|
131
|
+
(error) => {
|
|
132
|
+
if (!finish()) return;
|
|
133
|
+
reject(error);
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
run(key, task, { signal } = {}) {
|
|
140
|
+
try {
|
|
141
|
+
throwIfAborted(signal);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return Promise.reject(error);
|
|
144
|
+
}
|
|
145
|
+
const flight = flights.get(key) || createFlight(key, task);
|
|
146
|
+
return waitForFlight(key, flight, signal);
|
|
147
|
+
},
|
|
148
|
+
stats() {
|
|
149
|
+
return { inflight: flights.size };
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createBoundedResultCache({ maxBytes }) {
|
|
155
|
+
const cache = createByteLruCache({ maxBytes });
|
|
156
|
+
const singleflight = createAbortAwareSingleflight();
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
async getOrCreate(key, createValue, { signal } = {}) {
|
|
160
|
+
throwIfAborted(signal);
|
|
161
|
+
const cached = cache.get(key);
|
|
162
|
+
if (cached !== undefined) return cached;
|
|
163
|
+
return singleflight.run(key, async (sharedSignal) => {
|
|
164
|
+
const value = await createValue(sharedSignal);
|
|
165
|
+
throwIfAborted(sharedSignal);
|
|
166
|
+
if (typeof value === "string") cache.set(key, value);
|
|
167
|
+
return value;
|
|
168
|
+
}, { signal });
|
|
169
|
+
},
|
|
170
|
+
set(key, value) {
|
|
171
|
+
return cache.set(key, value);
|
|
172
|
+
},
|
|
173
|
+
clear() {
|
|
174
|
+
cache.clear();
|
|
175
|
+
},
|
|
176
|
+
stats() {
|
|
177
|
+
return { ...cache.stats(), ...singleflight.stats() };
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
package/src/claude-config.mjs
CHANGED
|
@@ -41,7 +41,7 @@ export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH, host
|
|
|
41
41
|
settings = JSON.parse(raw);
|
|
42
42
|
} catch (e) {
|
|
43
43
|
console.log(status("warn", `Could not parse ~/.claude/settings.json: ${e.message}`));
|
|
44
|
-
console.log(status("info", "Fix the JSON file and rerun
|
|
44
|
+
console.log(status("info", "Fix the JSON file and rerun ccdx to configure Claude Code automatically."));
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
47
|
|
package/src/cli-options.mjs
CHANGED
|
@@ -82,9 +82,11 @@ export function parseRuntimeOptions(env = process.env) {
|
|
|
82
82
|
|
|
83
83
|
export function cliHelp() {
|
|
84
84
|
return `Usage:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
ccdx [--configure-claude-desktop] [--show-request-id]
|
|
86
|
+
ccdx doctor [--online] [--compat]
|
|
87
|
+
ccdx usage
|
|
88
|
+
ccdx --version
|
|
89
|
+
ccdx --help
|
|
90
|
+
|
|
91
|
+
Compatibility alias: codex-copilot-dx`;
|
|
90
92
|
}
|
package/src/copilot.mjs
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
} from "./github-identity.mjs";
|
|
20
20
|
import { prepareResponsesPayload, summarizeReqBody } from "./image-optimization.mjs";
|
|
21
21
|
import { enforceResponsesPayloadByteBudget } from "./responses-byte-budget.mjs";
|
|
22
|
+
import { withChatStreamUsage } from "./stream-contract.mjs";
|
|
23
|
+
import { markUpstreamStarted } from "./stream-performance.mjs";
|
|
22
24
|
|
|
23
25
|
export {
|
|
24
26
|
optimizeImageDataUrl,
|
|
@@ -317,7 +319,7 @@ export function copilotRuntimeStatus(now = Date.now()) {
|
|
|
317
319
|
function getGithubToken({ home = os.homedir() } = {}) {
|
|
318
320
|
const GITHUB_TOKEN_PATH = githubTokenPath(home);
|
|
319
321
|
if (!fs.existsSync(GITHUB_TOKEN_PATH)) {
|
|
320
|
-
throw new Error("GitHub token not found. Run
|
|
322
|
+
throw new Error("GitHub token not found. Run ccdx again to log in.");
|
|
321
323
|
}
|
|
322
324
|
const token = fs.readFileSync(GITHUB_TOKEN_PATH, "utf-8").trim();
|
|
323
325
|
if (!token) throw new Error(githubReauthMessage("GitHub token file is empty."));
|
|
@@ -343,7 +345,7 @@ function cacheCopilotTokenData(data, {
|
|
|
343
345
|
const tokenChanged = Boolean(copilotTokenGithubFingerprint && fingerprint !== copilotTokenGithubFingerprint);
|
|
344
346
|
if (!allowAccountSwitch && copilotTokenGithubIdentity && identity
|
|
345
347
|
&& !githubIdentitiesEqual(copilotTokenGithubIdentity, identity)) {
|
|
346
|
-
throw new Error(`Refusing to switch GitHub Copilot account from ${githubIdentityLabel(copilotTokenGithubIdentity)} to ${githubIdentityLabel(identity)} while the adapter is running. Restart
|
|
348
|
+
throw new Error(`Refusing to switch GitHub Copilot account from ${githubIdentityLabel(copilotTokenGithubIdentity)} to ${githubIdentityLabel(identity)} while the adapter is running. Restart ccdx to switch accounts intentionally.`);
|
|
347
349
|
}
|
|
348
350
|
if (!allowAccountSwitch && copilotTokenGithubIdentity && tokenChanged && !identity) {
|
|
349
351
|
const error = new Error("The saved GitHub token changed, but its account could not be verified. Keeping the running adapter bound to the existing GitHub account.");
|
|
@@ -508,8 +510,11 @@ export async function chatCompletions(chatReq, {
|
|
|
508
510
|
bodyText,
|
|
509
511
|
} = {}) {
|
|
510
512
|
const token = await getCopilotToken({ signal });
|
|
511
|
-
const
|
|
512
|
-
const
|
|
513
|
+
const upstreamReq = chatReq.stream === true ? withChatStreamUsage(chatReq) : chatReq;
|
|
514
|
+
const messages = upstreamReq.messages || [];
|
|
515
|
+
const serializedBody = typeof bodyText === "string" && upstreamReq === chatReq
|
|
516
|
+
? bodyText
|
|
517
|
+
: JSON.stringify(upstreamReq);
|
|
513
518
|
const headers = buildHeaders({
|
|
514
519
|
token,
|
|
515
520
|
version: getVSCodeVersion(),
|
|
@@ -517,6 +522,7 @@ export async function chatCompletions(chatReq, {
|
|
|
517
522
|
vision: computeVision(messages),
|
|
518
523
|
});
|
|
519
524
|
headers["Content-Length"] = String(Buffer.byteLength(serializedBody));
|
|
525
|
+
if (upstreamReq.stream === true) markUpstreamStarted();
|
|
520
526
|
return fetchCopilotUpstream(`${getApiBase()}/chat/completions`, {
|
|
521
527
|
method: "POST",
|
|
522
528
|
headers,
|
|
@@ -624,6 +630,7 @@ export async function responses(reqBody, {
|
|
|
624
630
|
headers["Accept"] = reqBody.stream ? "text/event-stream" : "application/json";
|
|
625
631
|
|
|
626
632
|
try {
|
|
633
|
+
if (reqBody.stream === true) markUpstreamStarted();
|
|
627
634
|
return await fetchCopilotUpstream(`${getApiBase()}${responsesEndpointPath()}`, {
|
|
628
635
|
method: "POST",
|
|
629
636
|
headers,
|