github-router 0.3.16 → 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/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 = "2025-10-01";
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: output-128k- (Copilot returns 400).
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 oneM = models.find((m) => m.id.includes("opus") && m.id.endsWith("-1m"));
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 codexModels = models.filter((m) => {
449
+ const candidates = models.filter((m) => {
384
450
  const endpoints = m.supported_endpoints ?? [];
385
- return m.id.includes("codex") && !m.id.includes("mini") && (endpoints.length === 0 || endpoints.includes("/responses"));
451
+ if (m.id.includes("mini") || m.id.includes("nano")) return false;
452
+ return endpoints.length === 0 || endpoints.includes("/responses");
386
453
  });
387
- if (codexModels.length > 0) {
388
- codexModels.sort((a, b) => b.id.localeCompare(a.id));
389
- const best = codexModels[0].id;
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
- const DEFAULT_CODEX_MODEL = "gpt-5.3-codex";
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 threadsHeaders() {
1203
- return copilotHeaders(state, false, "copilot-chat");
1204
- }
1205
- async function createThread() {
1206
- const response = await fetch(`${copilotBaseUrl(state)}/github/chat/threads`, {
1207
- method: "POST",
1208
- headers: threadsHeaders(),
1209
- body: JSON.stringify({})
1210
- });
1211
- if (!response.ok) {
1212
- consola.error("Failed to create chat thread", response.status);
1213
- throw new Error(`Failed to create chat thread: ${response.status}`);
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 sendThreadMessage(threadId, query) {
1218
- const response = await fetch(`${copilotBaseUrl(state)}/github/chat/threads/${threadId}/messages`, {
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: threadsHeaders(),
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 (!response.ok) {
1229
- consola.error("Failed to send thread message", response.status);
1230
- throw new Error(`Failed to send thread message: ${response.status}`);
1394
+ if (!res.ok && retry && res.status >= 500) {
1395
+ await sleep(500);
1396
+ return postMcp(body, sid, false);
1231
1397
  }
1232
- return await response.json();
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 response = await sendThreadMessage(await createThread(), query);
1239
- const references = [];
1240
- for (const ref of response.message.references ?? []) if (ref.results) {
1241
- for (const result of ref.results) if (result.url && result.reference_type !== "bing_search") references.push({
1242
- title: result.title,
1243
- url: result.url
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
- const headers = {
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, and re-serialize. Returns the body string plus the original
1774
- * and resolved model names.
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
- id: model.id,
1869
- object: "model",
1870
- type: model.capabilities?.type ?? "model",
1871
- created: 0,
1872
- created_at: (/* @__PURE__ */ new Date(0)).toISOString(),
1873
- owned_by: model.vendor,
1874
- display_name: model.name,
1875
- capabilities: model.capabilities,
1876
- supported_endpoints: model.supported_endpoints,
1877
- preview: model.preview,
1878
- version: model.version,
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(filteredPayload)
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 (!query) return;
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
- /** Build environment variables for Claude Code. */
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
- /** Build environment variables for Codex CLI. */
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
- let resolvedModel;
2441
- if (args.model) {
2442
- resolvedModel = resolveModel(args.model);
2443
- if (resolvedModel !== args.model) consola.info(`Model "${args.model}" resolved to "${resolvedModel}"`);
2444
- if (!state.models?.data.find((m) => m.id === resolvedModel)) {
2445
- const available = listModelsForEndpoint("/v1/messages");
2446
- consola.warn(`Model "${resolvedModel}" not found. Available claude models: ${available.join(", ")}`);
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
- process$1.stderr.write(`Server ready on ${serverUrl}, launching Claude Code...\n`);
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, resolvedModel ?? args.model),
2805
+ envVars: getClaudeCodeEnvVars(serverUrl, chosenSlug),
2453
2806
  extraArgs: args._ ?? [],
2454
- model: resolvedModel ?? args.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
- const codexModel = resolveCodexModel(requestedModel);
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");