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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # codex-copilot-dx
2
2
 
3
- Use [Codex Desktop](https://openai.com/codex) **and** [Claude Code](https://claude.com/claude-code) with your **GitHub Copilot** subscription.
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) and/or [Claude Code](https://claude.com/claude-code) installed
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", "Ready. Codex and Claude Code are using your GitHub Copilot subscription.")}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Use Codex Desktop app with your GitHub Copilot subscription",
5
5
  "type": "module",
6
6
  "bin": {
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 resp = await upstream(reqContext.body, { signal: options.signal });
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 model = parsed.model || "unknown";
527
- console.log(status("info", `messages model=${model} stream=${!!parsed.stream}`));
528
- const chatReq = anthropicToChat(parsed);
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
- model,
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), model);
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
+ }