codex-copilot-dx 0.4.5 → 0.4.7
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 +22 -2
- package/bin/cli.mjs +19 -2
- package/package.json +1 -1
- package/src/adapter.mjs +123 -8
- package/src/anthropic.mjs +6 -6
- package/src/claude-desktop-config.mjs +232 -0
- package/src/models.mjs +110 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# codex-copilot-dx
|
|
2
2
|
|
|
3
|
-
Use [Codex Desktop](https://openai.com/codex)
|
|
3
|
+
Use [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and optionally Claude Desktop with your **GitHub Copilot** subscription.
|
|
4
4
|
|
|
5
5
|
## How it works
|
|
6
6
|
|
|
@@ -8,6 +8,7 @@ A single in-process adapter (port `2026`) exposes both APIs over your Copilot su
|
|
|
8
8
|
|
|
9
9
|
- **Codex** -> OpenAI Responses API (`/v1/responses`, `/v1/responses/compact`); Responses-only models and compaction proxy directly, chat models convert to Chat Completions.
|
|
10
10
|
- **Claude Code** -> Anthropic Messages API (`/v1/messages`, `/v1/messages/count_tokens`), translated to/from Chat Completions.
|
|
11
|
+
- **Claude Desktop** -> optional Anthropic gateway profile using the same local Messages API plus local model discovery for the configured gateway key.
|
|
11
12
|
|
|
12
13
|
Supports both HTTP SSE streaming and non-streaming.
|
|
13
14
|
|
|
@@ -15,7 +16,7 @@ Supports both HTTP SSE streaming and non-streaming.
|
|
|
15
16
|
|
|
16
17
|
- GitHub Copilot subscription (Individual, Business, or Enterprise)
|
|
17
18
|
- Node.js 18+
|
|
18
|
-
- [Codex Desktop](https://openai.com/codex)
|
|
19
|
+
- [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and/or Claude Desktop installed
|
|
19
20
|
|
|
20
21
|
## Usage
|
|
21
22
|
|
|
@@ -37,6 +38,22 @@ Claude Code picks up the new `ANTHROPIC_BASE_URL` on its next launch.
|
|
|
37
38
|
|
|
38
39
|
Do not set Claude Code by manually exporting `ANTHROPIC_BASE_URL` or `ANTHROPIC_AUTH_TOKEN` in your shell. Let `codex-copilot-dx` 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.
|
|
39
40
|
|
|
41
|
+
### Claude Desktop opt-in
|
|
42
|
+
|
|
43
|
+
Claude Desktop support is opt-in so the default Codex Desktop and Claude Code setup stays unchanged:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx codex-copilot-dx@latest --configure-claude-desktop
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or set:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
CCDX_CONFIGURE_CLAUDE_DESKTOP=1 npx codex-copilot-dx@latest
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This writes a local Claude Desktop 3P gateway profile that points to the adapter root URL, such as `http://127.0.0.1:2026`. The profile uses a generated local bearer key unless `CCDX_CLAUDE_DESKTOP_API_KEY` is set. Restart Claude Desktop after running the command.
|
|
56
|
+
|
|
40
57
|
## Configuration
|
|
41
58
|
|
|
42
59
|
Environment variables:
|
|
@@ -53,6 +70,9 @@ Environment variables:
|
|
|
53
70
|
| `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
|
|
54
71
|
| `CCDX_IMG_CONCURRENCY` | `4` | Concurrent image optimization tasks; values above `12` are capped at `12` |
|
|
55
72
|
| `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
|
|
73
|
+
| `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude Desktop 3P gateway profile during startup |
|
|
74
|
+
| `CCDX_CLAUDE_DESKTOP_API_KEY` | generated for opt-in setup | Bearer key written into the Claude Desktop profile and recognized by the adapter for model discovery |
|
|
75
|
+
| `CCDX_CLAUDE_MODEL_ALIASES` | built-in Claude aliases | Comma-separated Desktop-to-upstream aliases, for example `claude-sonnet-4-6=claude-sonnet-4.6` |
|
|
56
76
|
| `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token usage log |
|
|
57
77
|
| `CCDX_DISABLE_USAGE` | unset | Set to `1` to disable usage logging |
|
|
58
78
|
|
package/bin/cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { ensureAuth, openCodex } from "../src/launcher.mjs";
|
|
4
4
|
import { ensureCodexConfig } from "../src/config.mjs";
|
|
5
5
|
import { ensureClaudeConfig } from "../src/claude-config.mjs";
|
|
6
|
+
import { applyClaudeDesktopConfig, formatClaudeDesktopApplyResult, generatedClaudeDesktopApiKey } from "../src/claude-desktop-config.mjs";
|
|
6
7
|
import { startAdapter } from "../src/adapter.mjs";
|
|
7
8
|
import { refreshVSCodeVersion } from "../src/copilot.mjs";
|
|
8
9
|
import { status } from "../src/status.mjs";
|
|
@@ -13,6 +14,7 @@ const ADAPTER_PORT = parseInt(process.env.ADAPTER_PORT || "2026");
|
|
|
13
14
|
const ADAPTER_HOST = process.env.ADAPTER_HOST || "127.0.0.1";
|
|
14
15
|
const LOCAL_VERSION = localPackageVersion();
|
|
15
16
|
const command = process.argv[2];
|
|
17
|
+
const CONFIGURE_CLAUDE_DESKTOP = command === "--configure-claude-desktop" || process.env.CCDX_CONFIGURE_CLAUDE_DESKTOP === "1";
|
|
16
18
|
|
|
17
19
|
function isLoopbackHost(host) {
|
|
18
20
|
return ["127.0.0.1", "localhost", "::1"].includes(String(host).toLowerCase());
|
|
@@ -50,18 +52,33 @@ try {
|
|
|
50
52
|
console.log(status("warn", `ADAPTER_HOST=${ADAPTER_HOST} exposes the adapter beyond loopback. Use only on trusted networks.`));
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
const claudeDesktopApiKey = CONFIGURE_CLAUDE_DESKTOP
|
|
56
|
+
? (process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || generatedClaudeDesktopApiKey())
|
|
57
|
+
: (process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || "");
|
|
58
|
+
|
|
53
59
|
// Start the in-process adapter.
|
|
54
|
-
await startAdapter(ADAPTER_PORT, ADAPTER_HOST);
|
|
60
|
+
await startAdapter(ADAPTER_PORT, ADAPTER_HOST, { claudeDesktopApiKey });
|
|
55
61
|
|
|
56
62
|
// Point Codex and Claude Code at the adapter.
|
|
57
63
|
ensureCodexConfig(ADAPTER_PORT);
|
|
58
64
|
ensureClaudeConfig(ADAPTER_PORT);
|
|
65
|
+
if (CONFIGURE_CLAUDE_DESKTOP) {
|
|
66
|
+
const result = applyClaudeDesktopConfig({
|
|
67
|
+
port: ADAPTER_PORT,
|
|
68
|
+
host: ADAPTER_HOST,
|
|
69
|
+
gatewayApiKey: claudeDesktopApiKey,
|
|
70
|
+
});
|
|
71
|
+
console.log(status("ok", `Configured Claude Desktop gateway profile at ${result.baseUrl}`));
|
|
72
|
+
console.log(formatClaudeDesktopApplyResult(result));
|
|
73
|
+
}
|
|
59
74
|
|
|
60
75
|
// Launch Codex when available.
|
|
61
76
|
openCodex();
|
|
62
77
|
|
|
63
78
|
console.log(`
|
|
64
|
-
${status("ok",
|
|
79
|
+
${status("ok", CONFIGURE_CLAUDE_DESKTOP
|
|
80
|
+
? "Ready. Codex, Claude Code, and Claude Desktop are using your GitHub Copilot subscription."
|
|
81
|
+
: "Ready. Codex and Claude Code are using your GitHub Copilot subscription.")}
|
|
65
82
|
|
|
66
83
|
Adapter: http://${ADAPTER_HOST}:${ADAPTER_PORT}
|
|
67
84
|
|
package/package.json
CHANGED
package/src/adapter.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import * as zlib from "node:zlib";
|
|
|
5
5
|
import { chatCompletions, listModels, responses as copilotResponses, responsesCompact as copilotResponsesCompact } from "./copilot.mjs";
|
|
6
6
|
import { webStreamLines } from "./stream.mjs";
|
|
7
7
|
import { anthropicToChat, chatToAnthropic, streamAnthropicFromLines, countTokens } from "./anthropic.mjs";
|
|
8
|
+
import { claudeDesktopModelsResponse, resolveAnthropicModel } from "./models.mjs";
|
|
8
9
|
import { status } from "./status.mjs";
|
|
9
10
|
import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
|
|
10
11
|
|
|
@@ -125,6 +126,26 @@ function sendJsonError(res, err, fallbackStatus = 400) {
|
|
|
125
126
|
res.end(JSON.stringify({ error: err?.message || "Request failed" }));
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
function sendUpstreamError(res, resp, text) {
|
|
130
|
+
if (!res.headersSent) {
|
|
131
|
+
res.writeHead(resp.status || 502, { "Content-Type": resp.headers?.get("content-type") || "application/json" });
|
|
132
|
+
}
|
|
133
|
+
res.end(text || JSON.stringify({ error: "Upstream request failed" }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function bearerToken(headers = {}) {
|
|
137
|
+
const value = headers.authorization || headers.Authorization || "";
|
|
138
|
+
const match = /^Bearer\s+(.+)$/i.exec(String(value));
|
|
139
|
+
return match?.[1]?.trim() || "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function shouldServeClaudeDesktopModels(req, claudeDesktopApiKey) {
|
|
143
|
+
if (!claudeDesktopApiKey || claudeDesktopApiKey === "dummy") return false;
|
|
144
|
+
const token = bearerToken(req.headers);
|
|
145
|
+
const xApiKey = req.headers?.["x-api-key"] || req.headers?.["X-Api-Key"] || req.headers?.["X-API-Key"] || "";
|
|
146
|
+
return token === claudeDesktopApiKey || xApiKey === claudeDesktopApiKey;
|
|
147
|
+
}
|
|
148
|
+
|
|
128
149
|
export function writeOrDrain(res, chunk) {
|
|
129
150
|
if (res.destroyed || res.writableEnded) return Promise.resolve(false);
|
|
130
151
|
if (res.write(chunk)) return Promise.resolve(true);
|
|
@@ -180,6 +201,81 @@ function responsesOutputItems(output) {
|
|
|
180
201
|
return cloneJson(output.filter((item) => item?.type === "message" || item?.type === "function_call"));
|
|
181
202
|
}
|
|
182
203
|
|
|
204
|
+
function stripEncryptedReasoningValue(value, state) {
|
|
205
|
+
if (Array.isArray(value)) {
|
|
206
|
+
return value.map((item) => stripEncryptedReasoningValue(item, state));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (value && typeof value === "object") {
|
|
210
|
+
const out = {};
|
|
211
|
+
for (const [key, child] of Object.entries(value)) {
|
|
212
|
+
if (key === "encrypted_content") {
|
|
213
|
+
state.changed = true;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
out[key] = stripEncryptedReasoningValue(child, state);
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isEncryptedReasoningInputItem(item) {
|
|
225
|
+
return item && typeof item === "object"
|
|
226
|
+
&& (item.type === "reasoning" || Object.prototype.hasOwnProperty.call(item, "encrypted_content"));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function sanitizeEncryptedReasoningRequest(reqContext) {
|
|
230
|
+
const state = { changed: false };
|
|
231
|
+
let body = cloneJson(reqContext.body);
|
|
232
|
+
if (Array.isArray(body.input)) {
|
|
233
|
+
const input = [];
|
|
234
|
+
for (const item of body.input) {
|
|
235
|
+
if (isEncryptedReasoningInputItem(item)) {
|
|
236
|
+
state.changed = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
input.push(stripEncryptedReasoningValue(item, state));
|
|
240
|
+
}
|
|
241
|
+
body.input = input;
|
|
242
|
+
} else {
|
|
243
|
+
body = stripEncryptedReasoningValue(body, state);
|
|
244
|
+
}
|
|
245
|
+
if (!state.changed) return null;
|
|
246
|
+
return {
|
|
247
|
+
...reqContext,
|
|
248
|
+
body,
|
|
249
|
+
inputItems: Array.isArray(body.input) ? body.input : reqContext.inputItems,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function isEncryptedContentVerificationError(statusCode, text) {
|
|
254
|
+
if (statusCode < 400 || !text) return false;
|
|
255
|
+
const lower = String(text).toLowerCase();
|
|
256
|
+
return lower.includes("encrypted content")
|
|
257
|
+
&& lower.includes("could not be verified")
|
|
258
|
+
&& (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
|
|
262
|
+
let resp = await upstream(reqContext.body, { signal: options.signal });
|
|
263
|
+
if (resp.ok) return { resp, reqContext };
|
|
264
|
+
|
|
265
|
+
const errorText = await resp.text();
|
|
266
|
+
if (!isEncryptedContentVerificationError(resp.status, errorText)) {
|
|
267
|
+
return { resp, reqContext, errorText };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
|
|
271
|
+
if (!retryContext) return { resp, reqContext, errorText };
|
|
272
|
+
|
|
273
|
+
console.warn(status("warn", "encrypted reasoning rejected by upstream; retrying without encrypted reasoning"));
|
|
274
|
+
resp = await upstream(retryContext.body, { signal: options.signal });
|
|
275
|
+
if (resp.ok) return { resp, reqContext: retryContext };
|
|
276
|
+
return { resp, reqContext: retryContext, errorText: await resp.text() };
|
|
277
|
+
}
|
|
278
|
+
|
|
183
279
|
export function clearResponseHistoryForTests() {
|
|
184
280
|
responseHistories.clear();
|
|
185
281
|
}
|
|
@@ -256,7 +352,13 @@ function readSseEvents(buffer, onData) {
|
|
|
256
352
|
}
|
|
257
353
|
|
|
258
354
|
async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
|
|
259
|
-
const
|
|
355
|
+
const opened = await openCopilotResponse(reqContext, upstream, options);
|
|
356
|
+
const { resp, errorText } = opened;
|
|
357
|
+
reqContext = opened.reqContext;
|
|
358
|
+
if (errorText !== undefined) {
|
|
359
|
+
sendUpstreamError(res, resp, errorText);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
260
362
|
|
|
261
363
|
if (reqContext.body.stream) {
|
|
262
364
|
res.writeHead(resp.status, {
|
|
@@ -416,7 +518,12 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {})
|
|
|
416
518
|
|
|
417
519
|
// Public server entry point.
|
|
418
520
|
|
|
419
|
-
export function startAdapter(port = 2026, host = "127.0.0.1") {
|
|
521
|
+
export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
522
|
+
const claudeDesktopApiKey = options.claudeDesktopApiKey
|
|
523
|
+
|| process.env.CCDX_CLAUDE_DESKTOP_API_KEY
|
|
524
|
+
|| process.env.CCDX_PROXY_API_KEY
|
|
525
|
+
|| "";
|
|
526
|
+
|
|
420
527
|
const server = http.createServer((req, res) => {
|
|
421
528
|
const pathname = requestPath(req.url);
|
|
422
529
|
|
|
@@ -492,6 +599,11 @@ export function startAdapter(port = 2026, host = "127.0.0.1") {
|
|
|
492
599
|
}
|
|
493
600
|
|
|
494
601
|
if (req.method === "GET" && pathname === "/v1/models") {
|
|
602
|
+
if (shouldServeClaudeDesktopModels(req, claudeDesktopApiKey)) {
|
|
603
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
604
|
+
res.end(JSON.stringify(claudeDesktopModelsResponse()));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
495
607
|
const abort = createRequestAbort(req, res);
|
|
496
608
|
abort.setTimeout(UPSTREAM_TIMEOUT_MS);
|
|
497
609
|
listModels({ signal: abort.signal })
|
|
@@ -523,9 +635,11 @@ export function startAdapter(port = 2026, host = "127.0.0.1") {
|
|
|
523
635
|
try {
|
|
524
636
|
const parsed = await readJsonBody(req);
|
|
525
637
|
if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
638
|
+
const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown");
|
|
639
|
+
const modelNote = upstreamModel === requestedModel ? requestedModel : `${requestedModel} -> ${upstreamModel}`;
|
|
640
|
+
console.log(status("info", `messages model=${modelNote} stream=${!!parsed.stream}`));
|
|
641
|
+
const chatReq = anthropicToChat(parsed, { upstreamModel });
|
|
642
|
+
const forceRequestedModel = upstreamModel !== requestedModel;
|
|
529
643
|
if (parsed.stream) {
|
|
530
644
|
const upstream = await chatCompletions({ ...chatReq, stream: true }, { signal: abort.signal });
|
|
531
645
|
if (!upstream.ok) {
|
|
@@ -543,14 +657,15 @@ export function startAdapter(port = 2026, host = "127.0.0.1") {
|
|
|
543
657
|
if (event === "message_delta") usage = data.usage;
|
|
544
658
|
await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
545
659
|
},
|
|
546
|
-
|
|
660
|
+
requestedModel,
|
|
661
|
+
{ forceModel: forceRequestedModel },
|
|
547
662
|
);
|
|
548
|
-
recordAnthropicUsage({ surface: "messages", mode: "stream", model, responseId: messageId, usage });
|
|
663
|
+
recordAnthropicUsage({ surface: "messages", mode: "stream", model: requestedModel, responseId: messageId, usage });
|
|
549
664
|
if (!res.writableEnded) res.end();
|
|
550
665
|
} else {
|
|
551
666
|
const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
|
|
552
667
|
const data = await upstream.text();
|
|
553
|
-
const anthropicMsg = chatToAnthropic(JSON.parse(data),
|
|
668
|
+
const anthropicMsg = chatToAnthropic(JSON.parse(data), requestedModel, { forceModel: forceRequestedModel });
|
|
554
669
|
recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
|
|
555
670
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
556
671
|
res.end(JSON.stringify(anthropicMsg));
|
package/src/anthropic.mjs
CHANGED
|
@@ -89,7 +89,7 @@ function mapToolChoice(tc) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export function anthropicToChat(body) {
|
|
92
|
+
export function anthropicToChat(body, options = {}) {
|
|
93
93
|
const messages = [];
|
|
94
94
|
const sys = systemToText(body.system);
|
|
95
95
|
if (sys) messages.push({ role: "system", content: sys });
|
|
@@ -97,7 +97,7 @@ export function anthropicToChat(body) {
|
|
|
97
97
|
for (const converted of convertMessage(m)) messages.push(converted);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
const chatReq = { model: body.model, messages };
|
|
100
|
+
const chatReq = { model: options.upstreamModel || body.model, messages };
|
|
101
101
|
if (body.max_tokens !== undefined) chatReq.max_tokens = body.max_tokens;
|
|
102
102
|
if (body.temperature !== undefined) chatReq.temperature = body.temperature;
|
|
103
103
|
if (body.top_p !== undefined) chatReq.top_p = body.top_p;
|
|
@@ -117,7 +117,7 @@ export function anthropicToChat(body) {
|
|
|
117
117
|
|
|
118
118
|
function uid() { return randomUUID().replace(/-/g, ""); }
|
|
119
119
|
|
|
120
|
-
export function chatToAnthropic(openaiResp, model) {
|
|
120
|
+
export function chatToAnthropic(openaiResp, model, options = {}) {
|
|
121
121
|
const choice = openaiResp.choices?.[0];
|
|
122
122
|
const msg = choice?.message || {};
|
|
123
123
|
const content = [];
|
|
@@ -143,7 +143,7 @@ export function chatToAnthropic(openaiResp, model) {
|
|
|
143
143
|
id: `msg_${uid()}`,
|
|
144
144
|
type: "message",
|
|
145
145
|
role: "assistant",
|
|
146
|
-
model: openaiResp.model || model,
|
|
146
|
+
model: options.forceModel ? model : (openaiResp.model || model),
|
|
147
147
|
content,
|
|
148
148
|
stop_reason: mapStopReason(choice?.finish_reason),
|
|
149
149
|
stop_sequence: null,
|
|
@@ -152,7 +152,7 @@ export function chatToAnthropic(openaiResp, model) {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
// Consume OpenAI chat SSE lines and emit Anthropic SSE events.
|
|
155
|
-
export async function streamAnthropicFromLines(lineIterator, emit, model) {
|
|
155
|
+
export async function streamAnthropicFromLines(lineIterator, emit, model, options = {}) {
|
|
156
156
|
const msgId = `msg_${uid()}`;
|
|
157
157
|
let started = false;
|
|
158
158
|
let blockIndex = -1;
|
|
@@ -192,7 +192,7 @@ export async function streamAnthropicFromLines(lineIterator, emit, model) {
|
|
|
192
192
|
if (data === "[DONE]") break;
|
|
193
193
|
let parsed;
|
|
194
194
|
try { parsed = JSON.parse(data); } catch { continue; }
|
|
195
|
-
if (parsed.model) actualModel = parsed.model;
|
|
195
|
+
if (!options.forceModel && parsed.model) actualModel = parsed.model;
|
|
196
196
|
if (parsed.usage?.completion_tokens != null) outputTokens = parsed.usage.completion_tokens;
|
|
197
197
|
const choice = parsed.choices?.[0];
|
|
198
198
|
if (!choice) continue;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { claudeDesktopModelIds } from "./models.mjs";
|
|
6
|
+
import { status } from "./status.mjs";
|
|
7
|
+
|
|
8
|
+
const CONFIG_FILE = "claude_desktop_config.json";
|
|
9
|
+
const CONFIG_LIBRARY_DIR = "configLibrary";
|
|
10
|
+
export const DEFAULT_CLAUDE_DESKTOP_PROFILE_ID = "00000000-0000-4000-8000-000000202600";
|
|
11
|
+
export const DEFAULT_CLAUDE_DESKTOP_PROFILE_NAME = "Codex Copilot DX";
|
|
12
|
+
|
|
13
|
+
function jsonClone(value) {
|
|
14
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function valueOrDefault(value, fallback) {
|
|
18
|
+
const trimmed = String(value || "").trim();
|
|
19
|
+
return trimmed || fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isLoopbackHost(host) {
|
|
23
|
+
const normalized = String(host || "").toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
24
|
+
return ["127.0.0.1", "localhost", "::1"].includes(normalized);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function localGatewayBaseUrl(host, port) {
|
|
28
|
+
const safeHost = String(host || "127.0.0.1");
|
|
29
|
+
const urlHost = safeHost.includes(":") && !safeHost.startsWith("[") ? `[${safeHost}]` : safeHost;
|
|
30
|
+
return `http://${urlHost}:${port}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function claudeDesktopPaths(home = os.homedir(), platform = process.platform, env = process.env) {
|
|
34
|
+
if (platform === "win32") {
|
|
35
|
+
const localAppData = valueOrDefault(env.LOCALAPPDATA, path.join(home, "AppData", "Local"));
|
|
36
|
+
return pathsFromDirs(path.join(localAppData, "Claude"), path.join(localAppData, "Claude-3p"));
|
|
37
|
+
}
|
|
38
|
+
const appSupport = path.join(home, "Library", "Application Support");
|
|
39
|
+
return pathsFromDirs(path.join(appSupport, "Claude"), path.join(appSupport, "Claude-3p"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pathsFromDirs(normalDir, threepDir) {
|
|
43
|
+
const configLibraryPath = path.join(threepDir, CONFIG_LIBRARY_DIR);
|
|
44
|
+
return {
|
|
45
|
+
normalConfigPath: path.join(normalDir, CONFIG_FILE),
|
|
46
|
+
threepConfigPath: path.join(threepDir, CONFIG_FILE),
|
|
47
|
+
configLibraryPath,
|
|
48
|
+
metaPath: path.join(configLibraryPath, "_meta.json"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function validateClaudeDesktopBaseUrl(raw) {
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error(`Claude Desktop gateway base URL is not valid: ${raw}`);
|
|
58
|
+
}
|
|
59
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
60
|
+
throw new Error("Claude Desktop gateway base URL must use http or https");
|
|
61
|
+
}
|
|
62
|
+
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
|
|
63
|
+
throw new Error("Claude Desktop allows http only for loopback; use https for LAN or remote gateways");
|
|
64
|
+
}
|
|
65
|
+
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
66
|
+
throw new Error("Claude Desktop gateway base URL must be the root URL without /v1, query, or hash");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isGatewayProfileField(key) {
|
|
71
|
+
return [
|
|
72
|
+
"inferenceProvider",
|
|
73
|
+
"inferenceGatewayBaseUrl",
|
|
74
|
+
"inferenceGatewayApiKey",
|
|
75
|
+
"inferenceGatewayAuthScheme",
|
|
76
|
+
"inferenceModels",
|
|
77
|
+
].includes(key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function computeClaudeDesktopProfile(sourceProfile = {}, {
|
|
81
|
+
baseUrl,
|
|
82
|
+
gatewayApiKey,
|
|
83
|
+
modelIds = claudeDesktopModelIds(),
|
|
84
|
+
} = {}) {
|
|
85
|
+
validateClaudeDesktopBaseUrl(baseUrl);
|
|
86
|
+
if (!String(gatewayApiKey || "").trim()) {
|
|
87
|
+
throw new Error("Claude Desktop gateway API key is required");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const profile = {};
|
|
91
|
+
for (const [key, value] of Object.entries(sourceProfile || {})) {
|
|
92
|
+
if (key === "enterpriseConfig" || isGatewayProfileField(key)) continue;
|
|
93
|
+
profile[key] = jsonClone(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
profile.inferenceProvider = "gateway";
|
|
97
|
+
profile.inferenceGatewayBaseUrl = baseUrl;
|
|
98
|
+
profile.inferenceGatewayApiKey = gatewayApiKey;
|
|
99
|
+
profile.inferenceGatewayAuthScheme = "bearer";
|
|
100
|
+
profile.inferenceModels = JSON.stringify(modelIds);
|
|
101
|
+
if (!profile.disableDeploymentModeChooser) profile.disableDeploymentModeChooser = "true";
|
|
102
|
+
return profile;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function computeClaudeDesktopMeta(meta = {}, profileId = DEFAULT_CLAUDE_DESKTOP_PROFILE_ID, profileName = DEFAULT_CLAUDE_DESKTOP_PROFILE_NAME) {
|
|
106
|
+
const out = { ...(meta || {}) };
|
|
107
|
+
const entries = Array.isArray(out.entries) ? out.entries : [];
|
|
108
|
+
out.entries = [
|
|
109
|
+
...entries.filter((entry) => entry?.id !== profileId),
|
|
110
|
+
{ id: profileId, name: profileName },
|
|
111
|
+
];
|
|
112
|
+
out.appliedId = profileId;
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function computeDeploymentConfig(config = {}) {
|
|
117
|
+
return { ...(config || {}), deploymentMode: "3p" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readJsonFile(filePath, fallback = {}) {
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (e?.code === "ENOENT") return fallback;
|
|
125
|
+
throw e;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeJsonFile(filePath, data) {
|
|
130
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
131
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function snapshotFiles(files) {
|
|
135
|
+
return files.map((filePath) => {
|
|
136
|
+
try {
|
|
137
|
+
return { filePath, exists: true, data: fs.readFileSync(filePath) };
|
|
138
|
+
} catch (e) {
|
|
139
|
+
if (e?.code === "ENOENT") return { filePath, exists: false };
|
|
140
|
+
throw e;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function restoreSnapshots(snapshots) {
|
|
146
|
+
for (const snapshot of snapshots) {
|
|
147
|
+
if (!snapshot.exists) {
|
|
148
|
+
fs.rmSync(snapshot.filePath, { force: true });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
fs.mkdirSync(path.dirname(snapshot.filePath), { recursive: true });
|
|
152
|
+
fs.writeFileSync(snapshot.filePath, snapshot.data, { mode: 0o600 });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readActiveProfile(paths) {
|
|
157
|
+
const meta = readJsonFile(paths.metaPath, {});
|
|
158
|
+
const appliedId = String(meta.appliedId || "").trim();
|
|
159
|
+
if (!appliedId) return {};
|
|
160
|
+
return readJsonFile(path.join(paths.configLibraryPath, `${appliedId}.json`), {});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function applyClaudeDesktopConfig({
|
|
164
|
+
port = 2026,
|
|
165
|
+
host = "127.0.0.1",
|
|
166
|
+
home = os.homedir(),
|
|
167
|
+
platform = process.platform,
|
|
168
|
+
env = process.env,
|
|
169
|
+
baseUrl = localGatewayBaseUrl(host, port),
|
|
170
|
+
gatewayApiKey = env.CCDX_CLAUDE_DESKTOP_API_KEY || env.CCDX_PROXY_API_KEY || "dummy",
|
|
171
|
+
modelIds = claudeDesktopModelIds(env),
|
|
172
|
+
profileId = DEFAULT_CLAUDE_DESKTOP_PROFILE_ID,
|
|
173
|
+
profileName = DEFAULT_CLAUDE_DESKTOP_PROFILE_NAME,
|
|
174
|
+
} = {}) {
|
|
175
|
+
const paths = claudeDesktopPaths(home, platform, env);
|
|
176
|
+
const profilePath = path.join(paths.configLibraryPath, `${profileId}.json`);
|
|
177
|
+
const snapshots = snapshotFiles([
|
|
178
|
+
paths.normalConfigPath,
|
|
179
|
+
paths.threepConfigPath,
|
|
180
|
+
paths.metaPath,
|
|
181
|
+
profilePath,
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const sourceProfile = readActiveProfile(paths);
|
|
186
|
+
const normalConfig = readJsonFile(paths.normalConfigPath, {});
|
|
187
|
+
const threepConfig = readJsonFile(paths.threepConfigPath, {});
|
|
188
|
+
const meta = readJsonFile(paths.metaPath, {});
|
|
189
|
+
|
|
190
|
+
writeJsonFile(paths.normalConfigPath, computeDeploymentConfig(normalConfig));
|
|
191
|
+
writeJsonFile(paths.threepConfigPath, computeDeploymentConfig(threepConfig));
|
|
192
|
+
writeJsonFile(profilePath, computeClaudeDesktopProfile(sourceProfile, { baseUrl, gatewayApiKey, modelIds }));
|
|
193
|
+
writeJsonFile(paths.metaPath, computeClaudeDesktopMeta(meta, profileId, profileName));
|
|
194
|
+
} catch (e) {
|
|
195
|
+
restoreSnapshots(snapshots);
|
|
196
|
+
throw e;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
paths,
|
|
201
|
+
profileId,
|
|
202
|
+
profilePath,
|
|
203
|
+
baseUrl,
|
|
204
|
+
modelIds,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function formatClaudeDesktopApplyResult(result) {
|
|
209
|
+
return [
|
|
210
|
+
"Claude Desktop local gateway profile",
|
|
211
|
+
`profile id: ${result.profileId}`,
|
|
212
|
+
`profile path: ${result.profilePath}`,
|
|
213
|
+
`base url: ${result.baseUrl}`,
|
|
214
|
+
`models: ${result.modelIds.join(", ")}`,
|
|
215
|
+
"gateway api key: <redacted>",
|
|
216
|
+
].join("\n");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function ensureClaudeDesktopConfig(port = 2026, host = "127.0.0.1") {
|
|
220
|
+
const result = applyClaudeDesktopConfig({
|
|
221
|
+
port,
|
|
222
|
+
host,
|
|
223
|
+
gatewayApiKey: process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || generatedClaudeDesktopApiKey(),
|
|
224
|
+
});
|
|
225
|
+
console.log(status("ok", `Configured Claude Desktop gateway profile at ${result.baseUrl}`));
|
|
226
|
+
console.log(formatClaudeDesktopApplyResult(result));
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function generatedClaudeDesktopApiKey() {
|
|
231
|
+
return `ccdx_${randomUUID().replaceAll("-", "")}`;
|
|
232
|
+
}
|
package/src/models.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const DEFAULT_CLAUDE_DESKTOP_MODEL_DEFS = [
|
|
2
|
+
{
|
|
3
|
+
id: "claude-sonnet-4.6",
|
|
4
|
+
upstream: "claude-sonnet-4.6",
|
|
5
|
+
displayName: "Claude Sonnet 4.6",
|
|
6
|
+
createdAt: "2026-02-17T00:00:00Z",
|
|
7
|
+
maxInputTokens: 200000,
|
|
8
|
+
maxOutputTokens: 8192,
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: "claude-opus-4.8",
|
|
12
|
+
upstream: "claude-opus-4.8",
|
|
13
|
+
displayName: "Claude Opus 4.8",
|
|
14
|
+
createdAt: "2026-05-28T00:00:00Z",
|
|
15
|
+
maxInputTokens: 200000,
|
|
16
|
+
maxOutputTokens: 8192,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "claude-haiku-4.5",
|
|
20
|
+
upstream: "claude-haiku-4.5",
|
|
21
|
+
displayName: "Claude Haiku 4.5",
|
|
22
|
+
createdAt: "2025-10-01T00:00:00Z",
|
|
23
|
+
maxInputTokens: 200000,
|
|
24
|
+
maxOutputTokens: 8192,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "claude-sonnet-4-6",
|
|
28
|
+
upstream: "claude-sonnet-4.6",
|
|
29
|
+
displayName: "Claude Sonnet 4.6",
|
|
30
|
+
createdAt: "2026-02-17T00:00:00Z",
|
|
31
|
+
maxInputTokens: 200000,
|
|
32
|
+
maxOutputTokens: 8192,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "claude-opus-4-8",
|
|
36
|
+
upstream: "claude-opus-4.8",
|
|
37
|
+
displayName: "Claude Opus 4.8",
|
|
38
|
+
createdAt: "2026-05-28T00:00:00Z",
|
|
39
|
+
maxInputTokens: 200000,
|
|
40
|
+
maxOutputTokens: 8192,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "claude-haiku-4-5",
|
|
44
|
+
upstream: "claude-haiku-4.5",
|
|
45
|
+
displayName: "Claude Haiku 4.5",
|
|
46
|
+
createdAt: "2025-10-01T00:00:00Z",
|
|
47
|
+
maxInputTokens: 200000,
|
|
48
|
+
maxOutputTokens: 8192,
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
function cleanList(value) {
|
|
53
|
+
return String(value || "")
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((item) => item.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseModelAliasEnv(value) {
|
|
60
|
+
const aliases = [];
|
|
61
|
+
for (const entry of cleanList(value)) {
|
|
62
|
+
const [id, upstream] = entry.split("=", 2).map((part) => part?.trim());
|
|
63
|
+
if (!id || !upstream) continue;
|
|
64
|
+
aliases.push({
|
|
65
|
+
id,
|
|
66
|
+
upstream,
|
|
67
|
+
displayName: id,
|
|
68
|
+
createdAt: "2025-01-01T00:00:00Z",
|
|
69
|
+
maxInputTokens: 200000,
|
|
70
|
+
maxOutputTokens: 8192,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return aliases;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function claudeDesktopModelDefs(env = process.env) {
|
|
77
|
+
const custom = parseModelAliasEnv(env.CCDX_CLAUDE_MODEL_ALIASES);
|
|
78
|
+
return custom.length ? custom : DEFAULT_CLAUDE_DESKTOP_MODEL_DEFS.map((model) => ({ ...model }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function claudeDesktopModelIds(env = process.env) {
|
|
82
|
+
return claudeDesktopModelDefs(env).map((model) => model.id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resolveAnthropicModel(model, env = process.env) {
|
|
86
|
+
const requestedModel = String(model || "");
|
|
87
|
+
const match = claudeDesktopModelDefs(env).find((entry) => entry.id === requestedModel);
|
|
88
|
+
return {
|
|
89
|
+
requestedModel,
|
|
90
|
+
upstreamModel: match?.upstream || requestedModel,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function anthropicModelInfo(model) {
|
|
95
|
+
return {
|
|
96
|
+
id: model.id,
|
|
97
|
+
type: "model",
|
|
98
|
+
display_name: model.displayName || model.id,
|
|
99
|
+
created_at: model.createdAt || "2025-01-01T00:00:00Z",
|
|
100
|
+
max_input_tokens: model.maxInputTokens || 200000,
|
|
101
|
+
max_tokens: model.maxOutputTokens || 8192,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function claudeDesktopModelsResponse(env = process.env) {
|
|
106
|
+
return {
|
|
107
|
+
object: "list",
|
|
108
|
+
data: claudeDesktopModelDefs(env).map(anthropicModelInfo),
|
|
109
|
+
};
|
|
110
|
+
}
|