codex-copilot-dx 0.5.6 → 0.6.1

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,14 +1,16 @@
1
1
  # codex-copilot-dx
2
2
 
3
- Use [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and optionally Claude App with your **GitHub Copilot** subscription.
3
+ Use [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and optionally Claude App or PM Studio with your **GitHub Copilot** subscription.
4
4
 
5
5
  ## How it works
6
6
 
7
- A single in-process adapter (port `2026`) exposes both APIs over your Copilot subscription:
7
+ A single in-process adapter (port `2026`) exposes both APIs over one Copilot
8
+ account by default, or over isolated Codex and Claude accounts when configured:
8
9
 
9
10
  - **Codex** -> OpenAI Responses API (`/v1/responses`, `/v1/responses/compact`); Responses-only models and compaction proxy directly, chat models convert to Chat Completions.
10
11
  - **Claude Code** -> Anthropic Messages API (`/v1/messages`, `/v1/messages/count_tokens`), translated to/from Chat Completions.
11
12
  - **Claude App** -> optional Claude Desktop App gateway profile using the same local Messages API plus local model discovery for the configured gateway key.
13
+ - **PM Studio** -> optional macOS app patch that keeps the active PM profile for GPT models while routing enabled Claude models through an isolated CCDX Claude account.
12
14
 
13
15
  Supports both HTTP SSE streaming and non-streaming.
14
16
 
@@ -18,7 +20,7 @@ Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adap
18
20
 
19
21
  - GitHub Copilot subscription (Individual, Business, or Enterprise)
20
22
  - Node.js 22.15+ (required for built-in Zstandard request decompression)
21
- - [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and/or Claude App installed
23
+ - [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), Claude App, and/or a supported PM Studio installation
22
24
 
23
25
  ## Usage
24
26
 
@@ -33,8 +35,8 @@ global install, use `npx codex-copilot-dx@latest`.
33
35
  Both installed commands expose the same options and subcommands; help, version,
34
36
  doctor, status, and argument-error output use the command name that was invoked.
35
37
 
36
- To query the models currently advertised as selectable for the saved Copilot
37
- account, run:
38
+ To query the models currently advertised as selectable for the saved Codex
39
+ Copilot account, run:
38
40
 
39
41
  ```bash
40
42
  ccdx models
@@ -48,6 +50,122 @@ being advertised does not guarantee that a later inference request will avoid
48
50
  quota, rate-limit, or policy enforcement. `codex-copilot-dx models` is fully
49
51
  equivalent.
50
52
 
53
+ ### Separate GitHub account for Claude
54
+
55
+ The default remains fully backward compatible: Codex and Claude requests share
56
+ the existing GitHub authentication. To keep an enterprise account for Codex
57
+ while using a different personal account that has Claude models enabled, run:
58
+
59
+ ```bash
60
+ ccdx auth login claude --github-login <personal-github-login>
61
+ ```
62
+
63
+ This is the only command that starts the additional Device Flow. It verifies
64
+ the selected GitHub identity, Copilot entitlement, and an enabled Claude model
65
+ before saving anything. It refuses the current Codex account and never changes,
66
+ migrates, or deletes the existing Codex credential. The optional login pin is
67
+ recommended so authorizing the wrong browser account fails before activation.
68
+
69
+ The existing Codex token remains at
70
+ `~/.local/share/copilot-api/github_token`. The isolated Claude credential is
71
+ stored with mode `0600` under
72
+ `~/.local/share/copilot-api/profiles/claude/github_token`, with matching account
73
+ metadata committed last. Restart a running adapter after authentication.
74
+
75
+ Once enabled, routing is fixed by API surface rather than by model name:
76
+
77
+ - `/v1/responses`, `/v1/responses/compact`, their Chat fallback, and normal
78
+ `/v1/models` use the Codex account.
79
+ - `/v1/messages` and Claude App model discovery use the isolated Claude account.
80
+ - `/v1/messages/count_tokens` remains local and uses neither account.
81
+
82
+ The two profiles keep separate service tokens, API bases, refresh/backoff state,
83
+ model endpoint metadata, and model caches. If the isolated Claude profile later
84
+ becomes invalid, Claude requests fail with a reauthentication instruction while
85
+ Codex remains available; CCDX never silently falls back to the enterprise
86
+ account. Reauthorize it explicitly with:
87
+
88
+ ```bash
89
+ ccdx auth login claude --reauth --github-login <personal-github-login>
90
+ ```
91
+
92
+ These commands are read-only and never start Device Flow:
93
+
94
+ ```bash
95
+ ccdx auth status
96
+ ccdx auth status --online
97
+ ccdx models --profile claude
98
+ ccdx doctor --online --profile all
99
+ ```
100
+
101
+ ### PM Studio opt-in patch
102
+
103
+ CCDX can add the isolated Claude catalog to PM Studio without switching its
104
+ active account or profile. GPT requests continue to use the bearer supplied by
105
+ the current PM profile; exact enabled Anthropic model IDs use the isolated
106
+ CCDX Claude profile. Neither credential is copied into the other application.
107
+
108
+ The first recipe supports only an exact, unmodified PM Studio 2.9.7 bundle on
109
+ macOS. Authorize the isolated Claude profile first, quit PM Studio and its
110
+ updater, then run the one-time setup command:
111
+
112
+ ```bash
113
+ ccdx auth login claude --reauth --github-login <personal-github-login>
114
+ ccdx pms setup
115
+ ```
116
+
117
+ `ccdx pms setup` performs only local preflight, backup, staging, integrity,
118
+ signing, and replacement work. It never starts Device Flow, the adapter,
119
+ Codex, or PM Studio, and it does not modify PM profiles, cached model lists, or
120
+ saved PM tokens. Unknown versions, hashes, signatures, partial patches, a
121
+ running PM process, insufficient space, and every pre-replacement verification
122
+ failure leave the installed App unchanged. If the final read-only verification
123
+ after an atomic replacement detects an exceptional filesystem drift, setup
124
+ does not attempt a second automatic write: it preserves the verified backup,
125
+ retains any diagnostic stage still present, and prints recovery paths. Repeating
126
+ setup for the exact installed recipe is idempotent.
127
+
128
+ The patch is App-wide: every PM profile sends its Copilot API traffic through
129
+ the loopback-only `/pm-ccdx/*` relay. Run ordinary `ccdx` before opening PM
130
+ Studio; if port 2026 is unavailable, PM Copilot requests cannot connect. The
131
+ relay explicitly supports only the PM 2.9.7 Copilot paths discovered in the
132
+ validated bundle (`GET /models` and `POST /chat/completions`, `/responses`, and
133
+ `/embeddings`) and is not a general-purpose proxy.
134
+
135
+ Patching replaces the vendor signature with an ad-hoc signature. PM Studio's
136
+ updater is not disabled; an official update may overwrite the patch or may
137
+ require reinstalling the official App. Setup prints the verified backup and
138
+ manifest paths plus the version-matched restore procedure. Never restore a
139
+ 2.9.7 backup over a newer PM Studio installation.
140
+
141
+ To restore, first quit PM Studio and its updater. Use the exact verified backup
142
+ path printed by setup as `BACKUP_APP`, then confirm that both the installed App
143
+ and backup still report version/build `2.9.7/2.9.7`:
144
+
145
+ ```bash
146
+ BACKUP_APP="<verified-backup-path-from-ccdx-pms-setup>"
147
+ /usr/bin/plutil -extract CFBundleShortVersionString raw -o - "/Applications/PM Studio.app/Contents/Info.plist"
148
+ /usr/bin/plutil -extract CFBundleVersion raw -o - "/Applications/PM Studio.app/Contents/Info.plist"
149
+ /usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$BACKUP_APP/Contents/Info.plist"
150
+ /usr/bin/plutil -extract CFBundleVersion raw -o - "$BACKUP_APP/Contents/Info.plist"
151
+ ```
152
+
153
+ Only when all four values match `2.9.7`, move the patched App aside, restore the
154
+ complete vendor-signed backup, and verify it before launch:
155
+
156
+ ```bash
157
+ RECOVERY_DIR="$(/usr/bin/mktemp -d '/Applications/CCDX-PM-Studio-recovery.XXXXXX')"
158
+ PATCHED_APP="$RECOVERY_DIR/PM Studio.app"
159
+ mv "/Applications/PM Studio.app" "$PATCHED_APP"
160
+ /usr/bin/ditto --rsrc --extattr --acl "$BACKUP_APP" "/Applications/PM Studio.app"
161
+ /usr/bin/codesign --verify --deep --strict --verbose=2 "/Applications/PM Studio.app"
162
+ ```
163
+
164
+ If the version check, copy, or signature verification fails, do not launch the
165
+ restored App. Keep the moved patched bundle at `$PATCHED_APP` and reinstall the official PM Studio
166
+ instead of applying a 2.9.7 backup to unknown content. CCDX never runs these
167
+ restore commands or `sudo` automatically.
168
+
51
169
  To change the model used by Codex Auto-review, run:
52
170
 
53
171
  ```bash
@@ -123,6 +241,9 @@ ccdx doctor --online
123
241
  ```
124
242
 
125
243
  The online doctor never starts device flow, scans for replacement tokens, or changes the saved token.
244
+ Use `--profile codex`, `--profile claude`, or `--profile all` to choose which
245
+ account entitlement is checked. Without `--profile`, the legacy Codex check is
246
+ preserved.
126
247
 
127
248
  To actively verify the protocol path through an already-running adapter, run:
128
249
 
package/bin/cli.mjs CHANGED
@@ -5,8 +5,8 @@ import { ensureCodexConfig } from "../src/config.mjs";
5
5
  import { ensureClaudeConfig } from "../src/claude-config.mjs";
6
6
  import { applyClaudeDesktopConfig, formatClaudeDesktopApplyResult, generatedClaudeDesktopApiKey, loadManagedClaudeDesktopApiKey, syncManagedClaudeDesktopModels } from "../src/claude-desktop-config.mjs";
7
7
  import { startAdapter } from "../src/adapter.mjs";
8
- import { cacheModelEndpoints, listModels, refreshVSCodeVersion } from "../src/copilot.mjs";
9
- import { claudeDesktopModelDefsFromCopilotModels, claudeDesktopModelIds, codexAutoReviewModelStatus, gptModelIdsFromCopilotModels, parseModelAliasEnv } from "../src/models.mjs";
8
+ import { defaultCopilotClient, refreshVSCodeVersion } from "../src/copilot.mjs";
9
+ import { claudeDesktopModelIds } from "../src/models.mjs";
10
10
  import { status } from "../src/status.mjs";
11
11
  import { configureLogging } from "../src/log.mjs";
12
12
  import { flushUsageWrites, printUsageSummary } from "../src/usage.mjs";
@@ -14,14 +14,16 @@ import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
14
14
  import { runDoctor } from "../src/doctor.mjs";
15
15
  import { adapterBaseUrl, checkRunningAdapter } from "../src/running-adapter.mjs";
16
16
  import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
17
- import { isValidModelList, loadModelCache, saveModelCache } from "../src/model-cache.mjs";
18
- import { initializeModelRegistry, runInBackground } from "../src/startup.mjs";
17
+ import { runInBackground } from "../src/startup.mjs";
19
18
  import { cliCommandName, cliHelp, parseAdapterProbeOptions, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
20
19
  import { formatAdapterStatus, readAdapterStatus } from "../src/cli-status.mjs";
21
20
  import { closeHttpServer } from "../src/shutdown.mjs";
22
21
  import { runAutoReviewModelCommand } from "../src/auto-review-model.mjs";
23
22
  import { autoReviewModelPreference } from "../src/user-settings.mjs";
24
23
  import { fetchLiveCopilotModels, formatLiveCopilotModels } from "../src/cli-models.mjs";
24
+ import { runAuthCommand } from "../src/cli-auth.mjs";
25
+ import { createProfileRuntime } from "../src/profile-runtime.mjs";
26
+ import { createProfileModelRuntime } from "../src/profile-model-runtime.mjs";
25
27
 
26
28
  const LOCAL_VERSION = localPackageVersion();
27
29
  const CLI_NAME = cliCommandName();
@@ -48,6 +50,29 @@ if (CLI.command === "usage") {
48
50
  await printUsageSummary();
49
51
  process.exit(0);
50
52
  }
53
+ if (CLI.command === "auth") {
54
+ try {
55
+ const result = await runAuthCommand({
56
+ action: CLI.action,
57
+ profile: CLI.profile,
58
+ online: CLI.online,
59
+ reauth: CLI.reauth,
60
+ expectedLogin: CLI.expectedLogin,
61
+ commandName: CLI_NAME,
62
+ });
63
+ if (result.output) console.log(result.output);
64
+ if (result.action === "login") {
65
+ if (!result.changed) {
66
+ console.log(status("ok", `Claude profile is already configured for ${result.identity?.login || result.identity?.id || "the saved account"}`));
67
+ }
68
+ console.log(status("warn", `Restart the running ${CLI_NAME} adapter to activate the Claude profile`));
69
+ }
70
+ process.exit(0);
71
+ } catch (e) {
72
+ console.error(status("err", e.message));
73
+ process.exit(1);
74
+ }
75
+ }
51
76
  if (CLI.command === "status") {
52
77
  let probe;
53
78
  try {
@@ -71,7 +96,7 @@ if (CLI.command === "status") {
71
96
  }
72
97
  if (CLI.command === "models") {
73
98
  try {
74
- const catalog = await fetchLiveCopilotModels();
99
+ const catalog = await fetchLiveCopilotModels({ profile: CLI.profile });
75
100
  console.log(formatLiveCopilotModels(catalog, { commandName: CLI_NAME }));
76
101
  process.exit(0);
77
102
  } catch (e) {
@@ -98,6 +123,16 @@ if (CLI.command === "update") {
98
123
  process.exit(1);
99
124
  }
100
125
  }
126
+ if (CLI.command === "pms") {
127
+ try {
128
+ const { runPmStudioSetup } = await import("../src/pm-studio-setup.mjs");
129
+ await runPmStudioSetup({ commandName: CLI_NAME });
130
+ process.exit(0);
131
+ } catch (e) {
132
+ console.error(status("err", e.message));
133
+ process.exit(1);
134
+ }
135
+ }
101
136
 
102
137
  let RUNTIME;
103
138
  try {
@@ -113,7 +148,6 @@ const MODEL_REFRESH_TIMEOUT_MS = RUNTIME.modelRefreshTimeoutMs;
113
148
  const EXISTING_ADAPTER_TIMEOUT_MS = RUNTIME.existingAdapterTimeoutMs;
114
149
  const CONFIGURE_CLAUDE_DESKTOP = CLI.configureClaudeDesktop || process.env.CCDX_CONFIGURE_CLAUDE_DESKTOP === "1";
115
150
  const LOGGING = configureLogging();
116
- const MODEL_REGISTRY = { modelDefs: undefined, models: undefined, source: "built-in" };
117
151
  let activeServer = null;
118
152
  let modelRefreshTimer = null;
119
153
  let shuttingDown = false;
@@ -123,88 +157,11 @@ if (LOGGING.filePath) {
123
157
  if (LOGGING.level === "debug") console.log(status("debug", "Debug logging enabled"));
124
158
  }
125
159
 
126
- function applyModelsToRegistry(models, source, { updateClaudeModels = true } = {}) {
127
- if (!isValidModelList(models)) throw new Error("Copilot models response contained no valid models");
128
- const modelDefs = updateClaudeModels
129
- ? claudeDesktopModelDefsFromCopilotModels(models)
130
- : MODEL_REGISTRY.modelDefs;
131
- cacheModelEndpoints(models);
132
- MODEL_REGISTRY.models = models;
133
- if (updateClaudeModels && modelDefs.length) {
134
- MODEL_REGISTRY.modelDefs = modelDefs;
135
- MODEL_REGISTRY.source = source;
136
- }
137
- return modelDefs;
138
- }
139
-
140
- function loadCachedModelRegistry() {
141
- const cached = loadModelCache();
142
- if (!cached) return false;
143
- try {
144
- const customAliases = parseModelAliasEnv(process.env.CCDX_CLAUDE_MODEL_ALIASES).length > 0;
145
- applyModelsToRegistry(cached, "cache", { updateClaudeModels: !customAliases });
146
- return true;
147
- } catch {
148
- return false;
149
- }
150
- }
151
-
152
- async function refreshClaudeDesktopModelDefs() {
153
- const customAliases = parseModelAliasEnv(process.env.CCDX_CLAUDE_MODEL_ALIASES).length > 0;
154
-
155
- const ctrl = new AbortController();
156
- const timer = setTimeout(() => ctrl.abort(), MODEL_REFRESH_TIMEOUT_MS);
157
- try {
158
- const { status: httpStatus, body } = await listModels({ signal: ctrl.signal });
159
- if (httpStatus < 200 || httpStatus >= 300) {
160
- throw new Error(`Copilot models returned HTTP ${httpStatus}`);
161
- }
162
- const models = JSON.parse(body);
163
- const autoReview = codexAutoReviewModelStatus(models, process.env, {
164
- autoReviewModel: autoReviewModelPreference().model,
165
- });
166
- if (!autoReview.available) {
167
- console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run ${CLI_NAME} doctor --compat to verify the live path.`));
168
- }
169
- const gptModelIds = gptModelIdsFromCopilotModels(models);
170
- if (gptModelIds.length) {
171
- console.log(status("ok", `Refreshed GPT models from GitHub Copilot: ${gptModelIds.join(", ")}`));
172
- } else {
173
- console.log(status("warn", "Copilot models response contained no GPT models"));
174
- }
175
-
176
- const modelDefs = applyModelsToRegistry(models, "live", { updateClaudeModels: !customAliases });
177
- try {
178
- saveModelCache(models);
179
- } catch (e) {
180
- console.log(status("warn", `Could not persist the Copilot model cache (${e.message})`));
181
- }
182
- if (customAliases) {
183
- console.log(status("info", "Using CCDX_CLAUDE_MODEL_ALIASES for Claude models"));
184
- } else if (modelDefs?.length) {
185
- console.log(status("ok", `Refreshed Claude models from GitHub Copilot: ${modelDefs.map((model) => model.id).join(", ")}`));
186
- } else {
187
- console.log(status("warn", "Copilot models response contained no Claude models; using built-in Claude models"));
188
- }
189
- syncClaudeDesktopProfileModels(MODEL_REGISTRY.modelDefs);
190
- return modelDefs;
191
- } catch (e) {
192
- const fallback = MODEL_REGISTRY.modelDefs?.length ? `${MODEL_REGISTRY.source} model list` : "built-in model list";
193
- const message = ctrl.signal.aborted
194
- ? `Model refresh timed out after ${MODEL_REFRESH_TIMEOUT_MS}ms; using ${fallback}`
195
- : `Could not refresh model list; using ${fallback} (${e.message})`;
196
- console.log(status("warn", message));
197
- return MODEL_REGISTRY.modelDefs;
198
- } finally {
199
- clearTimeout(timer);
200
- }
201
- }
202
-
203
160
  function currentClaudeDesktopApiKey() {
204
161
  return String(process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || "").trim();
205
162
  }
206
163
 
207
- function syncClaudeDesktopProfileModels(modelDefs = MODEL_REGISTRY.modelDefs) {
164
+ function syncClaudeDesktopProfileModels(modelDefs) {
208
165
  const result = syncManagedClaudeDesktopModels({
209
166
  port: ADAPTER_PORT,
210
167
  host: ADAPTER_HOST,
@@ -268,6 +225,7 @@ if (CLI.command === "doctor") {
268
225
  host: ADAPTER_HOST,
269
226
  online: CLI.online,
270
227
  compat: CLI.compat,
228
+ profile: CLI.profile,
271
229
  });
272
230
  process.exit(checks.some((check) => check.kind === "err") ? 1 : 0);
273
231
  }
@@ -296,14 +254,33 @@ try {
296
254
  // Ensure GitHub login, using device flow if no token exists.
297
255
  await ensureAuth();
298
256
 
257
+ const profileRuntime = createProfileRuntime({ codexClient: defaultCopilotClient });
258
+ if (profileRuntime.claudeMode === "isolated") {
259
+ if (profileRuntime.claudeProfile.valid) {
260
+ const account = profileRuntime.claudeProfile.identity?.login
261
+ || profileRuntime.claudeProfile.identity?.id
262
+ || "the saved account";
263
+ console.log(status("ok", `Claude requests will use the isolated GitHub account ${account}`));
264
+ } else {
265
+ console.log(status("warn", `Claude profile is configured but invalid (${profileRuntime.claudeProfile.reason}); Codex remains available, but Claude requests will fail until you run ${CLI_NAME} auth login claude --reauth and restart`));
266
+ }
267
+ }
268
+
269
+ const profileModels = createProfileModelRuntime({
270
+ codexClient: profileRuntime.codexClient,
271
+ claudeClient: profileRuntime.claudeClient,
272
+ claudeMode: profileRuntime.claudeMode,
273
+ claudeCredentialFingerprint: profileRuntime.claudeCredentialFingerprint,
274
+ timeoutMs: MODEL_REFRESH_TIMEOUT_MS,
275
+ commandName: CLI_NAME,
276
+ autoReviewModelResolver: () => autoReviewModelPreference().model,
277
+ onClaudeModelsChanged: syncClaudeDesktopProfileModels,
278
+ });
279
+
299
280
  // Refresh the VS Code version in the background; fallback is non-blocking.
300
281
  void refreshVSCodeVersion();
301
- const modelInitialization = await initializeModelRegistry({
302
- loadCached: loadCachedModelRegistry,
303
- currentModelDefs: () => MODEL_REGISTRY.modelDefs,
304
- refresh: refreshClaudeDesktopModelDefs,
305
- });
306
- const claudeDesktopModelDefs = modelInitialization.modelDefs;
282
+ const modelInitialization = await profileModels.initialize();
283
+ const claudeDesktopModelDefs = modelInitialization.claude.modelDefs;
307
284
  syncClaudeDesktopProfileModels(claudeDesktopModelDefs);
308
285
 
309
286
  if (!isLoopbackHost(ADAPTER_HOST)) {
@@ -327,7 +304,12 @@ try {
327
304
  claudeDesktopApiKey,
328
305
  claudeDesktopModelDefs,
329
306
  autoReviewModelResolver: () => autoReviewModelPreference().model,
330
- modelRegistry: MODEL_REGISTRY,
307
+ codexClient: profileRuntime.codexClient,
308
+ claudeClient: profileRuntime.claudeClient,
309
+ claudeProfile: profileRuntime.claudeProfile,
310
+ codexModelRegistry: profileModels.codexRegistry,
311
+ claudeModelRegistry: profileModels.claudeRegistry,
312
+ claudeMode: profileRuntime.claudeMode,
331
313
  showRequestId: CLI.showRequestId,
332
314
  upstreamTimeoutMs: RUNTIME.upstreamTimeoutMs,
333
315
  streamHandshakeTimeoutMs: RUNTIME.streamHandshakeTimeoutMs,
@@ -358,7 +340,7 @@ try {
358
340
  const refreshIntervalMs = RUNTIME.modelRefreshIntervalMs;
359
341
  if (Number.isFinite(refreshIntervalMs) && refreshIntervalMs > 0) {
360
342
  modelRefreshTimer = setInterval(() => {
361
- refreshClaudeDesktopModelDefs().catch(() => {});
343
+ profileModels.refreshAll().catch(() => {});
362
344
  }, refreshIntervalMs);
363
345
  modelRefreshTimer.unref?.();
364
346
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.5.6",
3
+ "version": "0.6.1",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import http from "node:http";
2
2
  import { createAnthropicCountTokensHandler, createAnthropicMessagesHandler } from "./anthropic-handler.mjs";
3
- import { chatCompletions, listModels, responses as copilotResponses, responsesCompact as copilotResponsesCompact } from "./copilot.mjs";
3
+ import { defaultCopilotClient } from "./copilot.mjs";
4
4
  import { isValidModelList } from "./model-cache.mjs";
5
5
  import { claudeDesktopModelsResponse } from "./models.mjs";
6
6
  import {
@@ -11,6 +11,7 @@ import {
11
11
  runtimeStatusPayload,
12
12
  } from "./observability.mjs";
13
13
  import { createRequestId, runWithRequestContext } from "./request-context.mjs";
14
+ import { createPmStudioRelayHandler, PM_STUDIO_RELAY_PREFIX } from "./pm-studio-relay.mjs";
14
15
  import { createStreamPerformanceMetrics } from "./stream-performance.mjs";
15
16
  import { status } from "./status.mjs";
16
17
  import { createTerminalActivityIndicator } from "./terminal-activity.mjs";
@@ -90,10 +91,46 @@ export function createAdapterHandler(options = {}) {
90
91
  || process.env.CCDX_CLAUDE_DESKTOP_API_KEY
91
92
  || process.env.CCDX_PROXY_API_KEY
92
93
  || "";
93
- const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
94
- const responsesFn = options.responsesFn || copilotResponses;
95
- const responsesCompactFn = options.responsesCompactFn || copilotResponsesCompact;
96
- const listModelsFn = options.listModelsFn || listModels;
94
+ const codexClient = options.codexClient || defaultCopilotClient;
95
+ const hasExplicitClaudeMode = Object.hasOwn(options, "claudeMode");
96
+ if (hasExplicitClaudeMode && !["inherited", "isolated"].includes(options.claudeMode)) {
97
+ throw new Error(`Unsupported Claude mode: ${options.claudeMode}`);
98
+ }
99
+ if (options.claudeMode === "isolated"
100
+ && (!options.claudeClient || options.claudeClient === codexClient)) {
101
+ throw new Error("Isolated Claude mode requires a distinct Claude client");
102
+ }
103
+ if (options.claudeMode === "inherited"
104
+ && options.claudeClient && options.claudeClient !== codexClient) {
105
+ throw new Error("Inherited Claude mode requires the Codex client");
106
+ }
107
+ const claudeClient = options.claudeClient || codexClient;
108
+ // chatCompletionsFn is retained as a compatibility injection and continues to
109
+ // override both surfaces in existing tests and embedders. New dual-profile
110
+ // callers should pass codexClient/claudeClient instead.
111
+ const codexChatCompletionsFn = options.codexChatCompletionsFn
112
+ || options.chatCompletionsFn
113
+ || codexClient.chatCompletions;
114
+ const claudeChatCompletionsFn = options.claudeChatCompletionsFn
115
+ || options.chatCompletionsFn
116
+ || claudeClient.chatCompletions;
117
+ const responsesFn = options.responsesFn || codexClient.responses;
118
+ const responsesCompactFn = options.responsesCompactFn || codexClient.responsesCompact;
119
+ const listModelsFn = options.listModelsFn || codexClient.listModels;
120
+ const getCachedModelEndpointsFn = options.getCachedModelEndpointsFn
121
+ || codexClient.getCachedModelEndpoints;
122
+ const claudeMode = hasExplicitClaudeMode
123
+ ? options.claudeMode
124
+ : claudeClient === codexClient ? "inherited" : "isolated";
125
+ if (claudeMode === "isolated" && options.chatCompletionsFn) {
126
+ throw new Error("Isolated Claude mode cannot use the shared chatCompletionsFn override; pass codexChatCompletionsFn or claudeChatCompletionsFn explicitly");
127
+ }
128
+ const codexModelRegistry = options.codexModelRegistry || options.modelRegistry;
129
+ const claudeModelRegistry = options.claudeModelRegistry || options.modelRegistry || codexModelRegistry;
130
+ if (claudeMode === "isolated"
131
+ && (!options.claudeModelRegistry || claudeModelRegistry === codexModelRegistry)) {
132
+ throw new Error("Isolated Claude mode requires a distinct Claude model registry");
133
+ }
97
134
  const openAIModelEnv = options.openAIModelEnv || process.env;
98
135
  const upstreamTimeoutMs = parsePositiveInteger(options.upstreamTimeoutMs, ADAPTER_RUNTIME_CONFIG.upstreamTimeoutMs);
99
136
  const streamHandshakeTimeoutMs = parsePositiveInteger(options.streamHandshakeTimeoutMs, ADAPTER_RUNTIME_CONFIG.streamHandshakeTimeoutMs);
@@ -102,13 +139,14 @@ export function createAdapterHandler(options = {}) {
102
139
  const requestMetrics = options.requestMetrics || createRequestMetrics();
103
140
  const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
104
141
  const claudeDesktopModelOptions = () => {
105
- const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
142
+ const modelDefs = claudeModelRegistry?.modelDefs || options.claudeDesktopModelDefs;
106
143
  return Array.isArray(modelDefs) ? { modelDefs } : {};
107
144
  };
108
145
  const responsesHandler = createResponsesHandler({
109
146
  acquireRequest,
110
147
  autoReviewModelResolver: options.autoReviewModelResolver,
111
- chatCompletionsFn,
148
+ chatCompletionsFn: codexChatCompletionsFn,
149
+ getCachedModelEndpointsFn,
112
150
  openAIModelEnv,
113
151
  responsesPayloadOptions: options.responsesPayloadOptions,
114
152
  responsesFn,
@@ -128,13 +166,24 @@ export function createAdapterHandler(options = {}) {
128
166
  const anthropicCountTokensHandler = createAnthropicCountTokensHandler({ acquireRequest });
129
167
  const anthropicMessagesHandler = createAnthropicMessagesHandler({
130
168
  acquireRequest,
131
- chatCompletionsFn,
169
+ chatCompletionsFn: claudeChatCompletionsFn,
132
170
  environment: process.env,
133
171
  modelOptions: claudeDesktopModelOptions,
134
172
  streamHandshakeTimeoutMs,
135
173
  streamIdleTimeoutMs,
136
174
  upstreamTimeoutMs,
137
175
  });
176
+ const pmStudioRelayHandler = createPmStudioRelayHandler({
177
+ acquireRequest,
178
+ claudeClient,
179
+ claudeMode,
180
+ claudeProfile: options.claudeProfile,
181
+ claudeModelRegistry,
182
+ fetchImpl: options.pmStudioFetchImpl,
183
+ streamHandshakeTimeoutMs,
184
+ streamIdleTimeoutMs,
185
+ upstreamTimeoutMs,
186
+ });
138
187
 
139
188
  const dispatch = (req, res, pathname) => {
140
189
  if (req.method === "GET" && pathname === ADAPTER_STATUS_PATH) {
@@ -148,7 +197,12 @@ export function createAdapterHandler(options = {}) {
148
197
  metrics: requestMetrics,
149
198
  streamPerformance: streamPerformanceMetrics,
150
199
  admission: acquireRequest,
151
- modelRegistry: options.modelRegistry,
200
+ modelRegistry: codexModelRegistry,
201
+ codexClient,
202
+ claudeClient,
203
+ codexModelRegistry,
204
+ claudeModelRegistry,
205
+ claudeMode,
152
206
  })));
153
207
  return;
154
208
  }
@@ -159,6 +213,10 @@ export function createAdapterHandler(options = {}) {
159
213
  return;
160
214
  }
161
215
 
216
+ if (pathname.startsWith(`${PM_STUDIO_RELAY_PREFIX}/`)) {
217
+ return pmStudioRelayHandler(req, res, pathname);
218
+ }
219
+
162
220
  if (req.method === "POST" && pathname === "/v1/responses") {
163
221
  return responsesHandler(req, res);
164
222
  }
@@ -181,24 +239,24 @@ export function createAdapterHandler(options = {}) {
181
239
  let models;
182
240
  try { models = JSON.parse(body); } catch {}
183
241
  if (isValidModelList(models)) {
184
- if (options.modelRegistry) {
185
- options.modelRegistry.models = models;
242
+ if (codexModelRegistry) {
243
+ codexModelRegistry.models = models;
186
244
  }
187
245
  res.writeHead(status, { "Content-Type": "application/json" });
188
246
  res.end(body);
189
247
  return;
190
248
  }
191
- if (sendLastKnownGoodModels(res, options.modelRegistry)) return;
249
+ if (sendLastKnownGoodModels(res, codexModelRegistry)) return;
192
250
  sendJsonError(res, httpError("Copilot models response contained no valid models", 502), 502);
193
251
  return;
194
252
  }
195
- if (modelListCanUseLastKnownGood(status) && sendLastKnownGoodModels(res, options.modelRegistry)) return;
253
+ if (modelListCanUseLastKnownGood(status) && sendLastKnownGoodModels(res, codexModelRegistry)) return;
196
254
  res.writeHead(status, { "Content-Type": "application/json" });
197
255
  res.end(body);
198
256
  })
199
257
  .catch((e) => {
200
258
  logRequestFailure("Models", e, abort);
201
- if (sendLastKnownGoodModels(res, options.modelRegistry)) return;
259
+ if (sendLastKnownGoodModels(res, codexModelRegistry)) return;
202
260
  if (!res.destroyed && !res.writableEnded) {
203
261
  if (!res.headersSent) res.writeHead(e?.statusCode || 502);
204
262
  res.end(JSON.stringify({ error: e.message }));