github-router 0.3.15 → 0.3.17
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 +41 -6
- package/dist/main.js +486 -121
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -16,6 +16,7 @@ import { Hono } from "hono";
|
|
|
16
16
|
import { cors } from "hono/cors";
|
|
17
17
|
import { streamSSE } from "hono/streaming";
|
|
18
18
|
import { events } from "fetch-event-stream";
|
|
19
|
+
import { z } from "zod";
|
|
19
20
|
import clipboard from "clipboardy";
|
|
20
21
|
|
|
21
22
|
//#region src/lib/paths.ts
|
|
@@ -31,10 +32,14 @@ const PATHS = {
|
|
|
31
32
|
},
|
|
32
33
|
get ERROR_LOG_PATH() {
|
|
33
34
|
return path.join(appDir(), "error.log");
|
|
35
|
+
},
|
|
36
|
+
get CODEX_HOME() {
|
|
37
|
+
return path.join(appDir(), "codex-isolated");
|
|
34
38
|
}
|
|
35
39
|
};
|
|
36
40
|
async function ensurePaths() {
|
|
37
41
|
await fs.mkdir(PATHS.APP_DIR, { recursive: true });
|
|
42
|
+
await fs.mkdir(PATHS.CODEX_HOME, { recursive: true });
|
|
38
43
|
await ensureFile(PATHS.GITHUB_TOKEN_PATH);
|
|
39
44
|
}
|
|
40
45
|
async function ensureFile(filePath) {
|
|
@@ -68,7 +73,7 @@ const DEFAULT_COPILOT_VERSION = "0.43.2026033101";
|
|
|
68
73
|
function copilotVersion(state$1) {
|
|
69
74
|
return state$1.copilotVersion ?? DEFAULT_COPILOT_VERSION;
|
|
70
75
|
}
|
|
71
|
-
const API_VERSION = "
|
|
76
|
+
const API_VERSION = "2026-01-09";
|
|
72
77
|
const copilotBaseUrl = (state$1) => state$1.copilotApiUrl ?? "https://api.githubcopilot.com";
|
|
73
78
|
const copilotHeaders = (state$1, vision = false, integrationId = "vscode-chat") => {
|
|
74
79
|
const version = copilotVersion(state$1);
|
|
@@ -123,6 +128,17 @@ async function forwardError(c, error) {
|
|
|
123
128
|
} catch {
|
|
124
129
|
errorJson = void 0;
|
|
125
130
|
}
|
|
131
|
+
if (isContextOverflow(error.response.status, errorJson, errorText)) {
|
|
132
|
+
const upstream = resolveErrorMessage(errorJson, errorText);
|
|
133
|
+
consola.error("HTTP error (mapped to overflow):", errorJson ?? errorText);
|
|
134
|
+
return c.json({
|
|
135
|
+
type: "error",
|
|
136
|
+
error: {
|
|
137
|
+
type: "invalid_request_error",
|
|
138
|
+
message: `prompt is too long: ${upstream}`
|
|
139
|
+
}
|
|
140
|
+
}, 400);
|
|
141
|
+
}
|
|
126
142
|
if (isAnthropicError(errorJson)) {
|
|
127
143
|
consola.error("HTTP error:", errorJson);
|
|
128
144
|
return c.json(errorJson, error.response.status);
|
|
@@ -167,6 +183,29 @@ function isAnthropicError(json) {
|
|
|
167
183
|
const inner = record.error;
|
|
168
184
|
return typeof inner.type === "string" && typeof inner.message === "string";
|
|
169
185
|
}
|
|
186
|
+
const CONTEXT_OVERFLOW_SUBSTRINGS = [
|
|
187
|
+
"prompt is too long",
|
|
188
|
+
"context_length_exceeded",
|
|
189
|
+
"context length exceeded",
|
|
190
|
+
"input is too long",
|
|
191
|
+
"maximum context length",
|
|
192
|
+
"too many tokens"
|
|
193
|
+
];
|
|
194
|
+
/**
|
|
195
|
+
* Detect upstream context-overflow errors so we can remap them to a 400
|
|
196
|
+
* "prompt is too long" shape that triggers Claude Code self-compaction.
|
|
197
|
+
*
|
|
198
|
+
* Always remaps 413 (treated as a hard payload-size signal regardless of
|
|
199
|
+
* body wording). Remaps 400 only when the error text contains one of the
|
|
200
|
+
* known overflow substrings — a regular 400 (e.g. "model not found") must
|
|
201
|
+
* NOT remap.
|
|
202
|
+
*/
|
|
203
|
+
function isContextOverflow(status, errorJson, errorText) {
|
|
204
|
+
if (status === 413) return true;
|
|
205
|
+
if (status !== 400) return false;
|
|
206
|
+
const haystack = (errorText + " " + (typeof errorJson === "object" && errorJson !== null ? JSON.stringify(errorJson) : "")).toLowerCase();
|
|
207
|
+
return CONTEXT_OVERFLOW_SUBSTRINGS.some((s) => haystack.includes(s));
|
|
208
|
+
}
|
|
170
209
|
/**
|
|
171
210
|
* Map HTTP status to Anthropic error type.
|
|
172
211
|
*/
|
|
@@ -182,11 +221,35 @@ function resolveErrorType(status) {
|
|
|
182
221
|
|
|
183
222
|
//#endregion
|
|
184
223
|
//#region src/services/github/get-copilot-token.ts
|
|
224
|
+
/**
|
|
225
|
+
* Allowlist of hosts the router will trust as the Copilot API base URL.
|
|
226
|
+
* Anything else returned in `endpoints.api` (e.g. via a tampered or
|
|
227
|
+
* misconfigured token-exchange response) is rejected — otherwise a
|
|
228
|
+
* malicious value would receive the long-lived GitHub PAT we send to
|
|
229
|
+
* `/mcp` for web search (see `src/services/copilot/web-search.ts`).
|
|
230
|
+
*/
|
|
231
|
+
const COPILOT_HOST_ALLOWLIST = [
|
|
232
|
+
"api.githubcopilot.com",
|
|
233
|
+
"api.individual.githubcopilot.com",
|
|
234
|
+
"api.business.githubcopilot.com",
|
|
235
|
+
"api.enterprise.githubcopilot.com"
|
|
236
|
+
];
|
|
237
|
+
function isAllowedCopilotHost(rawUrl) {
|
|
238
|
+
let parsed;
|
|
239
|
+
try {
|
|
240
|
+
parsed = new URL(rawUrl);
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (parsed.protocol !== "https:") return false;
|
|
245
|
+
return COPILOT_HOST_ALLOWLIST.includes(parsed.hostname);
|
|
246
|
+
}
|
|
185
247
|
const getCopilotToken = async () => {
|
|
186
248
|
const response = await fetch(`${GITHUB_API_BASE_URL}/copilot_internal/v2/token`, { headers: githubHeaders(state) });
|
|
187
249
|
if (!response.ok) throw new HTTPError("Failed to get Copilot token", response);
|
|
188
250
|
const data = await response.json();
|
|
189
|
-
if (data.endpoints?.api) state.copilotApiUrl = data.endpoints.api;
|
|
251
|
+
if (data.endpoints?.api) if (isAllowedCopilotHost(data.endpoints.api)) state.copilotApiUrl = data.endpoints.api;
|
|
252
|
+
else consola.warn(`Refusing to honor Copilot API endpoint "${data.endpoints.api}" from the token-exchange response — not in allowlist (${COPILOT_HOST_ALLOWLIST.join(", ")}). ` + (state.copilotApiUrl ? `Keeping existing override "${state.copilotApiUrl}".` : `Falling back to the default api.githubcopilot.com.`));
|
|
190
253
|
return data;
|
|
191
254
|
};
|
|
192
255
|
|
|
@@ -297,12 +360,14 @@ const VSCODE_BETA_PREFIXES = [
|
|
|
297
360
|
* Enabled via --extended-betas flag. Includes all betas confirmed
|
|
298
361
|
* to work with the Copilot API.
|
|
299
362
|
*
|
|
300
|
-
* Notably absent
|
|
363
|
+
* Notably absent (Copilot 400s on these — verified live):
|
|
364
|
+
* context-1m-, skills-, files-api-, code-execution-, output-128k-.
|
|
365
|
+
* 1M context is unlocked by selecting `claude-opus-4.7-1m-internal`
|
|
366
|
+
* as the model id, not via a beta header.
|
|
301
367
|
*/
|
|
302
368
|
const EXTENDED_BETA_PREFIXES = [
|
|
303
369
|
...VSCODE_BETA_PREFIXES,
|
|
304
370
|
"claude-code-",
|
|
305
|
-
"context-1m-",
|
|
306
371
|
"effort-",
|
|
307
372
|
"prompt-caching-",
|
|
308
373
|
"computer-use-",
|
|
@@ -312,10 +377,8 @@ const EXTENDED_BETA_PREFIXES = [
|
|
|
312
377
|
"compact-",
|
|
313
378
|
"structured-outputs-",
|
|
314
379
|
"fast-mode-",
|
|
315
|
-
"skills-",
|
|
316
380
|
"mcp-client-",
|
|
317
381
|
"mcp-servers-",
|
|
318
|
-
"files-api-",
|
|
319
382
|
"redact-thinking-",
|
|
320
383
|
"web-search-"
|
|
321
384
|
];
|
|
@@ -355,7 +418,10 @@ function resolveModel(modelId) {
|
|
|
355
418
|
const ciMatch = models.find((m) => m.id.toLowerCase() === lower);
|
|
356
419
|
if (ciMatch) return ciMatch.id;
|
|
357
420
|
if (lower.includes("opus")) {
|
|
358
|
-
const
|
|
421
|
+
const oneMs = models.filter((m) => m.id.includes("opus") && /-1m(?:$|-)/.test(m.id));
|
|
422
|
+
const versionMatch = lower.match(/opus-(\d+)[.-](\d+)/);
|
|
423
|
+
const requestedVersion = versionMatch ? `${versionMatch[1]}.${versionMatch[2]}` : void 0;
|
|
424
|
+
const oneM = (requestedVersion ? oneMs.find((m) => m.id.includes(`opus-${requestedVersion}-`)) : void 0) ?? oneMs[0];
|
|
359
425
|
if (oneM) return oneM.id;
|
|
360
426
|
}
|
|
361
427
|
if (lower.includes("codex")) {
|
|
@@ -380,13 +446,19 @@ function resolveCodexModel(modelId) {
|
|
|
380
446
|
const models = state.models?.data;
|
|
381
447
|
if (!models) return resolved;
|
|
382
448
|
if (models.some((m) => m.id === resolved)) return resolved;
|
|
383
|
-
const
|
|
449
|
+
const candidates = models.filter((m) => {
|
|
384
450
|
const endpoints = m.supported_endpoints ?? [];
|
|
385
|
-
|
|
451
|
+
if (m.id.includes("mini") || m.id.includes("nano")) return false;
|
|
452
|
+
return endpoints.length === 0 || endpoints.includes("/responses");
|
|
386
453
|
});
|
|
387
|
-
if (
|
|
388
|
-
|
|
389
|
-
|
|
454
|
+
if (candidates.length > 0) {
|
|
455
|
+
candidates.sort((a, b) => {
|
|
456
|
+
const aCodex = a.id.includes("codex") ? 1 : 0;
|
|
457
|
+
const bCodex = b.id.includes("codex") ? 1 : 0;
|
|
458
|
+
if (aCodex !== bCodex) return bCodex - aCodex;
|
|
459
|
+
return b.id.localeCompare(a.id);
|
|
460
|
+
});
|
|
461
|
+
const best = candidates[0].id;
|
|
390
462
|
consola.warn(`Model "${modelId}" not available, using "${best}" instead`);
|
|
391
463
|
return best;
|
|
392
464
|
}
|
|
@@ -671,7 +743,38 @@ function enableFileLogging() {
|
|
|
671
743
|
//#endregion
|
|
672
744
|
//#region src/lib/port.ts
|
|
673
745
|
const DEFAULT_PORT = 8787;
|
|
674
|
-
|
|
746
|
+
/**
|
|
747
|
+
* Default model for `github-router claude`. The Anthropic-published dashed
|
|
748
|
+
* slug (`claude-opus-4-7`) — NOT the Copilot-internal slug
|
|
749
|
+
* (`claude-opus-4.7-1m-internal`) — because Claude Code 2.1.126's `/model`
|
|
750
|
+
* UI is backed by a hardcoded registry of Anthropic slugs, and an
|
|
751
|
+
* unrecognized slug causes the menu to highlight "Opus 4" with a
|
|
752
|
+
* "Newer version available" hint instead of "Opus 4.7 (1M context)".
|
|
753
|
+
*
|
|
754
|
+
* The proxy's `resolveModel` (`src/lib/utils.ts`) translates this to
|
|
755
|
+
* Copilot's `claude-opus-4.7-1m-internal` (enterprise) or
|
|
756
|
+
* `claude-opus-4.7` (Pro+/Business/Max) at request time via the
|
|
757
|
+
* family-preference + version-match branch — round-trip covered by
|
|
758
|
+
* `tests/lib-utils.test.ts:154`.
|
|
759
|
+
*
|
|
760
|
+
* `DEFAULT_CLAUDE_MODEL_FALLBACKS` covers major.minor regressions only;
|
|
761
|
+
* 1M↔200K downgrade is handled inside the resolver, so we don't need
|
|
762
|
+
* separate `-1m` entries here.
|
|
763
|
+
*/
|
|
764
|
+
const DEFAULT_CLAUDE_MODEL = "claude-opus-4-7";
|
|
765
|
+
const DEFAULT_CLAUDE_MODEL_FALLBACKS = ["claude-opus-4-6", "claude-opus-4-5"];
|
|
766
|
+
/**
|
|
767
|
+
* Default model for `github-router codex`. `gpt-5.5` is the new flagship
|
|
768
|
+
* `/responses` model; the fallback chain handles older Copilot tiers where
|
|
769
|
+
* 5.5 hasn't rolled out yet. `resolveCodexModel` provides a final
|
|
770
|
+
* "best available `/responses` model" safety net beyond this list.
|
|
771
|
+
*/
|
|
772
|
+
const DEFAULT_CODEX_MODEL = "gpt-5.5";
|
|
773
|
+
const DEFAULT_CODEX_MODEL_FALLBACKS = [
|
|
774
|
+
"gpt-5.4",
|
|
775
|
+
"gpt-5.3-codex",
|
|
776
|
+
"gpt-5.2-codex"
|
|
777
|
+
];
|
|
675
778
|
const PORT_RANGE_MIN = 11e3;
|
|
676
779
|
const PORT_RANGE_MAX = 65535;
|
|
677
780
|
/** Generate a random port number in the range [11000, 65535]. */
|
|
@@ -681,6 +784,49 @@ function generateRandomPort() {
|
|
|
681
784
|
|
|
682
785
|
//#endregion
|
|
683
786
|
//#region src/lib/launch.ts
|
|
787
|
+
/**
|
|
788
|
+
* Auth-related env keys we strip from the parent before spawning the
|
|
789
|
+
* child CLI. The proxy provides its own values for everything we care
|
|
790
|
+
* about (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, OPENAI_BASE_URL,
|
|
791
|
+
* OPENAI_API_KEY, CODEX_HOME, ANTHROPIC_MODEL); for the rest, we want
|
|
792
|
+
* the child to behave as if the user had no parent-env auth at all.
|
|
793
|
+
*
|
|
794
|
+
* Why strip rather than override-with-empty-string:
|
|
795
|
+
* - Claude Code emits "Auth conflict" warnings whenever both
|
|
796
|
+
* ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY are present (regardless
|
|
797
|
+
* of value, even when both are "dummy"). Stripping API_KEY entirely
|
|
798
|
+
* suppresses the warning AND prevents an inherited real shell key
|
|
799
|
+
* from leaking via x-api-key.
|
|
800
|
+
* - Cloud-provider toggles (CLAUDE_CODE_USE_*) and OAUTH_TOKEN, etc.
|
|
801
|
+
* are simpler dropped than overridden — a missing env var is
|
|
802
|
+
* unambiguously falsy/absent in every code path that reads it.
|
|
803
|
+
*/
|
|
804
|
+
const STRIPPED_PARENT_ENV_KEYS = [
|
|
805
|
+
"ANTHROPIC_API_KEY",
|
|
806
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
807
|
+
"ANTHROPIC_BASE_URL",
|
|
808
|
+
"ANTHROPIC_CUSTOM_HEADERS",
|
|
809
|
+
"ANTHROPIC_MODEL",
|
|
810
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
811
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
812
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
813
|
+
"CLAUDE_CODE_USE_FOUNDRY",
|
|
814
|
+
"CLAUDE_CONFIG_DIR",
|
|
815
|
+
"OPENAI_API_KEY",
|
|
816
|
+
"OPENAI_BASE_URL",
|
|
817
|
+
"CODEX_HOME"
|
|
818
|
+
];
|
|
819
|
+
/**
|
|
820
|
+
* Strip auth-related keys from a parent-process env object. The result
|
|
821
|
+
* is suitable to spread into a spawned child's env BEFORE the proxy's
|
|
822
|
+
* explicit overrides, so the proxy is the only source of truth for
|
|
823
|
+
* auth — and stale shell exports can't leak through.
|
|
824
|
+
*/
|
|
825
|
+
function sanitizeParentEnv(parent) {
|
|
826
|
+
const sanitized = { ...parent };
|
|
827
|
+
for (const key of STRIPPED_PARENT_ENV_KEYS) delete sanitized[key];
|
|
828
|
+
return sanitized;
|
|
829
|
+
}
|
|
684
830
|
function commandExists(name) {
|
|
685
831
|
try {
|
|
686
832
|
execFileSync(process$1.platform === "win32" ? "where.exe" : "which", [name], { stdio: "ignore" });
|
|
@@ -703,7 +849,7 @@ function buildLaunchCommand(target) {
|
|
|
703
849
|
...target.extraArgs
|
|
704
850
|
],
|
|
705
851
|
env: {
|
|
706
|
-
...process$1.env,
|
|
852
|
+
...sanitizeParentEnv(process$1.env),
|
|
707
853
|
...target.envVars
|
|
708
854
|
}
|
|
709
855
|
};
|
|
@@ -1185,6 +1331,31 @@ const createChatCompletions = async (payload, modelHeaders) => {
|
|
|
1185
1331
|
|
|
1186
1332
|
//#endregion
|
|
1187
1333
|
//#region src/services/copilot/web-search.ts
|
|
1334
|
+
const RpcSchema = z.object({
|
|
1335
|
+
jsonrpc: z.literal("2.0"),
|
|
1336
|
+
id: z.number().optional(),
|
|
1337
|
+
result: z.object({
|
|
1338
|
+
content: z.array(z.object({
|
|
1339
|
+
type: z.literal("text"),
|
|
1340
|
+
text: z.string()
|
|
1341
|
+
})).optional(),
|
|
1342
|
+
isError: z.boolean().optional()
|
|
1343
|
+
}).optional(),
|
|
1344
|
+
error: z.object({
|
|
1345
|
+
code: z.number(),
|
|
1346
|
+
message: z.string()
|
|
1347
|
+
}).optional()
|
|
1348
|
+
});
|
|
1349
|
+
const InnerSchema = z.object({
|
|
1350
|
+
text: z.object({
|
|
1351
|
+
value: z.string(),
|
|
1352
|
+
annotations: z.array(z.object({ url_citation: z.object({
|
|
1353
|
+
title: z.string(),
|
|
1354
|
+
url: z.string()
|
|
1355
|
+
}).optional() })).optional()
|
|
1356
|
+
}),
|
|
1357
|
+
bing_searches: z.array(z.unknown()).optional()
|
|
1358
|
+
});
|
|
1188
1359
|
const MAX_SEARCHES_PER_SECOND = 3;
|
|
1189
1360
|
let searchTimestamps = [];
|
|
1190
1361
|
async function throttleSearch() {
|
|
@@ -1199,55 +1370,129 @@ async function throttleSearch() {
|
|
|
1199
1370
|
}
|
|
1200
1371
|
searchTimestamps.push(Date.now());
|
|
1201
1372
|
}
|
|
1202
|
-
function
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
return (await response.json()).thread_id;
|
|
1373
|
+
function mcpHeaders(sid) {
|
|
1374
|
+
if (!state.githubToken) throw new Error("GitHub token missing — re-run auth flow. Web search uses the GitHub PAT (not the Copilot token); the on-disk token at ~/.local/share/github-router/github_token must be present.");
|
|
1375
|
+
const headers = {
|
|
1376
|
+
Authorization: `Bearer ${state.githubToken}`,
|
|
1377
|
+
"content-type": "application/json",
|
|
1378
|
+
accept: "application/json, text/event-stream",
|
|
1379
|
+
"X-MCP-Host": "copilot-cli",
|
|
1380
|
+
"X-MCP-Toolsets": "web_search",
|
|
1381
|
+
"Mcp-Protocol-Version": "2025-06-18",
|
|
1382
|
+
"user-agent": `GitHubCopilotChat/${copilotVersion(state)}`
|
|
1383
|
+
};
|
|
1384
|
+
if (sid) headers["Mcp-Session-Id"] = sid;
|
|
1385
|
+
return headers;
|
|
1216
1386
|
}
|
|
1217
|
-
async function
|
|
1218
|
-
const
|
|
1387
|
+
async function postMcp(body, sid, retry = true) {
|
|
1388
|
+
const url = `${copilotBaseUrl(state)}/mcp`;
|
|
1389
|
+
const res = await fetch(url, {
|
|
1219
1390
|
method: "POST",
|
|
1220
|
-
headers:
|
|
1221
|
-
body: JSON.stringify(
|
|
1222
|
-
content: query,
|
|
1223
|
-
intent: "conversation",
|
|
1224
|
-
skills: ["web-search"],
|
|
1225
|
-
references: []
|
|
1226
|
-
})
|
|
1391
|
+
headers: mcpHeaders(sid),
|
|
1392
|
+
body: JSON.stringify(body)
|
|
1227
1393
|
});
|
|
1228
|
-
if (!
|
|
1229
|
-
|
|
1230
|
-
|
|
1394
|
+
if (!res.ok && retry && res.status >= 500) {
|
|
1395
|
+
await sleep(500);
|
|
1396
|
+
return postMcp(body, sid, false);
|
|
1231
1397
|
}
|
|
1232
|
-
return
|
|
1398
|
+
return res;
|
|
1233
1399
|
}
|
|
1234
1400
|
async function searchWeb(query) {
|
|
1235
|
-
if (!state.copilotToken) throw new Error("Copilot token not found");
|
|
1236
1401
|
await throttleSearch();
|
|
1237
|
-
consola.info(`Web search: "${query.slice(0, 80)}"`);
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1402
|
+
consola.info(`Web search (MCP): "${query.slice(0, 80)}"`);
|
|
1403
|
+
const callId = Math.floor(Math.random() * 1e9);
|
|
1404
|
+
let sid;
|
|
1405
|
+
try {
|
|
1406
|
+
const initRes = await postMcp({
|
|
1407
|
+
jsonrpc: "2.0",
|
|
1408
|
+
id: 1,
|
|
1409
|
+
method: "initialize",
|
|
1410
|
+
params: {
|
|
1411
|
+
protocolVersion: "2024-11-05",
|
|
1412
|
+
capabilities: {},
|
|
1413
|
+
clientInfo: {
|
|
1414
|
+
name: "GitHubCopilotChat",
|
|
1415
|
+
version: copilotVersion(state)
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1244
1418
|
});
|
|
1419
|
+
if (!initRes.ok) {
|
|
1420
|
+
consola.error("MCP initialize failed", initRes.status);
|
|
1421
|
+
throw new HTTPError("MCP initialize failed", initRes);
|
|
1422
|
+
}
|
|
1423
|
+
sid = initRes.headers.get("mcp-session-id") ?? void 0;
|
|
1424
|
+
if (!sid) throw new HTTPError("MCP initialize: missing Mcp-Session-Id header", initRes);
|
|
1425
|
+
const notifRes = await postMcp({
|
|
1426
|
+
jsonrpc: "2.0",
|
|
1427
|
+
method: "notifications/initialized"
|
|
1428
|
+
}, sid);
|
|
1429
|
+
if (!notifRes.ok && notifRes.status !== 202) {
|
|
1430
|
+
consola.error("MCP notifications/initialized failed", notifRes.status);
|
|
1431
|
+
throw new HTTPError("MCP notifications/initialized failed", notifRes);
|
|
1432
|
+
}
|
|
1433
|
+
const callRes = await postMcp({
|
|
1434
|
+
jsonrpc: "2.0",
|
|
1435
|
+
id: callId,
|
|
1436
|
+
method: "tools/call",
|
|
1437
|
+
params: {
|
|
1438
|
+
name: "web_search",
|
|
1439
|
+
arguments: { query }
|
|
1440
|
+
}
|
|
1441
|
+
}, sid);
|
|
1442
|
+
if (!callRes.ok) {
|
|
1443
|
+
consola.error("MCP tools/call failed", callRes.status);
|
|
1444
|
+
throw new HTTPError("MCP tools/call failed", callRes);
|
|
1445
|
+
}
|
|
1446
|
+
let rpc;
|
|
1447
|
+
for await (const ev of events(callRes)) {
|
|
1448
|
+
if (!ev.data) continue;
|
|
1449
|
+
let parsedJson;
|
|
1450
|
+
try {
|
|
1451
|
+
parsedJson = JSON.parse(ev.data);
|
|
1452
|
+
} catch {
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
const parsed = RpcSchema.safeParse(parsedJson);
|
|
1456
|
+
if (parsed.success && parsed.data.id === callId) {
|
|
1457
|
+
rpc = parsed.data;
|
|
1458
|
+
break;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
if (!rpc) throw new HTTPError("MCP tools/call: no matching response id in SSE stream", callRes);
|
|
1462
|
+
if (rpc.error) throw new HTTPError(`MCP error ${rpc.error.code}: ${rpc.error.message}`, callRes);
|
|
1463
|
+
if (rpc.result?.isError) throw new HTTPError("MCP web_search tool error", callRes);
|
|
1464
|
+
const text = rpc.result?.content?.[0]?.text;
|
|
1465
|
+
if (!text) throw new HTTPError("MCP web_search: empty content", callRes);
|
|
1466
|
+
let innerRaw;
|
|
1467
|
+
try {
|
|
1468
|
+
innerRaw = JSON.parse(text);
|
|
1469
|
+
} catch (err) {
|
|
1470
|
+
throw new HTTPError(`MCP web_search: inner content not JSON: ${err instanceof Error ? err.message : String(err)}`, callRes);
|
|
1471
|
+
}
|
|
1472
|
+
const innerParsed = InnerSchema.safeParse(innerRaw);
|
|
1473
|
+
if (!innerParsed.success) throw new HTTPError(`MCP web_search: inner content shape changed (${innerParsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")})`, callRes);
|
|
1474
|
+
const inner = innerParsed.data;
|
|
1475
|
+
const references = [];
|
|
1476
|
+
for (const ann of inner.text.annotations ?? []) {
|
|
1477
|
+
const cite = ann.url_citation;
|
|
1478
|
+
if (cite && !cite.url.toLowerCase().includes("bing.com/search")) references.push({
|
|
1479
|
+
title: cite.title,
|
|
1480
|
+
url: cite.url
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
consola.debug(`Web search returned ${references.length} references`);
|
|
1484
|
+
return {
|
|
1485
|
+
content: inner.text.value,
|
|
1486
|
+
references
|
|
1487
|
+
};
|
|
1488
|
+
} finally {
|
|
1489
|
+
if (sid) try {
|
|
1490
|
+
fetch(`${copilotBaseUrl(state)}/mcp`, {
|
|
1491
|
+
method: "DELETE",
|
|
1492
|
+
headers: mcpHeaders(sid)
|
|
1493
|
+
}).catch(() => {});
|
|
1494
|
+
} catch {}
|
|
1245
1495
|
}
|
|
1246
|
-
consola.debug(`Web search returned ${references.length} references`);
|
|
1247
|
-
return {
|
|
1248
|
-
content: response.message.content,
|
|
1249
|
-
references
|
|
1250
|
-
};
|
|
1251
1496
|
}
|
|
1252
1497
|
|
|
1253
1498
|
//#endregion
|
|
@@ -1414,7 +1659,7 @@ embeddingRoutes.post("/", async (c) => {
|
|
|
1414
1659
|
* (anthropic-beta) so Copilot enables extended features.
|
|
1415
1660
|
*/
|
|
1416
1661
|
function buildHeaders(extraHeaders) {
|
|
1417
|
-
|
|
1662
|
+
return {
|
|
1418
1663
|
...copilotHeaders(state),
|
|
1419
1664
|
accept: "application/json",
|
|
1420
1665
|
"openai-intent": "messages-proxy",
|
|
@@ -1424,8 +1669,6 @@ function buildHeaders(extraHeaders) {
|
|
|
1424
1669
|
"X-Interaction-Id": randomUUID(),
|
|
1425
1670
|
...extraHeaders
|
|
1426
1671
|
};
|
|
1427
|
-
delete headers["copilot-integration-id"];
|
|
1428
|
-
return headers;
|
|
1429
1672
|
}
|
|
1430
1673
|
/**
|
|
1431
1674
|
* Forward an Anthropic Messages API request to Copilot's native /v1/messages endpoint.
|
|
@@ -1702,9 +1945,8 @@ async function handleCompletion(c) {
|
|
|
1702
1945
|
if (debugEnabled) consola.debug("Anthropic request body:", rawBody.slice(0, 2e3));
|
|
1703
1946
|
if (state.manualApprove) await awaitApproval();
|
|
1704
1947
|
const betaHeaders = extractBetaHeaders(c);
|
|
1705
|
-
const { body: resolvedBody, originalModel, resolvedModel } = resolveModelInBody(await processWebSearch(rawBody));
|
|
1948
|
+
const { body: resolvedBody, originalModel, resolvedModel, selectedModel } = resolveModelInBody(await processWebSearch(rawBody));
|
|
1706
1949
|
const modelId = resolvedModel ?? originalModel;
|
|
1707
|
-
const selectedModel = state.models?.data.find((m) => m.id === modelId);
|
|
1708
1950
|
if (modelId) logEndpointMismatch(modelId, "/v1/messages");
|
|
1709
1951
|
const effectiveBetas = applyDefaultBetas(betaHeaders, resolvedModel ?? originalModel);
|
|
1710
1952
|
let response;
|
|
@@ -1770,8 +2012,9 @@ async function handleCompletion(c) {
|
|
|
1770
2012
|
}
|
|
1771
2013
|
/**
|
|
1772
2014
|
* Parse the JSON body, resolve the model name, sanitize cache_control
|
|
1773
|
-
* fields,
|
|
1774
|
-
* and resolved model
|
|
2015
|
+
* fields, translate thinking-mode shape for adaptive-thinking models,
|
|
2016
|
+
* and re-serialize. Returns the body string, original/resolved model
|
|
2017
|
+
* names, and the matching model metadata (if any).
|
|
1775
2018
|
*
|
|
1776
2019
|
* Re-serialization is skipped when no modifications are needed.
|
|
1777
2020
|
*/
|
|
@@ -1791,14 +2034,85 @@ function resolveModelInBody(rawBody) {
|
|
|
1791
2034
|
modified = true;
|
|
1792
2035
|
}
|
|
1793
2036
|
}
|
|
1794
|
-
if (rawBody.includes("\"scope\"") && sanitizeCacheControl(parsed)) modified = true;
|
|
1795
2037
|
const resolvedModel = typeof parsed.model === "string" ? parsed.model : originalModel;
|
|
2038
|
+
const selectedModel = resolvedModel ? state.models?.data.find((m) => m.id === resolvedModel) : void 0;
|
|
2039
|
+
if (translateThinking(parsed, selectedModel)) modified = true;
|
|
2040
|
+
if (rawBody.includes("\"scope\"") && sanitizeCacheControl(parsed)) modified = true;
|
|
1796
2041
|
return {
|
|
1797
2042
|
body: modified ? JSON.stringify(parsed) : rawBody,
|
|
1798
2043
|
originalModel,
|
|
1799
|
-
resolvedModel
|
|
2044
|
+
resolvedModel,
|
|
2045
|
+
selectedModel
|
|
1800
2046
|
};
|
|
1801
2047
|
}
|
|
2048
|
+
const EFFORT_ORDER = [
|
|
2049
|
+
"low",
|
|
2050
|
+
"medium",
|
|
2051
|
+
"high",
|
|
2052
|
+
"xhigh"
|
|
2053
|
+
];
|
|
2054
|
+
/**
|
|
2055
|
+
* Bucket a thinking budget into a Copilot reasoning-effort string.
|
|
2056
|
+
* `<2000`→low, `<8000`→medium, `<24000`→high, else→xhigh.
|
|
2057
|
+
* Defaults missing/non-numeric budgets to 8000 ("high").
|
|
2058
|
+
*/
|
|
2059
|
+
function bucketEffort(budget) {
|
|
2060
|
+
const n = typeof budget === "number" && Number.isFinite(budget) ? budget : 8e3;
|
|
2061
|
+
if (n < 2e3) return "low";
|
|
2062
|
+
if (n < 8e3) return "medium";
|
|
2063
|
+
if (n < 24e3) return "high";
|
|
2064
|
+
return "xhigh";
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Clamp a bucketed effort to the closest value in `supported`. Ties
|
|
2068
|
+
* resolve to the lower-tier option (per EFFORT_ORDER).
|
|
2069
|
+
*
|
|
2070
|
+
* Iterates EFFORT_ORDER (canonical low→xhigh) so the first match on a
|
|
2071
|
+
* given distance is always the lower-tier value, regardless of input
|
|
2072
|
+
* order in `supported`.
|
|
2073
|
+
*/
|
|
2074
|
+
function clampEffort(bucketed, supported) {
|
|
2075
|
+
if (supported.includes(bucketed)) return bucketed;
|
|
2076
|
+
const targetIdx = EFFORT_ORDER.indexOf(bucketed);
|
|
2077
|
+
let best;
|
|
2078
|
+
let bestDist = Infinity;
|
|
2079
|
+
for (let i = 0; i < EFFORT_ORDER.length; i++) {
|
|
2080
|
+
const value = EFFORT_ORDER[i];
|
|
2081
|
+
if (!supported.includes(value)) continue;
|
|
2082
|
+
const dist = Math.abs(i - targetIdx);
|
|
2083
|
+
if (dist < bestDist) {
|
|
2084
|
+
bestDist = dist;
|
|
2085
|
+
best = value;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
return best ?? bucketed;
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Translate Anthropic-shape `thinking:{type:"enabled", budget_tokens}` to
|
|
2092
|
+
* Copilot-shape `thinking:{type:"adaptive"}` + `output_config.effort`
|
|
2093
|
+
* when the resolved model declares `adaptive_thinking: true`.
|
|
2094
|
+
*
|
|
2095
|
+
* Returns true if the body was modified. No-op when the model doesn't
|
|
2096
|
+
* support adaptive thinking, when thinking is missing/disabled/already
|
|
2097
|
+
* adaptive, or when `body` isn't a plain object. Client-supplied
|
|
2098
|
+
* `output_config.effort` always wins over the bucketed value.
|
|
2099
|
+
*/
|
|
2100
|
+
function translateThinking(body, model) {
|
|
2101
|
+
if (!model?.capabilities?.supports?.adaptive_thinking) return false;
|
|
2102
|
+
const thinking = body.thinking;
|
|
2103
|
+
if (!thinking || typeof thinking !== "object") return false;
|
|
2104
|
+
if (thinking.type !== "enabled") return false;
|
|
2105
|
+
const bucketed = bucketEffort(thinking.budget_tokens);
|
|
2106
|
+
const supported = model.capabilities.supports.reasoning_effort;
|
|
2107
|
+
const effort = Array.isArray(supported) && supported.length > 0 ? clampEffort(bucketed, supported) : bucketed;
|
|
2108
|
+
body.thinking = { type: "adaptive" };
|
|
2109
|
+
const existing = body.output_config && typeof body.output_config === "object" ? body.output_config : {};
|
|
2110
|
+
body.output_config = {
|
|
2111
|
+
...existing,
|
|
2112
|
+
effort: existing.effort ?? effort
|
|
2113
|
+
};
|
|
2114
|
+
return true;
|
|
2115
|
+
}
|
|
1802
2116
|
/**
|
|
1803
2117
|
* Strip the `scope` field from all `cache_control` objects in the body.
|
|
1804
2118
|
* Claude CLI 2.1.88+ sends {"type":"ephemeral","scope":"global"} which
|
|
@@ -1864,21 +2178,18 @@ const modelRoutes = new Hono();
|
|
|
1864
2178
|
modelRoutes.get("/", async (c) => {
|
|
1865
2179
|
try {
|
|
1866
2180
|
if (!state.models) await cacheModels();
|
|
1867
|
-
const models = state.models?.data.map((model) =>
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
model_picker_enabled: model.model_picker_enabled,
|
|
1880
|
-
policy: model.policy
|
|
1881
|
-
}));
|
|
2181
|
+
const models = state.models?.data.map((model) => {
|
|
2182
|
+
const { requestHeaders,...rest } = model;
|
|
2183
|
+
return {
|
|
2184
|
+
...rest,
|
|
2185
|
+
object: "model",
|
|
2186
|
+
type: model.capabilities?.type ?? "model",
|
|
2187
|
+
created: 0,
|
|
2188
|
+
created_at: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
2189
|
+
owned_by: model.vendor,
|
|
2190
|
+
display_name: model.name
|
|
2191
|
+
};
|
|
2192
|
+
});
|
|
1882
2193
|
return c.json({
|
|
1883
2194
|
object: "list",
|
|
1884
2195
|
data: models,
|
|
@@ -1900,11 +2211,10 @@ const createResponses = async (payload, modelHeaders) => {
|
|
|
1900
2211
|
...modelHeaders,
|
|
1901
2212
|
"X-Initiator": isAgentCall ? "agent" : "user"
|
|
1902
2213
|
};
|
|
1903
|
-
const filteredPayload = filterUnsupportedTools(payload);
|
|
1904
2214
|
const response = await fetch(`${copilotBaseUrl(state)}/responses`, {
|
|
1905
2215
|
method: "POST",
|
|
1906
2216
|
headers,
|
|
1907
|
-
body: JSON.stringify(
|
|
2217
|
+
body: JSON.stringify(payload)
|
|
1908
2218
|
});
|
|
1909
2219
|
if (!response.ok) {
|
|
1910
2220
|
consola.error("Failed to create responses", response);
|
|
@@ -1930,31 +2240,6 @@ function detectAgentCall(input) {
|
|
|
1930
2240
|
return false;
|
|
1931
2241
|
});
|
|
1932
2242
|
}
|
|
1933
|
-
function filterUnsupportedTools(payload) {
|
|
1934
|
-
if (!payload.tools || !Array.isArray(payload.tools)) return payload;
|
|
1935
|
-
const supported = payload.tools.filter((tool) => {
|
|
1936
|
-
const isSupported = tool.type === "function";
|
|
1937
|
-
if (!isSupported) consola.debug(`Stripping unsupported tool type: ${tool.type}`);
|
|
1938
|
-
return isSupported;
|
|
1939
|
-
});
|
|
1940
|
-
let toolChoice = payload.tool_choice;
|
|
1941
|
-
if (supported.length === 0) toolChoice = void 0;
|
|
1942
|
-
else if (toolChoice && typeof toolChoice === "object") {
|
|
1943
|
-
const supportedNames = new Set(supported.map((tool) => tool.name).filter(Boolean));
|
|
1944
|
-
const toolChoiceName = getToolChoiceName(toolChoice);
|
|
1945
|
-
if (toolChoiceName && !supportedNames.has(toolChoiceName)) toolChoice = void 0;
|
|
1946
|
-
}
|
|
1947
|
-
return {
|
|
1948
|
-
...payload,
|
|
1949
|
-
tools: supported.length > 0 ? supported : void 0,
|
|
1950
|
-
tool_choice: toolChoice
|
|
1951
|
-
};
|
|
1952
|
-
}
|
|
1953
|
-
function getToolChoiceName(toolChoice) {
|
|
1954
|
-
if (typeof toolChoice !== "object") return void 0;
|
|
1955
|
-
if ("function" in toolChoice && toolChoice.function && typeof toolChoice.function === "object") return toolChoice.function.name;
|
|
1956
|
-
if ("name" in toolChoice) return toolChoice.name;
|
|
1957
|
-
}
|
|
1958
2243
|
|
|
1959
2244
|
//#endregion
|
|
1960
2245
|
//#region src/routes/responses/handler.ts
|
|
@@ -2018,8 +2303,7 @@ async function injectWebSearchIfNeeded(payload) {
|
|
|
2018
2303
|
if (payload.input.some((item) => item.type === "function_call_output")) return;
|
|
2019
2304
|
}
|
|
2020
2305
|
const query = extractUserQuery(payload.input);
|
|
2021
|
-
if (
|
|
2022
|
-
try {
|
|
2306
|
+
if (query) try {
|
|
2023
2307
|
const results = await searchWeb(query);
|
|
2024
2308
|
const searchContext = [
|
|
2025
2309
|
"[Web Search Results]",
|
|
@@ -2032,6 +2316,13 @@ async function injectWebSearchIfNeeded(payload) {
|
|
|
2032
2316
|
} catch (error) {
|
|
2033
2317
|
consola.warn("Web search failed, continuing without results:", error);
|
|
2034
2318
|
}
|
|
2319
|
+
payload.tools = payload.tools?.filter((t) => t.type !== "web_search");
|
|
2320
|
+
if (payload.tools && payload.tools.length === 0) payload.tools = void 0;
|
|
2321
|
+
if (!payload.tools) payload.tool_choice = void 0;
|
|
2322
|
+
else if (payload.tool_choice && typeof payload.tool_choice === "object") {
|
|
2323
|
+
const choice = payload.tool_choice;
|
|
2324
|
+
if ((choice.function?.name ?? choice.name) === "web_search") payload.tool_choice = void 0;
|
|
2325
|
+
}
|
|
2035
2326
|
}
|
|
2036
2327
|
function extractUserQuery(input) {
|
|
2037
2328
|
if (typeof input === "string") return input;
|
|
@@ -2217,6 +2508,7 @@ server.route("/v1/models", modelRoutes);
|
|
|
2217
2508
|
server.route("/v1/embeddings", embeddingRoutes);
|
|
2218
2509
|
server.route("/v1/search", searchRoutes);
|
|
2219
2510
|
server.route("/v1/messages", messageRoutes);
|
|
2511
|
+
server.post("/api/event_logging/batch", (c) => c.body(null, 200));
|
|
2220
2512
|
server.notFound((c) => c.json({
|
|
2221
2513
|
type: "error",
|
|
2222
2514
|
error: {
|
|
@@ -2382,22 +2674,72 @@ function parseSharedArgs(args) {
|
|
|
2382
2674
|
extendedBetas: args["extended-betas"]
|
|
2383
2675
|
};
|
|
2384
2676
|
}
|
|
2385
|
-
/**
|
|
2677
|
+
/**
|
|
2678
|
+
* Build environment variables for Claude Code.
|
|
2679
|
+
*
|
|
2680
|
+
* The parent env is sanitized of every key in `STRIPPED_PARENT_ENV_KEYS`
|
|
2681
|
+
* (see `src/lib/launch.ts`) BEFORE these overrides are merged in, so we
|
|
2682
|
+
* only need to provide the positive values.
|
|
2683
|
+
*
|
|
2684
|
+
* Auth precedence in Claude Code (https://code.claude.com/docs/en/iam):
|
|
2685
|
+
* 1. Cloud provider (CLAUDE_CODE_USE_BEDROCK / VERTEX / FOUNDRY) — stripped at parent.
|
|
2686
|
+
* 2. ANTHROPIC_AUTH_TOKEN — set here to "dummy"; wins over #4–#6.
|
|
2687
|
+
* 3. ANTHROPIC_API_KEY — stripped at parent, intentionally NOT re-set
|
|
2688
|
+
* (Claude Code emits an Auth conflict warning when both AUTH_TOKEN
|
|
2689
|
+
* and API_KEY are present, even with dummy values).
|
|
2690
|
+
* 4. apiKeyHelper in settings.json — beaten by #2.
|
|
2691
|
+
* 5. CLAUDE_CODE_OAUTH_TOKEN — stripped at parent.
|
|
2692
|
+
* 6. Subscription OAuth (Keychain / ~/.claude/.credentials.json) —
|
|
2693
|
+
* INVISIBLE to the spawned child via the CLAUDE_CONFIG_DIR trick
|
|
2694
|
+
* below. The credential file is left in place so `claude /logout`
|
|
2695
|
+
* still works outside the proxy.
|
|
2696
|
+
*
|
|
2697
|
+
* `CLAUDE_CONFIG_DIR` activates Claude Code's per-config-dir keychain
|
|
2698
|
+
* isolation. Per binary-grep of Claude Code 2.1.126's `iN()` function:
|
|
2699
|
+
*
|
|
2700
|
+
* function iN(H = "") {
|
|
2701
|
+
* let _ = B6(), // resolved config-dir path
|
|
2702
|
+
* K = !process.env.CLAUDE_CONFIG_DIR ? "" : `-${sha256(_).slice(0, 8)}`;
|
|
2703
|
+
* return `Claude Code${OAUTH_FILE_SUFFIX}${H}${K}`
|
|
2704
|
+
* }
|
|
2705
|
+
*
|
|
2706
|
+
* The conditional is on PRESENCE, not value. When CLAUDE_CONFIG_DIR is
|
|
2707
|
+
* unset (the user's normal `claude` usage), the keychain service name is
|
|
2708
|
+
* "Claude Code" and their `/login` credential is found there. When set
|
|
2709
|
+
* (the proxy session), the service name becomes "Claude Code-<hash>" —
|
|
2710
|
+
* the user's credential is invisible, `iCH()` returns null, and all
|
|
2711
|
+
* three auth-conflict warnings fire `false`. The path resolves to the
|
|
2712
|
+
* default config-dir, so settings.json/skills/MCP/plugins/hooks/CLAUDE.md
|
|
2713
|
+
* still load from `~/.claude` as normal.
|
|
2714
|
+
*/
|
|
2386
2715
|
function getClaudeCodeEnvVars(serverUrl, model) {
|
|
2387
2716
|
const vars = {
|
|
2388
2717
|
ANTHROPIC_BASE_URL: serverUrl,
|
|
2389
2718
|
ANTHROPIC_AUTH_TOKEN: "dummy",
|
|
2719
|
+
CLAUDE_CONFIG_DIR: path.join(os.homedir(), ".claude"),
|
|
2390
2720
|
DISABLE_NON_ESSENTIAL_MODEL_CALLS: "1",
|
|
2391
2721
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
|
|
2392
2722
|
};
|
|
2393
2723
|
if (model) vars.ANTHROPIC_MODEL = model;
|
|
2394
2724
|
return vars;
|
|
2395
2725
|
}
|
|
2396
|
-
/**
|
|
2726
|
+
/**
|
|
2727
|
+
* Build environment variables for Codex CLI.
|
|
2728
|
+
*
|
|
2729
|
+
* Like `getClaudeCodeEnvVars`, the parent env is sanitized of
|
|
2730
|
+
* `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `CODEX_HOME` (see
|
|
2731
|
+
* `STRIPPED_PARENT_ENV_KEYS` in `src/lib/launch.ts`) before these
|
|
2732
|
+
* overrides are merged, so a stale shell `OPENAI_API_KEY` can't leak
|
|
2733
|
+
* through. Codex caches a ChatGPT subscription login under
|
|
2734
|
+
* `$CODEX_HOME/auth.json` which can override `OPENAI_API_KEY` per
|
|
2735
|
+
* openai/codex#2733; pointing `CODEX_HOME` at an isolated directory
|
|
2736
|
+
* masks any cached login.
|
|
2737
|
+
*/
|
|
2397
2738
|
function getCodexEnvVars(serverUrl) {
|
|
2398
2739
|
return {
|
|
2399
2740
|
OPENAI_BASE_URL: `${serverUrl}/v1`,
|
|
2400
|
-
OPENAI_API_KEY: "dummy"
|
|
2741
|
+
OPENAI_API_KEY: "dummy",
|
|
2742
|
+
CODEX_HOME: PATHS.CODEX_HOME
|
|
2401
2743
|
};
|
|
2402
2744
|
}
|
|
2403
2745
|
|
|
@@ -2437,21 +2779,32 @@ const claude = defineCommand({
|
|
|
2437
2779
|
process$1.exit(1);
|
|
2438
2780
|
}
|
|
2439
2781
|
enableFileLogging();
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2782
|
+
const usingDefault = !args.model;
|
|
2783
|
+
let chosenSlug = args.model ?? DEFAULT_CLAUDE_MODEL;
|
|
2784
|
+
let resolvedSlug = resolveModel(chosenSlug);
|
|
2785
|
+
if (usingDefault && state.models) {
|
|
2786
|
+
const inCache = (slug) => state.models?.data.some((m) => m.id === resolveModel(slug)) ?? false;
|
|
2787
|
+
if (!inCache(chosenSlug)) {
|
|
2788
|
+
for (const fallback of DEFAULT_CLAUDE_MODEL_FALLBACKS) if (inCache(fallback)) {
|
|
2789
|
+
consola.info(`Default model "${chosenSlug}" not in your Copilot model list; falling back to "${fallback}".`);
|
|
2790
|
+
chosenSlug = fallback;
|
|
2791
|
+
resolvedSlug = resolveModel(fallback);
|
|
2792
|
+
break;
|
|
2793
|
+
}
|
|
2447
2794
|
}
|
|
2448
2795
|
}
|
|
2449
|
-
|
|
2796
|
+
if (resolvedSlug !== chosenSlug) consola.info(`Model "${chosenSlug}" resolved to "${resolvedSlug}"`);
|
|
2797
|
+
if (!state.models?.data.find((m) => m.id === resolvedSlug)) {
|
|
2798
|
+
const available = listModelsForEndpoint("/v1/messages");
|
|
2799
|
+
consola.warn(`Model "${resolvedSlug}" not found. Available claude models: ${available.join(", ")}`);
|
|
2800
|
+
}
|
|
2801
|
+
const banner = chosenSlug === resolvedSlug ? chosenSlug : `${chosenSlug} → ${resolvedSlug}`;
|
|
2802
|
+
process$1.stderr.write(`Server ready on ${serverUrl}, launching Claude Code (${banner})...\n`);
|
|
2450
2803
|
launchChild({
|
|
2451
2804
|
kind: "claude-code",
|
|
2452
|
-
envVars: getClaudeCodeEnvVars(serverUrl,
|
|
2805
|
+
envVars: getClaudeCodeEnvVars(serverUrl, chosenSlug),
|
|
2453
2806
|
extraArgs: args._ ?? [],
|
|
2454
|
-
model:
|
|
2807
|
+
model: chosenSlug
|
|
2455
2808
|
}, server$1);
|
|
2456
2809
|
}
|
|
2457
2810
|
});
|
|
@@ -2491,10 +2844,22 @@ const codex = defineCommand({
|
|
|
2491
2844
|
consola.error("Failed to start server:", error instanceof Error ? error.message : error);
|
|
2492
2845
|
process$1.exit(1);
|
|
2493
2846
|
}
|
|
2847
|
+
const usingDefault = !args.model;
|
|
2494
2848
|
const requestedModel = args.model ?? DEFAULT_CODEX_MODEL;
|
|
2495
2849
|
enableFileLogging();
|
|
2496
|
-
|
|
2850
|
+
let codexModel = resolveCodexModel(requestedModel);
|
|
2497
2851
|
if (codexModel !== requestedModel) consola.info(`Model "${requestedModel}" resolved to "${codexModel}"`);
|
|
2852
|
+
if (usingDefault && state.models) {
|
|
2853
|
+
const inCache = (id) => state.models?.data.some((m) => m.id === id) ?? false;
|
|
2854
|
+
if (!inCache(codexModel)) for (const fallback of DEFAULT_CODEX_MODEL_FALLBACKS) {
|
|
2855
|
+
const resolved = resolveCodexModel(fallback);
|
|
2856
|
+
if (inCache(resolved)) {
|
|
2857
|
+
consola.info(`Default model "${codexModel}" not in your Copilot model list; falling back to "${resolved}".`);
|
|
2858
|
+
codexModel = resolved;
|
|
2859
|
+
break;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2498
2863
|
const modelEntry = state.models?.data.find((m) => m.id === codexModel);
|
|
2499
2864
|
if (!modelEntry) {
|
|
2500
2865
|
const available = listModelsForEndpoint("/responses");
|