github-router 0.3.10 → 0.3.12

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
@@ -6,6 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { randomBytes, randomUUID } from "node:crypto";
8
8
  import process$1 from "node:process";
9
+ import { execFileSync, spawn } from "node:child_process";
9
10
  import { serve } from "srvx";
10
11
  import { getProxyForUrl } from "proxy-from-env";
11
12
  import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
@@ -76,7 +77,7 @@ const copilotHeaders = (state$1, vision = false, integrationId = "vscode-chat")
76
77
  if (vision) headers["copilot-vision-request"] = "true";
77
78
  return headers;
78
79
  };
79
- const GITHUB_API_BASE_URL = "https://api.github.com";
80
+ const GITHUB_API_BASE_URL = process.env.GITHUB_API_URL ?? "https://api.github.com";
80
81
  const githubHeaders = (state$1) => ({
81
82
  ...standardHeaders(),
82
83
  authorization: `token ${state$1.githubToken}`,
@@ -222,19 +223,68 @@ function filterBetaHeader(value) {
222
223
  return value.split(",").map((v) => v.trim()).filter((v) => v && ALLOWED_BETA_PREFIXES.some((prefix) => v.startsWith(prefix))).join(",") || void 0;
223
224
  }
224
225
  /**
226
+ * Normalize a model ID for fuzzy comparison: lowercase, replace dots with
227
+ * dashes, insert dash at letter→digit boundaries, and collapse repeated
228
+ * dashes. E.g. "gpt5.3-codex" → "gpt-5-3-codex", "GPT-5.3-Codex" → "gpt-5-3-codex".
229
+ */
230
+ function normalizeModelId(id) {
231
+ return id.toLowerCase().replace(/\./g, "-").replace(/([a-z])(\d)/g, "$1-$2").replace(/-{2,}/g, "-");
232
+ }
233
+ /**
225
234
  * Resolve a model name to the best available variant in the Copilot model list.
226
- * Prefers the 1M context variant for opus models.
235
+ *
236
+ * Resolution cascade:
237
+ * 1. Exact match
238
+ * 2. Case-insensitive match
239
+ * 3. Family preference (opus→1m, codex→highest version)
240
+ * 4. Normalized match (dots→dashes, letter-digit boundaries)
241
+ * 5. Return as-is with a warning
227
242
  */
228
243
  function resolveModel(modelId) {
229
244
  const models = state.models?.data;
230
245
  if (!models) return modelId;
231
246
  if (models.some((m) => m.id === modelId)) return modelId;
232
- if (modelId.toLowerCase().includes("opus")) {
247
+ const lower = modelId.toLowerCase();
248
+ const ciMatch = models.find((m) => m.id.toLowerCase() === lower);
249
+ if (ciMatch) return ciMatch.id;
250
+ if (lower.includes("opus")) {
233
251
  const oneM = models.find((m) => m.id.includes("opus") && m.id.endsWith("-1m"));
234
252
  if (oneM) return oneM.id;
235
253
  }
254
+ if (lower.includes("codex")) {
255
+ const codexModels = models.filter((m) => m.id.includes("codex") && !m.id.includes("mini"));
256
+ if (codexModels.length > 0) {
257
+ codexModels.sort((a, b) => b.id.localeCompare(a.id));
258
+ return codexModels[0].id;
259
+ }
260
+ }
261
+ const normalized = normalizeModelId(modelId);
262
+ const normMatch = models.find((m) => normalizeModelId(m.id) === normalized);
263
+ if (normMatch) return normMatch.id;
264
+ consola.warn(`Model "${modelId}" not found in Copilot model list. Available: ${models.map((m) => m.id).join(", ")}`);
236
265
  return modelId;
237
266
  }
267
+ /**
268
+ * Resolve a codex model ID, falling back to the best available codex model.
269
+ * Used by the codex subcommand for model selection.
270
+ */
271
+ function resolveCodexModel(modelId) {
272
+ const resolved = resolveModel(modelId);
273
+ const models = state.models?.data;
274
+ if (!models) return resolved;
275
+ if (models.some((m) => m.id === resolved)) return resolved;
276
+ const codexModels = models.filter((m) => {
277
+ const endpoints = m.supported_endpoints ?? [];
278
+ return m.id.includes("codex") && !m.id.includes("mini") && (endpoints.length === 0 || endpoints.includes("/responses"));
279
+ });
280
+ if (codexModels.length > 0) {
281
+ codexModels.sort((a, b) => b.id.localeCompare(a.id));
282
+ const best = codexModels[0].id;
283
+ consola.warn(`Model "${modelId}" not available, using "${best}" instead`);
284
+ return best;
285
+ }
286
+ return resolved;
287
+ }
238
288
  async function cacheModels() {
239
289
  state.models = await getModels();
240
290
  }
@@ -415,7 +465,7 @@ const checkUsage = defineCommand({
415
465
  //#endregion
416
466
  //#region src/lib/port.ts
417
467
  const DEFAULT_PORT = 8787;
418
- const DEFAULT_CODEX_MODEL = "gpt5.3-codex";
468
+ const DEFAULT_CODEX_MODEL = "gpt-5.3-codex";
419
469
  const PORT_RANGE_MIN = 11e3;
420
470
  const PORT_RANGE_MAX = 65535;
421
471
  /** Generate a random port number in the range [11000, 65535]. */
@@ -425,6 +475,14 @@ function generateRandomPort() {
425
475
 
426
476
  //#endregion
427
477
  //#region src/lib/launch.ts
478
+ function commandExists(name) {
479
+ try {
480
+ execFileSync(process$1.platform === "win32" ? "where.exe" : "which", [name], { stdio: "ignore" });
481
+ return true;
482
+ } catch {
483
+ return false;
484
+ }
485
+ }
428
486
  function buildLaunchCommand(target) {
429
487
  return {
430
488
  cmd: target.kind === "claude-code" ? [
@@ -433,6 +491,7 @@ function buildLaunchCommand(target) {
433
491
  ...target.extraArgs
434
492
  ] : [
435
493
  "codex",
494
+ "--full-auto",
436
495
  "-m",
437
496
  target.model ?? DEFAULT_CODEX_MODEL,
438
497
  ...target.extraArgs
@@ -446,18 +505,20 @@ function buildLaunchCommand(target) {
446
505
  function launchChild(target, server$1) {
447
506
  const { cmd, env } = buildLaunchCommand(target);
448
507
  const executable = cmd[0];
449
- if (!Bun.which(executable)) {
508
+ if (!commandExists(executable)) {
450
509
  consola.error(`"${executable}" not found on PATH. Install it first, then try again.`);
451
510
  process$1.exit(1);
452
511
  }
453
512
  let child;
454
513
  try {
455
- child = Bun.spawn({
456
- cmd,
514
+ if (process$1.platform === "win32") child = spawn(cmd.map((a) => a.includes(" ") ? `"${a}"` : a).join(" "), [], {
515
+ env,
516
+ stdio: "inherit",
517
+ shell: true
518
+ });
519
+ else child = spawn(cmd[0], cmd.slice(1), {
457
520
  env,
458
- stdin: "inherit",
459
- stdout: "inherit",
460
- stderr: "inherit"
521
+ stdio: "inherit"
461
522
  });
462
523
  } catch (error) {
463
524
  consola.error(`Failed to launch ${executable}:`, error instanceof Error ? error.message : String(error));
@@ -488,10 +549,58 @@ function launchChild(target, server$1) {
488
549
  };
489
550
  process$1.on("SIGINT", onSignal);
490
551
  process$1.on("SIGTERM", onSignal);
491
- child.exited.then(async (exitCode) => {
492
- await cleanup();
493
- exit(exitCode ?? 0);
494
- }).catch(() => exit(1));
552
+ child.on("exit", (exitCode) => {
553
+ cleanup().then(() => exit(exitCode ?? 0)).catch(() => exit(1));
554
+ });
555
+ child.on("error", () => exit(1));
556
+ }
557
+
558
+ //#endregion
559
+ //#region src/lib/model-validation.ts
560
+ const ENDPOINT_ALIASES = {
561
+ "/chat/completions": "/chat/completions",
562
+ "/v1/chat/completions": "/chat/completions",
563
+ "/responses": "/responses",
564
+ "/v1/responses": "/responses",
565
+ "/v1/messages": "/v1/messages"
566
+ };
567
+ /**
568
+ * Check whether a model supports the given endpoint, based on cached
569
+ * `supported_endpoints` metadata from the Copilot `/models` response.
570
+ *
571
+ * Returns `true` (allow) when:
572
+ * - the model is not found in the cache (don't block unknown models)
573
+ * - the model has no `supported_endpoints` field (backward-compat)
574
+ * - the endpoint is listed in `supported_endpoints`
575
+ */
576
+ function modelSupportsEndpoint(modelId, path$1) {
577
+ const endpoint = ENDPOINT_ALIASES[path$1] ?? path$1;
578
+ const model = state.models?.data.find((m) => m.id === modelId);
579
+ if (!model) return true;
580
+ const supported = model.supported_endpoints;
581
+ if (!supported || supported.length === 0) return true;
582
+ return supported.includes(endpoint);
583
+ }
584
+ /**
585
+ * Log an error when a model is used on an endpoint it doesn't support.
586
+ * Returns `true` if a mismatch was detected (for testing).
587
+ */
588
+ function logEndpointMismatch(modelId, path$1) {
589
+ if (modelSupportsEndpoint(modelId, path$1)) return false;
590
+ const supported = (state.models?.data.find((m) => m.id === modelId))?.supported_endpoints ?? [];
591
+ consola.error(`Model "${modelId}" does not support ${path$1}. Supported endpoints: ${supported.join(", ")}`);
592
+ return true;
593
+ }
594
+ /**
595
+ * Return model IDs that support the given endpoint.
596
+ */
597
+ function listModelsForEndpoint(path$1) {
598
+ const endpoint = ENDPOINT_ALIASES[path$1] ?? path$1;
599
+ return (state.models?.data ?? []).filter((m) => {
600
+ const supported = m.supported_endpoints;
601
+ if (!supported || supported.length === 0) return true;
602
+ return supported.includes(endpoint);
603
+ }).map((m) => m.id);
495
604
  }
496
605
 
497
606
  //#endregion
@@ -588,7 +697,7 @@ function formatTokens(n) {
588
697
  function formatTokenInfo(inputTokens, outputTokens, model) {
589
698
  if (inputTokens === void 0) return void 0;
590
699
  const parts = [];
591
- const maxPrompt = model?.capabilities.limits.max_prompt_tokens;
700
+ const maxPrompt = model?.capabilities?.limits?.max_prompt_tokens;
592
701
  if (maxPrompt) {
593
702
  const pct = (inputTokens / maxPrompt * 100).toFixed(1);
594
703
  parts.push(`in:${formatTokens(inputTokens)}/${formatTokens(maxPrompt)} (${pct}%)`);
@@ -708,7 +817,7 @@ const getEncodeChatFunction = async (encoding) => {
708
817
  * Get tokenizer type from model information
709
818
  */
710
819
  const getTokenizerFromModel = (model) => {
711
- return model.capabilities.tokenizer || "o200k_base";
820
+ return model.capabilities?.tokenizer || "o200k_base";
712
821
  };
713
822
  /**
714
823
  * Get model-specific constants for token calculation
@@ -942,6 +1051,7 @@ async function handleCompletion$1(c) {
942
1051
  const resolvedModel = resolveModel(payload.model);
943
1052
  if (resolvedModel !== payload.model) payload.model = resolvedModel;
944
1053
  const selectedModel = state.models?.data.find((model) => model.id === payload.model);
1054
+ logEndpointMismatch(payload.model, "/chat/completions");
945
1055
  let inputTokens;
946
1056
  try {
947
1057
  if (selectedModel) inputTokens = (await getTokenCount(payload, selectedModel)).input;
@@ -949,7 +1059,7 @@ async function handleCompletion$1(c) {
949
1059
  if (isNullish(payload.max_tokens)) {
950
1060
  payload = {
951
1061
  ...payload,
952
- max_tokens: selectedModel?.capabilities.limits.max_output_tokens
1062
+ max_tokens: selectedModel?.capabilities?.limits?.max_output_tokens
953
1063
  };
954
1064
  if (debugEnabled) consola.debug("Set max_tokens to:", JSON.stringify(payload.max_tokens));
955
1065
  }
@@ -1360,7 +1470,9 @@ async function handleCompletion(c) {
1360
1470
  if (state.manualApprove) await awaitApproval();
1361
1471
  const betaHeaders = extractBetaHeaders(c);
1362
1472
  const { body: resolvedBody, originalModel, resolvedModel } = resolveModelInBody(await processWebSearch(rawBody));
1363
- const selectedModel = state.models?.data.find((m) => m.id === (resolvedModel ?? originalModel));
1473
+ const modelId = resolvedModel ?? originalModel;
1474
+ const selectedModel = state.models?.data.find((m) => m.id === modelId);
1475
+ if (modelId) logEndpointMismatch(modelId, "/v1/messages");
1364
1476
  const effectiveBetas = applyDefaultBetas(betaHeaders, resolvedModel ?? originalModel);
1365
1477
  let response;
1366
1478
  try {
@@ -1484,11 +1596,17 @@ modelRoutes.get("/", async (c) => {
1484
1596
  const models = state.models?.data.map((model) => ({
1485
1597
  id: model.id,
1486
1598
  object: "model",
1487
- type: "model",
1599
+ type: model.capabilities?.type ?? "model",
1488
1600
  created: 0,
1489
1601
  created_at: (/* @__PURE__ */ new Date(0)).toISOString(),
1490
1602
  owned_by: model.vendor,
1491
- display_name: model.name
1603
+ display_name: model.name,
1604
+ capabilities: model.capabilities,
1605
+ supported_endpoints: model.supported_endpoints,
1606
+ preview: model.preview,
1607
+ version: model.version,
1608
+ model_picker_enabled: model.model_picker_enabled,
1609
+ policy: model.policy
1492
1610
  }));
1493
1611
  return c.json({
1494
1612
  object: "list",
@@ -1579,12 +1697,9 @@ async function handleResponses(c) {
1579
1697
  const resolvedModel = resolveModel(payload.model);
1580
1698
  if (resolvedModel !== payload.model) payload.model = resolvedModel;
1581
1699
  const selectedModel = state.models?.data.find((model) => model.id === payload.model);
1700
+ logEndpointMismatch(payload.model, "/responses");
1582
1701
  if (state.manualApprove) await awaitApproval();
1583
1702
  await injectWebSearchIfNeeded(payload);
1584
- if (isNullish(payload.max_output_tokens)) {
1585
- payload.max_output_tokens = selectedModel?.capabilities.limits.max_output_tokens;
1586
- if (debugEnabled) consola.debug("Set max_output_tokens to:", JSON.stringify(payload.max_output_tokens));
1587
- }
1588
1703
  const response = await createResponses(payload, selectedModel?.requestHeaders).catch(async (error) => {
1589
1704
  if (error instanceof HTTPError) {
1590
1705
  const errorBody = await error.response.clone().text().catch(() => "");
@@ -1661,6 +1776,32 @@ function extractUserQuery(input) {
1661
1776
  }
1662
1777
  }
1663
1778
  }
1779
+ async function handleResponsesCompact(c) {
1780
+ const startTime = Date.now();
1781
+ await checkRateLimit(state);
1782
+ if (!state.copilotToken) throw new Error("Copilot token not found");
1783
+ if (state.manualApprove) await awaitApproval();
1784
+ const body = await c.req.text();
1785
+ const response = await fetch(`${copilotBaseUrl(state)}/responses/compact`, {
1786
+ method: "POST",
1787
+ headers: copilotHeaders(state),
1788
+ body
1789
+ });
1790
+ if (!response.ok) {
1791
+ logRequest({
1792
+ method: "POST",
1793
+ path: c.req.path,
1794
+ status: response.status
1795
+ }, void 0, startTime);
1796
+ throw new HTTPError("Copilot responses/compact request failed", response);
1797
+ }
1798
+ logRequest({
1799
+ method: "POST",
1800
+ path: c.req.path,
1801
+ status: 200
1802
+ }, void 0, startTime);
1803
+ return c.json(await response.json());
1804
+ }
1664
1805
 
1665
1806
  //#endregion
1666
1807
  //#region src/routes/responses/route.ts
@@ -1672,6 +1813,13 @@ responsesRoutes.post("/", async (c) => {
1672
1813
  return await forwardError(c, error);
1673
1814
  }
1674
1815
  });
1816
+ responsesRoutes.post("/compact", async (c) => {
1817
+ try {
1818
+ return await handleResponsesCompact(c);
1819
+ } catch (error) {
1820
+ return await forwardError(c, error);
1821
+ }
1822
+ });
1675
1823
 
1676
1824
  //#endregion
1677
1825
  //#region src/routes/search/route.ts
@@ -1745,6 +1893,7 @@ async function setupAndServe(options) {
1745
1893
  state.rateLimitSeconds = options.rateLimit;
1746
1894
  state.rateLimitWait = options.rateLimitWait;
1747
1895
  state.showToken = options.showToken;
1896
+ if (process.env.COPILOT_API_URL) state.copilotApiUrl = process.env.COPILOT_API_URL;
1748
1897
  await ensurePaths();
1749
1898
  await cacheVSCodeVersion();
1750
1899
  if (options.githubToken) {
@@ -1753,7 +1902,7 @@ async function setupAndServe(options) {
1753
1902
  } else await setupGitHubToken();
1754
1903
  await setupCopilotToken();
1755
1904
  await cacheModels();
1756
- consola.info(`Available models: \n${state.models?.data.map((model) => `- ${model.id}`).join("\n")}`);
1905
+ consola.debug(`Available models: \n${state.models?.data.map((model) => `- ${model.id}`).join("\n")}`);
1757
1906
  const serveOptions = {
1758
1907
  fetch: server.fetch,
1759
1908
  hostname: "127.0.0.1",
@@ -1932,13 +2081,22 @@ const claude = defineCommand({
1932
2081
  consola.error("Failed to start server:", error instanceof Error ? error.message : error);
1933
2082
  process$1.exit(1);
1934
2083
  }
2084
+ let resolvedModel;
2085
+ if (args.model) {
2086
+ resolvedModel = resolveModel(args.model);
2087
+ if (resolvedModel !== args.model) consola.info(`Model "${args.model}" resolved to "${resolvedModel}"`);
2088
+ if (!state.models?.data.find((m) => m.id === resolvedModel)) {
2089
+ const available = listModelsForEndpoint("/v1/messages");
2090
+ consola.warn(`Model "${resolvedModel}" not found. Available claude models: ${available.join(", ")}`);
2091
+ }
2092
+ }
1935
2093
  consola.success(`Server ready on ${serverUrl}, launching Claude Code...`);
1936
2094
  consola.level = 1;
1937
2095
  launchChild({
1938
2096
  kind: "claude-code",
1939
- envVars: getClaudeCodeEnvVars(serverUrl, args.model),
2097
+ envVars: getClaudeCodeEnvVars(serverUrl, resolvedModel ?? args.model),
1940
2098
  extraArgs: args._ ?? [],
1941
- model: args.model
2099
+ model: resolvedModel ?? args.model
1942
2100
  }, server$1);
1943
2101
  }
1944
2102
  });
@@ -1978,14 +2136,24 @@ const codex = defineCommand({
1978
2136
  consola.error("Failed to start server:", error instanceof Error ? error.message : error);
1979
2137
  process$1.exit(1);
1980
2138
  }
1981
- const codexModel = args.model ?? DEFAULT_CODEX_MODEL;
2139
+ const requestedModel = args.model ?? DEFAULT_CODEX_MODEL;
2140
+ const codexModel = resolveCodexModel(requestedModel);
2141
+ if (codexModel !== requestedModel) consola.info(`Model "${requestedModel}" resolved to "${codexModel}"`);
2142
+ const modelEntry = state.models?.data.find((m) => m.id === codexModel);
2143
+ if (!modelEntry) {
2144
+ const available = listModelsForEndpoint("/responses");
2145
+ consola.warn(`Model "${codexModel}" not found. Available codex models: ${available.join(", ")}`);
2146
+ } else {
2147
+ const ctx = modelEntry.capabilities?.limits?.max_context_window_tokens;
2148
+ if (ctx) consola.info(`Model context window: ${ctx.toLocaleString()} tokens`);
2149
+ }
1982
2150
  consola.success(`Server ready on ${serverUrl}, launching Codex CLI (${codexModel})...`);
1983
2151
  consola.level = 1;
1984
2152
  launchChild({
1985
2153
  kind: "codex",
1986
2154
  envVars: getCodexEnvVars(serverUrl),
1987
2155
  extraArgs: args._ ?? [],
1988
- model: args.model
2156
+ model: codexModel
1989
2157
  }, server$1);
1990
2158
  }
1991
2159
  });