github-router 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -56,7 +56,7 @@ const standardHeaders = () => ({
56
56
  const COPILOT_VERSION = "0.26.7";
57
57
  const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}`;
58
58
  const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`;
59
- const API_VERSION = "2025-04-01";
59
+ const API_VERSION = "2025-05-01";
60
60
  const copilotBaseUrl = (state$1) => state$1.accountType === "individual" ? "https://api.githubcopilot.com" : `https://api.${state$1.accountType}.githubcopilot.com`;
61
61
  const copilotHeaders = (state$1, vision = false, integrationId = "vscode-chat") => {
62
62
  const headers = {
@@ -67,6 +67,7 @@ const copilotHeaders = (state$1, vision = false, integrationId = "vscode-chat")
67
67
  "editor-plugin-version": EDITOR_PLUGIN_VERSION,
68
68
  "user-agent": USER_AGENT,
69
69
  "openai-intent": "conversation-panel",
70
+ "x-interaction-type": "conversation-panel",
70
71
  "x-github-api-version": API_VERSION,
71
72
  "x-request-id": randomUUID(),
72
73
  "x-vscode-user-agent-library-version": "electron-fetch"
@@ -105,19 +106,30 @@ async function forwardError(c, error) {
105
106
  try {
106
107
  errorJson = JSON.parse(errorText);
107
108
  } catch {
108
- errorJson = errorText;
109
+ errorJson = void 0;
109
110
  }
110
- consola.error("HTTP error:", errorJson);
111
+ const message = resolveErrorMessage(errorJson, errorText);
112
+ consola.error("HTTP error:", errorJson ?? errorText);
111
113
  return c.json({ error: {
112
- message: errorText,
114
+ message,
113
115
  type: "error"
114
116
  } }, error.response.status);
115
117
  }
116
118
  return c.json({ error: {
117
- message: error.message,
119
+ message: error instanceof Error ? error.message : String(error),
118
120
  type: "error"
119
121
  } }, 500);
120
122
  }
123
+ function resolveErrorMessage(errorJson, fallback) {
124
+ if (typeof errorJson !== "object" || errorJson === null) return fallback;
125
+ const errorRecord = errorJson;
126
+ if (errorRecord.message !== void 0) return String(errorRecord.message);
127
+ if (typeof errorRecord.error === "object" && errorRecord.error !== null) {
128
+ const nestedRecord = errorRecord.error;
129
+ if (nestedRecord.message !== void 0) return String(nestedRecord.message);
130
+ }
131
+ return fallback;
132
+ }
121
133
 
122
134
  //#endregion
123
135
  //#region src/services/github/get-copilot-token.ts
@@ -201,7 +213,8 @@ const cacheVSCodeVersion = async () => {
201
213
  async function pollAccessToken(deviceCode) {
202
214
  const sleepDuration = (deviceCode.interval + 1) * 1e3;
203
215
  consola.debug(`Polling access token with interval of ${sleepDuration}ms`);
204
- while (true) {
216
+ const expiresAt = Date.now() + deviceCode.expires_in * 1e3;
217
+ while (Date.now() < expiresAt) {
205
218
  const response = await fetch(`${GITHUB_BASE_URL}/login/oauth/access_token`, {
206
219
  method: "POST",
207
220
  headers: standardHeaders(),
@@ -212,16 +225,19 @@ async function pollAccessToken(deviceCode) {
212
225
  })
213
226
  });
214
227
  if (!response.ok) {
215
- await sleep(sleepDuration);
216
228
  consola.error("Failed to poll access token:", await response.text());
229
+ if (Date.now() >= expiresAt) break;
230
+ await sleep(sleepDuration);
217
231
  continue;
218
232
  }
219
233
  const json = await response.json();
220
234
  consola.debug("Polling access token response:", json);
221
235
  const { access_token } = json;
222
236
  if (access_token) return access_token;
223
- else await sleep(sleepDuration);
237
+ if (Date.now() >= expiresAt) break;
238
+ await sleep(sleepDuration);
224
239
  }
240
+ throw new Error("Device code expired. Please run auth again.");
225
241
  }
226
242
 
227
243
  //#endregion
@@ -233,7 +249,7 @@ const setupCopilotToken = async () => {
233
249
  state.copilotToken = token;
234
250
  consola.debug("GitHub Copilot Token fetched successfully!");
235
251
  if (state.showToken) consola.info("Copilot token:", token);
236
- const refreshInterval = (refresh_in - 60) * 1e3;
252
+ const refreshInterval = Math.max((refresh_in - 60) * 1e3, 1e3);
237
253
  setInterval(async () => {
238
254
  consola.debug("Refreshing Copilot token");
239
255
  try {
@@ -485,8 +501,10 @@ function initProxyFromEnv() {
485
501
  function getShell() {
486
502
  const { platform, ppid, env } = process$1;
487
503
  if (platform === "win32") {
504
+ if (env.POWERSHELL_DISTRIBUTION_CHANNEL) return "powershell";
488
505
  try {
489
- if (execSync(`wmic process get ParentProcessId,Name | findstr "${ppid}"`, { stdio: "pipe" }).toString().toLowerCase().includes("powershell.exe")) return "powershell";
506
+ const parentProcess = execSync(`wmic process get ParentProcessId,Name | findstr "${ppid}"`, { stdio: "pipe" }).toString();
507
+ if (parentProcess.toLowerCase().includes("powershell.exe") || parentProcess.toLowerCase().includes("pwsh.exe")) return "powershell";
490
508
  } catch {
491
509
  return "cmd";
492
510
  }
@@ -501,6 +519,12 @@ function getShell() {
501
519
  return "sh";
502
520
  }
503
521
  }
522
+ function quotePosixValue(value) {
523
+ return `'${value.replace(/'/g, "'\\''")}'`;
524
+ }
525
+ function quotePowerShellValue(value) {
526
+ return `'${value.replace(/'/g, "''")}'`;
527
+ }
504
528
  /**
505
529
  * Generates a copy-pasteable script to set multiple environment variables
506
530
  * and run a subsequent command.
@@ -514,28 +538,28 @@ function generateEnvScript(envVars, commandToRun = "") {
514
538
  let commandBlock;
515
539
  switch (shell) {
516
540
  case "powershell":
517
- commandBlock = filteredEnvVars.map(([key, value]) => `$env:${key} = ${value}`).join("; ");
541
+ commandBlock = filteredEnvVars.map(([key, value]) => `$env:${key} = ${quotePowerShellValue(value)}`).join("; ");
518
542
  break;
519
543
  case "cmd":
520
- commandBlock = filteredEnvVars.map(([key, value]) => `set ${key}=${value}`).join(" & ");
544
+ commandBlock = filteredEnvVars.map(([key, value]) => `set "${key}=${value}"`).join(" & ");
521
545
  break;
522
546
  case "fish":
523
- commandBlock = filteredEnvVars.map(([key, value]) => `set -gx ${key} ${value}`).join("; ");
547
+ commandBlock = filteredEnvVars.map(([key, value]) => `set -gx ${key} ${quotePosixValue(value)}`).join("; ");
524
548
  break;
525
549
  default: {
526
- const assignments = filteredEnvVars.map(([key, value]) => `${key}=${value}`).join(" ");
550
+ const assignments = filteredEnvVars.map(([key, value]) => `${key}=${quotePosixValue(value)}`).join(" ");
527
551
  commandBlock = filteredEnvVars.length > 0 ? `export ${assignments}` : "";
528
552
  break;
529
553
  }
530
554
  }
531
- if (commandBlock && commandToRun) return `${commandBlock}${shell === "cmd" ? " & " : " && "}${commandToRun}`;
555
+ if (commandBlock && commandToRun) return `${commandBlock}${shell === "cmd" ? " & " : shell === "powershell" ? "; " : " && "}${commandToRun}`;
532
556
  return commandBlock || commandToRun;
533
557
  }
534
558
 
535
559
  //#endregion
536
560
  //#region src/lib/approval.ts
537
561
  const awaitApproval = async () => {
538
- if (!await consola.prompt(`Accept incoming request?`, { type: "confirm" })) throw new HTTPError("Request rejected", Response.json({ message: "Request rejected" }, { status: 403 }));
562
+ if (!await consola.prompt(`Accept incoming request?`, { type: "confirm" })) throw new HTTPError("Request rejected by user", Response.json({ message: "Request rejected by user" }, { status: 403 }));
539
563
  };
540
564
 
541
565
  //#endregion
@@ -560,7 +584,7 @@ async function checkRateLimit(state$1) {
560
584
  const waitTimeMs = waitTimeSeconds * 1e3;
561
585
  consola.warn(`Rate limit reached. Waiting ${waitTimeSeconds} seconds before proceeding...`);
562
586
  await sleep(waitTimeMs);
563
- state$1.lastRequestTimestamp = now;
587
+ state$1.lastRequestTimestamp = Date.now();
564
588
  consola.info("Rate limit wait completed, proceeding with request");
565
589
  }
566
590
 
@@ -776,8 +800,19 @@ const createChatCompletions = async (payload) => {
776
800
  body: JSON.stringify(payload)
777
801
  });
778
802
  if (!response.ok) {
779
- consola.error("Failed to create chat completions", response);
780
- throw new HTTPError("Failed to create chat completions", response);
803
+ let errorBody = "";
804
+ try {
805
+ errorBody = await response.text();
806
+ } catch {
807
+ errorBody = "(could not read error body)";
808
+ }
809
+ const claudeModels = state.models?.data.filter((m) => m.id.startsWith("claude")).map((m) => m.id).join(", ") ?? "(models not loaded)";
810
+ consola.error(`Copilot rejected model "${payload.model}": ${response.status} ${errorBody} (available Claude models: ${claudeModels})`);
811
+ throw new HTTPError("Failed to create chat completions", new Response(errorBody, {
812
+ status: response.status,
813
+ statusText: response.statusText,
814
+ headers: response.headers
815
+ }));
781
816
  }
782
817
  if (payload.stream) return events(response);
783
818
  return await response.json();
@@ -837,7 +872,7 @@ async function searchWeb(query) {
837
872
  consola.info(`Web search: "${query.slice(0, 80)}"`);
838
873
  const response = await sendThreadMessage(await createThread(), query);
839
874
  const references = [];
840
- for (const ref of response.message.references) if (ref.results) {
875
+ for (const ref of response.message.references ?? []) if (ref.results) {
841
876
  for (const result of ref.results) if (result.url && result.reference_type !== "bing_search") references.push({
842
877
  title: result.title,
843
878
  url: result.url
@@ -855,8 +890,10 @@ async function searchWeb(query) {
855
890
  async function handleCompletion$1(c) {
856
891
  await checkRateLimit(state);
857
892
  let payload = await c.req.json();
858
- consola.debug("Request payload:", JSON.stringify(payload).slice(-400));
859
- await injectWebSearchIfNeeded$2(payload);
893
+ const debugEnabled = consola.level >= 4;
894
+ if (debugEnabled) consola.debug("Request payload:", JSON.stringify(payload).slice(-400));
895
+ if (state.manualApprove) await awaitApproval();
896
+ await injectWebSearchIfNeeded$1(payload);
860
897
  const selectedModel = state.models?.data.find((model) => model.id === payload.model);
861
898
  try {
862
899
  if (selectedModel) {
@@ -866,34 +903,31 @@ async function handleCompletion$1(c) {
866
903
  } catch (error) {
867
904
  consola.warn("Failed to calculate token count:", error);
868
905
  }
869
- if (state.manualApprove) await awaitApproval();
870
906
  if (isNullish(payload.max_tokens)) {
871
907
  payload = {
872
908
  ...payload,
873
909
  max_tokens: selectedModel?.capabilities.limits.max_output_tokens
874
910
  };
875
- consola.debug("Set max_tokens to:", JSON.stringify(payload.max_tokens));
911
+ if (debugEnabled) consola.debug("Set max_tokens to:", JSON.stringify(payload.max_tokens));
876
912
  }
877
913
  const response = await createChatCompletions(payload);
878
- if (isNonStreaming$2(response)) {
879
- consola.debug("Non-streaming response:", JSON.stringify(response));
914
+ if (isNonStreaming$1(response)) {
915
+ if (debugEnabled) consola.debug("Non-streaming response:", JSON.stringify(response));
880
916
  return c.json(response);
881
917
  }
882
918
  consola.debug("Streaming response");
883
919
  return streamSSE(c, async (stream) => {
884
920
  for await (const chunk of response) {
885
- consola.debug("Streaming chunk:", JSON.stringify(chunk));
921
+ if (debugEnabled) consola.debug("Streaming chunk:", JSON.stringify(chunk));
886
922
  await stream.writeSSE(chunk);
887
923
  }
888
924
  });
889
925
  }
890
- const isNonStreaming$2 = (response) => Object.hasOwn(response, "choices");
891
- async function injectWebSearchIfNeeded$2(payload) {
926
+ const isNonStreaming$1 = (response) => Object.hasOwn(response, "choices");
927
+ async function injectWebSearchIfNeeded$1(payload) {
892
928
  if (!payload.tools?.some((t) => "type" in t && t.type === "web_search" || t.function?.name === "web_search")) return;
893
- if (payload.messages.some((msg) => msg.role === "tool")) return;
894
- const query = extractUserQuery$2(payload.messages);
895
- if (!query) return;
896
- try {
929
+ const query = payload.messages.some((msg) => msg.role === "tool") ? void 0 : extractUserQuery$1(payload.messages);
930
+ if (query) try {
897
931
  const results = await searchWeb(query);
898
932
  const searchContext = [
899
933
  "[Web Search Results]",
@@ -913,8 +947,13 @@ async function injectWebSearchIfNeeded$2(payload) {
913
947
  }
914
948
  payload.tools = payload.tools?.filter((t) => !("type" in t && t.type === "web_search" || t.function?.name === "web_search"));
915
949
  if (payload.tools?.length === 0) payload.tools = void 0;
950
+ if (!payload.tools) payload.tool_choice = void 0;
951
+ else if (payload.tool_choice && typeof payload.tool_choice === "object" && "type" in payload.tool_choice && payload.tool_choice.type === "function") {
952
+ const toolChoiceName = payload.tool_choice.function?.name;
953
+ if (toolChoiceName && !payload.tools.some((tool) => tool.function.name === toolChoiceName)) payload.tool_choice = void 0;
954
+ }
916
955
  }
917
- function extractUserQuery$2(messages) {
956
+ function extractUserQuery$1(messages) {
918
957
  for (let i = messages.length - 1; i >= 0; i--) {
919
958
  const msg = messages[i];
920
959
  if (msg.role === "user") {
@@ -964,437 +1003,127 @@ embeddingRoutes.post("/", async (c) => {
964
1003
  });
965
1004
 
966
1005
  //#endregion
967
- //#region src/routes/messages/utils.ts
968
- function mapOpenAIStopReasonToAnthropic(finishReason) {
969
- if (finishReason === null) return null;
970
- return {
971
- stop: "end_turn",
972
- length: "max_tokens",
973
- tool_calls: "tool_use",
974
- content_filter: "end_turn"
975
- }[finishReason];
976
- }
977
-
978
- //#endregion
979
- //#region src/routes/messages/non-stream-translation.ts
980
- function translateToOpenAI(payload) {
1006
+ //#region src/services/copilot/create-messages.ts
1007
+ /**
1008
+ * Build headers that match what VS Code Copilot Chat sends to the Copilot API.
1009
+ *
1010
+ * copilotHeaders() provides: Authorization, content-type, copilot-integration-id,
1011
+ * editor-version, editor-plugin-version, user-agent, openai-intent,
1012
+ * x-github-api-version, x-request-id, x-vscode-user-agent-library-version.
1013
+ *
1014
+ * We add the remaining headers VS Code sends for /v1/messages:
1015
+ * - X-Initiator (VS Code sets dynamically; "agent" is safe for CLI use)
1016
+ * - anthropic-version (VS Code's Anthropic SDK sends this)
1017
+ * - X-Interaction-Id (VS Code sends a session-scoped UUID)
1018
+ *
1019
+ * We intentionally omit copilot-vision-request — VS Code only sends it when
1020
+ * images are present, and the native /v1/messages endpoint handles vision
1021
+ * without requiring the header.
1022
+ */
1023
+ function buildHeaders() {
981
1024
  return {
982
- model: translateModelName(payload.model),
983
- messages: translateAnthropicMessagesToOpenAI(payload.messages, payload.system),
984
- max_tokens: payload.max_tokens,
985
- stop: payload.stop_sequences,
986
- stream: payload.stream,
987
- temperature: payload.temperature,
988
- top_p: payload.top_p,
989
- user: payload.metadata?.user_id,
990
- tools: translateAnthropicToolsToOpenAI(payload.tools),
991
- tool_choice: translateAnthropicToolChoiceToOpenAI(payload.tool_choice)
1025
+ ...copilotHeaders(state),
1026
+ "X-Initiator": "agent",
1027
+ "anthropic-version": "2023-06-01",
1028
+ "X-Interaction-Id": randomUUID()
992
1029
  };
993
1030
  }
994
- function translateModelName(model) {
995
- if (model.startsWith("claude-sonnet-4-")) return model.replace(/^claude-sonnet-4-.*/, "claude-sonnet-4");
996
- else if (model.startsWith("claude-opus-")) return model.replace(/^claude-opus-4-.*/, "claude-opus-4");
997
- return model;
998
- }
999
- function translateAnthropicMessagesToOpenAI(anthropicMessages, system) {
1000
- const systemMessages = handleSystemPrompt(system);
1001
- const otherMessages = anthropicMessages.flatMap((message) => message.role === "user" ? handleUserMessage(message) : handleAssistantMessage(message));
1002
- return [...systemMessages, ...otherMessages];
1003
- }
1004
- function handleSystemPrompt(system) {
1005
- if (!system) return [];
1006
- if (typeof system === "string") return [{
1007
- role: "system",
1008
- content: system
1009
- }];
1010
- else return [{
1011
- role: "system",
1012
- content: system.map((block) => block.text).join("\n\n")
1013
- }];
1014
- }
1015
- function handleUserMessage(message) {
1016
- const newMessages = [];
1017
- if (Array.isArray(message.content)) {
1018
- const toolResultBlocks = message.content.filter((block) => block.type === "tool_result");
1019
- const otherBlocks = message.content.filter((block) => block.type !== "tool_result");
1020
- for (const block of toolResultBlocks) newMessages.push({
1021
- role: "tool",
1022
- tool_call_id: block.tool_use_id,
1023
- content: mapContent(block.content)
1024
- });
1025
- if (otherBlocks.length > 0) newMessages.push({
1026
- role: "user",
1027
- content: mapContent(otherBlocks)
1028
- });
1029
- } else newMessages.push({
1030
- role: "user",
1031
- content: mapContent(message.content)
1031
+ /**
1032
+ * Forward an Anthropic Messages API request to Copilot's native /v1/messages endpoint.
1033
+ * Returns the raw Response so callers can handle streaming vs non-streaming.
1034
+ */
1035
+ async function createMessages(body) {
1036
+ if (!state.copilotToken) throw new Error("Copilot token not found");
1037
+ const headers = buildHeaders();
1038
+ const url = `${copilotBaseUrl(state)}/v1/messages`;
1039
+ consola.debug(`Forwarding to ${url}`);
1040
+ const response = await fetch(url, {
1041
+ method: "POST",
1042
+ headers,
1043
+ body
1032
1044
  });
1033
- return newMessages;
1034
- }
1035
- function handleAssistantMessage(message) {
1036
- if (!Array.isArray(message.content)) return [{
1037
- role: "assistant",
1038
- content: mapContent(message.content)
1039
- }];
1040
- const toolUseBlocks = message.content.filter((block) => block.type === "tool_use");
1041
- const textBlocks = message.content.filter((block) => block.type === "text");
1042
- const thinkingBlocks = message.content.filter((block) => block.type === "thinking");
1043
- const allTextContent = [...textBlocks.map((b) => b.text), ...thinkingBlocks.map((b) => b.thinking)].join("\n\n");
1044
- return toolUseBlocks.length > 0 ? [{
1045
- role: "assistant",
1046
- content: allTextContent || null,
1047
- tool_calls: toolUseBlocks.map((toolUse) => ({
1048
- id: toolUse.id,
1049
- type: "function",
1050
- function: {
1051
- name: toolUse.name,
1052
- arguments: JSON.stringify(toolUse.input)
1053
- }
1054
- }))
1055
- }] : [{
1056
- role: "assistant",
1057
- content: mapContent(message.content)
1058
- }];
1059
- }
1060
- function mapContent(content) {
1061
- if (typeof content === "string") return content;
1062
- if (!Array.isArray(content)) return null;
1063
- if (!content.some((block) => block.type === "image")) return content.filter((block) => block.type === "text" || block.type === "thinking").map((block) => block.type === "text" ? block.text : block.thinking).join("\n\n");
1064
- const contentParts = [];
1065
- for (const block of content) switch (block.type) {
1066
- case "text":
1067
- contentParts.push({
1068
- type: "text",
1069
- text: block.text
1070
- });
1071
- break;
1072
- case "thinking":
1073
- contentParts.push({
1074
- type: "text",
1075
- text: block.thinking
1076
- });
1077
- break;
1078
- case "image":
1079
- contentParts.push({
1080
- type: "image_url",
1081
- image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` }
1082
- });
1083
- break;
1084
- }
1085
- return contentParts;
1086
- }
1087
- function translateAnthropicToolsToOpenAI(anthropicTools) {
1088
- if (!anthropicTools) return;
1089
- return anthropicTools.map((tool) => ({
1090
- type: "function",
1091
- function: {
1092
- name: tool.name,
1093
- description: tool.description,
1094
- parameters: tool.input_schema
1045
+ if (!response.ok) {
1046
+ let errorBody = "";
1047
+ try {
1048
+ errorBody = await response.text();
1049
+ } catch {
1050
+ errorBody = "(could not read error body)";
1095
1051
  }
1096
- }));
1097
- }
1098
- function translateAnthropicToolChoiceToOpenAI(anthropicToolChoice) {
1099
- if (!anthropicToolChoice) return;
1100
- switch (anthropicToolChoice.type) {
1101
- case "auto": return "auto";
1102
- case "any": return "required";
1103
- case "tool":
1104
- if (anthropicToolChoice.name) return {
1105
- type: "function",
1106
- function: { name: anthropicToolChoice.name }
1107
- };
1108
- return;
1109
- case "none": return "none";
1110
- default: return;
1052
+ consola.error(`Copilot /v1/messages error: ${response.status} ${errorBody}`);
1053
+ throw new HTTPError("Copilot messages request failed", new Response(errorBody, {
1054
+ status: response.status,
1055
+ statusText: response.statusText,
1056
+ headers: response.headers
1057
+ }));
1111
1058
  }
1059
+ return response;
1112
1060
  }
1113
- function translateToAnthropic(response) {
1114
- const allTextBlocks = [];
1115
- const allToolUseBlocks = [];
1116
- let stopReason = null;
1117
- stopReason = response.choices[0]?.finish_reason ?? stopReason;
1118
- for (const choice of response.choices) {
1119
- const textBlocks = getAnthropicTextBlocks(choice.message.content);
1120
- const toolUseBlocks = getAnthropicToolUseBlocks(choice.message.tool_calls);
1121
- allTextBlocks.push(...textBlocks);
1122
- allToolUseBlocks.push(...toolUseBlocks);
1123
- if (choice.finish_reason === "tool_calls" || stopReason === "stop") stopReason = choice.finish_reason;
1124
- }
1125
- return {
1126
- id: response.id,
1127
- type: "message",
1128
- role: "assistant",
1129
- model: response.model,
1130
- content: [...allTextBlocks, ...allToolUseBlocks],
1131
- stop_reason: mapOpenAIStopReasonToAnthropic(stopReason),
1132
- stop_sequence: null,
1133
- usage: {
1134
- input_tokens: (response.usage?.prompt_tokens ?? 0) - (response.usage?.prompt_tokens_details?.cached_tokens ?? 0),
1135
- output_tokens: response.usage?.completion_tokens ?? 0,
1136
- ...response.usage?.prompt_tokens_details?.cached_tokens !== void 0 && { cache_read_input_tokens: response.usage.prompt_tokens_details.cached_tokens }
1061
+ /**
1062
+ * Forward an Anthropic count_tokens request to Copilot's native endpoint.
1063
+ * Returns the raw Response.
1064
+ */
1065
+ async function countTokens(body) {
1066
+ if (!state.copilotToken) throw new Error("Copilot token not found");
1067
+ const headers = buildHeaders();
1068
+ const url = `${copilotBaseUrl(state)}/v1/messages/count_tokens`;
1069
+ consola.debug(`Forwarding to ${url}`);
1070
+ const response = await fetch(url, {
1071
+ method: "POST",
1072
+ headers,
1073
+ body
1074
+ });
1075
+ if (!response.ok) {
1076
+ let errorBody = "";
1077
+ try {
1078
+ errorBody = await response.text();
1079
+ } catch {
1080
+ errorBody = "(could not read error body)";
1137
1081
  }
1138
- };
1139
- }
1140
- function getAnthropicTextBlocks(messageContent) {
1141
- if (typeof messageContent === "string") return [{
1142
- type: "text",
1143
- text: messageContent
1144
- }];
1145
- if (Array.isArray(messageContent)) return messageContent.filter((part) => part.type === "text").map((part) => ({
1146
- type: "text",
1147
- text: part.text
1148
- }));
1149
- return [];
1150
- }
1151
- function getAnthropicToolUseBlocks(toolCalls) {
1152
- if (!toolCalls) return [];
1153
- return toolCalls.map((toolCall) => ({
1154
- type: "tool_use",
1155
- id: toolCall.id,
1156
- name: toolCall.function.name,
1157
- input: JSON.parse(toolCall.function.arguments)
1158
- }));
1082
+ consola.error(`Copilot count_tokens error: ${response.status} ${errorBody}`);
1083
+ throw new HTTPError("Copilot count_tokens request failed", new Response(errorBody, {
1084
+ status: response.status,
1085
+ statusText: response.statusText,
1086
+ headers: response.headers
1087
+ }));
1088
+ }
1089
+ return response;
1159
1090
  }
1160
1091
 
1161
1092
  //#endregion
1162
1093
  //#region src/routes/messages/count-tokens-handler.ts
1163
1094
  /**
1164
- * Handles token counting for Anthropic messages
1095
+ * Passthrough handler for Anthropic token counting.
1096
+ * Forwards the request directly to Copilot's native /v1/messages/count_tokens endpoint.
1165
1097
  */
1166
1098
  async function handleCountTokens(c) {
1167
- try {
1168
- const anthropicBeta = c.req.header("anthropic-beta");
1169
- const anthropicPayload = await c.req.json();
1170
- const openAIPayload = translateToOpenAI(anthropicPayload);
1171
- const selectedModel = state.models?.data.find((model) => model.id === anthropicPayload.model);
1172
- if (!selectedModel) {
1173
- consola.warn("Model not found, returning default token count");
1174
- return c.json({ input_tokens: 1 });
1175
- }
1176
- const tokenCount = await getTokenCount(openAIPayload, selectedModel);
1177
- if (anthropicPayload.tools && anthropicPayload.tools.length > 0) {
1178
- let mcpToolExist = false;
1179
- if (anthropicBeta?.startsWith("claude-code")) mcpToolExist = anthropicPayload.tools.some((tool) => tool.name.startsWith("mcp__"));
1180
- if (!mcpToolExist) {
1181
- if (anthropicPayload.model.startsWith("claude")) tokenCount.input = tokenCount.input + 346;
1182
- else if (anthropicPayload.model.startsWith("grok")) tokenCount.input = tokenCount.input + 480;
1183
- }
1184
- }
1185
- let finalTokenCount = tokenCount.input + tokenCount.output;
1186
- if (anthropicPayload.model.startsWith("claude")) finalTokenCount = Math.round(finalTokenCount * 1.15);
1187
- else if (anthropicPayload.model.startsWith("grok")) finalTokenCount = Math.round(finalTokenCount * 1.03);
1188
- consola.info("Token count:", finalTokenCount);
1189
- return c.json({ input_tokens: finalTokenCount });
1190
- } catch (error) {
1191
- consola.error("Error counting tokens:", error);
1192
- return c.json({ input_tokens: 1 });
1193
- }
1194
- }
1195
-
1196
- //#endregion
1197
- //#region src/routes/messages/stream-translation.ts
1198
- function isToolBlockOpen(state$1) {
1199
- if (!state$1.contentBlockOpen) return false;
1200
- return Object.values(state$1.toolCalls).some((tc) => tc.anthropicBlockIndex === state$1.contentBlockIndex);
1201
- }
1202
- function translateChunkToAnthropicEvents(chunk, state$1) {
1203
- const events$1 = [];
1204
- if (chunk.choices.length === 0) return events$1;
1205
- const choice = chunk.choices[0];
1206
- const { delta } = choice;
1207
- if (!state$1.messageStartSent) {
1208
- events$1.push({
1209
- type: "message_start",
1210
- message: {
1211
- id: chunk.id,
1212
- type: "message",
1213
- role: "assistant",
1214
- content: [],
1215
- model: chunk.model,
1216
- stop_reason: null,
1217
- stop_sequence: null,
1218
- usage: {
1219
- input_tokens: (chunk.usage?.prompt_tokens ?? 0) - (chunk.usage?.prompt_tokens_details?.cached_tokens ?? 0),
1220
- output_tokens: 0,
1221
- ...chunk.usage?.prompt_tokens_details?.cached_tokens !== void 0 && { cache_read_input_tokens: chunk.usage.prompt_tokens_details.cached_tokens }
1222
- }
1223
- }
1224
- });
1225
- state$1.messageStartSent = true;
1226
- }
1227
- if (delta.content) {
1228
- if (isToolBlockOpen(state$1)) {
1229
- events$1.push({
1230
- type: "content_block_stop",
1231
- index: state$1.contentBlockIndex
1232
- });
1233
- state$1.contentBlockIndex++;
1234
- state$1.contentBlockOpen = false;
1235
- }
1236
- if (!state$1.contentBlockOpen) {
1237
- events$1.push({
1238
- type: "content_block_start",
1239
- index: state$1.contentBlockIndex,
1240
- content_block: {
1241
- type: "text",
1242
- text: ""
1243
- }
1244
- });
1245
- state$1.contentBlockOpen = true;
1246
- }
1247
- events$1.push({
1248
- type: "content_block_delta",
1249
- index: state$1.contentBlockIndex,
1250
- delta: {
1251
- type: "text_delta",
1252
- text: delta.content
1253
- }
1254
- });
1255
- }
1256
- if (delta.tool_calls) for (const toolCall of delta.tool_calls) {
1257
- if (toolCall.id && toolCall.function?.name) {
1258
- if (state$1.contentBlockOpen) {
1259
- events$1.push({
1260
- type: "content_block_stop",
1261
- index: state$1.contentBlockIndex
1262
- });
1263
- state$1.contentBlockIndex++;
1264
- state$1.contentBlockOpen = false;
1265
- }
1266
- const anthropicBlockIndex = state$1.contentBlockIndex;
1267
- state$1.toolCalls[toolCall.index] = {
1268
- id: toolCall.id,
1269
- name: toolCall.function.name,
1270
- anthropicBlockIndex
1271
- };
1272
- events$1.push({
1273
- type: "content_block_start",
1274
- index: anthropicBlockIndex,
1275
- content_block: {
1276
- type: "tool_use",
1277
- id: toolCall.id,
1278
- name: toolCall.function.name,
1279
- input: {}
1280
- }
1281
- });
1282
- state$1.contentBlockOpen = true;
1283
- }
1284
- if (toolCall.function?.arguments) {
1285
- const toolCallInfo = state$1.toolCalls[toolCall.index];
1286
- if (toolCallInfo) events$1.push({
1287
- type: "content_block_delta",
1288
- index: toolCallInfo.anthropicBlockIndex,
1289
- delta: {
1290
- type: "input_json_delta",
1291
- partial_json: toolCall.function.arguments
1292
- }
1293
- });
1294
- }
1295
- }
1296
- if (choice.finish_reason) {
1297
- if (state$1.contentBlockOpen) {
1298
- events$1.push({
1299
- type: "content_block_stop",
1300
- index: state$1.contentBlockIndex
1301
- });
1302
- state$1.contentBlockOpen = false;
1303
- }
1304
- events$1.push({
1305
- type: "message_delta",
1306
- delta: {
1307
- stop_reason: mapOpenAIStopReasonToAnthropic(choice.finish_reason),
1308
- stop_sequence: null
1309
- },
1310
- usage: {
1311
- input_tokens: (chunk.usage?.prompt_tokens ?? 0) - (chunk.usage?.prompt_tokens_details?.cached_tokens ?? 0),
1312
- output_tokens: chunk.usage?.completion_tokens ?? 0,
1313
- ...chunk.usage?.prompt_tokens_details?.cached_tokens !== void 0 && { cache_read_input_tokens: chunk.usage.prompt_tokens_details.cached_tokens }
1314
- }
1315
- }, { type: "message_stop" });
1316
- }
1317
- return events$1;
1099
+ const body = await (await countTokens(await c.req.text())).json();
1100
+ consola.info("Token count:", JSON.stringify(body));
1101
+ return c.json(body);
1318
1102
  }
1319
1103
 
1320
1104
  //#endregion
1321
1105
  //#region src/routes/messages/handler.ts
1322
1106
  async function handleCompletion(c) {
1323
1107
  await checkRateLimit(state);
1324
- const anthropicPayload = await c.req.json();
1325
- consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload));
1326
- await injectWebSearchIfNeeded$1(anthropicPayload);
1327
- const openAIPayload = translateToOpenAI(anthropicPayload);
1328
- consola.debug("Translated OpenAI request payload:", JSON.stringify(openAIPayload));
1108
+ const rawBody = await c.req.text();
1109
+ const debugEnabled = consola.level >= 4;
1110
+ if (debugEnabled) consola.debug("Anthropic request body:", rawBody.slice(0, 2e3));
1329
1111
  if (state.manualApprove) await awaitApproval();
1330
- const response = await createChatCompletions(openAIPayload);
1331
- if (isNonStreaming$1(response)) {
1332
- consola.debug("Non-streaming response from Copilot:", JSON.stringify(response).slice(-400));
1333
- const anthropicResponse = translateToAnthropic(response);
1334
- consola.debug("Translated Anthropic response:", JSON.stringify(anthropicResponse));
1335
- return c.json(anthropicResponse);
1336
- }
1337
- consola.debug("Streaming response from Copilot");
1338
- return streamSSE(c, async (stream) => {
1339
- const streamState = {
1340
- messageStartSent: false,
1341
- contentBlockIndex: 0,
1342
- contentBlockOpen: false,
1343
- toolCalls: {}
1344
- };
1345
- for await (const rawEvent of response) {
1346
- consola.debug("Copilot raw stream event:", JSON.stringify(rawEvent));
1347
- if (rawEvent.data === "[DONE]") break;
1348
- if (!rawEvent.data) continue;
1349
- const events$1 = translateChunkToAnthropicEvents(JSON.parse(rawEvent.data), streamState);
1350
- for (const event of events$1) {
1351
- consola.debug("Translated Anthropic event:", JSON.stringify(event));
1352
- await stream.writeSSE({
1353
- event: event.type,
1354
- data: JSON.stringify(event)
1355
- });
1112
+ const response = await createMessages(rawBody);
1113
+ if ((response.headers.get("content-type") ?? "").includes("text/event-stream")) {
1114
+ if (debugEnabled) consola.debug("Streaming response from Copilot /v1/messages");
1115
+ return new Response(response.body, {
1116
+ status: response.status,
1117
+ headers: {
1118
+ "content-type": "text/event-stream",
1119
+ "cache-control": "no-cache",
1120
+ connection: "keep-alive"
1356
1121
  }
1357
- }
1358
- });
1359
- }
1360
- const isNonStreaming$1 = (response) => Object.hasOwn(response, "choices");
1361
- async function injectWebSearchIfNeeded$1(payload) {
1362
- if (!payload.tools?.some((t) => t.name === "web_search")) return;
1363
- if (payload.messages.some((msg) => msg.role === "user" && Array.isArray(msg.content) && msg.content.some((block) => "type" in block && block.type === "tool_result"))) return;
1364
- const query = extractUserQuery$1(payload.messages);
1365
- if (!query) return;
1366
- try {
1367
- const results = await searchWeb(query);
1368
- const searchContext = [
1369
- "[Web Search Results]",
1370
- results.content,
1371
- "",
1372
- results.references.map((r) => `- [${r.title}](${r.url})`).join("\n"),
1373
- "[End Web Search Results]"
1374
- ].join("\n");
1375
- if (typeof payload.system === "string") payload.system = `${searchContext}\n\n${payload.system}`;
1376
- else if (Array.isArray(payload.system)) payload.system = [{
1377
- type: "text",
1378
- text: searchContext
1379
- }, ...payload.system];
1380
- else payload.system = searchContext;
1381
- } catch (error) {
1382
- consola.warn("Web search failed, continuing without results:", error);
1383
- }
1384
- payload.tools = payload.tools?.filter((t) => t.name !== "web_search");
1385
- if (payload.tools?.length === 0) payload.tools = void 0;
1386
- }
1387
- function extractUserQuery$1(messages) {
1388
- for (let i = messages.length - 1; i >= 0; i--) {
1389
- const msg = messages[i];
1390
- if (msg.role === "user") {
1391
- if (typeof msg.content === "string") return msg.content;
1392
- if (Array.isArray(msg.content)) {
1393
- const text = msg.content.find((block) => "type" in block && block.type === "text");
1394
- if (text && "text" in text) return text.text;
1395
- }
1396
- }
1122
+ });
1397
1123
  }
1124
+ const body = await response.json();
1125
+ if (debugEnabled) consola.debug("Non-streaming response from Copilot /v1/messages:", JSON.stringify(body).slice(0, 2e3));
1126
+ return c.json(body, response.status);
1398
1127
  }
1399
1128
 
1400
1129
  //#endregion
@@ -1476,7 +1205,7 @@ function detectAgentCall(input) {
1476
1205
  if (!Array.isArray(input)) return false;
1477
1206
  return input.some((item) => {
1478
1207
  if ("role" in item && item.role === "assistant") return true;
1479
- if ("type" in item && item.type === "function_call_output") return true;
1208
+ if ("type" in item && (item.type === "function_call" || item.type === "function_call_output")) return true;
1480
1209
  return false;
1481
1210
  });
1482
1211
  }
@@ -1487,35 +1216,49 @@ function filterUnsupportedTools(payload) {
1487
1216
  if (!isSupported) consola.debug(`Stripping unsupported tool type: ${tool.type}`);
1488
1217
  return isSupported;
1489
1218
  });
1219
+ let toolChoice = payload.tool_choice;
1220
+ if (supported.length === 0) toolChoice = void 0;
1221
+ else if (toolChoice && typeof toolChoice === "object") {
1222
+ const supportedNames = new Set(supported.map((tool) => tool.name).filter(Boolean));
1223
+ const toolChoiceName = getToolChoiceName(toolChoice);
1224
+ if (toolChoiceName && !supportedNames.has(toolChoiceName)) toolChoice = void 0;
1225
+ }
1490
1226
  return {
1491
1227
  ...payload,
1492
- tools: supported.length > 0 ? supported : void 0
1228
+ tools: supported.length > 0 ? supported : void 0,
1229
+ tool_choice: toolChoice
1493
1230
  };
1494
1231
  }
1232
+ function getToolChoiceName(toolChoice) {
1233
+ if (typeof toolChoice !== "object") return void 0;
1234
+ if ("function" in toolChoice && toolChoice.function && typeof toolChoice.function === "object") return toolChoice.function.name;
1235
+ if ("name" in toolChoice) return toolChoice.name;
1236
+ }
1495
1237
 
1496
1238
  //#endregion
1497
1239
  //#region src/routes/responses/handler.ts
1498
1240
  async function handleResponses(c) {
1499
1241
  await checkRateLimit(state);
1500
1242
  const payload = await c.req.json();
1501
- consola.debug("Responses request payload:", JSON.stringify(payload).slice(-400));
1243
+ const debugEnabled = consola.level >= 4;
1244
+ if (debugEnabled) consola.debug("Responses request payload:", JSON.stringify(payload).slice(-400));
1502
1245
  const selectedModel = state.models?.data.find((model) => model.id === payload.model);
1503
1246
  consola.info("Token counting not yet supported for /responses endpoint");
1504
1247
  if (state.manualApprove) await awaitApproval();
1505
1248
  await injectWebSearchIfNeeded(payload);
1506
1249
  if (isNullish(payload.max_output_tokens)) {
1507
1250
  payload.max_output_tokens = selectedModel?.capabilities.limits.max_output_tokens;
1508
- consola.debug("Set max_output_tokens to:", JSON.stringify(payload.max_output_tokens));
1251
+ if (debugEnabled) consola.debug("Set max_output_tokens to:", JSON.stringify(payload.max_output_tokens));
1509
1252
  }
1510
1253
  const response = await createResponses(payload);
1511
1254
  if (isNonStreaming(response)) {
1512
- consola.debug("Non-streaming response:", JSON.stringify(response));
1255
+ if (debugEnabled) consola.debug("Non-streaming response:", JSON.stringify(response));
1513
1256
  return c.json(response);
1514
1257
  }
1515
1258
  consola.debug("Streaming response");
1516
1259
  return streamSSE(c, async (stream) => {
1517
1260
  for await (const chunk of response) {
1518
- consola.debug("Streaming chunk:", JSON.stringify(chunk));
1261
+ if (debugEnabled) consola.debug("Streaming chunk:", JSON.stringify(chunk));
1519
1262
  if (chunk.data === "[DONE]") break;
1520
1263
  if (!chunk.data) continue;
1521
1264
  await stream.writeSSE({
@@ -1592,6 +1335,10 @@ searchRoutes.post("/", async (c) => {
1592
1335
  //#region src/routes/token/route.ts
1593
1336
  const tokenRoute = new Hono();
1594
1337
  tokenRoute.get("/", (c) => {
1338
+ if (!state.showToken) return c.json({ error: {
1339
+ message: "Token endpoint disabled",
1340
+ type: "error"
1341
+ } }, 403);
1595
1342
  return c.json({ token: state.copilotToken });
1596
1343
  });
1597
1344
 
@@ -1630,18 +1377,35 @@ server.route("/v1/messages", messageRoutes);
1630
1377
 
1631
1378
  //#endregion
1632
1379
  //#region src/start.ts
1380
+ const allowedAccountTypes = new Set([
1381
+ "individual",
1382
+ "business",
1383
+ "enterprise"
1384
+ ]);
1385
+ function filterModelsByEndpoint(models, endpoint) {
1386
+ const filtered = models.filter((model) => {
1387
+ const endpoints = model.supported_endpoints;
1388
+ if (!endpoints || endpoints.length === 0) return true;
1389
+ return endpoints.some((entry) => {
1390
+ return entry.replace(/^\/?v1\//, "").replace(/^\//, "") === endpoint;
1391
+ });
1392
+ });
1393
+ return filtered.length > 0 ? filtered : models;
1394
+ }
1633
1395
  async function generateClaudeCodeCommand(serverUrl) {
1634
1396
  invariant(state.models, "Models should be loaded by now");
1397
+ const supportedModels = filterModelsByEndpoint(state.models.data, "v1/messages");
1635
1398
  const selectedModel = await consola.prompt("Select a model to use with Claude Code", {
1636
1399
  type: "select",
1637
- options: state.models.data.map((model) => model.id)
1400
+ options: supportedModels.map((model) => model.id)
1638
1401
  });
1639
1402
  const selectedSmallModel = await consola.prompt("Select a small model to use with Claude Code", {
1640
1403
  type: "select",
1641
- options: state.models.data.map((model) => model.id)
1404
+ options: supportedModels.map((model) => model.id)
1642
1405
  });
1643
1406
  const command = generateEnvScript({
1644
1407
  ANTHROPIC_BASE_URL: serverUrl,
1408
+ ANTHROPIC_API_KEY: "dummy",
1645
1409
  ANTHROPIC_AUTH_TOKEN: "dummy",
1646
1410
  ANTHROPIC_MODEL: selectedModel,
1647
1411
  ANTHROPIC_DEFAULT_SONNET_MODEL: selectedModel,
@@ -1660,15 +1424,17 @@ async function generateClaudeCodeCommand(serverUrl) {
1660
1424
  }
1661
1425
  async function generateCodexCommand(serverUrl) {
1662
1426
  invariant(state.models, "Models should be loaded by now");
1663
- const defaultCodexModel = state.models.data.find((model) => model.id === "gpt5.2-codex");
1427
+ const supportedModels = filterModelsByEndpoint(state.models.data, "responses");
1428
+ const defaultCodexModel = supportedModels.find((model) => model.id === "gpt5.2-codex");
1664
1429
  const selectedModel = defaultCodexModel ? defaultCodexModel.id : await consola.prompt("Select a model to use with Codex CLI", {
1665
1430
  type: "select",
1666
- options: state.models.data.map((model) => model.id)
1431
+ options: supportedModels.map((model) => model.id)
1667
1432
  });
1433
+ const quotedModel = JSON.stringify(selectedModel);
1668
1434
  const command = generateEnvScript({
1669
1435
  OPENAI_BASE_URL: `${serverUrl}/v1`,
1670
1436
  OPENAI_API_KEY: "dummy"
1671
- }, `codex -m ${selectedModel}`);
1437
+ }, `codex -m ${quotedModel}`);
1672
1438
  try {
1673
1439
  clipboard.writeSync(command);
1674
1440
  consola.success("Copied Codex CLI command to clipboard!");
@@ -1704,6 +1470,7 @@ async function runServer(options) {
1704
1470
  consola.box(`🌐 Usage Viewer: https://animeshkundu.github.io/github-router/dashboard.html?endpoint=${serverUrl}/usage`);
1705
1471
  serve({
1706
1472
  fetch: server.fetch,
1473
+ hostname: "127.0.0.1",
1707
1474
  port: options.port
1708
1475
  });
1709
1476
  }
@@ -1776,15 +1543,26 @@ const start = defineCommand({
1776
1543
  },
1777
1544
  run({ args }) {
1778
1545
  const rateLimitRaw = args["rate-limit"];
1779
- const rateLimit = rateLimitRaw === void 0 ? void 0 : Number.parseInt(rateLimitRaw, 10);
1546
+ let rateLimit;
1547
+ if (rateLimitRaw !== void 0) {
1548
+ rateLimit = Number.parseInt(rateLimitRaw, 10);
1549
+ if (Number.isNaN(rateLimit) || rateLimit <= 0) throw new Error("Invalid rate limit. Must be a positive integer.");
1550
+ }
1551
+ const port = Number.parseInt(args.port, 10);
1552
+ if (Number.isNaN(port) || port <= 0 || port > 65535) throw new Error("Invalid port. Must be between 1 and 65535.");
1553
+ const accountType = args["account-type"];
1554
+ if (!allowedAccountTypes.has(accountType)) throw new Error("Invalid account type. Must be individual, business, or enterprise.");
1555
+ const rateLimitWait = args.wait && rateLimit !== void 0;
1556
+ if (args.wait && rateLimit === void 0) consola.warn("Rate limit wait ignored because no rate limit was set.");
1557
+ const githubToken = args["github-token"] ?? process.env.GH_TOKEN;
1780
1558
  return runServer({
1781
- port: Number.parseInt(args.port, 10),
1559
+ port,
1782
1560
  verbose: args.verbose,
1783
- accountType: args["account-type"],
1561
+ accountType,
1784
1562
  manual: args.manual,
1785
1563
  rateLimit,
1786
- rateLimitWait: args.wait,
1787
- githubToken: args["github-token"],
1564
+ rateLimitWait,
1565
+ githubToken,
1788
1566
  claudeCode: args["claude-code"],
1789
1567
  codex: args.codex,
1790
1568
  showToken: args["show-token"],