@jiayunxie/aerial 0.1.5 → 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 +55 -211
- package/docs/usage.md +42 -10
- package/package.json +1 -1
- package/src/app-status.js +23 -0
- package/src/auth.js +23 -3
- package/src/cli.js +112 -19
- package/src/model-selection.js +144 -0
- package/src/server.js +53 -1
- package/src/setup.js +14 -6
package/README.md
CHANGED
|
@@ -3,278 +3,122 @@
|
|
|
3
3
|
[](https://github.com/Xiejiayun/aerial/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@jiayunxie/aerial)
|
|
5
5
|
|
|
6
|
-
Aerial
|
|
6
|
+
Aerial lets Codex CLI and Claude Code use your own GitHub Copilot subscription through a local proxy on your machine.
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
##
|
|
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 [--model <responses-model-id>]
|
|
29
|
-
aerial setup claude [--model <messages-model-id>]
|
|
30
|
-
aerial setup status
|
|
31
|
-
aerial setup restore <codex|claude|all> --latest
|
|
32
|
-
aerial service install
|
|
33
|
-
aerial service start
|
|
34
|
-
aerial service status
|
|
35
|
-
aerial service stop
|
|
36
|
-
aerial service restart
|
|
37
|
-
aerial service uninstall
|
|
38
|
-
aerial disable
|
|
39
|
-
aerial doctor
|
|
40
|
-
aerial probe
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
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
|
|
44
11
|
|
|
45
|
-
|
|
12
|
+
Requirements:
|
|
46
13
|
|
|
47
14
|
- Node.js 22+
|
|
48
15
|
- A GitHub account with an active Copilot subscription
|
|
49
|
-
- Codex CLI and/or Claude Code installed
|
|
50
|
-
|
|
51
|
-
## Install
|
|
16
|
+
- Codex CLI and/or Claude Code installed
|
|
52
17
|
|
|
53
18
|
```bash
|
|
54
19
|
npm install -g @jiayunxie/aerial
|
|
55
20
|
```
|
|
56
21
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
### Nightly builds (advanced, unstable)
|
|
60
|
-
|
|
61
|
-
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:
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
npm install -g @jiayunxie/aerial@nightly
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
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`.
|
|
68
|
-
|
|
69
|
-
### From source (local development)
|
|
22
|
+
## Quick Start
|
|
70
23
|
|
|
71
|
-
|
|
24
|
+
1. Sign in to GitHub:
|
|
72
25
|
|
|
73
26
|
```bash
|
|
74
|
-
|
|
75
|
-
cd aerial
|
|
76
|
-
npm install -g .
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
Run without global install:
|
|
80
|
-
|
|
81
|
-
```bash
|
|
82
|
-
node src/cli.js --help
|
|
27
|
+
aerial login
|
|
83
28
|
```
|
|
84
29
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
Configure the local client you use. These commands also create Aerial's local API key and wire it into the selected client:
|
|
30
|
+
2. Configure the client you use:
|
|
88
31
|
|
|
89
32
|
```bash
|
|
33
|
+
# Codex CLI
|
|
90
34
|
aerial setup codex
|
|
91
|
-
|
|
35
|
+
|
|
36
|
+
# Claude Code
|
|
92
37
|
aerial setup claude
|
|
93
38
|
```
|
|
94
39
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
Log in to GitHub with device flow:
|
|
98
|
-
|
|
99
|
-
```bash
|
|
100
|
-
aerial login
|
|
101
|
-
```
|
|
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.
|
|
102
41
|
|
|
103
|
-
|
|
42
|
+
3. Start Aerial in the background:
|
|
104
43
|
|
|
105
44
|
```bash
|
|
106
45
|
aerial service install
|
|
107
46
|
```
|
|
108
47
|
|
|
109
|
-
Check
|
|
48
|
+
4. Check everything from one place:
|
|
110
49
|
|
|
111
50
|
```bash
|
|
112
|
-
|
|
51
|
+
aerial status
|
|
113
52
|
```
|
|
114
53
|
|
|
115
|
-
|
|
54
|
+
If Aerial prints a hint, follow it before expecting the background service to work after reboot.
|
|
116
55
|
|
|
117
|
-
|
|
56
|
+
Restart Codex CLI or Claude Code if either one was already open while you ran setup.
|
|
118
57
|
|
|
119
|
-
|
|
120
|
-
aerial setup codex
|
|
121
|
-
```
|
|
58
|
+
## What Setup Changes
|
|
122
59
|
|
|
123
|
-
|
|
60
|
+
`aerial setup codex` updates `~/.codex/config.toml`.
|
|
124
61
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
wire_api = "responses"
|
|
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.
|
|
129
65
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
```
|
|
66
|
+
`aerial setup claude` updates `~/.claude/settings.json`.
|
|
67
|
+
|
|
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.
|
|
136
71
|
|
|
137
|
-
|
|
72
|
+
You normally do not need to create, copy, or export an API key yourself.
|
|
138
73
|
|
|
139
|
-
##
|
|
74
|
+
## Choosing A Model
|
|
140
75
|
|
|
141
|
-
|
|
76
|
+
The easiest path is to let setup show the compatible models:
|
|
142
77
|
|
|
143
78
|
```bash
|
|
79
|
+
aerial setup codex
|
|
144
80
|
aerial setup claude
|
|
145
81
|
```
|
|
146
82
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
```json
|
|
150
|
-
{
|
|
151
|
-
"model": "<available-copilot-model-id>",
|
|
152
|
-
"apiKeyHelper": "aerial key print",
|
|
153
|
-
"env": {
|
|
154
|
-
"ANTHROPIC_BASE_URL": "http://127.0.0.1:18181",
|
|
155
|
-
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1"
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
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.
|
|
161
|
-
|
|
162
|
-
If you need explicit models, pass `--model <responses-model-id>` to `aerial setup codex` and `--model <messages-model-id>` to `aerial setup claude`. Aerial intentionally does not provide `aerial setup all` as a setup shortcut because Codex and Claude Code may need different model IDs.
|
|
163
|
-
|
|
164
|
-
`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.
|
|
165
|
-
|
|
166
|
-
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.
|
|
167
|
-
|
|
168
|
-
## Diagnostics
|
|
83
|
+
To pin a model without the prompt:
|
|
169
84
|
|
|
170
85
|
```bash
|
|
171
|
-
aerial
|
|
172
|
-
aerial
|
|
86
|
+
aerial setup codex --model <responses-model-id>
|
|
87
|
+
aerial setup claude --model <messages-model-id>
|
|
173
88
|
```
|
|
174
89
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
## Security Boundary
|
|
178
|
-
|
|
179
|
-
Aerial is for personal local use only.
|
|
180
|
-
|
|
181
|
-
- It binds to `127.0.0.1` by default.
|
|
182
|
-
- Model routes require `Authorization: Bearer <AERIAL_API_KEY>` or `x-api-key: <AERIAL_API_KEY>`.
|
|
183
|
-
- GitHub tokens are stored under the user config directory with private file permissions where supported.
|
|
184
|
-
- Aerial does not log raw GitHub tokens, Copilot JWTs, API keys, or request bodies by default.
|
|
185
|
-
- Do not expose this service publicly or use it for account sharing.
|
|
186
|
-
|
|
187
|
-
## Current Limitations
|
|
188
|
-
|
|
189
|
-
- Copilot inference routes are an observed compatibility target, not a public stable GitHub REST API.
|
|
190
|
-
- `/v1/messages/count_tokens` is a local estimate, not upstream tokenization.
|
|
191
|
-
- 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.
|
|
192
|
-
- For explicit model overrides, query `/v1/models`, choose a `responses` model for Codex and a `messages` model for Claude Code, then rerun the client-specific setup command with `--model`.
|
|
193
|
-
- Chat Completions requests normalize `max_tokens` to `max_completion_tokens` for newer OpenAI models that reject the older field.
|
|
194
|
-
- Prompt caching is upstream-managed: Aerial does not store prompt bodies locally, and it preserves or injects cache protocol fields before forwarding.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
## Capability Probe
|
|
199
|
-
|
|
200
|
-
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:
|
|
201
91
|
|
|
202
92
|
```bash
|
|
203
93
|
aerial probe
|
|
204
94
|
```
|
|
205
95
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
Run low-cost live route checks when you want to verify end-to-end behavior:
|
|
96
|
+
## Daily Commands
|
|
209
97
|
|
|
210
98
|
```bash
|
|
211
|
-
aerial
|
|
212
|
-
#
|
|
213
|
-
aerial
|
|
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
|
|
214
103
|
```
|
|
215
104
|
|
|
216
|
-
|
|
217
|
-
## Prompt Cache
|
|
218
|
-
|
|
219
|
-
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.
|
|
220
|
-
|
|
221
|
-
Supported behavior:
|
|
222
|
-
|
|
223
|
-
- Responses and Chat Completions preserve `prompt_cache_retention` and `prompt_cache_key` when the client sends them.
|
|
224
|
-
- Responses and Chat Completions automatically add `prompt_cache_retention: "in_memory"` and a stable hashed `prompt_cache_key` when the client omits them.
|
|
225
|
-
- 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.
|
|
226
|
-
- 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.
|
|
227
|
-
- Aerial logs cache metadata to stderr as `cache_request` and `cache_observe` events. These logs do not include prompt text or request bodies.
|
|
228
|
-
|
|
229
|
-
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.
|
|
230
|
-
|
|
231
|
-
Aerial enables ephemeral prompt caching by default for OpenAI-style routes and Anthropic Messages. Override it only when needed:
|
|
105
|
+
`aerial start` is for foreground debugging in the current terminal. Most users should use `aerial service install`.
|
|
232
106
|
|
|
233
|
-
|
|
234
|
-
aerial config set promptCacheRetention in_memory
|
|
235
|
-
# or
|
|
236
|
-
aerial config set promptCacheRetention 24h
|
|
237
|
-
# disable automatic cache hints
|
|
238
|
-
aerial config set promptCacheRetention off
|
|
239
|
-
aerial config set promptCacheKey off
|
|
240
|
-
# pin all requests to an explicit cache partition
|
|
241
|
-
aerial config set promptCacheKey my-project
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
You can also set it per process:
|
|
245
|
-
|
|
246
|
-
```bash
|
|
247
|
-
export AERIAL_PROMPT_CACHE_RETENTION=in_memory
|
|
248
|
-
export AERIAL_PROMPT_CACHE_KEY=my-project
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
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.
|
|
252
|
-
## Model Support
|
|
253
|
-
|
|
254
|
-
`GET /v1/models` returns Copilot's raw model metadata and adds an Aerial-specific field:
|
|
255
|
-
|
|
256
|
-
```json
|
|
257
|
-
{
|
|
258
|
-
"id": "gpt-5.4-mini",
|
|
259
|
-
"supported_endpoints": ["/responses", "ws:/responses"],
|
|
260
|
-
"aerial": {
|
|
261
|
-
"supported": true,
|
|
262
|
-
"routes": ["responses", "responses_websocket"],
|
|
263
|
-
"notes": []
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
```
|
|
107
|
+
Advanced service lifecycle commands are documented in `docs/usage.md`.
|
|
267
108
|
|
|
268
|
-
|
|
109
|
+
## Troubleshooting
|
|
269
110
|
|
|
270
|
-
- `
|
|
271
|
-
- `
|
|
272
|
-
-
|
|
273
|
-
- `
|
|
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`.
|
|
274
116
|
|
|
275
|
-
|
|
117
|
+
## Notes
|
|
276
118
|
|
|
277
|
-
|
|
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.
|
|
278
123
|
|
|
279
|
-
|
|
280
|
-
- `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,12 +23,13 @@ node src/cli.js --help
|
|
|
23
23
|
## 2. Configure Local Clients
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
+
aerial login
|
|
26
27
|
aerial setup codex
|
|
27
28
|
# optional, if you use Claude Code too:
|
|
28
29
|
aerial setup claude
|
|
29
30
|
```
|
|
30
31
|
|
|
31
|
-
Aerial creates a local API key, stores it privately, and configures the selected clients to use it. 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.
|
|
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.
|
|
32
33
|
|
|
33
34
|
## 3. Login To GitHub
|
|
34
35
|
|
|
@@ -52,7 +53,7 @@ Default URL: `http://127.0.0.1:18181`. `aerial service install` is the daily-use
|
|
|
52
53
|
aerial setup codex
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
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:
|
|
56
57
|
|
|
57
58
|
```toml
|
|
58
59
|
[model_providers.aerial]
|
|
@@ -70,7 +71,7 @@ The local key is generated and stored by Aerial automatically. Users do not need
|
|
|
70
71
|
|
|
71
72
|
For a dry inspection without touching your real config, set `HOME`/`USERPROFILE` to a temporary directory before running this command.
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
To skip the prompt, pass `--model <responses-model-id>`.
|
|
74
75
|
|
|
75
76
|
## 6. Configure Claude Code
|
|
76
77
|
|
|
@@ -78,21 +79,50 @@ If you need to pin a model, first run `aerial probe` or call `/v1/models`, choos
|
|
|
78
79
|
aerial setup claude
|
|
79
80
|
```
|
|
80
81
|
|
|
81
|
-
The setup command backs up and merges `~/.claude/settings.json`,
|
|
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`.
|
|
82
83
|
|
|
83
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.
|
|
84
85
|
|
|
85
86
|
For a dry inspection without touching your real config, set `HOME`/`USERPROFILE` to a temporary directory before running this command.
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
To skip the prompt, pass `--model <messages-model-id>`.
|
|
88
89
|
|
|
89
90
|
## 7. Verify
|
|
90
91
|
|
|
91
92
|
```bash
|
|
93
|
+
aerial status
|
|
92
94
|
aerial doctor
|
|
93
95
|
aerial probe
|
|
94
96
|
```
|
|
95
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
|
+
|
|
96
126
|
Each model returned by `/v1/models` includes an `aerial` field that tells you whether the MVP can route it:
|
|
97
127
|
|
|
98
128
|
```json
|
|
@@ -131,7 +161,7 @@ aerial disable
|
|
|
131
161
|
"config": { "host": "127.0.0.1", "port": 18181 },
|
|
132
162
|
"auth": {
|
|
133
163
|
"api_key": { "file": "/Users/you/Library/Application Support/aerial/api_key", "exists": true },
|
|
134
|
-
"github_token": { "file": "/Users/you/Library/Application Support/aerial/github_token", "exists":
|
|
164
|
+
"github_token": { "file": "/Users/you/Library/Application Support/aerial/github_token", "exists": true, "source": "file" }
|
|
135
165
|
},
|
|
136
166
|
"clients": {
|
|
137
167
|
"codex": {
|
|
@@ -139,7 +169,7 @@ aerial disable
|
|
|
139
169
|
"state": "aerial",
|
|
140
170
|
"file": "/Users/you/.codex/config.toml",
|
|
141
171
|
"backups": ["/Users/you/.codex/config.toml.aerial-backup-2026-05-20T10-00-00-000Z"],
|
|
142
|
-
"model": "gpt-
|
|
172
|
+
"model": "gpt-5.5",
|
|
143
173
|
"baseUrl": "http://127.0.0.1:18181/v1"
|
|
144
174
|
},
|
|
145
175
|
"claude": {
|
|
@@ -154,7 +184,9 @@ aerial disable
|
|
|
154
184
|
}
|
|
155
185
|
```
|
|
156
186
|
|
|
157
|
-
|
|
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"`.
|
|
158
190
|
|
|
159
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.
|
|
160
192
|
|
|
@@ -191,7 +223,7 @@ Wrapper env values are baked in at install time. If you set `AERIAL_LOG_MAX_BYTE
|
|
|
191
223
|
|
|
192
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`.
|
|
193
225
|
|
|
194
|
-
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.
|
|
195
227
|
|
|
196
228
|
`aerial service status` reports a single aggregated view:
|
|
197
229
|
|
|
@@ -321,7 +353,7 @@ Best practice: put stable system/project/tool context first, put changing user i
|
|
|
321
353
|
|
|
322
354
|
- `503 Missing GitHub token`: run `aerial login`.
|
|
323
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.
|
|
324
|
-
- Claude Code cannot read key:
|
|
356
|
+
- Claude Code cannot read key: rerun `aerial setup claude` so the settings file gets a fresh absolute API-key helper command.
|
|
325
357
|
- Upstream compatibility error: run `aerial doctor`, then retry with a model returned by `/v1/models`.
|
|
326
358
|
- `Unsupported parameter: max_tokens`: Aerial normalizes Chat Completions `max_tokens` into `max_completion_tokens` before forwarding to newer OpenAI models.
|
|
327
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
|
@@ -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
|
-
|
|
51
|
+
const envToken = process.env.AERIAL_GITHUB_TOKEN?.trim();
|
|
52
|
+
if (envToken) return envToken;
|
|
52
53
|
if (!fs.existsSync(githubTokenPath())) return undefined;
|
|
53
|
-
|
|
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)
|
|
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,36 +22,35 @@ function codexAuthCommand() {
|
|
|
20
22
|
};
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
function quoteCommandPart(value) {
|
|
26
|
+
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function claudeApiKeyHelper() {
|
|
30
|
+
return [process.execPath, CLI_ENTRY, "key", "print"].map(quoteCommandPart).join(" ");
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
function printHelp() {
|
|
24
34
|
console.log(`Aerial local Copilot proxy
|
|
25
35
|
|
|
26
36
|
Usage:
|
|
27
37
|
aerial --version
|
|
28
38
|
aerial login
|
|
29
|
-
aerial key generate
|
|
30
|
-
aerial key print
|
|
31
|
-
aerial start [--host 127.0.0.1] [--port 18181]
|
|
32
39
|
aerial setup codex [--model <id>]
|
|
33
40
|
aerial setup claude [--model <id>]
|
|
41
|
+
aerial service install
|
|
42
|
+
aerial status [--json]
|
|
43
|
+
|
|
44
|
+
Diagnostics and rollback:
|
|
34
45
|
aerial setup status [--json]
|
|
35
46
|
aerial setup restore <codex|claude|all> --latest
|
|
36
|
-
aerial service install
|
|
37
|
-
aerial service start
|
|
38
|
-
aerial service stop
|
|
39
|
-
aerial service restart
|
|
40
47
|
aerial service status [--json]
|
|
41
|
-
aerial service uninstall
|
|
42
48
|
aerial disable
|
|
43
49
|
aerial doctor
|
|
44
50
|
aerial probe [--live] [--json]
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
GET /v1/models
|
|
49
|
-
POST /v1/responses
|
|
50
|
-
POST /v1/messages
|
|
51
|
-
POST /v1/messages/count_tokens
|
|
52
|
-
POST /v1/chat/completions`);
|
|
52
|
+
Debug:
|
|
53
|
+
aerial start [--host 127.0.0.1] [--port 18181]`);
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function argValue(args, name) {
|
|
@@ -57,13 +58,96 @@ function argValue(args, name) {
|
|
|
57
58
|
return index >= 0 ? args[index + 1] : undefined;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
async function selectSetupModel(target, route, args) {
|
|
62
|
+
const selected = await chooseSetupModel({ target, route, explicitModel: argValue(args, "--model") });
|
|
63
|
+
if (!selected.displayed) {
|
|
64
|
+
for (const line of formatModelChoices({ target, route, choices: selected.choices, selectedModel: selected.model, source: selected.source, recommended: selected.recommended })) {
|
|
65
|
+
console.log(line);
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
console.log(`Selected ${target} model: ${selected.model}`);
|
|
69
|
+
}
|
|
70
|
+
return selected.model;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function printSetupSummary(status) {
|
|
74
|
+
console.log("clients:");
|
|
75
|
+
for (const client of Object.values(status.clients)) {
|
|
76
|
+
const model = client.model ? ` model=${client.model}` : "";
|
|
77
|
+
console.log(` ${client.target}: ${client.state}${model}`);
|
|
78
|
+
}
|
|
79
|
+
console.log(`api key: ${status.auth.api_key.exists ? "present" : "missing"}`);
|
|
80
|
+
const ghSource = status.auth.github_token.source;
|
|
81
|
+
const ghText = ghSource === "missing" ? "missing" : `present (${ghSource})`;
|
|
82
|
+
console.log(`github login: ${ghText}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function printServiceSummary(status) {
|
|
86
|
+
console.log(`service: ${status.summary}`);
|
|
87
|
+
if (status.supported === false) {
|
|
88
|
+
console.log(`platform: ${status.platform} (service unsupported)`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const health = status.health?.aerial ? `ok (${status.health.supervisor})`
|
|
92
|
+
: status.health?.portConflict ? `port conflict (${status.health.conflictReason})`
|
|
93
|
+
: status.health?.ok ? "ok"
|
|
94
|
+
: `unreachable (${status.health?.error || `http ${status.health?.status}`})`;
|
|
95
|
+
console.log(`health: ${health}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function appStatus({ json = false } = {}) {
|
|
99
|
+
const setup = setupStatus();
|
|
100
|
+
const service = await serviceStatus();
|
|
101
|
+
const status = computeAppStatus(setup, service);
|
|
102
|
+
if (json) {
|
|
103
|
+
console.log(JSON.stringify(status, null, 2));
|
|
104
|
+
return status;
|
|
105
|
+
}
|
|
106
|
+
console.log("Aerial status");
|
|
107
|
+
printSetupSummary(setup);
|
|
108
|
+
printServiceSummary(service);
|
|
109
|
+
if (status.nextSteps.length) {
|
|
110
|
+
console.log("next:");
|
|
111
|
+
for (const step of status.nextSteps) console.log(` - ${step}`);
|
|
112
|
+
}
|
|
113
|
+
if (status.hints.length) {
|
|
114
|
+
console.log("hints:");
|
|
115
|
+
for (const hint of status.hints) console.log(` - ${hint}`);
|
|
116
|
+
}
|
|
117
|
+
return status;
|
|
118
|
+
}
|
|
119
|
+
|
|
60
120
|
async function main() {
|
|
61
121
|
const args = process.argv.slice(2);
|
|
62
122
|
const [command, subcommand, ...rest] = args;
|
|
63
123
|
if (!command || command === "--help" || command === "-h") return printHelp();
|
|
64
124
|
if (command === "--version") return printVersion();
|
|
125
|
+
if (command === "status") {
|
|
126
|
+
const status = await appStatus({ json: args.includes("--json") });
|
|
127
|
+
process.exitCode = status.ok ? 0 : 1;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
65
130
|
|
|
66
131
|
if (command === "login") {
|
|
132
|
+
const loginArgs = args.slice(1);
|
|
133
|
+
const force = loginArgs.includes("--force");
|
|
134
|
+
const source = gitHubTokenSource();
|
|
135
|
+
if (force && source === "env") {
|
|
136
|
+
console.error("AERIAL_GITHUB_TOKEN is set; unset it before running `aerial login --force`, otherwise the env value will continue to shadow any new file token.");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
if (!force && source === "env") {
|
|
140
|
+
console.log("GitHub login is provided by AERIAL_GITHUB_TOKEN (not verified). To use a different account, unset it or run with a different environment.");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!force && source === "file") {
|
|
144
|
+
console.log("GitHub login already exists (not verified). To sign in again, run aerial login --force.");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (process.env.AERIAL_TEST_LOGIN_NO_NETWORK === "1") {
|
|
148
|
+
console.log("AERIAL_TEST_LOGIN_NO_NETWORK=1 set; skipping GitHub device flow (test mode).");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
67
151
|
const flow = await startDeviceFlow();
|
|
68
152
|
console.log(`Open: ${flow.verification_uri}`);
|
|
69
153
|
console.log(`Code: ${flow.user_code}`);
|
|
@@ -102,16 +186,20 @@ async function main() {
|
|
|
102
186
|
|
|
103
187
|
if (command === "setup") {
|
|
104
188
|
if (subcommand === "codex") {
|
|
105
|
-
const
|
|
189
|
+
const model = await selectSetupModel("Codex", "responses", rest);
|
|
190
|
+
const result = setupCodex({ model, authCommand: codexAuthCommand() });
|
|
106
191
|
console.log(`Updated Codex config: ${result.file}`);
|
|
192
|
+
console.log(`Configured Codex model: ${result.model}`);
|
|
107
193
|
if (result.backup) console.log(`Backup: ${result.backup}`);
|
|
108
194
|
console.log("Configured Codex to read the local Aerial key automatically.");
|
|
109
195
|
return;
|
|
110
196
|
}
|
|
111
197
|
if (subcommand === "claude") {
|
|
112
|
-
const
|
|
198
|
+
const model = await selectSetupModel("Claude Code", "messages", rest);
|
|
199
|
+
const result = setupClaude({ model, apiKeyHelper: claudeApiKeyHelper() });
|
|
113
200
|
console.log(`Updated Claude settings: ${result.file}`);
|
|
114
201
|
if (result.model) console.log(`Configured Claude default model: ${result.model}`);
|
|
202
|
+
console.log("Configured Claude Code to read the local Aerial key automatically.");
|
|
115
203
|
if (result.backup) console.log(`Backup: ${result.backup}`);
|
|
116
204
|
return;
|
|
117
205
|
}
|
|
@@ -126,7 +214,12 @@ async function main() {
|
|
|
126
214
|
}
|
|
127
215
|
console.log(`Aerial: http://${status.config.host}:${status.config.port} (platform: ${status.platform})`);
|
|
128
216
|
console.log(`API key file: ${status.auth.api_key.file} (${status.auth.api_key.exists ? "present" : "missing"})`);
|
|
129
|
-
|
|
217
|
+
const ghSourceText = status.auth.github_token.source === "missing"
|
|
218
|
+
? `(missing)`
|
|
219
|
+
: status.auth.github_token.source === "env"
|
|
220
|
+
? `(present, source=env; file path ${status.auth.github_token.file} is not consulted while AERIAL_GITHUB_TOKEN is set)`
|
|
221
|
+
: `${status.auth.github_token.file} (present, source=file)`;
|
|
222
|
+
console.log(`GitHub token: ${ghSourceText}`);
|
|
130
223
|
for (const cs of Object.values(status.clients)) {
|
|
131
224
|
const head = `${cs.target.padEnd(7)} state=${cs.state}`;
|
|
132
225
|
console.log(`${head} file=${cs.file}`);
|
|
@@ -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
|
|
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
|
|
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 === "
|
|
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:
|
|
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(),
|