@jiayunxie/aerial 0.1.0
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 -0
- package/README.md +264 -0
- package/docs/usage.md +176 -0
- package/package.json +46 -0
- package/src/auth.js +92 -0
- package/src/cli.js +151 -0
- package/src/config.js +81 -0
- package/src/constants.js +11 -0
- package/src/copilot.js +493 -0
- package/src/crypto.js +25 -0
- package/src/doctor.js +15 -0
- package/src/log.js +9 -0
- package/src/paths.js +45 -0
- package/src/probe.js +141 -0
- package/src/responses-websocket.js +111 -0
- package/src/server.js +101 -0
- package/src/setup.js +121 -0
- package/src/version.js +19 -0
package/src/copilot.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { COPILOT_API_ORIGIN, DEFAULT_ANTHROPIC_VERSION } from "./constants.js";
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import { getCopilotToken } from "./auth.js";
|
|
5
|
+
import { logEvent } from "./log.js";
|
|
6
|
+
import { isResponsesWebSocketOptIn, proxyResponsesWebSocket, shouldUseResponsesWebSocket } from "./responses-websocket.js";
|
|
7
|
+
|
|
8
|
+
function upstreamHeaders(token, extra = {}) {
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
const requestId = extra["x-request-id"] || crypto.randomUUID();
|
|
11
|
+
return {
|
|
12
|
+
authorization: `Bearer ${token}`,
|
|
13
|
+
accept: extra.accept || "application/json",
|
|
14
|
+
"content-type": extra["content-type"] || "application/json",
|
|
15
|
+
"copilot-integration-id": "vscode-chat",
|
|
16
|
+
"editor-device-id": config.deviceId || "aerial-local",
|
|
17
|
+
"editor-version": `vscode/${config.versions.vscode}`,
|
|
18
|
+
"editor-plugin-version": `copilot-chat/${config.versions.copilotChat}`,
|
|
19
|
+
"user-agent": `GitHubCopilotChat/${config.versions.copilotChat}`,
|
|
20
|
+
"openai-intent": "conversation-agent",
|
|
21
|
+
"x-github-api-version": "2026-01-09",
|
|
22
|
+
"x-request-id": requestId,
|
|
23
|
+
"x-agent-task-id": requestId,
|
|
24
|
+
"x-interaction-type": "conversation-agent",
|
|
25
|
+
"x-vscode-user-agent-library-version": "electron-fetch",
|
|
26
|
+
...extra
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function copyResponseHeaders(upstream) {
|
|
31
|
+
const headers = new Headers();
|
|
32
|
+
const contentType = upstream.headers.get("content-type");
|
|
33
|
+
if (contentType) headers.set("content-type", contentType);
|
|
34
|
+
const cacheControl = upstream.headers.get("cache-control");
|
|
35
|
+
if (cacheControl) headers.set("cache-control", cacheControl);
|
|
36
|
+
return headers;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function aerialSupportForModel(model) {
|
|
40
|
+
const endpoints = Array.isArray(model.supported_endpoints) ? model.supported_endpoints : [];
|
|
41
|
+
const routes = [];
|
|
42
|
+
const notes = [];
|
|
43
|
+
if (endpoints.includes("/responses")) routes.push("responses");
|
|
44
|
+
if (endpoints.includes("ws:/responses")) routes.push("responses_websocket");
|
|
45
|
+
if (endpoints.includes("/v1/messages")) routes.push("messages");
|
|
46
|
+
if (endpoints.includes("/chat/completions")) routes.push("chat");
|
|
47
|
+
if (model.capabilities?.type === "embeddings") notes.push("embeddings_not_implemented");
|
|
48
|
+
if (routes.length === 0 && notes.length === 0) notes.push("no_supported_endpoint_advertised");
|
|
49
|
+
return {
|
|
50
|
+
supported: routes.length > 0,
|
|
51
|
+
routes,
|
|
52
|
+
notes
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function annotateModelsResponse(response) {
|
|
57
|
+
const contentType = response.headers.get("content-type") || "";
|
|
58
|
+
if (!response.ok || !contentType.includes("application/json")) return response;
|
|
59
|
+
const payload = await response.json();
|
|
60
|
+
if (!Array.isArray(payload.data)) return Response.json(payload, { status: response.status, headers: response.headers });
|
|
61
|
+
return Response.json({
|
|
62
|
+
...payload,
|
|
63
|
+
data: payload.data.map((model) => ({ ...model, aerial: aerialSupportForModel(model) }))
|
|
64
|
+
}, { status: response.status });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function configuredPromptCacheRetention(config = loadConfig()) {
|
|
68
|
+
const value = config.promptCacheRetention;
|
|
69
|
+
if (!value || value === "off") return undefined;
|
|
70
|
+
if (!["in_memory", "24h"].includes(value)) return undefined;
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stableCacheKeyPart(value) {
|
|
75
|
+
if (value === undefined || value === null) return undefined;
|
|
76
|
+
if (typeof value === "string") return value.trim() || undefined;
|
|
77
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
78
|
+
try {
|
|
79
|
+
return JSON.stringify(value);
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function metadataCacheKey(metadata) {
|
|
86
|
+
if (!metadata || typeof metadata !== "object") return undefined;
|
|
87
|
+
for (const key of ["prompt_cache_key", "session_id", "conversation_id", "thread_id", "user_id"]) {
|
|
88
|
+
const value = stableCacheKeyPart(metadata[key]);
|
|
89
|
+
if (value) return value;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function configuredPromptCacheKey(payload, config = loadConfig()) {
|
|
95
|
+
const value = config.promptCacheKey;
|
|
96
|
+
if (!value || value === "off") return undefined;
|
|
97
|
+
if (value !== "auto") return String(value);
|
|
98
|
+
const basis = [
|
|
99
|
+
stableCacheKeyPart(payload?.model),
|
|
100
|
+
metadataCacheKey(payload?.metadata),
|
|
101
|
+
stableCacheKeyPart(payload?.conversation_id),
|
|
102
|
+
stableCacheKeyPart(payload?.thread_id),
|
|
103
|
+
stableCacheKeyPart(process.cwd())
|
|
104
|
+
].filter(Boolean).join(":");
|
|
105
|
+
if (!basis) return undefined;
|
|
106
|
+
return "aerial:" + crypto.createHash("sha256").update(basis).digest("hex").slice(0, 32);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function withDefaultPromptCache(payload) {
|
|
110
|
+
const config = loadConfig();
|
|
111
|
+
const retention = configuredPromptCacheRetention(config);
|
|
112
|
+
const promptCacheKey = configuredPromptCacheKey(payload, config);
|
|
113
|
+
const next = { ...payload };
|
|
114
|
+
if (retention && next.prompt_cache_retention === undefined) next.prompt_cache_retention = retention;
|
|
115
|
+
if (promptCacheKey && next.prompt_cache_key === undefined) next.prompt_cache_key = promptCacheKey;
|
|
116
|
+
return next;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function openAIEffortRoute(model, effort) {
|
|
120
|
+
if (effort === undefined) return undefined;
|
|
121
|
+
if (/^gpt-5-mini(?:-|$)/.test(model) && ["xhigh", "max"].includes(effort)) return "high";
|
|
122
|
+
if (effort === "max") return "xhigh";
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function withSupportedOpenAIEffort(payload) {
|
|
127
|
+
const model = typeof payload?.model === "string" ? payload.model : "";
|
|
128
|
+
const reasoningEffort = payload?.reasoning && typeof payload.reasoning === "object" ? payload.reasoning.effort : undefined;
|
|
129
|
+
const nextReasoningEffort = openAIEffortRoute(model, reasoningEffort);
|
|
130
|
+
const nextFlatEffort = openAIEffortRoute(model, payload?.reasoning_effort);
|
|
131
|
+
if (!nextReasoningEffort && !nextFlatEffort) return payload;
|
|
132
|
+
|
|
133
|
+
const next = { ...payload };
|
|
134
|
+
if (nextReasoningEffort) next.reasoning = { ...payload.reasoning, effort: nextReasoningEffort };
|
|
135
|
+
if (nextFlatEffort) next.reasoning_effort = nextFlatEffort;
|
|
136
|
+
logEvent("openai_effort_route", {
|
|
137
|
+
model,
|
|
138
|
+
effort: reasoningEffort ?? payload?.reasoning_effort,
|
|
139
|
+
routedEffort: nextReasoningEffort ?? nextFlatEffort
|
|
140
|
+
});
|
|
141
|
+
return next;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function withOpenAIDefaults(payload) {
|
|
145
|
+
return withDefaultPromptCache(withSupportedOpenAIEffort(payload));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function addEphemeralCacheControl(value) {
|
|
149
|
+
if (typeof value === "string") {
|
|
150
|
+
return value.trim() ? { value: [{ type: "text", text: value, cache_control: { type: "ephemeral" } }], changed: true } : { value, changed: false };
|
|
151
|
+
}
|
|
152
|
+
if (!Array.isArray(value) || value.length === 0) return { value, changed: false };
|
|
153
|
+
for (let i = value.length - 1; i >= 0; i -= 1) {
|
|
154
|
+
const item = value[i];
|
|
155
|
+
if (item && typeof item === "object" && !Array.isArray(item) && item.cache_control === undefined) {
|
|
156
|
+
const next = [...value];
|
|
157
|
+
next[i] = { ...item, cache_control: { type: "ephemeral" } };
|
|
158
|
+
return { value: next, changed: true };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { value, changed: false };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function withDefaultAnthropicCache(payload) {
|
|
165
|
+
const retention = configuredPromptCacheRetention();
|
|
166
|
+
if (!retention || countCacheControlBlocks(payload) > 0) return payload;
|
|
167
|
+
const next = { ...payload };
|
|
168
|
+
const system = addEphemeralCacheControl(next.system);
|
|
169
|
+
if (system.changed) return { ...next, system: system.value };
|
|
170
|
+
if (Array.isArray(next.tools) && next.tools.length > 0) {
|
|
171
|
+
const tools = addEphemeralCacheControl(next.tools);
|
|
172
|
+
if (tools.changed) return { ...next, tools: tools.value };
|
|
173
|
+
}
|
|
174
|
+
return next;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function withSupportedAnthropicEffort(payload) {
|
|
178
|
+
const effort = payload?.output_config?.effort;
|
|
179
|
+
if (effort === undefined) return payload;
|
|
180
|
+
const model = typeof payload?.model === "string" ? payload.model : "";
|
|
181
|
+
if (!/^claude-opus-4[.-]7(?:-|$)/.test(model)) return payload;
|
|
182
|
+
const routes = {
|
|
183
|
+
low: "claude-opus-4.7-1m-internal",
|
|
184
|
+
medium: "claude-opus-4.7",
|
|
185
|
+
high: "claude-opus-4.7-high",
|
|
186
|
+
xhigh: "claude-opus-4.7-xhigh",
|
|
187
|
+
max: "claude-opus-4.7-xhigh"
|
|
188
|
+
};
|
|
189
|
+
const nextModel = routes[effort];
|
|
190
|
+
const nextEffort = effort === "max" ? "xhigh" : effort;
|
|
191
|
+
if (!nextModel) return payload;
|
|
192
|
+
if (model === nextModel && effort === nextEffort) return payload;
|
|
193
|
+
logEvent("anthropic_effort_route", { model, effort, routedModel: nextModel, routedEffort: nextEffort });
|
|
194
|
+
return { ...payload, model: nextModel, output_config: { ...payload.output_config, effort: nextEffort } };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function withAnthropicDefaults(payload) {
|
|
198
|
+
return withSupportedAnthropicEffort(withDefaultAnthropicCache(payload));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseJsonBody(body, contentType) {
|
|
202
|
+
if (!body || !contentType.includes("json")) return undefined;
|
|
203
|
+
try {
|
|
204
|
+
return JSON.parse(Buffer.from(body).toString("utf8"));
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function countCacheControlBlocks(value) {
|
|
211
|
+
if (!value || typeof value !== "object") return 0;
|
|
212
|
+
if (Array.isArray(value)) return value.reduce((total, item) => total + countCacheControlBlocks(item), 0);
|
|
213
|
+
return Object.entries(value).reduce((total, [key, item]) => total + (key === "cache_control" ? 1 : 0) + countCacheControlBlocks(item), 0);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function cacheRequestFields(payload) {
|
|
217
|
+
if (!payload || typeof payload !== "object") return {};
|
|
218
|
+
const fields = {
|
|
219
|
+
model: typeof payload.model === "string" ? payload.model : undefined,
|
|
220
|
+
promptCacheRetention: payload.prompt_cache_retention,
|
|
221
|
+
hasPromptCacheKey: payload.prompt_cache_key !== undefined,
|
|
222
|
+
cacheControlBlocks: countCacheControlBlocks(payload)
|
|
223
|
+
};
|
|
224
|
+
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined && value !== false && value !== 0));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function responseItems(payload) {
|
|
228
|
+
return Array.isArray(payload?.input) ? payload.input : [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function responseHasVision(payload) {
|
|
232
|
+
const visit = (value) => {
|
|
233
|
+
if (Array.isArray(value)) return value.some(visit);
|
|
234
|
+
if (!value || typeof value !== "object") return false;
|
|
235
|
+
if (value.type === "input_image") return true;
|
|
236
|
+
return Array.isArray(value.content) && value.content.some(visit);
|
|
237
|
+
};
|
|
238
|
+
return responseItems(payload).some(visit);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function responseInitiator(payload) {
|
|
242
|
+
const last = responseItems(payload).at(-1);
|
|
243
|
+
if (!last) return "user";
|
|
244
|
+
if (!last.role) return "agent";
|
|
245
|
+
return String(last.role).toLowerCase() === "assistant" ? "agent" : "user";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function hasExplicitCacheRequest(fields) {
|
|
249
|
+
return fields.promptCacheRetention !== undefined || fields.hasPromptCacheKey === true || Number(fields.cacheControlBlocks || 0) > 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function sumCopilotCacheDetails(details, kind) {
|
|
253
|
+
if (!Array.isArray(details)) return undefined;
|
|
254
|
+
const total = details.reduce((sum, detail) => {
|
|
255
|
+
const label = String(detail.type || detail.name || detail.kind || detail.cache || "");
|
|
256
|
+
if (!label.includes(kind)) return sum;
|
|
257
|
+
return sum + Number(detail.tokens || detail.count || detail.token_count || 0);
|
|
258
|
+
}, 0);
|
|
259
|
+
return total || undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function usageFields(payload) {
|
|
263
|
+
const usage = payload?.usage || payload?.response?.usage;
|
|
264
|
+
if (!usage || typeof usage !== "object") return {};
|
|
265
|
+
const copilotDetails = usage.copilot_usage?.token_details || payload?.copilot_usage?.token_details;
|
|
266
|
+
const fields = {
|
|
267
|
+
input: usage.input_tokens ?? usage.prompt_tokens,
|
|
268
|
+
output: usage.output_tokens ?? usage.completion_tokens,
|
|
269
|
+
cached: usage.input_tokens_details?.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens,
|
|
270
|
+
cacheCreation: usage.cache_creation_input_tokens,
|
|
271
|
+
cacheRead: usage.cache_read_input_tokens,
|
|
272
|
+
copilotCacheRead: sumCopilotCacheDetails(copilotDetails, "cache_read"),
|
|
273
|
+
copilotCacheWrite: sumCopilotCacheDetails(copilotDetails, "cache_write")
|
|
274
|
+
};
|
|
275
|
+
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function hasCacheUsage(fields) {
|
|
279
|
+
return fields.cached !== undefined || fields.cacheCreation !== undefined || fields.cacheRead !== undefined || fields.copilotCacheRead !== undefined || fields.copilotCacheWrite !== undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function sseJsonPayloads(text) {
|
|
283
|
+
return text.split(/\r?\n/)
|
|
284
|
+
.filter((line) => line.startsWith("data:"))
|
|
285
|
+
.map((line) => line.slice("data:".length).trim())
|
|
286
|
+
.filter((line) => line && line !== "[DONE]")
|
|
287
|
+
.flatMap((line) => {
|
|
288
|
+
try {
|
|
289
|
+
return [JSON.parse(line)];
|
|
290
|
+
} catch {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function shouldEmitCacheObserve(requestCache, usage) {
|
|
297
|
+
return hasExplicitCacheRequest(requestCache) || hasCacheUsage(usage);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Telemetry must never extend the lifetime of a long-lived upstream stream.
|
|
301
|
+
// For SSE/WebSocket bodies we pipe the upstream through a TransformStream that
|
|
302
|
+
// parses usage frames as they pass to the client; for short JSON bodies a
|
|
303
|
+
// clone-and-read is still cheap and finishes naturally.
|
|
304
|
+
function createSseCacheObserver(route, requestCache) {
|
|
305
|
+
const decoder = new TextDecoder();
|
|
306
|
+
let buffer = "";
|
|
307
|
+
let usage = {};
|
|
308
|
+
let logged = false;
|
|
309
|
+
|
|
310
|
+
const consume = () => {
|
|
311
|
+
let match;
|
|
312
|
+
const boundary = /\r?\n\r?\n/;
|
|
313
|
+
while ((match = boundary.exec(buffer)) !== null) {
|
|
314
|
+
const frame = buffer.slice(0, match.index);
|
|
315
|
+
buffer = buffer.slice(match.index + match[0].length);
|
|
316
|
+
for (const payload of sseJsonPayloads(frame)) {
|
|
317
|
+
const fields = usageFields(payload);
|
|
318
|
+
if (Object.keys(fields).length) usage = { ...usage, ...fields };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const emit = () => {
|
|
324
|
+
if (logged) return;
|
|
325
|
+
logged = true;
|
|
326
|
+
if (shouldEmitCacheObserve(requestCache, usage)) {
|
|
327
|
+
logEvent("cache_observe", { route, request: requestCache, usage });
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return new TransformStream({
|
|
332
|
+
transform(chunk, controller) {
|
|
333
|
+
// Forward first so a parser failure never blocks the client stream.
|
|
334
|
+
controller.enqueue(chunk);
|
|
335
|
+
try {
|
|
336
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
337
|
+
consume();
|
|
338
|
+
} catch (error) {
|
|
339
|
+
logEvent("cache_observe_error", { route, error: error.message });
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
flush() {
|
|
343
|
+
try {
|
|
344
|
+
buffer += decoder.decode();
|
|
345
|
+
consume();
|
|
346
|
+
} catch (error) {
|
|
347
|
+
logEvent("cache_observe_error", { route, error: error.message });
|
|
348
|
+
}
|
|
349
|
+
emit();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function logJsonCacheUsage(route, requestCache, clone) {
|
|
355
|
+
try {
|
|
356
|
+
const payload = await clone.json();
|
|
357
|
+
const usage = usageFields(payload);
|
|
358
|
+
if (shouldEmitCacheObserve(requestCache, usage)) {
|
|
359
|
+
logEvent("cache_observe", { route, request: requestCache, usage });
|
|
360
|
+
}
|
|
361
|
+
} catch (error) {
|
|
362
|
+
logEvent("cache_observe_error", { route, error: error.message });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function wrapResponseWithCacheObserver(upstream, route, requestCache) {
|
|
367
|
+
const headers = copyResponseHeaders(upstream);
|
|
368
|
+
const contentType = upstream.headers.get("content-type") || "";
|
|
369
|
+
if (!upstream.body) return new Response(upstream.body, { status: upstream.status, headers });
|
|
370
|
+
if (contentType.includes("text/event-stream")) {
|
|
371
|
+
const observer = createSseCacheObserver(route, requestCache);
|
|
372
|
+
return new Response(upstream.body.pipeThrough(observer), { status: upstream.status, headers });
|
|
373
|
+
}
|
|
374
|
+
if (contentType.includes("json")) {
|
|
375
|
+
void logJsonCacheUsage(route, requestCache, upstream.clone());
|
|
376
|
+
return new Response(upstream.body, { status: upstream.status, headers });
|
|
377
|
+
}
|
|
378
|
+
return new Response(upstream.body, { status: upstream.status, headers });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function requestWithJsonBody(request, transform) {
|
|
382
|
+
const payload = await request.json();
|
|
383
|
+
const nextPayload = transform(payload);
|
|
384
|
+
return new Request(request.url, {
|
|
385
|
+
method: request.method,
|
|
386
|
+
headers: request.headers,
|
|
387
|
+
body: JSON.stringify(nextPayload),
|
|
388
|
+
duplex: "half"
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function proxyFetch(path, request, { extraHeaders = {}, bodyOverride } = {}) {
|
|
393
|
+
const token = await getCopilotToken();
|
|
394
|
+
const body = bodyOverride ?? (request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer());
|
|
395
|
+
const accept = request.headers.get("accept") || "application/json";
|
|
396
|
+
const contentType = request.headers.get("content-type") || "application/json";
|
|
397
|
+
const requestCache = cacheRequestFields(parseJsonBody(body, contentType));
|
|
398
|
+
if (hasExplicitCacheRequest(requestCache)) logEvent("cache_request", { route: path, ...requestCache });
|
|
399
|
+
const headers = upstreamHeaders(token, { accept, "content-type": contentType, ...extraHeaders });
|
|
400
|
+
let upstream = await fetch(`${COPILOT_API_ORIGIN}${path}`, { method: request.method, headers, body });
|
|
401
|
+
if (upstream.status === 401) {
|
|
402
|
+
const refreshed = await getCopilotToken({ force: true });
|
|
403
|
+
upstream = await fetch(`${COPILOT_API_ORIGIN}${path}`, { method: request.method, headers: upstreamHeaders(refreshed, { accept, "content-type": contentType, ...extraHeaders }), body });
|
|
404
|
+
}
|
|
405
|
+
if (path === "/models") {
|
|
406
|
+
return new Response(upstream.body, { status: upstream.status, headers: copyResponseHeaders(upstream) });
|
|
407
|
+
}
|
|
408
|
+
return wrapResponseWithCacheObserver(upstream, path, requestCache);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function fetchModelsForResponseSelection() {
|
|
412
|
+
const token = await getCopilotToken();
|
|
413
|
+
const response = await fetch(`${COPILOT_API_ORIGIN}/models`, { method: "GET", headers: upstreamHeaders(token) });
|
|
414
|
+
if (!response.ok) return undefined;
|
|
415
|
+
const payload = await response.json().catch(() => ({}));
|
|
416
|
+
return Array.isArray(payload.data) ? payload.data : undefined;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function responseModelFromCatalog(modelId, models) {
|
|
420
|
+
return models.find((model) => model.id === modelId);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function proxyModels(request) {
|
|
424
|
+
const upstreamRequest = new Request(request.url, { method: "GET", headers: request.headers });
|
|
425
|
+
return annotateModelsResponse(await proxyFetch("/models", upstreamRequest));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function proxyResponses(request) {
|
|
429
|
+
const payload = withOpenAIDefaults(await request.json());
|
|
430
|
+
const body = Buffer.from(JSON.stringify(payload));
|
|
431
|
+
|
|
432
|
+
// The upstream WebSocket transport is opt-in via AERIAL_RESPONSES_WEBSOCKET=on
|
|
433
|
+
// AND only relevant for streaming requests. In every other case we skip the
|
|
434
|
+
// per-request /models lookup to avoid an extra upstream call and let the
|
|
435
|
+
// shared HTTP path handle cache_request logging via proxyFetch.
|
|
436
|
+
if (payload?.stream && isResponsesWebSocketOptIn()) {
|
|
437
|
+
const models = await fetchModelsForResponseSelection().catch(() => undefined);
|
|
438
|
+
const model = models ? responseModelFromCatalog(payload.model, models) : undefined;
|
|
439
|
+
if (shouldUseResponsesWebSocket(payload, model)) {
|
|
440
|
+
const requestCache = cacheRequestFields(payload);
|
|
441
|
+
if (hasExplicitCacheRequest(requestCache)) logEvent("cache_request", { route: "/responses", ...requestCache });
|
|
442
|
+
const token = await getCopilotToken();
|
|
443
|
+
const initiator = responseInitiator(payload);
|
|
444
|
+
const headers = upstreamHeaders(token, {
|
|
445
|
+
accept: "text/event-stream",
|
|
446
|
+
"content-type": request.headers.get("content-type") || "application/json",
|
|
447
|
+
...(responseHasVision(payload) ? { "copilot-vision-request": "true" } : {})
|
|
448
|
+
});
|
|
449
|
+
logEvent("responses_websocket", { model: payload.model });
|
|
450
|
+
const response = await proxyResponsesWebSocket(payload, headers, { initiator });
|
|
451
|
+
if (!response.body) return response;
|
|
452
|
+
// Pass-through usage observer for SSE; never another reader on the
|
|
453
|
+
// upstream WebSocket-backed stream.
|
|
454
|
+
const observer = createSseCacheObserver("/responses", requestCache);
|
|
455
|
+
return new Response(response.body.pipeThrough(observer), { status: response.status, headers: response.headers });
|
|
456
|
+
}
|
|
457
|
+
logEvent("responses_websocket_skip", {
|
|
458
|
+
model: payload.model,
|
|
459
|
+
reason: !model ? "model_unknown" : "model_no_ws_endpoint"
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const upstreamRequest = new Request(request.url, { method: request.method, headers: request.headers, body, duplex: "half" });
|
|
464
|
+
return proxyFetch("/responses", upstreamRequest, { bodyOverride: body });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function proxyMessages(request) {
|
|
468
|
+
const extraHeaders = {
|
|
469
|
+
"anthropic-version": request.headers.get("anthropic-version") || DEFAULT_ANTHROPIC_VERSION
|
|
470
|
+
};
|
|
471
|
+
const beta = request.headers.get("anthropic-beta");
|
|
472
|
+
if (beta) extraHeaders["anthropic-beta"] = beta;
|
|
473
|
+
const upstreamRequest = await requestWithJsonBody(request, withAnthropicDefaults);
|
|
474
|
+
return proxyFetch("/v1/messages", upstreamRequest, { extraHeaders });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export async function proxyChatCompletions(request) {
|
|
478
|
+
const upstreamRequest = await requestWithJsonBody(request, (payload) => {
|
|
479
|
+
payload = withOpenAIDefaults(payload);
|
|
480
|
+
if (payload.max_tokens !== undefined && payload.max_completion_tokens === undefined) {
|
|
481
|
+
const { max_tokens, ...rest } = payload;
|
|
482
|
+
return { ...rest, max_completion_tokens: max_tokens };
|
|
483
|
+
}
|
|
484
|
+
return payload;
|
|
485
|
+
});
|
|
486
|
+
return proxyFetch("/chat/completions", upstreamRequest);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function localCountTokens(request) {
|
|
490
|
+
const payload = await request.json().catch(() => ({}));
|
|
491
|
+
const serialized = JSON.stringify(payload.messages || payload.input || payload);
|
|
492
|
+
return Response.json({ input_tokens: Math.ceil(serialized.length / 4) });
|
|
493
|
+
}
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function randomApiKey() {
|
|
4
|
+
return `aerial_${crypto.randomBytes(24).toString("base64url")}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function hashApiKey(key, salt = crypto.randomBytes(16).toString("base64url")) {
|
|
8
|
+
const hash = crypto.scryptSync(key, salt, 32).toString("base64url");
|
|
9
|
+
return `scrypt$${salt}$${hash}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function verifyApiKey(key, encoded) {
|
|
13
|
+
if (!key || !encoded) return false;
|
|
14
|
+
const [kind, salt, expected] = encoded.split("$");
|
|
15
|
+
if (kind !== "scrypt" || !salt || !expected) return false;
|
|
16
|
+
const actual = crypto.scryptSync(key, salt, 32);
|
|
17
|
+
const expectedBuffer = Buffer.from(expected, "base64url");
|
|
18
|
+
return actual.length === expectedBuffer.length && crypto.timingSafeEqual(actual, expectedBuffer);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function redact(value) {
|
|
22
|
+
if (!value) return "";
|
|
23
|
+
if (value.length <= 8) return "********";
|
|
24
|
+
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
25
|
+
}
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { apiKeyPath, configPath, githubTokenPath } from "./paths.js";
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export function doctor() {
|
|
6
|
+
const config = loadConfig();
|
|
7
|
+
const checks = [
|
|
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" },
|
|
10
|
+
{ name: "github_token", ok: fs.existsSync(githubTokenPath()) || Boolean(process.env.AERIAL_GITHUB_TOKEN), detail: fs.existsSync(githubTokenPath()) ? githubTokenPath() : "run: aerial login" },
|
|
11
|
+
{ name: "node", ok: Number(process.versions.node.split(".")[0]) >= 22, detail: process.version },
|
|
12
|
+
{ name: "bind", ok: config.host === "127.0.0.1", detail: `${config.host}:${config.port}` }
|
|
13
|
+
];
|
|
14
|
+
return { ok: checks.every((check) => check.ok), checks };
|
|
15
|
+
}
|
package/src/log.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function logEvent(event, fields = {}) {
|
|
2
|
+
const safeFields = { ...fields };
|
|
3
|
+
delete safeFields.authorization;
|
|
4
|
+
delete safeFields.token;
|
|
5
|
+
delete safeFields.apiKey;
|
|
6
|
+
delete safeFields.body;
|
|
7
|
+
const line = { ts: new Date().toISOString(), event, ...safeFields };
|
|
8
|
+
console.error(JSON.stringify(line));
|
|
9
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
|
|
5
|
+
export function configDir() {
|
|
6
|
+
if (process.env.AERIAL_CONFIG_DIR) return process.env.AERIAL_CONFIG_DIR;
|
|
7
|
+
if (process.platform === "darwin") {
|
|
8
|
+
return path.join(os.homedir(), "Library", "Application Support", "aerial");
|
|
9
|
+
}
|
|
10
|
+
if (process.platform === "win32") {
|
|
11
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "aerial");
|
|
12
|
+
}
|
|
13
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "aerial");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function configPath() {
|
|
17
|
+
return path.join(configDir(), "config.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function githubTokenPath() {
|
|
21
|
+
return path.join(configDir(), "github_token");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function apiKeyPath() {
|
|
25
|
+
return path.join(configDir(), "api_key");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ensureDir(dir = configDir()) {
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writePrivateFile(file, content) {
|
|
33
|
+
ensureDir(path.dirname(file));
|
|
34
|
+
fs.writeFileSync(file, content, { mode: 0o600 });
|
|
35
|
+
if (process.platform !== "win32") fs.chmodSync(file, 0o600);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readJsonIfExists(file) {
|
|
39
|
+
if (!fs.existsSync(file)) return undefined;
|
|
40
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeJsonPrivate(file, value) {
|
|
44
|
+
writePrivateFile(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
45
|
+
}
|