codex-copilot-dx 0.4.42 → 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 +28 -16
- package/bin/cli.mjs +4 -4
- package/package.json +2 -1
- package/src/adapter.mjs +17 -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 +42 -8
- package/src/doctor.mjs +11 -2
- package/src/http-transport.mjs +5 -2
- package/src/image-optimization.mjs +312 -116
- package/src/observability.mjs +2 -1
- package/src/responses-bridge.mjs +76 -20
- package/src/responses-byte-budget.mjs +314 -0
- package/src/responses-chat-payload.mjs +129 -0
- package/src/responses-compaction.mjs +113 -0
- package/src/responses-content.mjs +86 -0
- package/src/responses-handler.mjs +67 -20
- package/src/responses-image-limit.mjs +15 -26
- package/src/responses-proxy.mjs +123 -23
- package/src/responses-request.mjs +43 -6
- 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
|
|
@@ -125,7 +128,7 @@ Environment variables:
|
|
|
125
128
|
| `CCDX_MAX_INFLIGHT_BODY_BYTES` | `33554432` | Shared byte budget for admitted request bodies; a larger single request runs exclusively |
|
|
126
129
|
| `CCDX_MAX_QUEUED_REQUESTS` | `16` | Maximum body requests waiting for the shared byte budget |
|
|
127
130
|
| `CCDX_REQUEST_QUEUE_TIMEOUT_MS` | `120000` | Maximum wait for request-body admission before returning `503` |
|
|
128
|
-
| `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Responses body
|
|
131
|
+
| `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Strict maximum forwarded Responses body size; larger payloads are adapted locally or rejected with `413` |
|
|
129
132
|
| `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
|
|
130
133
|
| `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
|
|
131
134
|
| `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
|
|
@@ -137,10 +140,11 @@ Environment variables:
|
|
|
137
140
|
| `CCDX_LOG_LEVEL` | `info` | Set to `debug` to include upstream request attempts, status codes, retry causes, and timings |
|
|
138
141
|
| `CCDX_LOG_MAX_BYTES` | `16777216` | Rotate the debug log at this size, retaining one `.1` backup; set to `0` to disable rotation |
|
|
139
142
|
| `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
|
|
140
|
-
| `CCDX_IMG_QUALITY` | `
|
|
141
|
-
| `CCDX_IMG_MIN_BYTES` | `100000` |
|
|
143
|
+
| `CCDX_IMG_QUALITY` | `82` | Initial WebP quality used when re-encoding images |
|
|
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,17 +174,25 @@ 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
|
|
177
181
|
|
|
178
|
-
Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns, and GitHub Copilot's `/responses` endpoint can reject oversized requests with `413 Payload Too Large`.
|
|
182
|
+
Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns, and GitHub Copilot's `/responses` endpoint can reject oversized requests with `408 Request Timeout` while reading the body or `413 Payload Too Large`.
|
|
179
183
|
|
|
180
|
-
The adapter automatically downsamples embedded screenshots to
|
|
184
|
+
The adapter automatically downsamples embedded screenshots to model-appropriate pixel bounds (with a conservative 2048 px long-edge fallback) and initially re-encodes them as WebP quality 82 before forwarding `/v1/responses`. It never replaces an image with a larger encoding and applies one global concurrency limit across direct, function-tool, and custom-tool output images.
|
|
185
|
+
|
|
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.
|
|
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.
|
|
181
189
|
|
|
182
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.
|
|
183
191
|
|
|
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.
|
|
195
|
+
|
|
184
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.
|
|
185
197
|
|
|
186
198
|
## License
|
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 } : {};
|
|
@@ -106,6 +108,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
106
108
|
acquireRequest,
|
|
107
109
|
chatCompletionsFn,
|
|
108
110
|
openAIModelEnv,
|
|
111
|
+
responsesPayloadOptions: options.responsesPayloadOptions,
|
|
109
112
|
responsesFn,
|
|
110
113
|
streamHandshakeTimeoutMs,
|
|
111
114
|
streamIdleTimeoutMs,
|
|
@@ -140,6 +143,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
140
143
|
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
141
144
|
res.end(JSON.stringify(runtimeStatusPayload({
|
|
142
145
|
metrics: requestMetrics,
|
|
146
|
+
streamPerformance: streamPerformanceMetrics,
|
|
143
147
|
admission: acquireRequest,
|
|
144
148
|
modelRegistry: options.modelRegistry,
|
|
145
149
|
})));
|
|
@@ -223,12 +227,16 @@ export function createAdapterHandler(options = {}) {
|
|
|
223
227
|
}
|
|
224
228
|
|
|
225
229
|
const trackRequest = pathname !== ADAPTER_HEALTH_PATH && pathname !== ADAPTER_STATUS_PATH;
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
};
|
|
229
237
|
if (trackRequest && typeof res.once === "function") {
|
|
230
|
-
res.once("finish", () =>
|
|
231
|
-
res.once("close", () =>
|
|
238
|
+
res.once("finish", () => finishRequest({ statusCode: res.statusCode }));
|
|
239
|
+
res.once("close", () => finishRequest({
|
|
232
240
|
statusCode: res.statusCode,
|
|
233
241
|
aborted: !res.writableFinished && !res.writableEnded,
|
|
234
242
|
}));
|
|
@@ -239,16 +247,17 @@ export function createAdapterHandler(options = {}) {
|
|
|
239
247
|
requestId,
|
|
240
248
|
pathname,
|
|
241
249
|
showRequestId: options.showRequestId === true,
|
|
250
|
+
streamPerformance,
|
|
242
251
|
}, () => dispatch(req, res, pathname));
|
|
243
252
|
if (result && typeof result.then === "function") {
|
|
244
253
|
return result.catch((error) => {
|
|
245
|
-
|
|
254
|
+
finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
|
|
246
255
|
throw error;
|
|
247
256
|
});
|
|
248
257
|
}
|
|
249
258
|
return result;
|
|
250
259
|
} catch (error) {
|
|
251
|
-
|
|
260
|
+
finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
|
|
252
261
|
throw error;
|
|
253
262
|
}
|
|
254
263
|
};
|
|
@@ -272,7 +281,7 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
272
281
|
return new Promise((resolve, reject) => {
|
|
273
282
|
const onListenError = (e) => {
|
|
274
283
|
if (e?.code === "EADDRINUSE") {
|
|
275
|
-
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.`));
|
|
276
285
|
return;
|
|
277
286
|
}
|
|
278
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
|
}
|