codex-copilot-dx 0.4.43 → 0.5.0
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 +31 -13
- package/bin/cli.mjs +31 -7
- package/bin/codex-copilot-dx.mjs +2 -0
- package/package.json +3 -2
- 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 +30 -6
- package/src/cli-status.mjs +122 -0
- 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 +4 -3
- 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/running-adapter.mjs +1 -0
- 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,15 @@ 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`.
|
|
33
|
+
Both installed commands expose the same options and subcommands; help, version,
|
|
34
|
+
doctor, status, and argument-error output use the command name that was invoked.
|
|
30
35
|
|
|
31
36
|
On first run, it will:
|
|
32
37
|
1. Authenticate with GitHub via device flow (if needed), after first trying compatible local Copilot token sources
|
|
@@ -42,14 +47,14 @@ If an existing `codex-copilot-dx` adapter is already running on the configured h
|
|
|
42
47
|
|
|
43
48
|
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
49
|
|
|
45
|
-
Do not set Claude Code by manually exporting `ANTHROPIC_BASE_URL` or `ANTHROPIC_AUTH_TOKEN` in your shell. Let `
|
|
50
|
+
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
51
|
|
|
47
52
|
### Diagnostics
|
|
48
53
|
|
|
49
54
|
Run a read-only config check without starting the adapter or changing files:
|
|
50
55
|
|
|
51
56
|
```bash
|
|
52
|
-
|
|
57
|
+
ccdx doctor
|
|
53
58
|
```
|
|
54
59
|
|
|
55
60
|
The command exits with status `1` when it finds an invalid configuration and `0` when checks contain only OK or warning results.
|
|
@@ -59,7 +64,7 @@ The doctor checks the GitHub token, Codex config, Claude Code settings, Claude A
|
|
|
59
64
|
For a read-only live check of the saved GitHub token, Copilot entitlement, and models endpoint, run:
|
|
60
65
|
|
|
61
66
|
```bash
|
|
62
|
-
|
|
67
|
+
ccdx doctor --online
|
|
63
68
|
```
|
|
64
69
|
|
|
65
70
|
The online doctor never starts device flow, scans for replacement tokens, or changes the saved token.
|
|
@@ -67,30 +72,38 @@ The online doctor never starts device flow, scans for replacement tokens, or cha
|
|
|
67
72
|
To actively verify the protocol path through an already-running adapter, run:
|
|
68
73
|
|
|
69
74
|
```bash
|
|
70
|
-
|
|
75
|
+
ccdx doctor --compat
|
|
71
76
|
```
|
|
72
77
|
|
|
73
78
|
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.
|
|
74
79
|
|
|
75
|
-
For
|
|
80
|
+
For a read-only summary of the running adapter, use:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
ccdx status
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The compatibility alias supports the same command as
|
|
87
|
+
`codex-copilot-dx status`. For the complete machine-readable payload, query the
|
|
88
|
+
loopback endpoint directly:
|
|
76
89
|
|
|
77
90
|
```bash
|
|
78
91
|
curl -s http://127.0.0.1:2026/_ccdx/status | jq
|
|
79
92
|
```
|
|
80
93
|
|
|
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,
|
|
94
|
+
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
95
|
|
|
83
96
|
```bash
|
|
84
|
-
|
|
97
|
+
ccdx --show-request-id
|
|
85
98
|
```
|
|
86
99
|
|
|
87
|
-
When the saved token is missing, `
|
|
100
|
+
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
101
|
|
|
89
102
|
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
103
|
|
|
91
104
|
```bash
|
|
92
105
|
rm ~/.local/share/copilot-api/github_token
|
|
93
|
-
|
|
106
|
+
ccdx
|
|
94
107
|
```
|
|
95
108
|
|
|
96
109
|
### Claude App opt-in
|
|
@@ -141,6 +154,7 @@ Environment variables:
|
|
|
141
154
|
| `CCDX_IMG_MIN_BYTES` | `100000` | In-bounds images smaller than this are left untouched; oversized images are still downscaled |
|
|
142
155
|
| `CCDX_IMG_CONCURRENCY` | `2` | Global concurrent image optimization tasks; values above `12` are capped at `12` |
|
|
143
156
|
| `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted by `sharp` for one image |
|
|
157
|
+
| `CCDX_IMG_CACHE_MAX_BYTES` | `67108864` | Process-local byte ceiling for cached image optimization results; set to `0` to disable result caching |
|
|
144
158
|
| `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
|
|
145
159
|
| `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
|
|
146
160
|
| `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 +184,7 @@ The adapter records token usage metadata to `~/.local/share/codex-copilot-dx/usa
|
|
|
170
184
|
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
185
|
|
|
172
186
|
```bash
|
|
173
|
-
|
|
187
|
+
ccdx usage
|
|
174
188
|
```
|
|
175
189
|
|
|
176
190
|
### Image optimization
|
|
@@ -181,9 +195,13 @@ The adapter automatically downsamples embedded screenshots to model-appropriate
|
|
|
181
195
|
|
|
182
196
|
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
197
|
|
|
198
|
+
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.
|
|
199
|
+
|
|
184
200
|
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
201
|
|
|
186
|
-
|
|
202
|
+
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.
|
|
203
|
+
|
|
204
|
+
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
205
|
|
|
188
206
|
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
207
|
|
package/bin/cli.mjs
CHANGED
|
@@ -16,23 +16,25 @@ import { adapterBaseUrl, checkRunningAdapter } from "../src/running-adapter.mjs"
|
|
|
16
16
|
import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
|
|
17
17
|
import { isValidModelList, loadModelCache, saveModelCache } from "../src/model-cache.mjs";
|
|
18
18
|
import { initializeModelRegistry, runInBackground } from "../src/startup.mjs";
|
|
19
|
-
import { cliHelp, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
|
|
19
|
+
import { cliCommandName, cliHelp, parseAdapterProbeOptions, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
|
|
20
|
+
import { formatAdapterStatus, readAdapterStatus } from "../src/cli-status.mjs";
|
|
20
21
|
import { closeHttpServer } from "../src/shutdown.mjs";
|
|
21
22
|
|
|
22
23
|
const LOCAL_VERSION = localPackageVersion();
|
|
23
|
-
const
|
|
24
|
+
const CLI_NAME = cliCommandName();
|
|
25
|
+
const CLI_BANNER = `${CLI_NAME} v${LOCAL_VERSION} by Dale Xiao`;
|
|
24
26
|
|
|
25
27
|
let CLI;
|
|
26
28
|
try {
|
|
27
29
|
CLI = parseCliArgs(process.argv.slice(2));
|
|
28
30
|
} catch (e) {
|
|
29
31
|
console.error(e.message);
|
|
30
|
-
console.error(
|
|
32
|
+
console.error(`Run ${CLI_NAME} --help for usage.`);
|
|
31
33
|
process.exit(2);
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
if (CLI.command === "help") {
|
|
35
|
-
console.log(cliHelp());
|
|
37
|
+
console.log(cliHelp(CLI_NAME));
|
|
36
38
|
process.exit(0);
|
|
37
39
|
}
|
|
38
40
|
if (CLI.command === "version") {
|
|
@@ -43,6 +45,27 @@ if (CLI.command === "usage") {
|
|
|
43
45
|
await printUsageSummary();
|
|
44
46
|
process.exit(0);
|
|
45
47
|
}
|
|
48
|
+
if (CLI.command === "status") {
|
|
49
|
+
let probe;
|
|
50
|
+
try {
|
|
51
|
+
probe = parseAdapterProbeOptions(process.env);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.error(e.message);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const snapshot = await readAdapterStatus({
|
|
58
|
+
host: probe.adapterHost,
|
|
59
|
+
port: probe.adapterPort,
|
|
60
|
+
timeoutMs: probe.existingAdapterTimeoutMs,
|
|
61
|
+
});
|
|
62
|
+
console.log(formatAdapterStatus(snapshot, { commandName: CLI_NAME, cliVersion: LOCAL_VERSION }));
|
|
63
|
+
process.exit(0);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error(status("err", e.message));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
46
69
|
|
|
47
70
|
let RUNTIME;
|
|
48
71
|
try {
|
|
@@ -107,7 +130,7 @@ async function refreshClaudeDesktopModelDefs() {
|
|
|
107
130
|
const models = JSON.parse(body);
|
|
108
131
|
const autoReview = codexAutoReviewModelStatus(models);
|
|
109
132
|
if (!autoReview.available) {
|
|
110
|
-
console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run
|
|
133
|
+
console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run ${CLI_NAME} doctor --compat to verify the live path.`));
|
|
111
134
|
}
|
|
112
135
|
const gptModelIds = gptModelIdsFromCopilotModels(models);
|
|
113
136
|
if (gptModelIds.length) {
|
|
@@ -169,7 +192,7 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
169
192
|
});
|
|
170
193
|
if (running.incompatible) {
|
|
171
194
|
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
|
|
195
|
+
throw new Error(`Adapter ${found} is already running at ${running.baseUrl}, but this CLI is ${LOCAL_VERSION}. Stop the existing process and run ${CLI_NAME} again.`);
|
|
173
196
|
}
|
|
174
197
|
if (!running.ok) return false;
|
|
175
198
|
|
|
@@ -197,7 +220,7 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
197
220
|
|
|
198
221
|
await openCodex();
|
|
199
222
|
console.log(`
|
|
200
|
-
${status("ok", "Ready, using the existing
|
|
223
|
+
${status("ok", "Ready, using the existing ccdx adapter")}
|
|
201
224
|
|
|
202
225
|
Adapter: ${running.baseUrl}
|
|
203
226
|
`);
|
|
@@ -206,6 +229,7 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
206
229
|
|
|
207
230
|
if (CLI.command === "doctor") {
|
|
208
231
|
const checks = await runDoctor({
|
|
232
|
+
commandName: CLI_NAME,
|
|
209
233
|
port: ADAPTER_PORT,
|
|
210
234
|
host: ADAPTER_HOST,
|
|
211
235
|
online: CLI.online,
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-copilot-dx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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
|
-
"
|
|
8
|
+
"ccdx": "bin/cli.mjs",
|
|
9
|
+
"codex-copilot-dx": "bin/codex-copilot-dx.mjs"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"bin/",
|
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
|
|