@jiayunxie/aerial 0.1.11 → 0.2.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/src/copilot.js CHANGED
@@ -3,6 +3,7 @@ import { COPILOT_API_ORIGIN, DEFAULT_ANTHROPIC_VERSION } from "./constants.js";
3
3
  import { loadConfig } from "./config.js";
4
4
  import { getCopilotToken } from "./auth.js";
5
5
  import { logEvent } from "./log.js";
6
+ import { upstreamFetch } from "./upstream-fetch.js";
6
7
  import { isResponsesWebSocketOptIn, proxyResponsesWebSocket, shouldUseResponsesWebSocket } from "./responses-websocket.js";
7
8
  import {
8
9
  fetchModelsCatalog as fetchModelsCatalogShared,
@@ -124,11 +125,20 @@ function withDefaultPromptCache(payload) {
124
125
 
125
126
  function openAIEffortRoute(model, effort) {
126
127
  if (effort === undefined) return undefined;
127
- if (/^gpt-5-mini(?:-|$)/.test(model) && ["xhigh", "max"].includes(effort)) return "high";
128
- if (effort === "max") return "xhigh";
128
+ const normalized = normalizeProxyEffort(effort);
129
+ if (/^gpt-5-mini(?:-|$)/.test(model) && normalized === "xhigh") return "high";
130
+ if (normalized !== effort) return normalized;
129
131
  return undefined;
130
132
  }
131
133
 
134
+ function objectOrEmpty(value) {
135
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
136
+ }
137
+
138
+ function normalizeProxyEffort(effort) {
139
+ return effort === "max" ? "xhigh" : effort;
140
+ }
141
+
132
142
  function withSupportedOpenAIEffort(payload) {
133
143
  const model = typeof payload?.model === "string" ? payload.model : "";
134
144
  const reasoningEffort = payload?.reasoning && typeof payload.reasoning === "object" ? payload.reasoning.effort : undefined;
@@ -186,7 +196,7 @@ function withSupportedAnthropicEffort(payload, models) {
186
196
  const model = typeof payload?.model === "string" ? payload.model : "";
187
197
  const family = canonicalClaudeFamily(model);
188
198
  if (!family) return payload;
189
- const nextEffort = effort === "max" ? "xhigh" : effort;
199
+ const nextEffort = normalizeProxyEffort(effort);
190
200
  if (!["low", "medium", "high", "xhigh"].includes(nextEffort)) return payload;
191
201
  const routed = findCompatibleModelShared({
192
202
  models,
@@ -220,9 +230,7 @@ function isLegacyThinkingEnabled(thinking) {
220
230
 
221
231
  function withSupportedAnthropicThinking(payload) {
222
232
  if (!isLegacyThinkingEnabled(payload?.thinking)) return payload;
223
- const outputConfig = payload?.output_config && typeof payload.output_config === "object" && !Array.isArray(payload.output_config)
224
- ? payload.output_config
225
- : {};
233
+ const outputConfig = objectOrEmpty(payload?.output_config);
226
234
  const effort = outputConfig.effort ?? legacyThinkingEffort(payload.thinking);
227
235
  logEvent("anthropic_thinking_route", { model: payload.model, routedType: "adaptive", routedEffort: effort });
228
236
  return {
@@ -243,7 +251,7 @@ async function fetchModelsCatalogForCopilot() {
243
251
  return fetchModelsCatalogShared({
244
252
  tokenFingerprint: tokenFingerprintOf(token),
245
253
  fetchImpl: async () => {
246
- const response = await fetch(`${COPILOT_API_ORIGIN}/models`, { method: "GET", headers: upstreamHeaders(token) });
254
+ const response = await upstreamFetch(`${COPILOT_API_ORIGIN}/models`, { method: "GET", headers: upstreamHeaders(token) });
247
255
  if (!response.ok) return undefined;
248
256
  const payload = await response.json().catch(() => ({}));
249
257
  return Array.isArray(payload?.data) ? payload.data : undefined;
@@ -258,9 +266,7 @@ function withDefaultAnthropicEffort(payload) {
258
266
  if (!canonicalClaudeFamily(model)) return payload;
259
267
  const config = loadConfig();
260
268
  const effort = config.defaultEffort || "medium";
261
- const outputConfig = payload?.output_config && typeof payload.output_config === "object" && !Array.isArray(payload.output_config)
262
- ? payload.output_config
263
- : {};
269
+ const outputConfig = objectOrEmpty(payload?.output_config);
264
270
  logEvent("anthropic_default_effort", { model, effort });
265
271
  return { ...payload, output_config: { ...outputConfig, effort } };
266
272
  }
@@ -474,10 +480,10 @@ async function proxyFetch(path, request, { extraHeaders = {}, bodyOverride } = {
474
480
  const requestCache = cacheRequestFields(parseJsonBody(body, contentType));
475
481
  if (hasExplicitCacheRequest(requestCache)) logEvent("cache_request", { route: path, ...requestCache });
476
482
  const headers = upstreamHeaders(token, { accept, "content-type": contentType, ...extraHeaders });
477
- let upstream = await fetch(`${COPILOT_API_ORIGIN}${path}`, { method: request.method, headers, body });
483
+ let upstream = await upstreamFetch(`${COPILOT_API_ORIGIN}${path}`, { method: request.method, headers, body });
478
484
  if (upstream.status === 401) {
479
485
  const refreshed = await getCopilotToken({ force: true });
480
- upstream = await fetch(`${COPILOT_API_ORIGIN}${path}`, { method: request.method, headers: upstreamHeaders(refreshed, { accept, "content-type": contentType, ...extraHeaders }), body });
486
+ upstream = await upstreamFetch(`${COPILOT_API_ORIGIN}${path}`, { method: request.method, headers: upstreamHeaders(refreshed, { accept, "content-type": contentType, ...extraHeaders }), body });
481
487
  }
482
488
  if (path === "/models") {
483
489
  return new Response(upstream.body, { status: upstream.status, headers: copyResponseHeaders(upstream) });
@@ -0,0 +1,9 @@
1
+ export async function readJsonSafely(response) {
2
+ const text = await response.text();
3
+ if (!text) return {};
4
+ try {
5
+ return JSON.parse(text);
6
+ } catch {
7
+ return { raw: text };
8
+ }
9
+ }
@@ -1,30 +1,15 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
3
  import { proxyModels } from "./copilot.js";
4
+ import { readJsonSafely } from "./http-utils.js";
5
+ import { modelsForRoute } from "./model-utils.js";
6
+ import { parseNumberChoice } from "./prompt-utils.js";
4
7
 
5
8
  const MAX_LISTED_MODELS = 20;
6
9
  const GPT_VERSION_RE = /^gpt-(\d+)(?:\.(\d+))?/i;
7
10
  const STABLE_GPT_RE = /^gpt-\d+(?:\.\d+)?$/i;
8
11
 
9
- async function readJson(response) {
10
- const text = await response.text();
11
- if (!text) return {};
12
- try {
13
- return JSON.parse(text);
14
- } catch {
15
- return { raw: text };
16
- }
17
- }
18
-
19
- function modelRoutes(model) {
20
- return Array.isArray(model?.aerial?.routes) ? model.aerial.routes : [];
21
- }
22
-
23
- export function modelsForRoute(models, route) {
24
- return models
25
- .filter((model) => typeof model?.id === "string" && modelRoutes(model).includes(route))
26
- .map((model) => ({ id: model.id, routes: modelRoutes(model), notes: model.aerial?.notes || [] }));
27
- }
12
+ export { modelsForRoute } from "./model-utils.js";
28
13
 
29
14
  function gptVersionScore(id) {
30
15
  const match = GPT_VERSION_RE.exec(id);
@@ -63,7 +48,7 @@ export function orderForPrompt(ranked, recommended) {
63
48
 
64
49
  export async function discoverModelsForRoute(route) {
65
50
  const response = await proxyModels(new Request("http://aerial.local/v1/models", { method: "GET" }));
66
- const payload = await readJson(response);
51
+ const payload = await readJsonSafely(response);
67
52
  if (!response.ok) {
68
53
  const detail = payload.error || payload.raw || JSON.stringify(payload);
69
54
  throw new Error(`Could not load Copilot models (${response.status}): ${detail}`);
@@ -72,14 +57,6 @@ export async function discoverModelsForRoute(route) {
72
57
  return modelsForRoute(models, route);
73
58
  }
74
59
 
75
- function parseChoice(value, max) {
76
- const trimmed = String(value || "").trim();
77
- if (!trimmed) return 1;
78
- if (!/^\d+$/.test(trimmed)) return undefined;
79
- const n = Number(trimmed);
80
- return n >= 1 && n <= max ? n : undefined;
81
- }
82
-
83
60
  export async function chooseSetupModel({ target, route, explicitModel, prompt = input.isTTY }) {
84
61
  if (explicitModel) return { model: explicitModel, choices: [], source: "explicit" };
85
62
  let raw;
@@ -109,7 +86,7 @@ export async function chooseSetupModel({ target, route, explicitModel, prompt =
109
86
  if (choices.length > MAX_LISTED_MODELS) output.write(` ... ${choices.length - MAX_LISTED_MODELS} more\n`);
110
87
  while (true) {
111
88
  const answer = await rl.question(`Choose ${target} model [1-${promptListed.length}, default 1 = ${recommended}]: `);
112
- const selected = parseChoice(answer, promptListed.length);
89
+ const selected = parseNumberChoice(answer, { max: promptListed.length, defaultIndex: 1, oneBased: true });
113
90
  if (selected) return { model: promptListed[selected - 1].id, choices, source: "prompt", displayed: true, recommended };
114
91
  output.write(`Enter a number from 1 to ${promptListed.length}, or press Enter for 1.\n`);
115
92
  }
@@ -131,9 +108,7 @@ export function formatModelChoices({ target, route, choices, selectedModel, sour
131
108
  lines.push(` ${index + 1}. ${choice.id}${suffix}`);
132
109
  }
133
110
  if (choices.length > MAX_LISTED_MODELS) lines.push(` ... ${choices.length - MAX_LISTED_MODELS} more`);
134
- if (source === "first_available") {
135
- lines.push(`No interactive terminal detected; selected ${selectedModel}. Pass --model <id> to choose a different model.`);
136
- } else if (source === "recommended_stable") {
111
+ if (source === "first_available" || source === "recommended_stable") {
137
112
  lines.push(`No interactive terminal detected; selected ${selectedModel}. Pass --model <id> to choose a different model.`);
138
113
  } else if (source === "recommended_fallback") {
139
114
  lines.push(`No stable gpt-N.M model available; selected ${selectedModel}. Pass --model <id> to override.`);
@@ -0,0 +1,20 @@
1
+ export function aerialRoutes(model) {
2
+ return Array.isArray(model?.aerial?.routes) ? model.aerial.routes : [];
3
+ }
4
+
5
+ export function modelsForRoute(models, route) {
6
+ return models
7
+ .filter((model) => typeof model?.id === "string" && aerialRoutes(model).includes(route))
8
+ .map((model) => ({ id: model.id, routes: aerialRoutes(model), notes: model.aerial?.notes || [] }));
9
+ }
10
+
11
+ export function usageSummary(payload) {
12
+ const usage = payload?.usage || payload?.response?.usage || {};
13
+ return {
14
+ input: usage.input_tokens ?? usage.prompt_tokens,
15
+ output: usage.output_tokens ?? usage.completion_tokens,
16
+ cached: usage.input_tokens_details?.cached_tokens
17
+ ?? usage.prompt_tokens_details?.cached_tokens
18
+ ?? usage.cache_read_input_tokens
19
+ };
20
+ }
package/src/probe.js CHANGED
@@ -1,46 +1,29 @@
1
1
  import { proxyChatCompletions, proxyMessages, proxyModels, proxyResponses } from "./copilot.js";
2
+ import { readJsonSafely } from "./http-utils.js";
3
+ import { aerialRoutes, usageSummary } from "./model-utils.js";
2
4
 
3
5
  function modelRoutes(model) {
4
- return model.aerial?.routes || [];
6
+ return aerialRoutes(model);
5
7
  }
6
8
 
7
9
  function firstModel(models, route) {
8
10
  return models.find((model) => modelRoutes(model).includes(route));
9
11
  }
10
12
 
11
- async function readJson(response) {
12
- const text = await response.text();
13
- if (!text) return {};
14
- try {
15
- return JSON.parse(text);
16
- } catch {
17
- return { raw: text };
18
- }
19
- }
20
-
21
- function tokenSummary(payload) {
22
- const usage = payload.usage || {};
23
- return {
24
- input: usage.input_tokens ?? usage.prompt_tokens,
25
- output: usage.output_tokens ?? usage.completion_tokens,
26
- cached: usage.input_tokens_details?.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? usage.cache_read_input_tokens
27
- };
28
- }
29
-
30
13
  async function probeRoute(name, model, handler, payload, headers = {}) {
31
14
  const response = await handler(new Request(`http://aerial.local/probe/${name}`, {
32
15
  method: "POST",
33
16
  headers: { "content-type": "application/json", ...headers },
34
17
  body: JSON.stringify(payload)
35
18
  }));
36
- const body = await readJson(response);
19
+ const body = await readJsonSafely(response);
37
20
  return {
38
21
  route: name,
39
22
  model: model.id,
40
23
  ok: response.ok,
41
24
  status: response.status,
42
25
  contentType: response.headers.get("content-type") || undefined,
43
- usage: response.ok ? tokenSummary(body) : undefined,
26
+ usage: response.ok ? usageSummary(body) : undefined,
44
27
  error: response.ok ? undefined : body.error || body
45
28
  };
46
29
  }
@@ -52,7 +35,7 @@ function summarizeModels(models) {
52
35
  if (routes.includes("responses")) summary.responses += 1;
53
36
  if (routes.includes("messages")) summary.messages += 1;
54
37
  if (routes.includes("chat")) summary.chat += 1;
55
- if (modelRoutes(model).includes("responses_websocket")) summary.websocketResponses += 1;
38
+ if (routes.includes("responses_websocket")) summary.websocketResponses += 1;
56
39
  if (model.aerial?.notes?.includes("embeddings_not_implemented")) summary.embeddings += 1;
57
40
  if (!model.aerial?.supported) summary.unsupported += 1;
58
41
  }
@@ -61,7 +44,7 @@ function summarizeModels(models) {
61
44
 
62
45
  export async function runProbe({ live = false } = {}) {
63
46
  const modelsResponse = await proxyModels(new Request("http://aerial.local/v1/models", { method: "GET" }));
64
- const modelsPayload = await readJson(modelsResponse);
47
+ const modelsPayload = await readJsonSafely(modelsResponse);
65
48
  if (!modelsResponse.ok) {
66
49
  return { ok: false, generatedAt: new Date().toISOString(), error: modelsPayload.error || modelsPayload };
67
50
  }
@@ -0,0 +1,8 @@
1
+ export function parseNumberChoice(value, { max, defaultIndex = 0, oneBased = false } = {}) {
2
+ const trimmed = String(value || "").trim();
3
+ if (!trimmed) return defaultIndex;
4
+ if (!/^\d+$/.test(trimmed)) return undefined;
5
+ const n = Number(trimmed);
6
+ if (n < 1 || n > max) return undefined;
7
+ return oneBased ? n : n - 1;
8
+ }
@@ -0,0 +1,51 @@
1
+ export const PROXY_MODE_DISABLED = "disabled";
2
+ export const PROXY_MODE_AUTO = "auto";
3
+
4
+ export function normalizeProxyMode(value) {
5
+ return value === PROXY_MODE_AUTO ? PROXY_MODE_AUTO : PROXY_MODE_DISABLED;
6
+ }
7
+
8
+ export function normalizeProxyEndpoint(value) {
9
+ if (typeof value !== "string" || !value.trim()) return undefined;
10
+ let url;
11
+ try {
12
+ url = new URL(value.trim());
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ const protocol = url.protocol === "socks:" || url.protocol === "socks5h:" ? "socks5:" : url.protocol;
17
+ if (!["http:", "https:", "socks5:"].includes(protocol)) return undefined;
18
+ if (!url.hostname) return undefined;
19
+ const pathPart = `${url.pathname || "/"}${url.search || ""}${url.hash || ""}`;
20
+ if (pathPart !== "/") return undefined;
21
+ const auth = url.username ? `${url.username}${url.password ? `:${url.password}` : ""}@` : "";
22
+ return `${protocol}//${auth}${url.host}`;
23
+ }
24
+
25
+ export function isSocksProxyEndpoint(value) {
26
+ const endpoint = normalizeProxyEndpoint(value);
27
+ return Boolean(endpoint?.startsWith("socks5://"));
28
+ }
29
+
30
+ export function redactProxyEndpoint(value) {
31
+ const normalized = normalizeProxyEndpoint(value);
32
+ const text = normalized || (typeof value === "string" ? value : undefined);
33
+ if (!text) return undefined;
34
+ try {
35
+ const url = new URL(text);
36
+ if (!url.username && !url.password) return normalized || text;
37
+ const protocol = url.protocol === "socks:" || url.protocol === "socks5h:" ? "socks5:" : url.protocol;
38
+ if (normalized) return `${protocol}//redacted@${url.host}`;
39
+ url.protocol = protocol;
40
+ url.username = "redacted";
41
+ url.password = "";
42
+ return url.toString();
43
+ } catch {
44
+ return text;
45
+ }
46
+ }
47
+
48
+ export function redactProxySource(value) {
49
+ if (typeof value !== "string") return value;
50
+ return value.replace(/\b(?:https?|socks5h?|socks):\/\/[^\s),]+/g, (url) => redactProxyEndpoint(url) || url);
51
+ }
@@ -1,5 +1,6 @@
1
1
  import { WebSocket } from "undici";
2
2
  import { COPILOT_API_ORIGIN } from "./constants.js";
3
+ import { upstreamDispatcher } from "./upstream-fetch.js";
3
4
 
4
5
  const TERMINAL_RESPONSE_TYPES = new Set([
5
6
  "response.completed",
@@ -45,9 +46,10 @@ function isTerminalMessage(message) {
45
46
  }
46
47
  }
47
48
 
48
- function openWebSocket(headers) {
49
+ async function openWebSocket(headers) {
50
+ const dispatcher = await upstreamDispatcher();
49
51
  return new Promise((resolve, reject) => {
50
- const ws = new WebSocket(websocketUrl(), { headers });
52
+ const ws = new WebSocket(websocketUrl(), dispatcher ? { headers, dispatcher } : { headers });
51
53
  const cleanup = () => {
52
54
  ws.removeEventListener("open", onOpen);
53
55
  ws.removeEventListener("error", onError);