codex-copilot-dx 0.4.6 → 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 +34 -7
- 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
|
|
|
@@ -132,6 +133,19 @@ function sendUpstreamError(res, resp, text) {
|
|
|
132
133
|
res.end(text || JSON.stringify({ error: "Upstream request failed" }));
|
|
133
134
|
}
|
|
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
|
+
|
|
135
149
|
export function writeOrDrain(res, chunk) {
|
|
136
150
|
if (res.destroyed || res.writableEnded) return Promise.resolve(false);
|
|
137
151
|
if (res.write(chunk)) return Promise.resolve(true);
|
|
@@ -504,7 +518,12 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {})
|
|
|
504
518
|
|
|
505
519
|
// Public server entry point.
|
|
506
520
|
|
|
507
|
-
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
|
+
|
|
508
527
|
const server = http.createServer((req, res) => {
|
|
509
528
|
const pathname = requestPath(req.url);
|
|
510
529
|
|
|
@@ -580,6 +599,11 @@ export function startAdapter(port = 2026, host = "127.0.0.1") {
|
|
|
580
599
|
}
|
|
581
600
|
|
|
582
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
|
+
}
|
|
583
607
|
const abort = createRequestAbort(req, res);
|
|
584
608
|
abort.setTimeout(UPSTREAM_TIMEOUT_MS);
|
|
585
609
|
listModels({ signal: abort.signal })
|
|
@@ -611,9 +635,11 @@ export function startAdapter(port = 2026, host = "127.0.0.1") {
|
|
|
611
635
|
try {
|
|
612
636
|
const parsed = await readJsonBody(req);
|
|
613
637
|
if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
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;
|
|
617
643
|
if (parsed.stream) {
|
|
618
644
|
const upstream = await chatCompletions({ ...chatReq, stream: true }, { signal: abort.signal });
|
|
619
645
|
if (!upstream.ok) {
|
|
@@ -631,14 +657,15 @@ export function startAdapter(port = 2026, host = "127.0.0.1") {
|
|
|
631
657
|
if (event === "message_delta") usage = data.usage;
|
|
632
658
|
await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
633
659
|
},
|
|
634
|
-
|
|
660
|
+
requestedModel,
|
|
661
|
+
{ forceModel: forceRequestedModel },
|
|
635
662
|
);
|
|
636
|
-
recordAnthropicUsage({ surface: "messages", mode: "stream", model, responseId: messageId, usage });
|
|
663
|
+
recordAnthropicUsage({ surface: "messages", mode: "stream", model: requestedModel, responseId: messageId, usage });
|
|
637
664
|
if (!res.writableEnded) res.end();
|
|
638
665
|
} else {
|
|
639
666
|
const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
|
|
640
667
|
const data = await upstream.text();
|
|
641
|
-
const anthropicMsg = chatToAnthropic(JSON.parse(data),
|
|
668
|
+
const anthropicMsg = chatToAnthropic(JSON.parse(data), requestedModel, { forceModel: forceRequestedModel });
|
|
642
669
|
recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
|
|
643
670
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
644
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
|
+
}
|