@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/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
- MVP routes:
47
- GET /health
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 result = setupCodex({ model: argValue(rest, "--model"), authCommand: codexAuthCommand() });
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 result = setupClaude({ model: argValue(rest, "--model") });
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
- console.log(`GitHub token: ${status.auth.github_token.file} (${status.auth.github_token.exists ? "present" : "missing"})`);
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 (!nextModel) return payload;
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);