@jiayunxie/aerial 0.1.5 → 0.1.7
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 +55 -211
- package/docs/usage.md +42 -10
- package/package.json +1 -1
- package/src/app-status.js +23 -0
- package/src/auth.js +23 -3
- package/src/cli.js +134 -19
- package/src/copilot.js +37 -10
- package/src/model-selection.js +144 -0
- package/src/server.js +53 -1
- package/src/service.js +118 -13
- package/src/setup.js +14 -6
package/src/cli.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { startDeviceFlow, pollDeviceFlow } from "./auth.js";
|
|
3
|
+
import { startDeviceFlow, pollDeviceFlow, readGitHubToken, gitHubTokenSource } from "./auth.js";
|
|
4
4
|
import { ensureApiKey, loadConfig, saveConfig } from "./config.js";
|
|
5
5
|
import { startServer } from "./server.js";
|
|
6
6
|
import { setupClaude, setupCodex, setupStatus, restoreClient, restoreAllClients } from "./setup.js";
|
|
7
7
|
import { serviceInstall, serviceStart, serviceStop, serviceRestart, serviceUninstall, serviceStatus } from "./service.js";
|
|
8
8
|
import { doctor } from "./doctor.js";
|
|
9
9
|
import { runProbe, formatProbeReport } from "./probe.js";
|
|
10
|
+
import { chooseSetupModel, formatModelChoices } from "./model-selection.js";
|
|
10
11
|
import { printVersion } from "./version.js";
|
|
12
|
+
import { computeAppStatus } from "./app-status.js";
|
|
11
13
|
|
|
12
14
|
const CLI_ENTRY = fileURLToPath(import.meta.url);
|
|
13
15
|
|
|
@@ -20,36 +22,35 @@ function codexAuthCommand() {
|
|
|
20
22
|
};
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
function quoteCommandPart(value) {
|
|
26
|
+
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function claudeApiKeyHelper() {
|
|
30
|
+
return [process.execPath, CLI_ENTRY, "key", "print"].map(quoteCommandPart).join(" ");
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
function printHelp() {
|
|
24
34
|
console.log(`Aerial local Copilot proxy
|
|
25
35
|
|
|
26
36
|
Usage:
|
|
27
37
|
aerial --version
|
|
28
38
|
aerial login
|
|
29
|
-
aerial key generate
|
|
30
|
-
aerial key print
|
|
31
|
-
aerial start [--host 127.0.0.1] [--port 18181]
|
|
32
39
|
aerial setup codex [--model <id>]
|
|
33
40
|
aerial setup claude [--model <id>]
|
|
41
|
+
aerial service install
|
|
42
|
+
aerial status [--json]
|
|
43
|
+
|
|
44
|
+
Diagnostics and rollback:
|
|
34
45
|
aerial setup status [--json]
|
|
35
46
|
aerial setup restore <codex|claude|all> --latest
|
|
36
|
-
aerial service install
|
|
37
|
-
aerial service start
|
|
38
|
-
aerial service stop
|
|
39
|
-
aerial service restart
|
|
40
47
|
aerial service status [--json]
|
|
41
|
-
aerial service uninstall
|
|
42
48
|
aerial disable
|
|
43
49
|
aerial doctor
|
|
44
50
|
aerial probe [--live] [--json]
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
GET /v1/models
|
|
49
|
-
POST /v1/responses
|
|
50
|
-
POST /v1/messages
|
|
51
|
-
POST /v1/messages/count_tokens
|
|
52
|
-
POST /v1/chat/completions`);
|
|
52
|
+
Debug:
|
|
53
|
+
aerial start [--host 127.0.0.1] [--port 18181]`);
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function argValue(args, name) {
|
|
@@ -57,13 +58,96 @@ function argValue(args, name) {
|
|
|
57
58
|
return index >= 0 ? args[index + 1] : undefined;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
async function selectSetupModel(target, route, args) {
|
|
62
|
+
const selected = await chooseSetupModel({ target, route, explicitModel: argValue(args, "--model") });
|
|
63
|
+
if (!selected.displayed) {
|
|
64
|
+
for (const line of formatModelChoices({ target, route, choices: selected.choices, selectedModel: selected.model, source: selected.source, recommended: selected.recommended })) {
|
|
65
|
+
console.log(line);
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
console.log(`Selected ${target} model: ${selected.model}`);
|
|
69
|
+
}
|
|
70
|
+
return selected.model;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function printSetupSummary(status) {
|
|
74
|
+
console.log("clients:");
|
|
75
|
+
for (const client of Object.values(status.clients)) {
|
|
76
|
+
const model = client.model ? ` model=${client.model}` : "";
|
|
77
|
+
console.log(` ${client.target}: ${client.state}${model}`);
|
|
78
|
+
}
|
|
79
|
+
console.log(`api key: ${status.auth.api_key.exists ? "present" : "missing"}`);
|
|
80
|
+
const ghSource = status.auth.github_token.source;
|
|
81
|
+
const ghText = ghSource === "missing" ? "missing" : `present (${ghSource})`;
|
|
82
|
+
console.log(`github login: ${ghText}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function printServiceSummary(status) {
|
|
86
|
+
console.log(`service: ${status.summary}`);
|
|
87
|
+
if (status.supported === false) {
|
|
88
|
+
console.log(`platform: ${status.platform} (service unsupported)`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const health = status.health?.aerial ? `ok (${status.health.supervisor})`
|
|
92
|
+
: status.health?.portConflict ? `port conflict (${status.health.conflictReason})`
|
|
93
|
+
: status.health?.ok ? "ok"
|
|
94
|
+
: `unreachable (${status.health?.error || `http ${status.health?.status}`})`;
|
|
95
|
+
console.log(`health: ${health}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function appStatus({ json = false } = {}) {
|
|
99
|
+
const setup = setupStatus();
|
|
100
|
+
const service = await serviceStatus();
|
|
101
|
+
const status = computeAppStatus(setup, service);
|
|
102
|
+
if (json) {
|
|
103
|
+
console.log(JSON.stringify(status, null, 2));
|
|
104
|
+
return status;
|
|
105
|
+
}
|
|
106
|
+
console.log("Aerial status");
|
|
107
|
+
printSetupSummary(setup);
|
|
108
|
+
printServiceSummary(service);
|
|
109
|
+
if (status.nextSteps.length) {
|
|
110
|
+
console.log("next:");
|
|
111
|
+
for (const step of status.nextSteps) console.log(` - ${step}`);
|
|
112
|
+
}
|
|
113
|
+
if (status.hints.length) {
|
|
114
|
+
console.log("hints:");
|
|
115
|
+
for (const hint of status.hints) console.log(` - ${hint}`);
|
|
116
|
+
}
|
|
117
|
+
return status;
|
|
118
|
+
}
|
|
119
|
+
|
|
60
120
|
async function main() {
|
|
61
121
|
const args = process.argv.slice(2);
|
|
62
122
|
const [command, subcommand, ...rest] = args;
|
|
63
123
|
if (!command || command === "--help" || command === "-h") return printHelp();
|
|
64
124
|
if (command === "--version") return printVersion();
|
|
125
|
+
if (command === "status") {
|
|
126
|
+
const status = await appStatus({ json: args.includes("--json") });
|
|
127
|
+
process.exitCode = status.ok ? 0 : 1;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
65
130
|
|
|
66
131
|
if (command === "login") {
|
|
132
|
+
const loginArgs = args.slice(1);
|
|
133
|
+
const force = loginArgs.includes("--force");
|
|
134
|
+
const source = gitHubTokenSource();
|
|
135
|
+
if (force && source === "env") {
|
|
136
|
+
console.error("AERIAL_GITHUB_TOKEN is set; unset it before running `aerial login --force`, otherwise the env value will continue to shadow any new file token.");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
if (!force && source === "env") {
|
|
140
|
+
console.log("GitHub login is provided by AERIAL_GITHUB_TOKEN (not verified). To use a different account, unset it or run with a different environment.");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!force && source === "file") {
|
|
144
|
+
console.log("GitHub login already exists (not verified). To sign in again, run aerial login --force.");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (process.env.AERIAL_TEST_LOGIN_NO_NETWORK === "1") {
|
|
148
|
+
console.log("AERIAL_TEST_LOGIN_NO_NETWORK=1 set; skipping GitHub device flow (test mode).");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
67
151
|
const flow = await startDeviceFlow();
|
|
68
152
|
console.log(`Open: ${flow.verification_uri}`);
|
|
69
153
|
console.log(`Code: ${flow.user_code}`);
|
|
@@ -102,16 +186,20 @@ async function main() {
|
|
|
102
186
|
|
|
103
187
|
if (command === "setup") {
|
|
104
188
|
if (subcommand === "codex") {
|
|
105
|
-
const
|
|
189
|
+
const model = await selectSetupModel("Codex", "responses", rest);
|
|
190
|
+
const result = setupCodex({ model, authCommand: codexAuthCommand() });
|
|
106
191
|
console.log(`Updated Codex config: ${result.file}`);
|
|
192
|
+
console.log(`Configured Codex model: ${result.model}`);
|
|
107
193
|
if (result.backup) console.log(`Backup: ${result.backup}`);
|
|
108
194
|
console.log("Configured Codex to read the local Aerial key automatically.");
|
|
109
195
|
return;
|
|
110
196
|
}
|
|
111
197
|
if (subcommand === "claude") {
|
|
112
|
-
const
|
|
198
|
+
const model = await selectSetupModel("Claude Code", "messages", rest);
|
|
199
|
+
const result = setupClaude({ model, apiKeyHelper: claudeApiKeyHelper() });
|
|
113
200
|
console.log(`Updated Claude settings: ${result.file}`);
|
|
114
201
|
if (result.model) console.log(`Configured Claude default model: ${result.model}`);
|
|
202
|
+
console.log("Configured Claude Code to read the local Aerial key automatically.");
|
|
115
203
|
if (result.backup) console.log(`Backup: ${result.backup}`);
|
|
116
204
|
return;
|
|
117
205
|
}
|
|
@@ -126,7 +214,12 @@ async function main() {
|
|
|
126
214
|
}
|
|
127
215
|
console.log(`Aerial: http://${status.config.host}:${status.config.port} (platform: ${status.platform})`);
|
|
128
216
|
console.log(`API key file: ${status.auth.api_key.file} (${status.auth.api_key.exists ? "present" : "missing"})`);
|
|
129
|
-
|
|
217
|
+
const ghSourceText = status.auth.github_token.source === "missing"
|
|
218
|
+
? `(missing)`
|
|
219
|
+
: status.auth.github_token.source === "env"
|
|
220
|
+
? `(present, source=env; file path ${status.auth.github_token.file} is not consulted while AERIAL_GITHUB_TOKEN is set)`
|
|
221
|
+
: `${status.auth.github_token.file} (present, source=file)`;
|
|
222
|
+
console.log(`GitHub token: ${ghSourceText}`);
|
|
130
223
|
for (const cs of Object.values(status.clients)) {
|
|
131
224
|
const head = `${cs.target.padEnd(7)} state=${cs.state}`;
|
|
132
225
|
console.log(`${head} file=${cs.file}`);
|
|
@@ -221,6 +314,17 @@ async function main() {
|
|
|
221
314
|
if (r.bootstrap?.stderr) console.log(` bootstrap stderr: ${r.bootstrap.stderr.trim()}`);
|
|
222
315
|
if (r.create?.stderr) console.log(` schtasks stderr: ${r.create.stderr.trim()}`);
|
|
223
316
|
if (r.run?.stderr) console.log(` schtasks /Run stderr: ${r.run.stderr.trim()}`);
|
|
317
|
+
if (r.diagnostics) {
|
|
318
|
+
if (r.diagnostics.stdioLog) console.log(` stdio log: ${r.diagnostics.stdioLog}`);
|
|
319
|
+
if (r.diagnostics.aerialLog) console.log(` aerial log: ${r.diagnostics.aerialLog}`);
|
|
320
|
+
if (r.diagnostics.wrapperNode) console.log(` wrapper node: ${r.diagnostics.wrapperNode}`);
|
|
321
|
+
if (r.diagnostics.health) {
|
|
322
|
+
const h = r.diagnostics.health;
|
|
323
|
+
const tail = h.lastError ? `, last error: ${h.lastError}` : (h.lastStatus !== undefined ? `, last status: ${h.lastStatus}` : "");
|
|
324
|
+
console.log(` health probe: ${h.attempts} attempts over ${h.elapsedMs}ms${tail}`);
|
|
325
|
+
}
|
|
326
|
+
console.log(` Run: aerial service status --json`);
|
|
327
|
+
}
|
|
224
328
|
if (r.warning) console.log(` WARNING: ${r.warning.message}`);
|
|
225
329
|
process.exitCode = r.ok ? 0 : 1;
|
|
226
330
|
} catch (err) {
|
|
@@ -236,6 +340,17 @@ async function main() {
|
|
|
236
340
|
else if (r.ok) console.log(`Service start: ok (${r.platform})`);
|
|
237
341
|
else console.log(`Service start: FAILED (${r.reason || `status=${r.status}`})${r.message ? ": " + r.message : ""}`);
|
|
238
342
|
if (r.stderr) console.log(` ${r.stderr.trim()}`);
|
|
343
|
+
if (r.diagnostics) {
|
|
344
|
+
if (r.diagnostics.stdioLog) console.log(` stdio log: ${r.diagnostics.stdioLog}`);
|
|
345
|
+
if (r.diagnostics.aerialLog) console.log(` aerial log: ${r.diagnostics.aerialLog}`);
|
|
346
|
+
if (r.diagnostics.wrapperNode) console.log(` wrapper node: ${r.diagnostics.wrapperNode}`);
|
|
347
|
+
if (r.diagnostics.health) {
|
|
348
|
+
const h = r.diagnostics.health;
|
|
349
|
+
const tail = h.lastError ? `, last error: ${h.lastError}` : (h.lastStatus !== undefined ? `, last status: ${h.lastStatus}` : "");
|
|
350
|
+
console.log(` health probe: ${h.attempts} attempts over ${h.elapsedMs}ms${tail}`);
|
|
351
|
+
}
|
|
352
|
+
console.log(` Run: aerial service status --json`);
|
|
353
|
+
}
|
|
239
354
|
if (r.warning) console.log(` WARNING: ${r.warning.message}`);
|
|
240
355
|
process.exitCode = r.ok ? 0 : 1;
|
|
241
356
|
} catch (err) {
|
package/src/copilot.js
CHANGED
|
@@ -179,23 +179,50 @@ function withSupportedAnthropicEffort(payload) {
|
|
|
179
179
|
if (effort === undefined) return payload;
|
|
180
180
|
const model = typeof payload?.model === "string" ? payload.model : "";
|
|
181
181
|
if (!/^claude-opus-4[.-]7(?:-|$)/.test(model)) return payload;
|
|
182
|
-
const routes = {
|
|
183
|
-
low: "claude-opus-4.7-1m-internal",
|
|
184
|
-
medium: "claude-opus-4.7",
|
|
185
|
-
high: "claude-opus-4.7-high",
|
|
186
|
-
xhigh: "claude-opus-4.7-xhigh",
|
|
187
|
-
max: "claude-opus-4.7-xhigh"
|
|
188
|
-
};
|
|
189
|
-
const nextModel = routes[effort];
|
|
190
182
|
const nextEffort = effort === "max" ? "xhigh" : effort;
|
|
191
|
-
if (!
|
|
183
|
+
if (!["low", "medium", "high", "xhigh"].includes(nextEffort)) return payload;
|
|
184
|
+
const baseModel = /^claude-opus-4[.-]7$/.test(model);
|
|
185
|
+
const legacyEffortModel = /^claude-opus-4[.-]7-(?:high|xhigh)$/.test(model);
|
|
186
|
+
const nextModel = legacyEffortModel || (baseModel && nextEffort !== "medium")
|
|
187
|
+
? "claude-opus-4.7-1m-internal"
|
|
188
|
+
: model;
|
|
192
189
|
if (model === nextModel && effort === nextEffort) return payload;
|
|
193
190
|
logEvent("anthropic_effort_route", { model, effort, routedModel: nextModel, routedEffort: nextEffort });
|
|
194
191
|
return { ...payload, model: nextModel, output_config: { ...payload.output_config, effort: nextEffort } };
|
|
195
192
|
}
|
|
196
193
|
|
|
194
|
+
function legacyThinkingEffort(thinking) {
|
|
195
|
+
if (typeof thinking?.effort === "string" && thinking.effort.trim()) return thinking.effort.trim();
|
|
196
|
+
const budget = Number(thinking?.budget_tokens);
|
|
197
|
+
if (!Number.isFinite(budget) || budget <= 0) return "medium";
|
|
198
|
+
if (budget <= 4096) return "low";
|
|
199
|
+
if (budget <= 16000) return "medium";
|
|
200
|
+
if (budget <= 64000) return "high";
|
|
201
|
+
return "xhigh";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isLegacyThinkingEnabled(thinking) {
|
|
205
|
+
if (!thinking || typeof thinking !== "object" || Array.isArray(thinking)) return false;
|
|
206
|
+
if (thinking.type === "enabled") return true;
|
|
207
|
+
return Boolean(thinking.type && typeof thinking.type === "object" && thinking.type.enabled);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function withSupportedAnthropicThinking(payload) {
|
|
211
|
+
if (!isLegacyThinkingEnabled(payload?.thinking)) return payload;
|
|
212
|
+
const outputConfig = payload?.output_config && typeof payload.output_config === "object" && !Array.isArray(payload.output_config)
|
|
213
|
+
? payload.output_config
|
|
214
|
+
: {};
|
|
215
|
+
const effort = outputConfig.effort ?? legacyThinkingEffort(payload.thinking);
|
|
216
|
+
logEvent("anthropic_thinking_route", { model: payload.model, routedType: "adaptive", routedEffort: effort });
|
|
217
|
+
return {
|
|
218
|
+
...payload,
|
|
219
|
+
thinking: { type: "adaptive" },
|
|
220
|
+
output_config: { ...outputConfig, effort }
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
197
224
|
function withAnthropicDefaults(payload) {
|
|
198
|
-
return withSupportedAnthropicEffort(withDefaultAnthropicCache(payload));
|
|
225
|
+
return withSupportedAnthropicEffort(withSupportedAnthropicThinking(withDefaultAnthropicCache(payload)));
|
|
199
226
|
}
|
|
200
227
|
|
|
201
228
|
function parseJsonBody(body, contentType) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { proxyModels } from "./copilot.js";
|
|
4
|
+
|
|
5
|
+
const MAX_LISTED_MODELS = 20;
|
|
6
|
+
const GPT_VERSION_RE = /^gpt-(\d+)(?:\.(\d+))?/i;
|
|
7
|
+
const STABLE_GPT_RE = /^gpt-\d+(?:\.\d+)?$/i;
|
|
8
|
+
|
|
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
|
+
}
|
|
28
|
+
|
|
29
|
+
function gptVersionScore(id) {
|
|
30
|
+
const match = GPT_VERSION_RE.exec(id);
|
|
31
|
+
if (!match) return undefined;
|
|
32
|
+
const major = Number(match[1]);
|
|
33
|
+
const minor = match[2] === undefined ? 0 : Number(match[2]);
|
|
34
|
+
return major * 1000 + minor;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function rankModels(choices) {
|
|
38
|
+
return [...choices]
|
|
39
|
+
.map((choice, index) => ({ choice, index, score: gptVersionScore(choice.id) }))
|
|
40
|
+
.sort((a, b) => {
|
|
41
|
+
if (a.score !== undefined && b.score !== undefined && a.score !== b.score) return b.score - a.score;
|
|
42
|
+
if (a.score !== undefined && b.score === undefined) return -1;
|
|
43
|
+
if (a.score === undefined && b.score !== undefined) return 1;
|
|
44
|
+
return a.index - b.index;
|
|
45
|
+
})
|
|
46
|
+
.map((entry) => entry.choice);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function pickRecommended(choices) {
|
|
50
|
+
if (!choices.length) return { recommended: undefined, source: "first_available" };
|
|
51
|
+
const ranked = rankModels(choices);
|
|
52
|
+
const stable = ranked.filter((c) => STABLE_GPT_RE.test(c.id));
|
|
53
|
+
if (stable.length) return { recommended: stable[0].id, source: "recommended_stable", ranked };
|
|
54
|
+
return { recommended: ranked[0].id, source: "recommended_fallback", ranked };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function orderForPrompt(ranked, recommended) {
|
|
58
|
+
if (!recommended) return [...ranked];
|
|
59
|
+
const found = ranked.find((c) => c.id === recommended);
|
|
60
|
+
if (!found) return [...ranked];
|
|
61
|
+
return [found, ...ranked.filter((c) => c.id !== recommended)];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function discoverModelsForRoute(route) {
|
|
65
|
+
const response = await proxyModels(new Request("http://aerial.local/v1/models", { method: "GET" }));
|
|
66
|
+
const payload = await readJson(response);
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
const detail = payload.error || payload.raw || JSON.stringify(payload);
|
|
69
|
+
throw new Error(`Could not load Copilot models (${response.status}): ${detail}`);
|
|
70
|
+
}
|
|
71
|
+
const models = Array.isArray(payload.data) ? payload.data : [];
|
|
72
|
+
return modelsForRoute(models, route);
|
|
73
|
+
}
|
|
74
|
+
|
|
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
|
+
export async function chooseSetupModel({ target, route, explicitModel, prompt = input.isTTY }) {
|
|
84
|
+
if (explicitModel) return { model: explicitModel, choices: [], source: "explicit" };
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = await discoverModelsForRoute(route);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
throw new Error(`${err.message}\nRun \`aerial login\` first, or pass \`--model <id>\` if you already know which ${target} model to use.`);
|
|
90
|
+
}
|
|
91
|
+
if (!raw.length) {
|
|
92
|
+
throw new Error(`No Copilot models currently advertise the ${route} route needed by ${target}. Run \`aerial probe\` to inspect the full model list.`);
|
|
93
|
+
}
|
|
94
|
+
const { recommended, source: recommendedSource, ranked } = pickRecommended(raw);
|
|
95
|
+
const choices = ranked;
|
|
96
|
+
const promptListed = orderForPrompt(ranked, recommended).slice(0, MAX_LISTED_MODELS);
|
|
97
|
+
if (!prompt) return { model: recommended, choices, source: recommendedSource, recommended };
|
|
98
|
+
|
|
99
|
+
const rl = createInterface({ input, output });
|
|
100
|
+
try {
|
|
101
|
+
output.write(`Available ${target} models (${route} route):\n`);
|
|
102
|
+
if (recommendedSource === "recommended_fallback") {
|
|
103
|
+
output.write(` No stable gpt-N.M model available; recommending ${recommended} as a fallback. Pass --model <id> to override.\n`);
|
|
104
|
+
}
|
|
105
|
+
for (const [index, choice] of promptListed.entries()) {
|
|
106
|
+
const marker = choice.id === recommended ? " (recommended)" : "";
|
|
107
|
+
output.write(` ${index + 1}. ${choice.id}${marker}\n`);
|
|
108
|
+
}
|
|
109
|
+
if (choices.length > MAX_LISTED_MODELS) output.write(` ... ${choices.length - MAX_LISTED_MODELS} more\n`);
|
|
110
|
+
while (true) {
|
|
111
|
+
const answer = await rl.question(`Choose ${target} model [1-${promptListed.length}, default 1 = ${recommended}]: `);
|
|
112
|
+
const selected = parseChoice(answer, promptListed.length);
|
|
113
|
+
if (selected) return { model: promptListed[selected - 1].id, choices, source: "prompt", displayed: true, recommended };
|
|
114
|
+
output.write(`Enter a number from 1 to ${promptListed.length}, or press Enter for 1.\n`);
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
rl.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function formatModelChoices({ target, route, choices, selectedModel, source, recommended }) {
|
|
122
|
+
if (!choices.length) return [];
|
|
123
|
+
const lines = [
|
|
124
|
+
`Available ${target} models (${route} route):`
|
|
125
|
+
];
|
|
126
|
+
for (const [index, choice] of choices.slice(0, MAX_LISTED_MODELS).entries()) {
|
|
127
|
+
const markers = [];
|
|
128
|
+
if (choice.id === recommended) markers.push("recommended");
|
|
129
|
+
if (choice.id === selectedModel) markers.push("selected");
|
|
130
|
+
const suffix = markers.length ? ` (${markers.join(", ")})` : "";
|
|
131
|
+
lines.push(` ${index + 1}. ${choice.id}${suffix}`);
|
|
132
|
+
}
|
|
133
|
+
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") {
|
|
137
|
+
lines.push(`No interactive terminal detected; selected ${selectedModel}. Pass --model <id> to choose a different model.`);
|
|
138
|
+
} else if (source === "recommended_fallback") {
|
|
139
|
+
lines.push(`No stable gpt-N.M model available; selected ${selectedModel}. Pass --model <id> to override.`);
|
|
140
|
+
} else {
|
|
141
|
+
lines.push(`Selected ${target} model: ${selectedModel}`);
|
|
142
|
+
}
|
|
143
|
+
return lines;
|
|
144
|
+
}
|
package/src/server.js
CHANGED
|
@@ -2,12 +2,53 @@ import http from "node:http";
|
|
|
2
2
|
import { DEFAULT_HOST, DEFAULT_PORT } from "./constants.js";
|
|
3
3
|
import { loadConfig, validateLocalAuth } from "./config.js";
|
|
4
4
|
import { proxyChatCompletions, proxyMessages, proxyModels, proxyResponses, localCountTokens } from "./copilot.js";
|
|
5
|
+
import { readGitHubToken } from "./auth.js";
|
|
5
6
|
import { logEvent } from "./log.js";
|
|
6
7
|
|
|
7
8
|
function json(status, body) {
|
|
8
9
|
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
function aerialLoginRequired() {
|
|
13
|
+
return json(401, {
|
|
14
|
+
error: {
|
|
15
|
+
type: "authentication_error",
|
|
16
|
+
message: "GitHub login required. Run aerial login.",
|
|
17
|
+
aerial: { status: "login_required" }
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function modelsRouteOrUpstreamAuthFailed(fetchRequest) {
|
|
23
|
+
if (!readGitHubToken()) return aerialLoginRequired();
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await proxyModels(fetchRequest);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const status = err?.aerialUpstreamStatus;
|
|
29
|
+
if (status === 401 || status === 403) {
|
|
30
|
+
return json(status, {
|
|
31
|
+
error: {
|
|
32
|
+
type: "authentication_error",
|
|
33
|
+
message: "GitHub login was rejected by Copilot. Run aerial login --force.",
|
|
34
|
+
aerial: { status: "upstream_auth_failed", upstream_status: status }
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
if (response.status === 401 || response.status === 403) {
|
|
41
|
+
return json(response.status, {
|
|
42
|
+
error: {
|
|
43
|
+
type: "authentication_error",
|
|
44
|
+
message: "GitHub login was rejected by Copilot. Run aerial login --force.",
|
|
45
|
+
aerial: { status: "upstream_auth_failed", upstream_status: response.status }
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return response;
|
|
50
|
+
}
|
|
51
|
+
|
|
11
52
|
function nodeRequestToFetch(req, body, signal) {
|
|
12
53
|
return new Request(`http://${req.headers.host}${req.url}`, { method: req.method, headers: req.headers, body: body.length ? body : undefined, duplex: "half", signal });
|
|
13
54
|
}
|
|
@@ -24,12 +65,23 @@ async function handle(fetchRequest, runtime = {}) {
|
|
|
24
65
|
if (fetchRequest.method === "GET" && url.pathname === "/health") {
|
|
25
66
|
return Response.json({ ok: true, service: "aerial", host: runtime.host || config.host, port: runtime.port || config.port });
|
|
26
67
|
}
|
|
68
|
+
if (fetchRequest.method === "GET" && url.pathname === "/") {
|
|
69
|
+
return Response.json({
|
|
70
|
+
service: "aerial",
|
|
71
|
+
ok: true,
|
|
72
|
+
message: "Aerial is running. This is a local-only Copilot proxy; inference routes require the local Aerial API key.",
|
|
73
|
+
endpoints: { health: "/health", models: "/v1/models" },
|
|
74
|
+
next_steps: ["Open /health for an unauthenticated check", "Run `aerial status` for a full diagnostic"]
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (fetchRequest.method === "GET" && url.pathname === "/v1/models") {
|
|
78
|
+
return modelsRouteOrUpstreamAuthFailed(fetchRequest);
|
|
79
|
+
}
|
|
27
80
|
|
|
28
81
|
if (!validateLocalAuth(Object.fromEntries(fetchRequest.headers), config)) {
|
|
29
82
|
return json(401, { error: { type: "authentication_error", message: "Invalid or missing Aerial API key" } });
|
|
30
83
|
}
|
|
31
84
|
|
|
32
|
-
if (fetchRequest.method === "GET" && url.pathname === "/v1/models") return proxyModels(fetchRequest);
|
|
33
85
|
if (fetchRequest.method === "POST" && url.pathname === "/v1/responses") return proxyResponses(fetchRequest);
|
|
34
86
|
if (fetchRequest.method === "POST" && url.pathname === "/v1/messages") return proxyMessages(fetchRequest);
|
|
35
87
|
if (fetchRequest.method === "POST" && url.pathname === "/v1/messages/count_tokens") return localCountTokens(fetchRequest);
|