@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/README.md +25 -1
- package/docs/usage.md +24 -1
- package/package.json +9 -4
- package/src/auth.js +4 -3
- package/src/cli.js +199 -98
- package/src/config.js +22 -1
- package/src/copilot.js +18 -12
- package/src/http-utils.js +9 -0
- package/src/model-selection.js +7 -32
- package/src/model-utils.js +20 -0
- package/src/probe.js +7 -24
- package/src/prompt-utils.js +8 -0
- package/src/proxy-config.js +51 -0
- package/src/responses-websocket.js +4 -2
- package/src/service.js +305 -214
- package/src/setup-selection.js +2 -9
- package/src/setup.js +41 -22
- package/src/socks5-bridge.js +189 -0
- package/src/upstream-fetch.js +354 -0
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
|
-
|
|
128
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) });
|
package/src/model-selection.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 ?
|
|
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 (
|
|
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
|
|
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);
|