@jiayunxie/aerial 0.1.4 → 0.1.6

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 CHANGED
@@ -3,275 +3,122 @@
3
3
  [![CI](https://github.com/Xiejiayun/aerial/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Xiejiayun/aerial/actions/workflows/ci.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/@jiayunxie/aerial.svg)](https://www.npmjs.com/package/@jiayunxie/aerial)
5
5
 
6
- Aerial is a lightweight local-only proxy that lets one user connect their own GitHub Copilot subscription to local coding CLIs.
6
+ Aerial lets Codex CLI and Claude Code use your own GitHub Copilot subscription through a local proxy on your machine.
7
7
 
8
- The MVP runs on `127.0.0.1:18181`, requires a local Aerial API key for model routes, stores credentials on the user's machine, and avoids public deployment, account sharing, quota bypass, dashboards, analytics, and image APIs.
8
+ It runs locally on `127.0.0.1:18181`. Your GitHub token and Aerial key stay on your machine. It is built for personal local use, not for public hosting or account sharing.
9
9
 
10
- ## MVP Support
11
-
12
- Implemented routes:
13
-
14
- - `GET /health` without auth
15
- - `GET /v1/models` for model discovery, with an `aerial` support annotation per model
16
- - `POST /v1/responses` for Codex CLI
17
- - `POST /v1/messages` for Claude Code's Anthropic gateway path
18
- - `POST /v1/messages/count_tokens` with a local estimate
19
- - `POST /v1/chat/completions` as a simple passthrough fallback
20
-
21
- Implemented CLI commands:
22
-
23
- ```bash
24
- aerial login
25
- aerial key generate
26
- aerial key print
27
- aerial start
28
- aerial setup codex
29
- aerial setup claude --model <available-copilot-model-id>
30
- aerial setup all
31
- aerial setup status
32
- aerial setup restore <codex|claude|all> --latest
33
- aerial service install
34
- aerial service start
35
- aerial service status
36
- aerial service stop
37
- aerial service restart
38
- aerial service uninstall
39
- aerial disable
40
- aerial doctor
41
- aerial probe
42
- ```
43
-
44
- Rollback safety net, log file rotation, and bundled service management (macOS launchd + Windows Task Scheduler) are part of this release. Gemini CLI support, Linux service management, dashboards, and analytics are intentionally out of this MVP. For Codex, local clients keep using HTTP POST /v1/responses. Aerial can optionally use Copilot's upstream `ws:/responses` transport for streaming Responses and translate events back to SSE — this transport is opt-in (`AERIAL_RESPONSES_WEBSOCKET=on`) and HTTP is the default.
10
+ ## Install
45
11
 
46
- ## Requirements
12
+ Requirements:
47
13
 
48
14
  - Node.js 22+
49
15
  - A GitHub account with an active Copilot subscription
50
- - Codex CLI and/or Claude Code installed locally
51
-
52
- ## Install
16
+ - Codex CLI and/or Claude Code installed
53
17
 
54
18
  ```bash
55
19
  npm install -g @jiayunxie/aerial
56
20
  ```
57
21
 
58
- After install, the CLI is available as `aerial`.
59
-
60
- ### Nightly builds (advanced, unstable)
22
+ ## Quick Start
61
23
 
62
- Nightly pre-releases are published by the same `release.yml` workflow as stable releases, dispatched with `mode=nightly` (and, once scheduled publishing is enabled, by a daily cron on the same workflow). They are routed through a single workflow because npm only allows one trusted publisher per package. The default install command above is unaffected — nightlies are gated behind the `@nightly` dist-tag and will never be served as `latest`. To opt in:
24
+ 1. Sign in to GitHub:
63
25
 
64
26
  ```bash
65
- npm install -g @jiayunxie/aerial@nightly
66
- ```
67
-
68
- Nightly versions look like `0.1.1-nightly.YYYYMMDD.<sha7>`. They reflect the current `main` and may break; report regressions with `aerial --version` so the exact build can be reproduced. Switch back to stable any time with `npm install -g @jiayunxie/aerial`.
69
-
70
- ### From source (local development)
71
-
72
- For working directly on the Aerial codebase:
73
-
74
- ```bash
75
- git clone https://github.com/Xiejiayun/aerial.git
76
- cd aerial
77
- npm install -g .
27
+ aerial login
78
28
  ```
79
29
 
80
- Run without global install:
30
+ 2. Configure the client you use:
81
31
 
82
32
  ```bash
83
- node src/cli.js --help
84
- ```
85
-
86
- ## First Run
87
-
88
- Configure your local clients. This also creates Aerial's local API key and wires it into supported clients:
33
+ # Codex CLI
34
+ aerial setup codex
89
35
 
90
- ```bash
91
- aerial setup all --model <available-copilot-model-id>
36
+ # Claude Code
37
+ aerial setup claude
92
38
  ```
93
39
 
94
- If Codex or Claude Code was already open, restart it after setup so it rereads the updated client config.
40
+ During setup, Aerial reads your Copilot model list, shows the models that work for that client, and asks which one to write into the client config. Codex uses models with the `responses` route. Claude Code uses models with the `messages` route.
95
41
 
96
- Log in to GitHub with device flow:
42
+ 3. Start Aerial in the background:
97
43
 
98
44
  ```bash
99
- aerial login
45
+ aerial service install
100
46
  ```
101
47
 
102
- Start the local server:
48
+ 4. Check everything from one place:
103
49
 
104
50
  ```bash
105
- aerial start
51
+ aerial status
106
52
  ```
107
53
 
108
- Check health:
109
-
110
- ```bash
111
- curl http://127.0.0.1:18181/health
112
- ```
54
+ If Aerial prints a hint, follow it before expecting the background service to work after reboot.
113
55
 
114
- ## Codex CLI Setup
56
+ Restart Codex CLI or Claude Code if either one was already open while you ran setup.
115
57
 
116
- Aerial configures Codex through the Responses wire API provider path:
58
+ ## What Setup Changes
117
59
 
118
- ```bash
119
- aerial setup codex --model <available-copilot-model-id>
120
- ```
60
+ `aerial setup codex` updates `~/.codex/config.toml`.
121
61
 
122
- This updates `~/.codex/config.toml` and creates a timestamped backup first. If you only want to inspect the exact change, set `HOME`/`USERPROFILE` to a temporary directory before running setup. The inserted provider uses:
62
+ - It points Codex at `http://127.0.0.1:18181/v1`.
63
+ - It uses Codex's command-backed auth helper so Codex can read the local Aerial key automatically.
64
+ - It creates a timestamped backup before writing.
123
65
 
124
- ```toml
125
- [model_providers.aerial]
126
- base_url = "http://127.0.0.1:18181/v1"
127
- wire_api = "responses"
66
+ `aerial setup claude` updates `~/.claude/settings.json`.
128
67
 
129
- [model_providers.aerial.auth]
130
- command = "<node>"
131
- args = ["<aerial-cli.js>", "key", "print"]
132
- timeout_ms = 5000
133
- refresh_interval_ms = 0
134
- ```
68
+ - It points Claude Code at `http://127.0.0.1:18181`.
69
+ - It configures an API key helper so Claude Code can read the local Aerial key automatically.
70
+ - It creates a timestamped backup before writing.
135
71
 
136
- Aerial creates and stores the local key automatically. Codex reads it through the configured helper command, so users do not need to run `aerial key generate`, copy a key, or export `AERIAL_API_KEY`.
72
+ You normally do not need to create, copy, or export an API key yourself.
137
73
 
138
- ## Claude Code Setup
74
+ ## Choosing A Model
139
75
 
140
- Aerial configures Claude Code through its Anthropic-compatible gateway settings:
76
+ The easiest path is to let setup show the compatible models:
141
77
 
142
78
  ```bash
79
+ aerial setup codex
143
80
  aerial setup claude
144
81
  ```
145
82
 
146
- This updates `~/.claude/settings.json` and creates a timestamped backup first. If you only want to inspect the exact change, set `HOME`/`USERPROFILE` to a temporary directory before running setup. It sets:
147
-
148
- ```json
149
- {
150
- "model": "<available-copilot-model-id>",
151
- "apiKeyHelper": "aerial key print",
152
- "env": {
153
- "ANTHROPIC_BASE_URL": "http://127.0.0.1:18181",
154
- "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1"
155
- }
156
- }
157
- ```
158
-
159
- The `model` field is written when you pass `--model` or set Aerial's `defaultModel`; otherwise Aerial leaves any existing Claude Code model choice alone while still switching the gateway to the local Aerial endpoint.
160
-
161
- `aerial key print` prints the locally stored Aerial API key for Claude Code's helper flow. Users normally do not need to call it directly.
162
-
163
- When switching from another Anthropic-compatible gateway, setup removes stale `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, and `ANTHROPIC_DEFAULT_*_MODEL` entries from Claude Code's managed `env` block so `apiKeyHelper` and the Aerial gateway are the active route.
164
-
165
- ## Diagnostics
83
+ To pin a model without the prompt:
166
84
 
167
85
  ```bash
168
- aerial doctor
169
- aerial probe
86
+ aerial setup codex --model <responses-model-id>
87
+ aerial setup claude --model <messages-model-id>
170
88
  ```
171
89
 
172
- The doctor checks config, local API key presence, GitHub login state, Node version, and local bind address.
173
-
174
- ## Security Boundary
175
-
176
- Aerial is for personal local use only.
177
-
178
- - It binds to `127.0.0.1` by default.
179
- - Model routes require `Authorization: Bearer <AERIAL_API_KEY>` or `x-api-key: <AERIAL_API_KEY>`.
180
- - GitHub tokens are stored under the user config directory with private file permissions where supported.
181
- - Aerial does not log raw GitHub tokens, Copilot JWTs, API keys, or request bodies by default.
182
- - Do not expose this service publicly or use it for account sharing.
183
-
184
- ## Current Limitations
185
-
186
- - Copilot inference routes are an observed compatibility target, not a public stable GitHub REST API.
187
- - `/v1/messages/count_tokens` is a local estimate, not upstream tokenization.
188
- - Bundled service management is implemented for macOS (launchd) and Windows (Task Scheduler). On Linux, run `aerial start` directly or wrap it in your own init system.
189
- - Model choice is not automated; query `/v1/models` and select an available model explicitly.
190
- - Chat Completions requests normalize `max_tokens` to `max_completion_tokens` for newer OpenAI models that reject the older field.
191
- - Prompt caching is upstream-managed: Aerial does not store prompt bodies locally, and it preserves or injects cache protocol fields before forwarding.
192
-
193
-
194
-
195
- ## Capability Probe
196
-
197
- Use `aerial probe` to inspect the live Copilot model matrix through the same local credentials Aerial uses for proxying:
90
+ To inspect the full model matrix:
198
91
 
199
92
  ```bash
200
93
  aerial probe
201
94
  ```
202
95
 
203
- This prints model IDs, Aerial routes, and unsupported notes such as `embeddings_not_implemented` or `no_supported_endpoint_advertised`. Models with upstream `ws:/responses` support are shown with the `responses_websocket` route.
204
-
205
- Run low-cost live route checks when you want to verify end-to-end behavior:
206
-
207
- ```bash
208
- aerial probe --live
209
- # machine-readable output
210
- aerial probe --live --json
211
- ```
212
-
213
- `--live` sends one small request through the first available `responses`, `messages`, and `chat` model. It does not test every model by default, which keeps the command lightweight.
214
- ## Prompt Cache
215
-
216
- Aerial uses the upstream Copilot/OpenAI/Anthropic cache protocols instead of keeping a local prompt-content cache. That keeps the MVP lightweight and avoids storing prompts on disk.
217
-
218
- Supported behavior:
219
-
220
- - Responses and Chat Completions preserve `prompt_cache_retention` and `prompt_cache_key` when the client sends them.
221
- - Responses and Chat Completions automatically add `prompt_cache_retention: "in_memory"` and a stable hashed `prompt_cache_key` when the client omits them.
222
- - Anthropic Messages preserves client `cache_control` blocks. When none are present and caching is enabled, Aerial automatically adds `cache_control: { "type": "ephemeral" }` to stable `system` content, or to the final tool definition when there is no system content.
223
- - Usage fields from upstream are returned unchanged, including `cached_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens`, and Copilot `cache_read` / `cache_write` token details when present.
224
- - Aerial logs cache metadata to stderr as `cache_request` and `cache_observe` events. These logs do not include prompt text or request bodies.
225
-
226
- OpenAI's prompt caching documentation says cache hits require an exact prompt prefix match and are only possible once the prompt is at least 1024 tokens. Confirm cache behavior from returned usage fields such as `usage.prompt_tokens_details.cached_tokens` or `usage.input_tokens_details.cached_tokens`; for shorter prompts this value is expected to be zero.
227
-
228
- Aerial enables ephemeral prompt caching by default for OpenAI-style routes and Anthropic Messages. Override it only when needed:
96
+ ## Daily Commands
229
97
 
230
98
  ```bash
231
- aerial config set promptCacheRetention in_memory
232
- # or
233
- aerial config set promptCacheRetention 24h
234
- # disable automatic cache hints
235
- aerial config set promptCacheRetention off
236
- aerial config set promptCacheKey off
237
- # pin all requests to an explicit cache partition
238
- aerial config set promptCacheKey my-project
99
+ aerial status # setup, login, service, and health summary
100
+ aerial service install # install and start the background service
101
+ aerial doctor # local diagnostics
102
+ aerial disable # restore client configs and uninstall the service
239
103
  ```
240
104
 
241
- You can also set it per process:
105
+ `aerial start` is for foreground debugging in the current terminal. Most users should use `aerial service install`.
242
106
 
243
- ```bash
244
- export AERIAL_PROMPT_CACHE_RETENTION=in_memory
245
- export AERIAL_PROMPT_CACHE_KEY=my-project
246
- ```
247
-
248
- Per-request fields win over the configured default. Use `24h` only with models whose upstream route accepts extended retention; Anthropic Messages still uses Anthropic-style `cache_control: { "type": "ephemeral" }`. Set `promptCacheKey` to `auto` to use Aerial's hashed stable key, `off` to omit it, or a string to force a specific cache partition.
249
- ## Model Support
250
-
251
- `GET /v1/models` returns Copilot's raw model metadata and adds an Aerial-specific field:
252
-
253
- ```json
254
- {
255
- "id": "gpt-5.4-mini",
256
- "supported_endpoints": ["/responses", "ws:/responses"],
257
- "aerial": {
258
- "supported": true,
259
- "routes": ["responses", "responses_websocket"],
260
- "notes": []
261
- }
262
- }
263
- ```
107
+ Advanced service lifecycle commands are documented in `docs/usage.md`.
264
108
 
265
- Route meanings:
109
+ ## Troubleshooting
266
110
 
267
- - `responses`: usable by Codex through HTTP `POST /v1/responses`.
268
- - `responses_websocket`: Aerial can use Copilot's upstream `ws:/responses` transport for streaming `/v1/responses` requests when `AERIAL_RESPONSES_WEBSOCKET=on` is set, then return SSE to the local client. When the opt-in is off (default), Aerial always uses HTTP upstream Responses even if a model advertises `ws:/responses`.
269
- - `messages`: usable by Claude Code through `POST /v1/messages`.
270
- - `chat`: usable by generic OpenAI Chat clients through `POST /v1/chat/completions`.
111
+ - `Missing GitHub token`: run `aerial login`.
112
+ - `Invalid or missing Aerial API key`: rerun `aerial setup codex` or `aerial setup claude`, then restart the client.
113
+ - No models listed during setup: run `aerial login` first, then retry setup.
114
+ - Port conflict on `18181`: run `aerial status` to see whether another process is using the port.
115
+ - Need to undo setup: run `aerial disable`, or restore one client with `aerial setup restore <codex|claude> --latest`.
271
116
 
272
- Local clients should not open WebSocket connections to Aerial directly. Direct WebSocket upgrades still return `501 Not Implemented`; use HTTP `POST /v1/responses` and let Aerial choose the upstream transport. HTTP upstream Responses is the default; set `AERIAL_RESPONSES_WEBSOCKET=on` to opt into Copilot's upstream WebSocket transport for streaming Responses calls (still experimental, only effective for streaming requests on models that advertise `ws:/responses`).
117
+ ## Notes
273
118
 
274
- Known unsupported notes:
119
+ - macOS background service support uses a user LaunchAgent.
120
+ - Windows background service support uses a user Task Scheduler task.
121
+ - Linux service management is not built in yet; run `aerial start` or use your own init system.
122
+ - Copilot inference routes are an observed compatibility target and may change upstream.
275
123
 
276
- - `embeddings_not_implemented`: Aerial does not expose `/v1/embeddings` yet.
277
- - `no_supported_endpoint_advertised`: the model did not declare a route Aerial can safely select.
124
+ More details: `docs/usage.md`.
package/docs/usage.md CHANGED
@@ -23,10 +23,13 @@ node src/cli.js --help
23
23
  ## 2. Configure Local Clients
24
24
 
25
25
  ```bash
26
- aerial setup all --model <model-id>
26
+ aerial login
27
+ aerial setup codex
28
+ # optional, if you use Claude Code too:
29
+ aerial setup claude
27
30
  ```
28
31
 
29
- Aerial creates a local API key, stores it privately, and configures supported clients to use it. If Codex or Claude Code was already open, restart it after setup so it rereads the updated client config.
32
+ Aerial creates a local API key, stores it privately, asks you to choose from compatible Copilot models, and configures the selected clients to use it. Run `aerial login` before setup so model discovery can read your Copilot model list. There is intentionally no `aerial setup all` setup shortcut: Codex and Claude Code can need different model IDs, so setup stays client-specific. If Codex or Claude Code was already open, restart it after setup so it rereads the updated client config.
30
33
 
31
34
  ## 3. Login To GitHub
32
35
 
@@ -39,18 +42,18 @@ Open the printed URL, enter the user code, and authorize the GitHub OAuth device
39
42
  ## 4. Start Server
40
43
 
41
44
  ```bash
42
- aerial start
45
+ aerial service install
43
46
  ```
44
47
 
45
- Default URL: `http://127.0.0.1:18181`.
48
+ Default URL: `http://127.0.0.1:18181`. `aerial service install` is the daily-use path on macOS and Windows because it installs and starts the local background service. Use `aerial start` only when you want a foreground debug process in the current terminal.
46
49
 
47
50
  ## 5. Configure Codex CLI
48
51
 
49
52
  ```bash
50
- aerial setup codex --model <model-id>
53
+ aerial setup codex
51
54
  ```
52
55
 
53
- The setup command backs up and merges `~/.codex/config.toml`, then configures Codex to fetch the local Aerial key through a command-backed provider auth helper:
56
+ The setup command backs up and merges `~/.codex/config.toml`, asks you to choose a model whose Aerial routes include `responses`, then configures Codex to fetch the local Aerial key through a command-backed provider auth helper:
54
57
 
55
58
  ```toml
56
59
  [model_providers.aerial]
@@ -68,25 +71,58 @@ The local key is generated and stored by Aerial automatically. Users do not need
68
71
 
69
72
  For a dry inspection without touching your real config, set `HOME`/`USERPROFILE` to a temporary directory before running this command.
70
73
 
74
+ To skip the prompt, pass `--model <responses-model-id>`.
75
+
71
76
  ## 6. Configure Claude Code
72
77
 
73
78
  ```bash
74
- aerial setup claude --model <model-id>
79
+ aerial setup claude
75
80
  ```
76
81
 
77
- The setup command backs up and merges `~/.claude/settings.json`, using `apiKeyHelper = "aerial key print"` and `ANTHROPIC_BASE_URL=http://127.0.0.1:18181`. When you pass `--model` or set Aerial's `defaultModel`, it also writes Claude Code's default `model` to that Aerial-routed model.
82
+ The setup command backs up and merges `~/.claude/settings.json`, asks you to choose a model whose Aerial routes include `messages`, then writes an absolute `apiKeyHelper` command and `ANTHROPIC_BASE_URL=http://127.0.0.1:18181`. The helper lets Claude Code read the local Aerial key automatically without relying on a shell `AERIAL_API_KEY` export or a refreshed `PATH`.
78
83
 
79
84
  If Claude Code was previously pointed at another Anthropic-compatible gateway, setup removes stale `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, and `ANTHROPIC_DEFAULT_*_MODEL` entries from the managed `env` block.
80
85
 
81
86
  For a dry inspection without touching your real config, set `HOME`/`USERPROFILE` to a temporary directory before running this command.
82
87
 
88
+ To skip the prompt, pass `--model <messages-model-id>`.
89
+
83
90
  ## 7. Verify
84
91
 
85
92
  ```bash
93
+ aerial status
86
94
  aerial doctor
87
95
  aerial probe
88
96
  ```
89
97
 
98
+ `aerial status` is the top-level daily check. It combines setup state, local auth files, service state, and health into one short report. Use `--json` for a machine-readable `aerial.status.v1` document.
99
+
100
+ `aerial status --json` top-level fields: `schema`, `ok`, `setup`, `service`, `nextSteps`, `hints`. `nextSteps` lists actions you must take to reach `ok: true` (login / setup / service install / recover local key). `hints` lists non-blocking advisories that you should still pay attention to, such as `AERIAL_GITHUB_TOKEN` being set for the current shell only (the background service may not see it after reboot). Example:
101
+
102
+ ```json
103
+ {
104
+ "schema": "aerial.status.v1",
105
+ "ok": false,
106
+ "nextSteps": ["run: aerial setup codex or aerial setup claude"],
107
+ "hints": ["AERIAL_GITHUB_TOKEN is set for this process only; run aerial login without that env var to persist a service-readable login."],
108
+ "setup": { "...": "aerial.setup-status.v1 fields here" },
109
+ "service": { "...": "aerial.service-status.v1 fields here" }
110
+ }
111
+ ```
112
+
113
+ ### Local HTTP endpoints
114
+
115
+ | Request | Local Aerial API key | Behavior |
116
+ | --- | --- | --- |
117
+ | `GET /` | not required | 200 + friendly status JSON pointing at `/health` and `aerial status`; no secrets in body |
118
+ | `GET /health` | not required | 200 + minimal `{ ok, service, host, port }` |
119
+ | `GET /v1/models` (with GitHub login) | not required | 200 + Copilot model list with Aerial route annotations |
120
+ | `GET /v1/models` (no GitHub login) | not required | 401 + `error.aerial.status = "login_required"` |
121
+ | `GET /v1/models` (GitHub login rejected by Copilot) | not required | 401/403 + `error.aerial.status = "upstream_auth_failed"` and `upstream_status` |
122
+ | `POST /v1/responses`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/chat/completions` | required | 401 if key missing/invalid; otherwise proxied to Copilot |
123
+
124
+ None of these endpoints emit `WWW-Authenticate` or open CORS (`Access-Control-Allow-Origin: *`). The `login_required` and `upstream_auth_failed` JSON shapes never include tokens, keys, or filesystem paths.
125
+
90
126
  Each model returned by `/v1/models` includes an `aerial` field that tells you whether the MVP can route it:
91
127
 
92
128
  ```json
@@ -125,7 +161,7 @@ aerial disable
125
161
  "config": { "host": "127.0.0.1", "port": 18181 },
126
162
  "auth": {
127
163
  "api_key": { "file": "/Users/you/Library/Application Support/aerial/api_key", "exists": true },
128
- "github_token": { "file": "/Users/you/Library/Application Support/aerial/github_token", "exists": false }
164
+ "github_token": { "file": "/Users/you/Library/Application Support/aerial/github_token", "exists": true, "source": "file" }
129
165
  },
130
166
  "clients": {
131
167
  "codex": {
@@ -133,7 +169,7 @@ aerial disable
133
169
  "state": "aerial",
134
170
  "file": "/Users/you/.codex/config.toml",
135
171
  "backups": ["/Users/you/.codex/config.toml.aerial-backup-2026-05-20T10-00-00-000Z"],
136
- "model": "gpt-4.1",
172
+ "model": "gpt-5.5",
137
173
  "baseUrl": "http://127.0.0.1:18181/v1"
138
174
  },
139
175
  "claude": {
@@ -148,7 +184,9 @@ aerial disable
148
184
  }
149
185
  ```
150
186
 
151
- Stability rules for `aerial.setup-status.v1`: new fields may be added at any level in future Aerial releases consumers must ignore unknown keys. Existing fields will not be removed or repurposed without bumping the `schema` value. Field types remain constant within a schema version.
187
+ `auth.github_token.source` is one of `"missing"`, `"file"`, or `"env"`. `exists` is derived: `source !== "missing"` ("the current process can read a GitHub token"). `source = "env"` means the token came from `AERIAL_GITHUB_TOKEN`, which the background service generally cannot see — run `aerial login` without that env var to persist a service-readable login. To detect a persisted file login specifically, read `source === "file"`.
188
+
189
+ Stability rules for `aerial.setup-status.v1`: new fields may be added at any level in future Aerial releases — consumers must ignore unknown keys. Existing fields will not be removed or repurposed without bumping the `schema` value. Field types remain constant within a schema version. Note: in 0.1.6, `auth.github_token.exists` reflects "process-readable" (file or env), not strictly "token file present". Downstream consumers that need the old file-only semantics should read `auth.github_token.source === "file"`.
152
190
 
153
191
  `aerial setup restore <codex|claude|all> --latest` restores the most recent `*.aerial-backup-<ISO>` snapshot for the named client. Before overwriting, it takes a `*.aerial-pre-restore-<ISO>` snapshot of the current file so the restore itself is reversible. With `all`, both clients are restored best-effort and the command exits non-zero if any individual restore failed. If there is no backup to restore, the command prints a note and exits 0.
154
192
 
@@ -185,7 +223,7 @@ Wrapper env values are baked in at install time. If you set `AERIAL_LOG_MAX_BYTE
185
223
 
186
224
  `aerial service start` enforces a similar shape: it refuses with `reason=not_installed` and exit 1 if the unit/task does not exist; it refuses with `reason=port_conflict` and exit 1 if a non-Aerial process owns the port; it refuses with `reason=foreground_running` and exit 1 if Aerial is already running in the foreground (not via the service manager); it returns idempotent success with `note=already running (service-managed)` if the service is already up; otherwise it starts the service. `aerial service stop` is idempotent (exit 0 + `note` when nothing is installed or nothing is running). `aerial service uninstall` is idempotent on the "no service installed" branch, but does NOT swallow real teardown failures: on macOS, if the service is loaded and `launchctl bootout` returns non-zero, the plist and wrapper are preserved and the command exits 1 with `reason=bootout_failed`; on Windows, if `schtasks /Delete` returns non-zero, the wrapper is preserved and the command exits 1 with `reason=delete_failed`. In both cases the message includes a retry pointer. `aerial service restart` blocks the start step when the stop step fails: if `stop` returns `ok=false`, the response includes `reason=stop_failed` and `start` is not attempted. `aerial disable` follows the same contract: it restores client configs first, then calls `serviceUninstall`. Only an `unsupported platform` exception is treated as a silent skip (Linux); any supported-platform uninstall failure (`ok=false` or non-`unsupported-platform` throw) propagates as exit 1 with a retry pointer at `aerial service uninstall`.
187
225
 
188
- If GitHub login is not yet configured, install and start still succeed (when reachable) and emit a structured warning pointing at `aerial login`; proxy requests return 503 until you log in. After rotating the local API key or moving the config directory, restart the service with `aerial service restart` so the new credentials are picked up.
226
+ If GitHub login is not yet configured, install and start still succeed (when reachable) and emit a structured warning pointing at `aerial login`; inference proxy requests return 503 until you log in, while `GET /v1/models` returns 401 with `error.aerial.status = "login_required"`. After rotating the local API key or moving the config directory, restart the service with `aerial service restart` so the new credentials are picked up.
189
227
 
190
228
  `aerial service status` reports a single aggregated view:
191
229
 
@@ -314,8 +352,8 @@ Best practice: put stable system/project/tool context first, put changing user i
314
352
  ## Troubleshooting
315
353
 
316
354
  - `503 Missing GitHub token`: run `aerial login`.
317
- - `401 Invalid or missing Aerial API key`: run `aerial setup all`, then restart the client terminal or VS Code.
318
- - Claude Code cannot read key: ensure `aerial` is on `PATH`; it uses `aerial key print` as its helper.
355
+ - `401 Invalid or missing Aerial API key`: run `aerial setup codex` or `aerial setup claude` for the client you use, then restart the client terminal or VS Code.
356
+ - Claude Code cannot read key: rerun `aerial setup claude` so the settings file gets a fresh absolute API-key helper command.
319
357
  - Upstream compatibility error: run `aerial doctor`, then retry with a model returned by `/v1/models`.
320
358
  - `Unsupported parameter: max_tokens`: Aerial normalizes Chat Completions `max_tokens` into `max_completion_tokens` before forwarding to newer OpenAI models.
321
359
  - Cache hit stays zero: ensure the stable prefix is long enough, unchanged, and placed before variable content. For Responses/Chat, try a stable `prompt_cache_key`; for Messages, keep stable content in `system` or put manual `cache_control` on the stable content block.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiayunxie/aerial",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Local-only GitHub Copilot proxy for Codex CLI and Claude Code.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -0,0 +1,23 @@
1
+ export function computeAppStatus(setup, service) {
2
+ const githubTokenPresent = setup.auth.github_token.source !== "missing";
3
+ const apiKeyPresent = setup.auth.api_key.exists;
4
+ const hasAerialClient = setup.clients.codex.state === "aerial" || setup.clients.claude.state === "aerial";
5
+ const serviceHealthy = service.supported !== false && service.health?.aerial === true;
6
+ const ok = apiKeyPresent && githubTokenPresent && hasAerialClient && serviceHealthy;
7
+ const nextSteps = [];
8
+ const hints = [];
9
+ if (!githubTokenPresent) nextSteps.push("run: aerial login");
10
+ if (!hasAerialClient) nextSteps.push("run: aerial setup codex or aerial setup claude");
11
+ if (hasAerialClient && !apiKeyPresent) nextSteps.push("run: aerial setup codex or aerial setup claude to recreate the local Aerial key");
12
+ if (service.supported !== false && !service.service?.loaded) {
13
+ if (serviceHealthy) {
14
+ hints.push("Aerial is running in the foreground but no background service is installed; run `aerial service install` so it starts on reboot.");
15
+ } else {
16
+ nextSteps.push("run: aerial service install");
17
+ }
18
+ }
19
+ if (setup.auth.github_token.source === "env") {
20
+ hints.push("AERIAL_GITHUB_TOKEN is set for this process only; run aerial login without that env var to persist a service-readable login.");
21
+ }
22
+ return { schema: "aerial.status.v1", ok, setup, service, nextSteps, hints };
23
+ }
package/src/auth.js CHANGED
@@ -48,9 +48,20 @@ export async function pollDeviceFlow(deviceCode, intervalSeconds) {
48
48
  }
49
49
 
50
50
  export function readGitHubToken() {
51
- if (process.env.AERIAL_GITHUB_TOKEN) return process.env.AERIAL_GITHUB_TOKEN;
51
+ const envToken = process.env.AERIAL_GITHUB_TOKEN?.trim();
52
+ if (envToken) return envToken;
52
53
  if (!fs.existsSync(githubTokenPath())) return undefined;
53
- return fs.readFileSync(githubTokenPath(), "utf8").trim();
54
+ const fileToken = fs.readFileSync(githubTokenPath(), "utf8").trim();
55
+ return fileToken || undefined;
56
+ }
57
+
58
+ export function gitHubTokenSource() {
59
+ if (process.env.AERIAL_GITHUB_TOKEN?.trim()) return "env";
60
+ if (fs.existsSync(githubTokenPath())) {
61
+ const fileToken = fs.readFileSync(githubTokenPath(), "utf8").trim();
62
+ if (fileToken) return "file";
63
+ }
64
+ return "missing";
54
65
  }
55
66
 
56
67
  function jwtExpirySeconds(token) {
@@ -71,7 +82,11 @@ export async function exchangeCopilotToken(githubToken = readGitHubToken()) {
71
82
  "user-agent": "Aerial/0.1"
72
83
  }
73
84
  });
74
- if (!response.ok) throw new Error(`Copilot token exchange failed: ${response.status} ${await response.text()}`);
85
+ if (!response.ok) {
86
+ const err = new Error(`Copilot token exchange failed: ${response.status} ${await response.text()}`);
87
+ err.aerialUpstreamStatus = response.status;
88
+ throw err;
89
+ }
75
90
  const payload = await response.json();
76
91
  const token = payload.token;
77
92
  if (!token) throw new Error("Copilot token exchange response did not include token");
@@ -90,3 +105,8 @@ export async function getCopilotToken({ force = false } = {}) {
90
105
  }
91
106
  return refreshPromise;
92
107
  }
108
+
109
+ export function _resetCopilotTokenCacheForTests() {
110
+ cachedCopilotToken = undefined;
111
+ refreshPromise = undefined;
112
+ }
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,37 +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>]
34
- aerial setup all [--model <id>]
41
+ aerial service install
42
+ aerial status [--json]
43
+
44
+ Diagnostics and rollback:
35
45
  aerial setup status [--json]
36
46
  aerial setup restore <codex|claude|all> --latest
37
- aerial service install
38
- aerial service start
39
- aerial service stop
40
- aerial service restart
41
47
  aerial service status [--json]
42
- aerial service uninstall
43
48
  aerial disable
44
49
  aerial doctor
45
50
  aerial probe [--live] [--json]
46
51
 
47
- MVP routes:
48
- GET /health
49
- GET /v1/models
50
- POST /v1/responses
51
- POST /v1/messages
52
- POST /v1/messages/count_tokens
53
- POST /v1/chat/completions`);
52
+ Debug:
53
+ aerial start [--host 127.0.0.1] [--port 18181]`);
54
54
  }
55
55
 
56
56
  function argValue(args, name) {
@@ -58,13 +58,96 @@ function argValue(args, name) {
58
58
  return index >= 0 ? args[index + 1] : undefined;
59
59
  }
60
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
+
61
120
  async function main() {
62
121
  const args = process.argv.slice(2);
63
122
  const [command, subcommand, ...rest] = args;
64
123
  if (!command || command === "--help" || command === "-h") return printHelp();
65
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
+ }
66
130
 
67
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
+ }
68
151
  const flow = await startDeviceFlow();
69
152
  console.log(`Open: ${flow.verification_uri}`);
70
153
  console.log(`Code: ${flow.user_code}`);
@@ -103,28 +186,25 @@ async function main() {
103
186
 
104
187
  if (command === "setup") {
105
188
  if (subcommand === "codex") {
106
- const result = setupCodex({ model: argValue(rest, "--model"), authCommand: codexAuthCommand() });
189
+ const model = await selectSetupModel("Codex", "responses", rest);
190
+ const result = setupCodex({ model, authCommand: codexAuthCommand() });
107
191
  console.log(`Updated Codex config: ${result.file}`);
192
+ console.log(`Configured Codex model: ${result.model}`);
108
193
  if (result.backup) console.log(`Backup: ${result.backup}`);
109
194
  console.log("Configured Codex to read the local Aerial key automatically.");
110
195
  return;
111
196
  }
112
197
  if (subcommand === "claude") {
113
- const result = setupClaude({ model: argValue(rest, "--model") });
198
+ const model = await selectSetupModel("Claude Code", "messages", rest);
199
+ const result = setupClaude({ model, apiKeyHelper: claudeApiKeyHelper() });
114
200
  console.log(`Updated Claude settings: ${result.file}`);
115
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.");
116
203
  if (result.backup) console.log(`Backup: ${result.backup}`);
117
204
  return;
118
205
  }
119
206
  if (subcommand === "all") {
120
- const model = argValue(rest, "--model");
121
- const codex = setupCodex({ model, authCommand: codexAuthCommand() });
122
- const claude = setupClaude({ model });
123
- console.log(`Updated Codex config: ${codex.file}`);
124
- console.log("Configured Codex to read the local Aerial key automatically.");
125
- console.log(`Updated Claude settings: ${claude.file}`);
126
- if (claude.model) console.log(`Configured Claude default model: ${claude.model}`);
127
- return;
207
+ throw new Error("aerial setup all has been removed. Run `aerial setup codex` and/or `aerial setup claude` instead.");
128
208
  }
129
209
  if (subcommand === "status") {
130
210
  const status = setupStatus();
@@ -134,7 +214,12 @@ async function main() {
134
214
  }
135
215
  console.log(`Aerial: http://${status.config.host}:${status.config.port} (platform: ${status.platform})`);
136
216
  console.log(`API key file: ${status.auth.api_key.file} (${status.auth.api_key.exists ? "present" : "missing"})`);
137
- 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}`);
138
223
  for (const cs of Object.values(status.clients)) {
139
224
  const head = `${cs.target.padEnd(7)} state=${cs.state}`;
140
225
  console.log(`${head} file=${cs.file}`);
package/src/doctor.js CHANGED
@@ -6,7 +6,7 @@ export function doctor() {
6
6
  const config = loadConfig();
7
7
  const checks = [
8
8
  { name: "config", ok: fs.existsSync(configPath()), detail: configPath() },
9
- { name: "api_key", ok: Boolean(config.apiKeyHash), detail: fs.existsSync(apiKeyPath()) ? apiKeyPath() : config.apiKeyHash ? "hash configured" : "run: aerial setup all" },
9
+ { name: "api_key", ok: Boolean(config.apiKeyHash), detail: fs.existsSync(apiKeyPath()) ? apiKeyPath() : config.apiKeyHash ? "hash configured" : "run: aerial setup codex or aerial setup claude" },
10
10
  { name: "github_token", ok: fs.existsSync(githubTokenPath()) || Boolean(process.env.AERIAL_GITHUB_TOKEN), detail: fs.existsSync(githubTokenPath()) ? githubTokenPath() : "run: aerial login" },
11
11
  { name: "node", ok: Number(process.versions.node.split(".")[0]) >= 22, detail: process.version },
12
12
  { name: "bind", ok: config.host === "127.0.0.1", detail: `${config.host}:${config.port}` }
@@ -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);
package/src/setup.js CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { parse as parseToml } from "smol-toml";
5
5
  import { ensureApiKey, loadConfig } from "./config.js";
6
6
  import { apiKeyPath, githubTokenPath } from "./paths.js";
7
+ import { gitHubTokenSource } from "./auth.js";
7
8
  import { logEvent } from "./log.js";
8
9
 
9
10
  const BACKUP_PREFIX = ".aerial-backup-";
@@ -15,6 +16,7 @@ const DEFAULT_CODEX_AUTH = Object.freeze({
15
16
  timeout_ms: 5000,
16
17
  refresh_interval_ms: 0
17
18
  });
19
+ const DEFAULT_CLAUDE_API_KEY_HELPER = "aerial key print";
18
20
 
19
21
  function backupIfExists(file) {
20
22
  if (!fs.existsSync(file)) return undefined;
@@ -76,7 +78,10 @@ function claudeEnvForAerial(currentEnv, config) {
76
78
  export function setupCodex({ model, authCommand = DEFAULT_CODEX_AUTH } = {}) {
77
79
  ensureApiKey();
78
80
  const config = loadConfig();
79
- const selectedModel = model || config.defaultModel || "gpt-4.1";
81
+ const selectedModel = model || config.defaultModel;
82
+ if (!selectedModel) {
83
+ throw new Error("setupCodex requires a model id; pass --model or let `aerial setup codex` select one from live Copilot models.");
84
+ }
80
85
  const file = path.join(os.homedir(), ".codex", "config.toml");
81
86
  ensureParent(file);
82
87
  const backup = backupIfExists(file);
@@ -100,7 +105,7 @@ export function setupCodex({ model, authCommand = DEFAULT_CODEX_AUTH } = {}) {
100
105
  return { file, backup, model: selectedModel, auth: { type: "command", command: authCommand.command, args: authCommand.args || [] } };
101
106
  }
102
107
 
103
- export function setupClaude({ model } = {}) {
108
+ export function setupClaude({ model, apiKeyHelper = DEFAULT_CLAUDE_API_KEY_HELPER } = {}) {
104
109
  ensureApiKey();
105
110
  const config = loadConfig();
106
111
  const selectedModel = model || config.defaultModel;
@@ -111,13 +116,13 @@ export function setupClaude({ model } = {}) {
111
116
  const current = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
112
117
  const next = {
113
118
  ...current,
114
- apiKeyHelper: "aerial key print",
119
+ apiKeyHelper,
115
120
  env: claudeEnvForAerial(current.env, config)
116
121
  };
117
122
  if (selectedModel) next.model = selectedModel;
118
123
  fs.writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`, "utf8");
119
124
  logEvent("setup_write", { target: "claude", file, backup, model: selectedModel });
120
- return { file, backup, model: selectedModel };
125
+ return { file, backup, model: selectedModel, apiKeyHelper };
121
126
  }
122
127
 
123
128
  function codexConfigFile() {
@@ -199,7 +204,7 @@ export function codexStatus() {
199
204
  }
200
205
 
201
206
  function claudeStateFromDoc(doc, expectedBaseUrl) {
202
- const helperIsAerial = doc?.apiKeyHelper === "aerial key print";
207
+ const helperIsAerial = typeof doc?.apiKeyHelper === "string" && /\bkey\s+print\b/.test(doc.apiKeyHelper);
203
208
  const baseUrl = doc?.env?.ANTHROPIC_BASE_URL;
204
209
  const baseUrlMatches = baseUrl === expectedBaseUrl;
205
210
  const baseUrlAerialShape = typeof baseUrl === "string" && /^http:\/\/127\.0\.0\.1:\d+/.test(baseUrl);
@@ -238,7 +243,10 @@ export function setupStatus() {
238
243
  config: { host: config.host, port: config.port },
239
244
  auth: {
240
245
  api_key: { file: apiKeyFile, exists: fs.existsSync(apiKeyFile) },
241
- github_token: { file: githubTokenFile, exists: fs.existsSync(githubTokenFile) }
246
+ github_token: (() => {
247
+ const source = gitHubTokenSource();
248
+ return { file: githubTokenFile, exists: source !== "missing", source };
249
+ })()
242
250
  },
243
251
  clients: {
244
252
  codex: codexStatus(),