@jiayunxie/aerial 0.1.6 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiayunxie/aerial",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Local-only GitHub Copilot proxy for Codex CLI and Claude Code.",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli.js CHANGED
@@ -314,6 +314,17 @@ async function main() {
314
314
  if (r.bootstrap?.stderr) console.log(` bootstrap stderr: ${r.bootstrap.stderr.trim()}`);
315
315
  if (r.create?.stderr) console.log(` schtasks stderr: ${r.create.stderr.trim()}`);
316
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
+ }
317
328
  if (r.warning) console.log(` WARNING: ${r.warning.message}`);
318
329
  process.exitCode = r.ok ? 0 : 1;
319
330
  } catch (err) {
@@ -329,6 +340,17 @@ async function main() {
329
340
  else if (r.ok) console.log(`Service start: ok (${r.platform})`);
330
341
  else console.log(`Service start: FAILED (${r.reason || `status=${r.status}`})${r.message ? ": " + r.message : ""}`);
331
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
+ }
332
354
  if (r.warning) console.log(` WARNING: ${r.warning.message}`);
333
355
  process.exitCode = r.ok ? 0 : 1;
334
356
  } catch (err) {
package/src/copilot.js CHANGED
@@ -174,28 +174,99 @@ function withDefaultAnthropicCache(payload) {
174
174
  return next;
175
175
  }
176
176
 
177
- function withSupportedAnthropicEffort(payload) {
177
+ function canonicalClaudeOpus47Family(model) {
178
+ return /^claude-opus-4[.-]7(?:-|$)/.test(model) ? "claude-opus-4.7" : undefined;
179
+ }
180
+
181
+ function modelHasMessagesRoute(model) {
182
+ const endpoints = Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : [];
183
+ const routes = Array.isArray(model?.aerial?.routes) ? model.aerial.routes : [];
184
+ return endpoints.includes("/v1/messages") || routes.includes("messages");
185
+ }
186
+
187
+ function supportedReasoningEfforts(model) {
188
+ const supports = model?.capabilities?.supports;
189
+ const values = supports?.reasoning_effort ?? supports?.reasoning_efforts;
190
+ if (Array.isArray(values)) return values.map(String);
191
+ if (typeof values === "string") return [values];
192
+ return [];
193
+ }
194
+
195
+ function modelSupportsAdaptiveThinking(model) {
196
+ const supports = model?.capabilities?.supports;
197
+ return supports?.adaptive_thinking === true || supports?.thinking?.adaptive === true;
198
+ }
199
+
200
+ function findAnthropicEffortModel(modelId, effort, models) {
201
+ if (!Array.isArray(models)) return undefined;
202
+ const family = canonicalClaudeOpus47Family(modelId);
203
+ if (!family) return undefined;
204
+ const candidates = models.filter((model) => {
205
+ const id = typeof model?.id === "string" ? model.id : "";
206
+ return canonicalClaudeOpus47Family(id) === family &&
207
+ modelHasMessagesRoute(model) &&
208
+ modelSupportsAdaptiveThinking(model) &&
209
+ supportedReasoningEfforts(model).includes(effort);
210
+ });
211
+ return candidates.find((model) => model.id === modelId) || candidates[0];
212
+ }
213
+
214
+ function withSupportedAnthropicEffort(payload, models) {
178
215
  const effort = payload?.output_config?.effort;
179
216
  if (effort === undefined) return payload;
180
217
  const model = typeof payload?.model === "string" ? payload.model : "";
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];
218
+ if (!canonicalClaudeOpus47Family(model)) return payload;
190
219
  const nextEffort = effort === "max" ? "xhigh" : effort;
191
- if (!nextModel) return payload;
220
+ if (!["low", "medium", "high", "xhigh"].includes(nextEffort)) return payload;
221
+ const routed = findAnthropicEffortModel(model, nextEffort, models);
222
+ const nextModel = routed?.id || model;
192
223
  if (model === nextModel && effort === nextEffort) return payload;
193
224
  logEvent("anthropic_effort_route", { model, effort, routedModel: nextModel, routedEffort: nextEffort });
194
225
  return { ...payload, model: nextModel, output_config: { ...payload.output_config, effort: nextEffort } };
195
226
  }
196
227
 
197
- function withAnthropicDefaults(payload) {
198
- return withSupportedAnthropicEffort(withDefaultAnthropicCache(payload));
228
+ function legacyThinkingEffort(thinking) {
229
+ if (typeof thinking?.effort === "string" && thinking.effort.trim()) return thinking.effort.trim();
230
+ const budget = Number(thinking?.budget_tokens);
231
+ if (!Number.isFinite(budget) || budget <= 0) return "medium";
232
+ if (budget <= 4096) return "low";
233
+ if (budget <= 16000) return "medium";
234
+ if (budget <= 64000) return "high";
235
+ return "xhigh";
236
+ }
237
+
238
+ function isLegacyThinkingEnabled(thinking) {
239
+ if (!thinking || typeof thinking !== "object" || Array.isArray(thinking)) return false;
240
+ if (thinking.type === "enabled") return true;
241
+ return Boolean(thinking.type && typeof thinking.type === "object" && thinking.type.enabled);
242
+ }
243
+
244
+ function withSupportedAnthropicThinking(payload) {
245
+ if (!isLegacyThinkingEnabled(payload?.thinking)) return payload;
246
+ const outputConfig = payload?.output_config && typeof payload.output_config === "object" && !Array.isArray(payload.output_config)
247
+ ? payload.output_config
248
+ : {};
249
+ const effort = outputConfig.effort ?? legacyThinkingEffort(payload.thinking);
250
+ logEvent("anthropic_thinking_route", { model: payload.model, routedType: "adaptive", routedEffort: effort });
251
+ return {
252
+ ...payload,
253
+ thinking: { type: "adaptive" },
254
+ output_config: { ...outputConfig, effort }
255
+ };
256
+ }
257
+
258
+ function shouldLoadAnthropicCatalog(payload) {
259
+ const effort = payload?.output_config?.effort;
260
+ const model = typeof payload?.model === "string" ? payload.model : "";
261
+ return effort !== undefined && Boolean(canonicalClaudeOpus47Family(model));
262
+ }
263
+
264
+ async function withAnthropicDefaults(payload) {
265
+ const next = withSupportedAnthropicThinking(withDefaultAnthropicCache(payload));
266
+ const models = shouldLoadAnthropicCatalog(next)
267
+ ? await fetchModelsCatalog().catch(() => undefined)
268
+ : undefined;
269
+ return withSupportedAnthropicEffort(next, models);
199
270
  }
200
271
 
201
272
  function parseJsonBody(body, contentType) {
@@ -380,7 +451,7 @@ function wrapResponseWithCacheObserver(upstream, route, requestCache) {
380
451
 
381
452
  async function requestWithJsonBody(request, transform) {
382
453
  const payload = await request.json();
383
- const nextPayload = transform(payload);
454
+ const nextPayload = await transform(payload);
384
455
  return new Request(request.url, {
385
456
  method: request.method,
386
457
  headers: request.headers,
@@ -408,7 +479,7 @@ async function proxyFetch(path, request, { extraHeaders = {}, bodyOverride } = {
408
479
  return wrapResponseWithCacheObserver(upstream, path, requestCache);
409
480
  }
410
481
 
411
- async function fetchModelsForResponseSelection() {
482
+ async function fetchModelsCatalog() {
412
483
  const token = await getCopilotToken();
413
484
  const response = await fetch(`${COPILOT_API_ORIGIN}/models`, { method: "GET", headers: upstreamHeaders(token) });
414
485
  if (!response.ok) return undefined;
@@ -434,7 +505,7 @@ export async function proxyResponses(request) {
434
505
  // per-request /models lookup to avoid an extra upstream call and let the
435
506
  // shared HTTP path handle cache_request logging via proxyFetch.
436
507
  if (payload?.stream && isResponsesWebSocketOptIn()) {
437
- const models = await fetchModelsForResponseSelection().catch(() => undefined);
508
+ const models = await fetchModelsCatalog().catch(() => undefined);
438
509
  const model = models ? responseModelFromCatalog(payload.model, models) : undefined;
439
510
  if (shouldUseResponsesWebSocket(payload, model)) {
440
511
  const requestCache = cacheRequestFields(payload);
package/src/service.js CHANGED
@@ -15,6 +15,7 @@ const DEFAULT_WRAPPER_LOG_MAX_BYTES = 5 * 1024 * 1024;
15
15
  const DEFAULT_WRAPPER_LOG_BACKUPS = 3;
16
16
  const HEALTH_TIMEOUT_MS = 1500;
17
17
  const HEALTH_START_TIMEOUT_MS = 5000;
18
+ const HEALTH_POLL_INTERVAL_MS = 250;
18
19
 
19
20
  function wrapperLogConfig() {
20
21
  const out = { maxBytes: DEFAULT_WRAPPER_LOG_MAX_BYTES, backups: DEFAULT_WRAPPER_LOG_BACKUPS };
@@ -339,6 +340,53 @@ function classifyHealth(probe) {
339
340
  return { mode: "aerial_running", body: probe.body };
340
341
  }
341
342
 
343
+ async function pollForAerialUp(host, port, healthFetch, deadlineMs = HEALTH_START_TIMEOUT_MS) {
344
+ const fetcher = healthFetch || defaultHealthFetch;
345
+ const start = Date.now();
346
+ let lastProbe;
347
+ let lastCls;
348
+ let attempts = 0;
349
+ while (Date.now() - start < deadlineMs) {
350
+ attempts += 1;
351
+ lastProbe = await fetcher(host, port);
352
+ lastCls = classifyHealth(lastProbe);
353
+ if (lastCls.mode === "aerial_running" || lastCls.mode === "port_conflict") {
354
+ return { cls: lastCls, probe: lastProbe, attempts, elapsedMs: Date.now() - start };
355
+ }
356
+ await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS));
357
+ }
358
+ return { cls: lastCls || { mode: "absent" }, probe: lastProbe, attempts, elapsedMs: Date.now() - start };
359
+ }
360
+
361
+ function readWrapperNodePath(wrapperFile) {
362
+ if (!wrapperFile) return undefined;
363
+ try {
364
+ if (!fs.existsSync(wrapperFile)) return undefined;
365
+ const data = fs.readFileSync(wrapperFile, "utf8");
366
+ if (wrapperFile.endsWith(".sh")) {
367
+ const m = data.match(/^NODE_BIN='([^']*)'/m);
368
+ return m ? m[1] : undefined;
369
+ }
370
+ if (wrapperFile.endsWith(".ps1")) {
371
+ const m = data.match(/^\$node\s*=\s*'([^']*)'/m);
372
+ return m ? m[1] : undefined;
373
+ }
374
+ } catch {}
375
+ return undefined;
376
+ }
377
+
378
+ function healthFailedDiagnostics({ wrapper, probe, attempts, elapsedMs }) {
379
+ const out = {
380
+ stdioLog: stdioLogPath(),
381
+ aerialLog: aerialLogPath(),
382
+ wrapperNode: readWrapperNodePath(wrapper),
383
+ health: { attempts, elapsedMs }
384
+ };
385
+ if (probe && probe.error) out.health.lastError = probe.error;
386
+ if (probe && probe.status !== undefined) out.health.lastStatus = probe.status;
387
+ return out;
388
+ }
389
+
342
390
  function authFileStatus(file) {
343
391
  if (!fs.existsSync(file)) return { file, state: "missing" };
344
392
  try {
@@ -461,11 +509,12 @@ function tokenWarning() {
461
509
  if (data && data.trim()) return undefined;
462
510
  } catch {}
463
511
  }
464
- return {
465
- level: "warning",
466
- code: "github_token_missing",
467
- message: "GitHub token is not configured. Service will start, but proxy requests return 503 until you run `aerial login`."
468
- };
512
+ const envToken = process.env.AERIAL_GITHUB_TOKEN;
513
+ const envOnly = envToken && envToken.trim();
514
+ const message = envOnly
515
+ ? "AERIAL_GITHUB_TOKEN is set in this shell only; the background service does not inherit it. Run `aerial login` to persist a service-readable GitHub token, otherwise proxy requests return 503."
516
+ : "GitHub token is not configured. Service will start, but proxy requests return 503 until you run `aerial login`.";
517
+ return { level: "warning", code: "github_token_missing", message };
469
518
  }
470
519
 
471
520
  function darwinWriteDefinition() {
@@ -534,7 +583,7 @@ async function describeRunning(ctx, host, port, healthFetch) {
534
583
  return { cls, probe, supervisor };
535
584
  }
536
585
 
537
- export async function serviceInstall({ run = defaultRunCommand, healthFetch } = {}) {
586
+ export async function serviceInstall({ run = defaultRunCommand, healthFetch, healthDeadlineMs } = {}) {
538
587
  if (isUnsupportedPlatform()) throw unsupportedError("install");
539
588
  const ctx = { run };
540
589
  const config = loadConfig();
@@ -634,12 +683,34 @@ export async function serviceInstall({ run = defaultRunCommand, healthFetch } =
634
683
  if (!ok) result.reason = "run_failed";
635
684
  }
636
685
  }
686
+ if (result.ok) {
687
+ if (process.env.AERIAL_SERVICE_DRYRUN === "1") {
688
+ result.health = { ok: true, attempts: 0, elapsedMs: 0, dryRun: true };
689
+ } else {
690
+ const poll = await pollForAerialUp(config.host, config.port, healthFetch, healthDeadlineMs);
691
+ if (poll.cls.mode === "aerial_running") {
692
+ result.health = { ok: true, attempts: poll.attempts, elapsedMs: poll.elapsedMs };
693
+ } else if (poll.cls.mode === "port_conflict") {
694
+ result.ok = false;
695
+ result.reason = "port_conflict";
696
+ result.message = `After install, port ${config.port} is responding as a non-Aerial process: ${poll.cls.reason}. Free the port and rerun.`;
697
+ result.diagnostics = healthFailedDiagnostics({ wrapper: result.wrapper, probe: poll.probe, attempts: poll.attempts, elapsedMs: poll.elapsedMs });
698
+ } else {
699
+ result.ok = false;
700
+ result.reason = "health_check_failed";
701
+ result.definitionWritten = true;
702
+ result.startAttempted = true;
703
+ result.message = `Service definition was written and start was triggered, but /health did not become Aerial within ${poll.elapsedMs}ms (${poll.attempts} attempts). Inspect logs and rerun \`aerial service status --json\`.`;
704
+ result.diagnostics = healthFailedDiagnostics({ wrapper: result.wrapper, probe: poll.probe, attempts: poll.attempts, elapsedMs: poll.elapsedMs });
705
+ }
706
+ }
707
+ }
637
708
  result.warning = tokenWarning();
638
- logEvent("service_install", { platform: process.platform, ok: result.ok });
709
+ logEvent("service_install", { platform: process.platform, ok: result.ok, reason: result.reason });
639
710
  return result;
640
711
  }
641
712
 
642
- export async function serviceStart({ run = defaultRunCommand, healthFetch } = {}) {
713
+ export async function serviceStart({ run = defaultRunCommand, healthFetch, healthDeadlineMs } = {}) {
643
714
  if (isUnsupportedPlatform()) throw unsupportedError("start");
644
715
  const ctx = { run };
645
716
  const config = loadConfig();
@@ -687,16 +758,50 @@ export async function serviceStart({ run = defaultRunCommand, healthFetch } = {}
687
758
  } else {
688
759
  r = ctx.run("schtasks.exe", buildSchtasksArgs("run"));
689
760
  }
690
- const ok = r.status === 0;
691
- logEvent("service_start", { platform: process.platform, status: r.status });
692
- return {
693
- ok,
761
+ const triggerOk = r.status === 0;
762
+ const base = {
763
+ ok: triggerOk,
694
764
  action: "start",
695
765
  platform: process.platform,
696
766
  status: r.status,
697
767
  stderr: r.stderr,
698
768
  warning: tokenWarning(),
699
- ...(ok ? {} : { reason: process.platform === "darwin" ? "bootstrap_failed" : "run_failed" })
769
+ ...(triggerOk ? {} : { reason: process.platform === "darwin" ? "bootstrap_failed" : "run_failed" })
770
+ };
771
+ if (!triggerOk) {
772
+ logEvent("service_start", { platform: process.platform, status: r.status, reason: base.reason });
773
+ return base;
774
+ }
775
+ if (process.env.AERIAL_SERVICE_DRYRUN === "1") {
776
+ base.health = { ok: true, attempts: 0, elapsedMs: 0, dryRun: true };
777
+ logEvent("service_start", { platform: process.platform, status: r.status, ok: true, dryRun: true });
778
+ return base;
779
+ }
780
+ const poll = await pollForAerialUp(config.host, config.port, healthFetch, healthDeadlineMs);
781
+ if (poll.cls.mode === "aerial_running") {
782
+ base.health = { ok: true, attempts: poll.attempts, elapsedMs: poll.elapsedMs };
783
+ logEvent("service_start", { platform: process.platform, status: r.status, ok: true });
784
+ return base;
785
+ }
786
+ const wrapper = process.platform === "darwin" ? darwinWrapperPath() : winWrapperPath();
787
+ if (poll.cls.mode === "port_conflict") {
788
+ logEvent("service_start", { platform: process.platform, ok: false, reason: "port_conflict" });
789
+ return {
790
+ ...base,
791
+ ok: false,
792
+ reason: "port_conflict",
793
+ message: `After start, port ${config.port} is responding as a non-Aerial process: ${poll.cls.reason}. Free the port and rerun.`,
794
+ diagnostics: healthFailedDiagnostics({ wrapper, probe: poll.probe, attempts: poll.attempts, elapsedMs: poll.elapsedMs })
795
+ };
796
+ }
797
+ logEvent("service_start", { platform: process.platform, ok: false, reason: "health_check_failed" });
798
+ return {
799
+ ...base,
800
+ ok: false,
801
+ reason: "health_check_failed",
802
+ startAttempted: true,
803
+ message: `Start was triggered, but /health did not become Aerial within ${poll.elapsedMs}ms (${poll.attempts} attempts). Inspect logs and rerun \`aerial service status --json\`.`,
804
+ diagnostics: healthFailedDiagnostics({ wrapper, probe: poll.probe, attempts: poll.attempts, elapsedMs: poll.elapsedMs })
700
805
  };
701
806
  }
702
807