@jiayunxie/aerial 0.1.0 → 0.1.2
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/LICENSE +21 -21
- package/README.md +263 -264
- package/docs/usage.md +176 -176
- package/package.json +46 -46
- package/src/auth.js +92 -92
- package/src/cli.js +151 -151
- package/src/config.js +30 -30
- package/src/constants.js +11 -11
- package/src/crypto.js +25 -25
- package/src/doctor.js +11 -11
- package/src/log.js +9 -9
- package/src/paths.js +38 -38
- package/src/probe.js +141 -141
- package/src/server.js +101 -101
- package/src/setup.js +42 -42
package/docs/usage.md
CHANGED
|
@@ -1,176 +1,176 @@
|
|
|
1
|
-
# Aerial MVP Usage
|
|
2
|
-
|
|
3
|
-
This document describes the current MVP implementation.
|
|
4
|
-
|
|
5
|
-
## 1. Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install -g @jiayunxie/aerial
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
After install, the CLI is available as `aerial`.
|
|
12
|
-
|
|
13
|
-
For local development against the repo source:
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
git clone https://github.com/Xiejiayun/aerial.git
|
|
17
|
-
cd aerial
|
|
18
|
-
npm install -g .
|
|
19
|
-
# or run without global install:
|
|
20
|
-
node src/cli.js --help
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
## 2. Configure Local Clients
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
aerial setup all --model <model-id>
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
Aerial creates a local API key, stores it privately, and configures supported clients to use it. On Windows, restart the terminal or VS Code after first setup so newly persisted user environment variables are visible to Codex.
|
|
30
|
-
|
|
31
|
-
## 3. Login To GitHub
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
aerial login
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
Open the printed URL, enter the user code, and authorize the GitHub OAuth device flow. Aerial saves the GitHub access token locally and exchanges it for short-lived Copilot JWTs when proxy requests arrive.
|
|
38
|
-
|
|
39
|
-
## 4. Start Server
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
aerial start
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Default URL: `http://127.0.0.1:18181`.
|
|
46
|
-
|
|
47
|
-
## 5. Configure Codex CLI
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
aerial setup codex --model <model-id>
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
The setup command backs up and merges `~/.codex/config.toml`, then persists the local key for new user sessions when the platform supports it.
|
|
54
|
-
|
|
55
|
-
For a dry inspection without touching your real config, set `HOME`/`USERPROFILE` to a temporary directory before running this command.
|
|
56
|
-
|
|
57
|
-
## 6. Configure Claude Code
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
aerial setup claude --model <model-id>
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
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.
|
|
64
|
-
|
|
65
|
-
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.
|
|
66
|
-
|
|
67
|
-
For a dry inspection without touching your real config, set `HOME`/`USERPROFILE` to a temporary directory before running this command.
|
|
68
|
-
|
|
69
|
-
## 7. Verify
|
|
70
|
-
|
|
71
|
-
```bash
|
|
72
|
-
aerial doctor
|
|
73
|
-
aerial probe
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Each model returned by `/v1/models` includes an `aerial` field that tells you whether the MVP can route it:
|
|
77
|
-
|
|
78
|
-
```json
|
|
79
|
-
"aerial": {
|
|
80
|
-
"supported": true,
|
|
81
|
-
"routes": ["responses", "responses_websocket", "chat"],
|
|
82
|
-
"notes": []
|
|
83
|
-
}
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Use `responses` models for Codex, `messages` models for Claude Code, and `chat` models only for generic Chat Completions clients. `responses_websocket` means Aerial can optionally use Copilot's upstream `ws:/responses` transport for streaming Responses calls when `AERIAL_RESPONSES_WEBSOCKET=on` is set; with the opt-in off (default), Aerial always uses HTTP upstream Responses. Models marked with `embeddings_not_implemented` or `no_supported_endpoint_advertised` need additional Aerial work before they are first-class choices.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
## 8. Probe Capabilities
|
|
91
|
-
|
|
92
|
-
```bash
|
|
93
|
-
aerial probe
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
This prints the current model matrix returned by Copilot plus Aerial's route annotations. It is the fastest way to see which models currently expose `responses`, `messages`, or `chat` routes.
|
|
97
|
-
|
|
98
|
-
For a low-cost end-to-end check:
|
|
99
|
-
|
|
100
|
-
```bash
|
|
101
|
-
aerial probe --live
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
`--live` sends one small request through the first available model for each supported route. Use `--json` when you want machine-readable output for CI or debugging.
|
|
105
|
-
|
|
106
|
-
For streaming `/v1/responses` requests, Aerial defaults to HTTP upstream Responses. Set `AERIAL_RESPONSES_WEBSOCKET=on` to opt into Copilot's upstream `ws:/responses` transport for streaming Responses calls; Aerial then converts upstream WebSocket events back to SSE for the local client. Direct client WebSocket upgrades to Aerial are still not exposed and return `501 Not Implemented`; clients should keep using HTTP `POST /v1/responses`.
|
|
107
|
-
|
|
108
|
-
## 9. Use Prompt Cache
|
|
109
|
-
|
|
110
|
-
Aerial does not implement a local prompt-content cache. It forwards cache protocol fields to Copilot and returns upstream usage fields unchanged. This is the intended design: prompts are not written to a local cache, and cache hits are controlled by the upstream service.
|
|
111
|
-
|
|
112
|
-
For Codex/OpenAI Responses clients, users normally do not need to send cache fields directly. A manual request can still override Aerial's defaults:
|
|
113
|
-
|
|
114
|
-
```bash
|
|
115
|
-
curl -s http://127.0.0.1:18181/v1/responses \
|
|
116
|
-
-H "Authorization: Bearer $AERIAL_API_KEY" \
|
|
117
|
-
-H "Content-Type: application/json" \
|
|
118
|
-
-d '{
|
|
119
|
-
"model": "gpt-5.4-mini",
|
|
120
|
-
"input": "<long stable prefix first, variable request last>",
|
|
121
|
-
"prompt_cache_retention": "in_memory",
|
|
122
|
-
"prompt_cache_key": "my-project"
|
|
123
|
-
}'
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
For Claude Code or Anthropic Messages clients, Aerial automatically adds Anthropic `cache_control` to stable `system` content when the client omits cache hints. You can still send `cache_control` manually to choose the exact breakpoint:
|
|
127
|
-
|
|
128
|
-
```json
|
|
129
|
-
{
|
|
130
|
-
"model": "claude-sonnet-4.6",
|
|
131
|
-
"max_tokens": 1024,
|
|
132
|
-
"system": [
|
|
133
|
-
{
|
|
134
|
-
"type": "text",
|
|
135
|
-
"text": "Long stable project context...",
|
|
136
|
-
"cache_control": { "type": "ephemeral" }
|
|
137
|
-
}
|
|
138
|
-
],
|
|
139
|
-
"messages": [{ "role": "user", "content": "Summarize the current diff" }]
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
Aerial automatically applies ephemeral prompt cache hints for OpenAI-style `/v1/responses`, `/v1/chat/completions`, and Anthropic-style `/v1/messages` requests that do not set them. Override this only when needed:
|
|
144
|
-
|
|
145
|
-
```bash
|
|
146
|
-
aerial config set promptCacheRetention in_memory
|
|
147
|
-
# or: aerial config set promptCacheRetention 24h
|
|
148
|
-
# disable automatic cache hints: aerial config set promptCacheRetention off && aerial config set promptCacheKey off
|
|
149
|
-
# pin cache partition: aerial config set promptCacheKey my-project
|
|
150
|
-
# per process: export AERIAL_PROMPT_CACHE_RETENTION=in_memory && export AERIAL_PROMPT_CACHE_KEY=my-project
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Look for these usage fields to confirm cache behavior:
|
|
154
|
-
|
|
155
|
-
- Responses/Chat: `usage.input_tokens_details.cached_tokens` or `usage.prompt_tokens_details.cached_tokens`.
|
|
156
|
-
- Messages: `usage.cache_creation_input_tokens` and `usage.cache_read_input_tokens`.
|
|
157
|
-
- Copilot details when present: `copilot_usage.token_details` entries with `cache_read` or `cache_write`.
|
|
158
|
-
|
|
159
|
-
When `aerial start` is running in a terminal, Aerial also writes cache-only metadata logs to stderr:
|
|
160
|
-
|
|
161
|
-
```json
|
|
162
|
-
{"event":"cache_request","route":"/v1/messages","cacheControlBlocks":1}
|
|
163
|
-
{"event":"cache_observe","route":"/v1/messages","usage":{"cacheRead":1920}}
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
These logs deliberately omit prompt text and request bodies. If `cached` stays zero, first check that the repeated prefix is identical and at least 1024 tokens. This matches OpenAI's prompt caching requirements; GitHub's public Copilot REST docs cover management and usage-metrics APIs, but do not document Copilot inference cache fields.
|
|
167
|
-
|
|
168
|
-
Best practice: put stable system/project/tool context first, put changing user input last, and keep the prefix identical between requests. OpenAI-style prompt caching generally needs at least 1024 tokens before hits appear.
|
|
169
|
-
## Troubleshooting
|
|
170
|
-
|
|
171
|
-
- `503 Missing GitHub token`: run `aerial login`.
|
|
172
|
-
- `401 Invalid or missing Aerial API key`: run `aerial setup all`, then restart the client terminal or VS Code.
|
|
173
|
-
- Claude Code cannot read key: ensure `aerial` is on `PATH`; it uses `aerial key print` as its helper.
|
|
174
|
-
- Upstream compatibility error: run `aerial doctor`, then retry with a model returned by `/v1/models`.
|
|
175
|
-
- `Unsupported parameter: max_tokens`: Aerial normalizes Chat Completions `max_tokens` into `max_completion_tokens` before forwarding to newer OpenAI models.
|
|
176
|
-
- 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.
|
|
1
|
+
# Aerial MVP Usage
|
|
2
|
+
|
|
3
|
+
This document describes the current MVP implementation.
|
|
4
|
+
|
|
5
|
+
## 1. Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @jiayunxie/aerial
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
After install, the CLI is available as `aerial`.
|
|
12
|
+
|
|
13
|
+
For local development against the repo source:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://github.com/Xiejiayun/aerial.git
|
|
17
|
+
cd aerial
|
|
18
|
+
npm install -g .
|
|
19
|
+
# or run without global install:
|
|
20
|
+
node src/cli.js --help
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 2. Configure Local Clients
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
aerial setup all --model <model-id>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Aerial creates a local API key, stores it privately, and configures supported clients to use it. On Windows, restart the terminal or VS Code after first setup so newly persisted user environment variables are visible to Codex.
|
|
30
|
+
|
|
31
|
+
## 3. Login To GitHub
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
aerial login
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Open the printed URL, enter the user code, and authorize the GitHub OAuth device flow. Aerial saves the GitHub access token locally and exchanges it for short-lived Copilot JWTs when proxy requests arrive.
|
|
38
|
+
|
|
39
|
+
## 4. Start Server
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
aerial start
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Default URL: `http://127.0.0.1:18181`.
|
|
46
|
+
|
|
47
|
+
## 5. Configure Codex CLI
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
aerial setup codex --model <model-id>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The setup command backs up and merges `~/.codex/config.toml`, then persists the local key for new user sessions when the platform supports it.
|
|
54
|
+
|
|
55
|
+
For a dry inspection without touching your real config, set `HOME`/`USERPROFILE` to a temporary directory before running this command.
|
|
56
|
+
|
|
57
|
+
## 6. Configure Claude Code
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
aerial setup claude --model <model-id>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
For a dry inspection without touching your real config, set `HOME`/`USERPROFILE` to a temporary directory before running this command.
|
|
68
|
+
|
|
69
|
+
## 7. Verify
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
aerial doctor
|
|
73
|
+
aerial probe
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Each model returned by `/v1/models` includes an `aerial` field that tells you whether the MVP can route it:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
"aerial": {
|
|
80
|
+
"supported": true,
|
|
81
|
+
"routes": ["responses", "responses_websocket", "chat"],
|
|
82
|
+
"notes": []
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use `responses` models for Codex, `messages` models for Claude Code, and `chat` models only for generic Chat Completions clients. `responses_websocket` means Aerial can optionally use Copilot's upstream `ws:/responses` transport for streaming Responses calls when `AERIAL_RESPONSES_WEBSOCKET=on` is set; with the opt-in off (default), Aerial always uses HTTP upstream Responses. Models marked with `embeddings_not_implemented` or `no_supported_endpoint_advertised` need additional Aerial work before they are first-class choices.
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## 8. Probe Capabilities
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
aerial probe
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This prints the current model matrix returned by Copilot plus Aerial's route annotations. It is the fastest way to see which models currently expose `responses`, `messages`, or `chat` routes.
|
|
97
|
+
|
|
98
|
+
For a low-cost end-to-end check:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
aerial probe --live
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`--live` sends one small request through the first available model for each supported route. Use `--json` when you want machine-readable output for CI or debugging.
|
|
105
|
+
|
|
106
|
+
For streaming `/v1/responses` requests, Aerial defaults to HTTP upstream Responses. Set `AERIAL_RESPONSES_WEBSOCKET=on` to opt into Copilot's upstream `ws:/responses` transport for streaming Responses calls; Aerial then converts upstream WebSocket events back to SSE for the local client. Direct client WebSocket upgrades to Aerial are still not exposed and return `501 Not Implemented`; clients should keep using HTTP `POST /v1/responses`.
|
|
107
|
+
|
|
108
|
+
## 9. Use Prompt Cache
|
|
109
|
+
|
|
110
|
+
Aerial does not implement a local prompt-content cache. It forwards cache protocol fields to Copilot and returns upstream usage fields unchanged. This is the intended design: prompts are not written to a local cache, and cache hits are controlled by the upstream service.
|
|
111
|
+
|
|
112
|
+
For Codex/OpenAI Responses clients, users normally do not need to send cache fields directly. A manual request can still override Aerial's defaults:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
curl -s http://127.0.0.1:18181/v1/responses \
|
|
116
|
+
-H "Authorization: Bearer $AERIAL_API_KEY" \
|
|
117
|
+
-H "Content-Type: application/json" \
|
|
118
|
+
-d '{
|
|
119
|
+
"model": "gpt-5.4-mini",
|
|
120
|
+
"input": "<long stable prefix first, variable request last>",
|
|
121
|
+
"prompt_cache_retention": "in_memory",
|
|
122
|
+
"prompt_cache_key": "my-project"
|
|
123
|
+
}'
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
For Claude Code or Anthropic Messages clients, Aerial automatically adds Anthropic `cache_control` to stable `system` content when the client omits cache hints. You can still send `cache_control` manually to choose the exact breakpoint:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"model": "claude-sonnet-4.6",
|
|
131
|
+
"max_tokens": 1024,
|
|
132
|
+
"system": [
|
|
133
|
+
{
|
|
134
|
+
"type": "text",
|
|
135
|
+
"text": "Long stable project context...",
|
|
136
|
+
"cache_control": { "type": "ephemeral" }
|
|
137
|
+
}
|
|
138
|
+
],
|
|
139
|
+
"messages": [{ "role": "user", "content": "Summarize the current diff" }]
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Aerial automatically applies ephemeral prompt cache hints for OpenAI-style `/v1/responses`, `/v1/chat/completions`, and Anthropic-style `/v1/messages` requests that do not set them. Override this only when needed:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
aerial config set promptCacheRetention in_memory
|
|
147
|
+
# or: aerial config set promptCacheRetention 24h
|
|
148
|
+
# disable automatic cache hints: aerial config set promptCacheRetention off && aerial config set promptCacheKey off
|
|
149
|
+
# pin cache partition: aerial config set promptCacheKey my-project
|
|
150
|
+
# per process: export AERIAL_PROMPT_CACHE_RETENTION=in_memory && export AERIAL_PROMPT_CACHE_KEY=my-project
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Look for these usage fields to confirm cache behavior:
|
|
154
|
+
|
|
155
|
+
- Responses/Chat: `usage.input_tokens_details.cached_tokens` or `usage.prompt_tokens_details.cached_tokens`.
|
|
156
|
+
- Messages: `usage.cache_creation_input_tokens` and `usage.cache_read_input_tokens`.
|
|
157
|
+
- Copilot details when present: `copilot_usage.token_details` entries with `cache_read` or `cache_write`.
|
|
158
|
+
|
|
159
|
+
When `aerial start` is running in a terminal, Aerial also writes cache-only metadata logs to stderr:
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{"event":"cache_request","route":"/v1/messages","cacheControlBlocks":1}
|
|
163
|
+
{"event":"cache_observe","route":"/v1/messages","usage":{"cacheRead":1920}}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
These logs deliberately omit prompt text and request bodies. If `cached` stays zero, first check that the repeated prefix is identical and at least 1024 tokens. This matches OpenAI's prompt caching requirements; GitHub's public Copilot REST docs cover management and usage-metrics APIs, but do not document Copilot inference cache fields.
|
|
167
|
+
|
|
168
|
+
Best practice: put stable system/project/tool context first, put changing user input last, and keep the prefix identical between requests. OpenAI-style prompt caching generally needs at least 1024 tokens before hits appear.
|
|
169
|
+
## Troubleshooting
|
|
170
|
+
|
|
171
|
+
- `503 Missing GitHub token`: run `aerial login`.
|
|
172
|
+
- `401 Invalid or missing Aerial API key`: run `aerial setup all`, then restart the client terminal or VS Code.
|
|
173
|
+
- Claude Code cannot read key: ensure `aerial` is on `PATH`; it uses `aerial key print` as its helper.
|
|
174
|
+
- Upstream compatibility error: run `aerial doctor`, then retry with a model returned by `/v1/models`.
|
|
175
|
+
- `Unsupported parameter: max_tokens`: Aerial normalizes Chat Completions `max_tokens` into `max_completion_tokens` before forwarding to newer OpenAI models.
|
|
176
|
+
- 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,46 +1,46 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@jiayunxie/aerial",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Local-only GitHub Copilot proxy for Codex CLI and Claude Code.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"private": false,
|
|
7
|
-
"homepage": "https://github.com/Xiejiayun/aerial#readme",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/Xiejiayun/aerial.git"
|
|
11
|
-
},
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/Xiejiayun/aerial/issues"
|
|
14
|
-
},
|
|
15
|
-
"keywords": [
|
|
16
|
-
"copilot",
|
|
17
|
-
"codex",
|
|
18
|
-
"claude-code",
|
|
19
|
-
"local-proxy"
|
|
20
|
-
],
|
|
21
|
-
"files": [
|
|
22
|
-
"src",
|
|
23
|
-
"docs/usage.md",
|
|
24
|
-
"README.md",
|
|
25
|
-
"LICENSE"
|
|
26
|
-
],
|
|
27
|
-
"bin": {
|
|
28
|
-
"aerial": "src/cli.js"
|
|
29
|
-
},
|
|
30
|
-
"scripts": {
|
|
31
|
-
"start": "node src/cli.js start",
|
|
32
|
-
"doctor": "node src/cli.js doctor",
|
|
33
|
-
"test": "node --test",
|
|
34
|
-
"prepublishOnly": "npm test"
|
|
35
|
-
},
|
|
36
|
-
"engines": {
|
|
37
|
-
"node": ">=22"
|
|
38
|
-
},
|
|
39
|
-
"license": "MIT",
|
|
40
|
-
"publishConfig": {
|
|
41
|
-
"access": "public"
|
|
42
|
-
},
|
|
43
|
-
"dependencies": {
|
|
44
|
-
"undici": "^7.25.0"
|
|
45
|
-
}
|
|
46
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@jiayunxie/aerial",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Local-only GitHub Copilot proxy for Codex CLI and Claude Code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"homepage": "https://github.com/Xiejiayun/aerial#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Xiejiayun/aerial.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Xiejiayun/aerial/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"copilot",
|
|
17
|
+
"codex",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"local-proxy"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"docs/usage.md",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"bin": {
|
|
28
|
+
"aerial": "src/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"start": "node src/cli.js start",
|
|
32
|
+
"doctor": "node src/cli.js doctor",
|
|
33
|
+
"test": "node --test",
|
|
34
|
+
"prepublishOnly": "npm test"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22"
|
|
38
|
+
},
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"undici": "^7.25.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/auth.js
CHANGED
|
@@ -1,92 +1,92 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import { COPILOT_TOKEN_URL, GITHUB_CLIENT_ID } from "./constants.js";
|
|
3
|
-
import { githubTokenPath, writePrivateFile } from "./paths.js";
|
|
4
|
-
import { logEvent } from "./log.js";
|
|
5
|
-
|
|
6
|
-
let cachedCopilotToken;
|
|
7
|
-
let refreshPromise;
|
|
8
|
-
|
|
9
|
-
function formBody(values) {
|
|
10
|
-
return new URLSearchParams(values).toString();
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export async function startDeviceFlow() {
|
|
14
|
-
logEvent("login_start");
|
|
15
|
-
const response = await fetch("https://github.com/login/device/code", {
|
|
16
|
-
method: "POST",
|
|
17
|
-
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
|
18
|
-
body: formBody({ client_id: GITHUB_CLIENT_ID, scope: "read:user copilot" })
|
|
19
|
-
});
|
|
20
|
-
if (!response.ok) throw new Error(`GitHub device flow failed: ${response.status} ${await response.text()}`);
|
|
21
|
-
return response.json();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export async function pollDeviceFlow(deviceCode, intervalSeconds) {
|
|
25
|
-
let interval = Math.max(Number(intervalSeconds || 5), 1);
|
|
26
|
-
while (true) {
|
|
27
|
-
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
28
|
-
const response = await fetch("https://github.com/login/oauth/access_token", {
|
|
29
|
-
method: "POST",
|
|
30
|
-
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
|
31
|
-
body: formBody({ client_id: GITHUB_CLIENT_ID, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" })
|
|
32
|
-
});
|
|
33
|
-
if (!response.ok) throw new Error(`GitHub token poll failed: ${response.status} ${await response.text()}`);
|
|
34
|
-
const payload = await response.json();
|
|
35
|
-
if (payload.access_token) {
|
|
36
|
-
writePrivateFile(githubTokenPath(), `${payload.access_token}\n`);
|
|
37
|
-
logEvent("login_success");
|
|
38
|
-
return payload.access_token;
|
|
39
|
-
}
|
|
40
|
-
if (payload.error === "authorization_pending") continue;
|
|
41
|
-
if (payload.error === "slow_down") {
|
|
42
|
-
interval += 5;
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
if (payload.error === "expired_token") throw new Error("GitHub device code expired. Run `aerial login` again.");
|
|
46
|
-
throw new Error(`GitHub login failed: ${payload.error_description || payload.error}`);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function readGitHubToken() {
|
|
51
|
-
if (process.env.AERIAL_GITHUB_TOKEN) return process.env.AERIAL_GITHUB_TOKEN;
|
|
52
|
-
if (!fs.existsSync(githubTokenPath())) return undefined;
|
|
53
|
-
return fs.readFileSync(githubTokenPath(), "utf8").trim();
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function jwtExpirySeconds(token) {
|
|
57
|
-
try {
|
|
58
|
-
const [, payload] = token.split(".");
|
|
59
|
-
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).exp;
|
|
60
|
-
} catch {
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export async function exchangeCopilotToken(githubToken = readGitHubToken()) {
|
|
66
|
-
if (!githubToken) throw new Error("Missing GitHub token. Run: aerial login");
|
|
67
|
-
const response = await fetch(COPILOT_TOKEN_URL, {
|
|
68
|
-
headers: {
|
|
69
|
-
authorization: `Bearer ${githubToken}`,
|
|
70
|
-
accept: "application/json",
|
|
71
|
-
"user-agent": "Aerial/0.1"
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
if (!response.ok) throw new Error(`Copilot token exchange failed: ${response.status} ${await response.text()}`);
|
|
75
|
-
const payload = await response.json();
|
|
76
|
-
const token = payload.token;
|
|
77
|
-
if (!token) throw new Error("Copilot token exchange response did not include token");
|
|
78
|
-
cachedCopilotToken = { token, expiresAt: (payload.expires_at ? Date.parse(payload.expires_at) / 1000 : jwtExpirySeconds(token)) || Math.floor(Date.now() / 1000) + 1200 };
|
|
79
|
-
logEvent("token_refresh_success", { expiresAt: cachedCopilotToken.expiresAt });
|
|
80
|
-
return cachedCopilotToken.token;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export async function getCopilotToken({ force = false } = {}) {
|
|
84
|
-
const now = Math.floor(Date.now() / 1000);
|
|
85
|
-
if (!force && cachedCopilotToken && cachedCopilotToken.expiresAt - now > 120) return cachedCopilotToken.token;
|
|
86
|
-
if (!refreshPromise) {
|
|
87
|
-
refreshPromise = exchangeCopilotToken().finally(() => {
|
|
88
|
-
refreshPromise = undefined;
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
return refreshPromise;
|
|
92
|
-
}
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { COPILOT_TOKEN_URL, GITHUB_CLIENT_ID } from "./constants.js";
|
|
3
|
+
import { githubTokenPath, writePrivateFile } from "./paths.js";
|
|
4
|
+
import { logEvent } from "./log.js";
|
|
5
|
+
|
|
6
|
+
let cachedCopilotToken;
|
|
7
|
+
let refreshPromise;
|
|
8
|
+
|
|
9
|
+
function formBody(values) {
|
|
10
|
+
return new URLSearchParams(values).toString();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function startDeviceFlow() {
|
|
14
|
+
logEvent("login_start");
|
|
15
|
+
const response = await fetch("https://github.com/login/device/code", {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
|
18
|
+
body: formBody({ client_id: GITHUB_CLIENT_ID, scope: "read:user copilot" })
|
|
19
|
+
});
|
|
20
|
+
if (!response.ok) throw new Error(`GitHub device flow failed: ${response.status} ${await response.text()}`);
|
|
21
|
+
return response.json();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function pollDeviceFlow(deviceCode, intervalSeconds) {
|
|
25
|
+
let interval = Math.max(Number(intervalSeconds || 5), 1);
|
|
26
|
+
while (true) {
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
28
|
+
const response = await fetch("https://github.com/login/oauth/access_token", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
|
31
|
+
body: formBody({ client_id: GITHUB_CLIENT_ID, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" })
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) throw new Error(`GitHub token poll failed: ${response.status} ${await response.text()}`);
|
|
34
|
+
const payload = await response.json();
|
|
35
|
+
if (payload.access_token) {
|
|
36
|
+
writePrivateFile(githubTokenPath(), `${payload.access_token}\n`);
|
|
37
|
+
logEvent("login_success");
|
|
38
|
+
return payload.access_token;
|
|
39
|
+
}
|
|
40
|
+
if (payload.error === "authorization_pending") continue;
|
|
41
|
+
if (payload.error === "slow_down") {
|
|
42
|
+
interval += 5;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (payload.error === "expired_token") throw new Error("GitHub device code expired. Run `aerial login` again.");
|
|
46
|
+
throw new Error(`GitHub login failed: ${payload.error_description || payload.error}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readGitHubToken() {
|
|
51
|
+
if (process.env.AERIAL_GITHUB_TOKEN) return process.env.AERIAL_GITHUB_TOKEN;
|
|
52
|
+
if (!fs.existsSync(githubTokenPath())) return undefined;
|
|
53
|
+
return fs.readFileSync(githubTokenPath(), "utf8").trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function jwtExpirySeconds(token) {
|
|
57
|
+
try {
|
|
58
|
+
const [, payload] = token.split(".");
|
|
59
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).exp;
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function exchangeCopilotToken(githubToken = readGitHubToken()) {
|
|
66
|
+
if (!githubToken) throw new Error("Missing GitHub token. Run: aerial login");
|
|
67
|
+
const response = await fetch(COPILOT_TOKEN_URL, {
|
|
68
|
+
headers: {
|
|
69
|
+
authorization: `Bearer ${githubToken}`,
|
|
70
|
+
accept: "application/json",
|
|
71
|
+
"user-agent": "Aerial/0.1"
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) throw new Error(`Copilot token exchange failed: ${response.status} ${await response.text()}`);
|
|
75
|
+
const payload = await response.json();
|
|
76
|
+
const token = payload.token;
|
|
77
|
+
if (!token) throw new Error("Copilot token exchange response did not include token");
|
|
78
|
+
cachedCopilotToken = { token, expiresAt: (payload.expires_at ? Date.parse(payload.expires_at) / 1000 : jwtExpirySeconds(token)) || Math.floor(Date.now() / 1000) + 1200 };
|
|
79
|
+
logEvent("token_refresh_success", { expiresAt: cachedCopilotToken.expiresAt });
|
|
80
|
+
return cachedCopilotToken.token;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function getCopilotToken({ force = false } = {}) {
|
|
84
|
+
const now = Math.floor(Date.now() / 1000);
|
|
85
|
+
if (!force && cachedCopilotToken && cachedCopilotToken.expiresAt - now > 120) return cachedCopilotToken.token;
|
|
86
|
+
if (!refreshPromise) {
|
|
87
|
+
refreshPromise = exchangeCopilotToken().finally(() => {
|
|
88
|
+
refreshPromise = undefined;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return refreshPromise;
|
|
92
|
+
}
|