codex-copilot-dx 0.5.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -3
- package/bin/cli.mjs +63 -92
- package/package.json +1 -1
- package/src/adapter.mjs +56 -14
- package/src/auth-profile.mjs +205 -0
- package/src/cli-auth.mjs +493 -0
- package/src/cli-models.mjs +38 -13
- package/src/cli-options.mjs +92 -12
- package/src/cli-status.mjs +29 -3
- package/src/copilot-client.mjs +226 -0
- package/src/copilot-token-session.mjs +362 -0
- package/src/copilot.mjs +45 -387
- package/src/doctor.mjs +169 -23
- package/src/model-cache.mjs +22 -6
- package/src/observability.mjs +53 -3
- package/src/profile-model-runtime.mjs +261 -0
- package/src/profile-runtime.mjs +80 -0
- package/src/responses-handler.mjs +7 -6
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@ Use [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/
|
|
|
4
4
|
|
|
5
5
|
## How it works
|
|
6
6
|
|
|
7
|
-
A single in-process adapter (port `2026`) exposes both APIs over
|
|
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.
|
|
@@ -33,8 +34,8 @@ global install, use `npx codex-copilot-dx@latest`.
|
|
|
33
34
|
Both installed commands expose the same options and subcommands; help, version,
|
|
34
35
|
doctor, status, and argument-error output use the command name that was invoked.
|
|
35
36
|
|
|
36
|
-
To query the models currently advertised as selectable for the saved
|
|
37
|
-
account, run:
|
|
37
|
+
To query the models currently advertised as selectable for the saved Codex
|
|
38
|
+
Copilot account, run:
|
|
38
39
|
|
|
39
40
|
```bash
|
|
40
41
|
ccdx models
|
|
@@ -48,6 +49,54 @@ being advertised does not guarantee that a later inference request will avoid
|
|
|
48
49
|
quota, rate-limit, or policy enforcement. `codex-copilot-dx models` is fully
|
|
49
50
|
equivalent.
|
|
50
51
|
|
|
52
|
+
### Separate GitHub account for Claude
|
|
53
|
+
|
|
54
|
+
The default remains fully backward compatible: Codex and Claude requests share
|
|
55
|
+
the existing GitHub authentication. To keep an enterprise account for Codex
|
|
56
|
+
while using a different personal account that has Claude models enabled, run:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
ccdx auth login claude --github-login <personal-github-login>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This is the only command that starts the additional Device Flow. It verifies
|
|
63
|
+
the selected GitHub identity, Copilot entitlement, and an enabled Claude model
|
|
64
|
+
before saving anything. It refuses the current Codex account and never changes,
|
|
65
|
+
migrates, or deletes the existing Codex credential. The optional login pin is
|
|
66
|
+
recommended so authorizing the wrong browser account fails before activation.
|
|
67
|
+
|
|
68
|
+
The existing Codex token remains at
|
|
69
|
+
`~/.local/share/copilot-api/github_token`. The isolated Claude credential is
|
|
70
|
+
stored with mode `0600` under
|
|
71
|
+
`~/.local/share/copilot-api/profiles/claude/github_token`, with matching account
|
|
72
|
+
metadata committed last. Restart a running adapter after authentication.
|
|
73
|
+
|
|
74
|
+
Once enabled, routing is fixed by API surface rather than by model name:
|
|
75
|
+
|
|
76
|
+
- `/v1/responses`, `/v1/responses/compact`, their Chat fallback, and normal
|
|
77
|
+
`/v1/models` use the Codex account.
|
|
78
|
+
- `/v1/messages` and Claude App model discovery use the isolated Claude account.
|
|
79
|
+
- `/v1/messages/count_tokens` remains local and uses neither account.
|
|
80
|
+
|
|
81
|
+
The two profiles keep separate service tokens, API bases, refresh/backoff state,
|
|
82
|
+
model endpoint metadata, and model caches. If the isolated Claude profile later
|
|
83
|
+
becomes invalid, Claude requests fail with a reauthentication instruction while
|
|
84
|
+
Codex remains available; CCDX never silently falls back to the enterprise
|
|
85
|
+
account. Reauthorize it explicitly with:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
ccdx auth login claude --reauth --github-login <personal-github-login>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
These commands are read-only and never start Device Flow:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
ccdx auth status
|
|
95
|
+
ccdx auth status --online
|
|
96
|
+
ccdx models --profile claude
|
|
97
|
+
ccdx doctor --online --profile all
|
|
98
|
+
```
|
|
99
|
+
|
|
51
100
|
To change the model used by Codex Auto-review, run:
|
|
52
101
|
|
|
53
102
|
```bash
|
|
@@ -123,6 +172,9 @@ ccdx doctor --online
|
|
|
123
172
|
```
|
|
124
173
|
|
|
125
174
|
The online doctor never starts device flow, scans for replacement tokens, or changes the saved token.
|
|
175
|
+
Use `--profile codex`, `--profile claude`, or `--profile all` to choose which
|
|
176
|
+
account entitlement is checked. Without `--profile`, the legacy Codex check is
|
|
177
|
+
preserved.
|
|
126
178
|
|
|
127
179
|
To actively verify the protocol path through an already-running adapter, run:
|
|
128
180
|
|
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 {
|
|
9
|
-
import {
|
|
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 {
|
|
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) {
|
|
@@ -113,7 +138,6 @@ const MODEL_REFRESH_TIMEOUT_MS = RUNTIME.modelRefreshTimeoutMs;
|
|
|
113
138
|
const EXISTING_ADAPTER_TIMEOUT_MS = RUNTIME.existingAdapterTimeoutMs;
|
|
114
139
|
const CONFIGURE_CLAUDE_DESKTOP = CLI.configureClaudeDesktop || process.env.CCDX_CONFIGURE_CLAUDE_DESKTOP === "1";
|
|
115
140
|
const LOGGING = configureLogging();
|
|
116
|
-
const MODEL_REGISTRY = { modelDefs: undefined, models: undefined, source: "built-in" };
|
|
117
141
|
let activeServer = null;
|
|
118
142
|
let modelRefreshTimer = null;
|
|
119
143
|
let shuttingDown = false;
|
|
@@ -123,88 +147,11 @@ if (LOGGING.filePath) {
|
|
|
123
147
|
if (LOGGING.level === "debug") console.log(status("debug", "Debug logging enabled"));
|
|
124
148
|
}
|
|
125
149
|
|
|
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
150
|
function currentClaudeDesktopApiKey() {
|
|
204
151
|
return String(process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || "").trim();
|
|
205
152
|
}
|
|
206
153
|
|
|
207
|
-
function syncClaudeDesktopProfileModels(modelDefs
|
|
154
|
+
function syncClaudeDesktopProfileModels(modelDefs) {
|
|
208
155
|
const result = syncManagedClaudeDesktopModels({
|
|
209
156
|
port: ADAPTER_PORT,
|
|
210
157
|
host: ADAPTER_HOST,
|
|
@@ -268,6 +215,7 @@ if (CLI.command === "doctor") {
|
|
|
268
215
|
host: ADAPTER_HOST,
|
|
269
216
|
online: CLI.online,
|
|
270
217
|
compat: CLI.compat,
|
|
218
|
+
profile: CLI.profile,
|
|
271
219
|
});
|
|
272
220
|
process.exit(checks.some((check) => check.kind === "err") ? 1 : 0);
|
|
273
221
|
}
|
|
@@ -296,14 +244,33 @@ try {
|
|
|
296
244
|
// Ensure GitHub login, using device flow if no token exists.
|
|
297
245
|
await ensureAuth();
|
|
298
246
|
|
|
247
|
+
const profileRuntime = createProfileRuntime({ codexClient: defaultCopilotClient });
|
|
248
|
+
if (profileRuntime.claudeMode === "isolated") {
|
|
249
|
+
if (profileRuntime.claudeProfile.valid) {
|
|
250
|
+
const account = profileRuntime.claudeProfile.identity?.login
|
|
251
|
+
|| profileRuntime.claudeProfile.identity?.id
|
|
252
|
+
|| "the saved account";
|
|
253
|
+
console.log(status("ok", `Claude requests will use the isolated GitHub account ${account}`));
|
|
254
|
+
} else {
|
|
255
|
+
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`));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const profileModels = createProfileModelRuntime({
|
|
260
|
+
codexClient: profileRuntime.codexClient,
|
|
261
|
+
claudeClient: profileRuntime.claudeClient,
|
|
262
|
+
claudeMode: profileRuntime.claudeMode,
|
|
263
|
+
claudeCredentialFingerprint: profileRuntime.claudeCredentialFingerprint,
|
|
264
|
+
timeoutMs: MODEL_REFRESH_TIMEOUT_MS,
|
|
265
|
+
commandName: CLI_NAME,
|
|
266
|
+
autoReviewModelResolver: () => autoReviewModelPreference().model,
|
|
267
|
+
onClaudeModelsChanged: syncClaudeDesktopProfileModels,
|
|
268
|
+
});
|
|
269
|
+
|
|
299
270
|
// Refresh the VS Code version in the background; fallback is non-blocking.
|
|
300
271
|
void refreshVSCodeVersion();
|
|
301
|
-
const modelInitialization = await
|
|
302
|
-
|
|
303
|
-
currentModelDefs: () => MODEL_REGISTRY.modelDefs,
|
|
304
|
-
refresh: refreshClaudeDesktopModelDefs,
|
|
305
|
-
});
|
|
306
|
-
const claudeDesktopModelDefs = modelInitialization.modelDefs;
|
|
272
|
+
const modelInitialization = await profileModels.initialize();
|
|
273
|
+
const claudeDesktopModelDefs = modelInitialization.claude.modelDefs;
|
|
307
274
|
syncClaudeDesktopProfileModels(claudeDesktopModelDefs);
|
|
308
275
|
|
|
309
276
|
if (!isLoopbackHost(ADAPTER_HOST)) {
|
|
@@ -327,7 +294,11 @@ try {
|
|
|
327
294
|
claudeDesktopApiKey,
|
|
328
295
|
claudeDesktopModelDefs,
|
|
329
296
|
autoReviewModelResolver: () => autoReviewModelPreference().model,
|
|
330
|
-
|
|
297
|
+
codexClient: profileRuntime.codexClient,
|
|
298
|
+
claudeClient: profileRuntime.claudeClient,
|
|
299
|
+
codexModelRegistry: profileModels.codexRegistry,
|
|
300
|
+
claudeModelRegistry: profileModels.claudeRegistry,
|
|
301
|
+
claudeMode: profileRuntime.claudeMode,
|
|
331
302
|
showRequestId: CLI.showRequestId,
|
|
332
303
|
upstreamTimeoutMs: RUNTIME.upstreamTimeoutMs,
|
|
333
304
|
streamHandshakeTimeoutMs: RUNTIME.streamHandshakeTimeoutMs,
|
|
@@ -358,7 +329,7 @@ try {
|
|
|
358
329
|
const refreshIntervalMs = RUNTIME.modelRefreshIntervalMs;
|
|
359
330
|
if (Number.isFinite(refreshIntervalMs) && refreshIntervalMs > 0) {
|
|
360
331
|
modelRefreshTimer = setInterval(() => {
|
|
361
|
-
|
|
332
|
+
profileModels.refreshAll().catch(() => {});
|
|
362
333
|
}, refreshIntervalMs);
|
|
363
334
|
modelRefreshTimer.unref?.();
|
|
364
335
|
}
|
package/package.json
CHANGED
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 {
|
|
3
|
+
import { defaultCopilotClient } from "./copilot.mjs";
|
|
4
4
|
import { isValidModelList } from "./model-cache.mjs";
|
|
5
5
|
import { claudeDesktopModelsResponse } from "./models.mjs";
|
|
6
6
|
import {
|
|
@@ -90,10 +90,46 @@ export function createAdapterHandler(options = {}) {
|
|
|
90
90
|
|| process.env.CCDX_CLAUDE_DESKTOP_API_KEY
|
|
91
91
|
|| process.env.CCDX_PROXY_API_KEY
|
|
92
92
|
|| "";
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
const codexClient = options.codexClient || defaultCopilotClient;
|
|
94
|
+
const hasExplicitClaudeMode = Object.hasOwn(options, "claudeMode");
|
|
95
|
+
if (hasExplicitClaudeMode && !["inherited", "isolated"].includes(options.claudeMode)) {
|
|
96
|
+
throw new Error(`Unsupported Claude mode: ${options.claudeMode}`);
|
|
97
|
+
}
|
|
98
|
+
if (options.claudeMode === "isolated"
|
|
99
|
+
&& (!options.claudeClient || options.claudeClient === codexClient)) {
|
|
100
|
+
throw new Error("Isolated Claude mode requires a distinct Claude client");
|
|
101
|
+
}
|
|
102
|
+
if (options.claudeMode === "inherited"
|
|
103
|
+
&& options.claudeClient && options.claudeClient !== codexClient) {
|
|
104
|
+
throw new Error("Inherited Claude mode requires the Codex client");
|
|
105
|
+
}
|
|
106
|
+
const claudeClient = options.claudeClient || codexClient;
|
|
107
|
+
// chatCompletionsFn is retained as a compatibility injection and continues to
|
|
108
|
+
// override both surfaces in existing tests and embedders. New dual-profile
|
|
109
|
+
// callers should pass codexClient/claudeClient instead.
|
|
110
|
+
const codexChatCompletionsFn = options.codexChatCompletionsFn
|
|
111
|
+
|| options.chatCompletionsFn
|
|
112
|
+
|| codexClient.chatCompletions;
|
|
113
|
+
const claudeChatCompletionsFn = options.claudeChatCompletionsFn
|
|
114
|
+
|| options.chatCompletionsFn
|
|
115
|
+
|| claudeClient.chatCompletions;
|
|
116
|
+
const responsesFn = options.responsesFn || codexClient.responses;
|
|
117
|
+
const responsesCompactFn = options.responsesCompactFn || codexClient.responsesCompact;
|
|
118
|
+
const listModelsFn = options.listModelsFn || codexClient.listModels;
|
|
119
|
+
const getCachedModelEndpointsFn = options.getCachedModelEndpointsFn
|
|
120
|
+
|| codexClient.getCachedModelEndpoints;
|
|
121
|
+
const claudeMode = hasExplicitClaudeMode
|
|
122
|
+
? options.claudeMode
|
|
123
|
+
: claudeClient === codexClient ? "inherited" : "isolated";
|
|
124
|
+
if (claudeMode === "isolated" && options.chatCompletionsFn) {
|
|
125
|
+
throw new Error("Isolated Claude mode cannot use the shared chatCompletionsFn override; pass codexChatCompletionsFn or claudeChatCompletionsFn explicitly");
|
|
126
|
+
}
|
|
127
|
+
const codexModelRegistry = options.codexModelRegistry || options.modelRegistry;
|
|
128
|
+
const claudeModelRegistry = options.claudeModelRegistry || options.modelRegistry || codexModelRegistry;
|
|
129
|
+
if (claudeMode === "isolated"
|
|
130
|
+
&& (!options.claudeModelRegistry || claudeModelRegistry === codexModelRegistry)) {
|
|
131
|
+
throw new Error("Isolated Claude mode requires a distinct Claude model registry");
|
|
132
|
+
}
|
|
97
133
|
const openAIModelEnv = options.openAIModelEnv || process.env;
|
|
98
134
|
const upstreamTimeoutMs = parsePositiveInteger(options.upstreamTimeoutMs, ADAPTER_RUNTIME_CONFIG.upstreamTimeoutMs);
|
|
99
135
|
const streamHandshakeTimeoutMs = parsePositiveInteger(options.streamHandshakeTimeoutMs, ADAPTER_RUNTIME_CONFIG.streamHandshakeTimeoutMs);
|
|
@@ -102,13 +138,14 @@ export function createAdapterHandler(options = {}) {
|
|
|
102
138
|
const requestMetrics = options.requestMetrics || createRequestMetrics();
|
|
103
139
|
const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
|
|
104
140
|
const claudeDesktopModelOptions = () => {
|
|
105
|
-
const modelDefs =
|
|
141
|
+
const modelDefs = claudeModelRegistry?.modelDefs || options.claudeDesktopModelDefs;
|
|
106
142
|
return Array.isArray(modelDefs) ? { modelDefs } : {};
|
|
107
143
|
};
|
|
108
144
|
const responsesHandler = createResponsesHandler({
|
|
109
145
|
acquireRequest,
|
|
110
146
|
autoReviewModelResolver: options.autoReviewModelResolver,
|
|
111
|
-
chatCompletionsFn,
|
|
147
|
+
chatCompletionsFn: codexChatCompletionsFn,
|
|
148
|
+
getCachedModelEndpointsFn,
|
|
112
149
|
openAIModelEnv,
|
|
113
150
|
responsesPayloadOptions: options.responsesPayloadOptions,
|
|
114
151
|
responsesFn,
|
|
@@ -128,7 +165,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
128
165
|
const anthropicCountTokensHandler = createAnthropicCountTokensHandler({ acquireRequest });
|
|
129
166
|
const anthropicMessagesHandler = createAnthropicMessagesHandler({
|
|
130
167
|
acquireRequest,
|
|
131
|
-
chatCompletionsFn,
|
|
168
|
+
chatCompletionsFn: claudeChatCompletionsFn,
|
|
132
169
|
environment: process.env,
|
|
133
170
|
modelOptions: claudeDesktopModelOptions,
|
|
134
171
|
streamHandshakeTimeoutMs,
|
|
@@ -148,7 +185,12 @@ export function createAdapterHandler(options = {}) {
|
|
|
148
185
|
metrics: requestMetrics,
|
|
149
186
|
streamPerformance: streamPerformanceMetrics,
|
|
150
187
|
admission: acquireRequest,
|
|
151
|
-
modelRegistry:
|
|
188
|
+
modelRegistry: codexModelRegistry,
|
|
189
|
+
codexClient,
|
|
190
|
+
claudeClient,
|
|
191
|
+
codexModelRegistry,
|
|
192
|
+
claudeModelRegistry,
|
|
193
|
+
claudeMode,
|
|
152
194
|
})));
|
|
153
195
|
return;
|
|
154
196
|
}
|
|
@@ -181,24 +223,24 @@ export function createAdapterHandler(options = {}) {
|
|
|
181
223
|
let models;
|
|
182
224
|
try { models = JSON.parse(body); } catch {}
|
|
183
225
|
if (isValidModelList(models)) {
|
|
184
|
-
if (
|
|
185
|
-
|
|
226
|
+
if (codexModelRegistry) {
|
|
227
|
+
codexModelRegistry.models = models;
|
|
186
228
|
}
|
|
187
229
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
188
230
|
res.end(body);
|
|
189
231
|
return;
|
|
190
232
|
}
|
|
191
|
-
if (sendLastKnownGoodModels(res,
|
|
233
|
+
if (sendLastKnownGoodModels(res, codexModelRegistry)) return;
|
|
192
234
|
sendJsonError(res, httpError("Copilot models response contained no valid models", 502), 502);
|
|
193
235
|
return;
|
|
194
236
|
}
|
|
195
|
-
if (modelListCanUseLastKnownGood(status) && sendLastKnownGoodModels(res,
|
|
237
|
+
if (modelListCanUseLastKnownGood(status) && sendLastKnownGoodModels(res, codexModelRegistry)) return;
|
|
196
238
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
197
239
|
res.end(body);
|
|
198
240
|
})
|
|
199
241
|
.catch((e) => {
|
|
200
242
|
logRequestFailure("Models", e, abort);
|
|
201
|
-
if (sendLastKnownGoodModels(res,
|
|
243
|
+
if (sendLastKnownGoodModels(res, codexModelRegistry)) return;
|
|
202
244
|
if (!res.destroyed && !res.writableEnded) {
|
|
203
245
|
if (!res.headersSent) res.writeHead(e?.statusCode || 502);
|
|
204
246
|
res.end(JSON.stringify({ error: e.message }));
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { atomicWriteFileSync } from "./atomic-file.mjs";
|
|
5
|
+
import {
|
|
6
|
+
githubTokenLockPath,
|
|
7
|
+
githubTokenMetadataPath,
|
|
8
|
+
githubTokenPath,
|
|
9
|
+
} from "./auth.mjs";
|
|
10
|
+
import {
|
|
11
|
+
githubTokenFingerprint,
|
|
12
|
+
normalizeGithubIdentity,
|
|
13
|
+
} from "./github-identity.mjs";
|
|
14
|
+
import { withFileLock } from "./lock.mjs";
|
|
15
|
+
import { parsePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
|
|
16
|
+
|
|
17
|
+
export const AUTH_PROFILE_CODEX = "codex";
|
|
18
|
+
export const AUTH_PROFILE_CLAUDE = "claude";
|
|
19
|
+
|
|
20
|
+
const AUTH_PROFILES = new Set([AUTH_PROFILE_CODEX, AUTH_PROFILE_CLAUDE]);
|
|
21
|
+
|
|
22
|
+
function checkedProfile(profile) {
|
|
23
|
+
const value = String(profile || "").trim().toLowerCase();
|
|
24
|
+
if (!AUTH_PROFILES.has(value)) throw new Error(`Unsupported authentication profile: ${profile}`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function authProfilePaths(profile, { home = os.homedir() } = {}) {
|
|
29
|
+
const name = checkedProfile(profile);
|
|
30
|
+
if (name === AUTH_PROFILE_CODEX) {
|
|
31
|
+
return {
|
|
32
|
+
profile: name,
|
|
33
|
+
tokenPath: githubTokenPath(home),
|
|
34
|
+
metadataPath: githubTokenMetadataPath(home),
|
|
35
|
+
lockPath: githubTokenLockPath(home),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const directory = path.join(home, ".local", "share", "copilot-api", "profiles", AUTH_PROFILE_CLAUDE);
|
|
40
|
+
const tokenPath = path.join(directory, "github_token");
|
|
41
|
+
return {
|
|
42
|
+
profile: name,
|
|
43
|
+
tokenPath,
|
|
44
|
+
metadataPath: `${tokenPath}.account.json`,
|
|
45
|
+
lockPath: `${tokenPath}.lock`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readOptionalFile(filePath) {
|
|
50
|
+
try {
|
|
51
|
+
return { exists: true, data: fs.readFileSync(filePath), mode: fs.statSync(filePath).mode & 0o777 };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error?.code === "ENOENT") return { exists: false, data: null, mode: 0o600 };
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parsedMetadata(snapshot) {
|
|
59
|
+
if (!snapshot.exists) return { value: null, malformed: false };
|
|
60
|
+
try {
|
|
61
|
+
return { value: JSON.parse(snapshot.data.toString("utf8")), malformed: false };
|
|
62
|
+
} catch {
|
|
63
|
+
return { value: null, malformed: true };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function readAuthProfileCredentials(profile, { home = os.homedir() } = {}) {
|
|
68
|
+
const paths = authProfilePaths(profile, { home });
|
|
69
|
+
const tokenSnapshot = readOptionalFile(paths.tokenPath);
|
|
70
|
+
const metadataSnapshot = readOptionalFile(paths.metadataPath);
|
|
71
|
+
const token = tokenSnapshot.exists ? tokenSnapshot.data.toString("utf8").trim() : "";
|
|
72
|
+
const metadataResult = parsedMetadata(metadataSnapshot);
|
|
73
|
+
const metadataFingerprint = metadataResult.value?.token_fingerprint;
|
|
74
|
+
const metadataMatchesToken = Boolean(token)
|
|
75
|
+
&& Boolean(metadataFingerprint)
|
|
76
|
+
&& metadataFingerprint === githubTokenFingerprint(token);
|
|
77
|
+
const metadataIdentity = normalizeGithubIdentity(metadataResult.value);
|
|
78
|
+
const identity = metadataMatchesToken ? metadataIdentity : null;
|
|
79
|
+
|
|
80
|
+
if (paths.profile === AUTH_PROFILE_CODEX) {
|
|
81
|
+
const configured = tokenSnapshot.exists;
|
|
82
|
+
return {
|
|
83
|
+
profile: paths.profile,
|
|
84
|
+
configured,
|
|
85
|
+
valid: configured && Boolean(token),
|
|
86
|
+
reason: !configured ? "unconfigured" : token ? "" : "empty_token",
|
|
87
|
+
token,
|
|
88
|
+
identity,
|
|
89
|
+
metadata: metadataResult.value,
|
|
90
|
+
paths,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Metadata is written last, but any credential artifact reserves the Claude
|
|
95
|
+
// profile. A partial write must fail closed instead of borrowing Codex.
|
|
96
|
+
const configured = metadataSnapshot.exists || tokenSnapshot.exists;
|
|
97
|
+
let reason = "";
|
|
98
|
+
if (!configured) reason = "unconfigured";
|
|
99
|
+
else if (!metadataSnapshot.exists) reason = "missing_metadata";
|
|
100
|
+
else if (metadataResult.malformed) reason = "metadata_malformed";
|
|
101
|
+
else if (!tokenSnapshot.exists) reason = "missing_token";
|
|
102
|
+
else if (!token) reason = "empty_token";
|
|
103
|
+
else if (!metadataIdentity) reason = "metadata_identity_missing";
|
|
104
|
+
else if (!metadataFingerprint || metadataFingerprint !== githubTokenFingerprint(token)) reason = "token_metadata_mismatch";
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
profile: paths.profile,
|
|
108
|
+
configured,
|
|
109
|
+
valid: configured && !reason,
|
|
110
|
+
reason,
|
|
111
|
+
token,
|
|
112
|
+
identity,
|
|
113
|
+
metadata: metadataResult.value,
|
|
114
|
+
paths,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function lockOptions(env = process.env) {
|
|
119
|
+
return {
|
|
120
|
+
timeoutMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
|
|
121
|
+
staleMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function withAuthProfileLock(profile, fn, {
|
|
126
|
+
home = os.homedir(),
|
|
127
|
+
env = process.env,
|
|
128
|
+
...options
|
|
129
|
+
} = {}) {
|
|
130
|
+
const { lockPath } = authProfilePaths(profile, { home });
|
|
131
|
+
return withFileLock(lockPath, fn, { ...lockOptions(env), ...options });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writeSecret(filePath, data, writeFile) {
|
|
135
|
+
writeFile(filePath, data, { mode: 0o600 });
|
|
136
|
+
fs.chmodSync(filePath, 0o600);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function restoreSnapshot(filePath, snapshot, writeFile) {
|
|
140
|
+
if (snapshot.exists) {
|
|
141
|
+
writeSecret(filePath, snapshot.data, writeFile);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
fs.unlinkSync(filePath);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error?.code !== "ENOENT") throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function writeClaudeAuthProfile(token, identity, {
|
|
152
|
+
home = os.homedir(),
|
|
153
|
+
now = () => new Date(),
|
|
154
|
+
writeFile = atomicWriteFileSync,
|
|
155
|
+
} = {}) {
|
|
156
|
+
const cleanToken = String(token || "").trim();
|
|
157
|
+
const normalizedIdentity = normalizeGithubIdentity(identity);
|
|
158
|
+
if (!cleanToken) throw new Error("Claude GitHub token is empty");
|
|
159
|
+
if (!normalizedIdentity) throw new Error("Claude GitHub account identity is missing");
|
|
160
|
+
|
|
161
|
+
const paths = authProfilePaths(AUTH_PROFILE_CLAUDE, { home });
|
|
162
|
+
const directory = path.dirname(paths.tokenPath);
|
|
163
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
164
|
+
fs.chmodSync(directory, 0o700);
|
|
165
|
+
|
|
166
|
+
const previousToken = readOptionalFile(paths.tokenPath);
|
|
167
|
+
const previousMetadata = readOptionalFile(paths.metadataPath);
|
|
168
|
+
const timestamp = now();
|
|
169
|
+
const updatedAt = timestamp instanceof Date ? timestamp.toISOString() : new Date(timestamp).toISOString();
|
|
170
|
+
const metadata = `${JSON.stringify({
|
|
171
|
+
profile: AUTH_PROFILE_CLAUDE,
|
|
172
|
+
...normalizedIdentity,
|
|
173
|
+
token_fingerprint: githubTokenFingerprint(cleanToken),
|
|
174
|
+
updated_at: updatedAt,
|
|
175
|
+
}, null, 2)}\n`;
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
writeSecret(paths.tokenPath, cleanToken, writeFile);
|
|
179
|
+
// Commit the activation marker last. Readers never observe a configured
|
|
180
|
+
// Claude profile unless the token and its fingerprint agree.
|
|
181
|
+
writeSecret(paths.metadataPath, metadata, writeFile);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
try {
|
|
184
|
+
restoreSnapshot(paths.tokenPath, previousToken, writeFile);
|
|
185
|
+
restoreSnapshot(paths.metadataPath, previousMetadata, writeFile);
|
|
186
|
+
} catch (rollbackError) {
|
|
187
|
+
error.rollbackError = rollbackError;
|
|
188
|
+
}
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
profile: AUTH_PROFILE_CLAUDE,
|
|
194
|
+
identity: normalizedIdentity,
|
|
195
|
+
paths,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function profileReauthMessage(profile, { home = os.homedir() } = {}) {
|
|
200
|
+
const name = checkedProfile(profile);
|
|
201
|
+
if (name === AUTH_PROFILE_CLAUDE) {
|
|
202
|
+
return "Claude authentication is unavailable. Run `ccdx auth login claude --reauth`, then restart ccdx.";
|
|
203
|
+
}
|
|
204
|
+
return `Codex authentication is unavailable at ${authProfilePaths(name, { home }).tokenPath}. Run ccdx again to log in.`;
|
|
205
|
+
}
|