@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/probe.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { proxyChatCompletions, proxyMessages, proxyModels, proxyResponses } from "./copilot.js";
|
|
2
|
+
|
|
3
|
+
function modelRoutes(model) {
|
|
4
|
+
return model.aerial?.routes || [];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function firstModel(models, route) {
|
|
8
|
+
return models.find((model) => modelRoutes(model).includes(route));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function readJson(response) {
|
|
12
|
+
const text = await response.text();
|
|
13
|
+
if (!text) return {};
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(text);
|
|
16
|
+
} catch {
|
|
17
|
+
return { raw: text };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tokenSummary(payload) {
|
|
22
|
+
const usage = payload.usage || {};
|
|
23
|
+
return {
|
|
24
|
+
input: usage.input_tokens ?? usage.prompt_tokens,
|
|
25
|
+
output: usage.output_tokens ?? usage.completion_tokens,
|
|
26
|
+
cached: usage.input_tokens_details?.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? usage.cache_read_input_tokens
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function probeRoute(name, model, handler, payload, headers = {}) {
|
|
31
|
+
const response = await handler(new Request(`http://aerial.local/probe/${name}`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "content-type": "application/json", ...headers },
|
|
34
|
+
body: JSON.stringify(payload)
|
|
35
|
+
}));
|
|
36
|
+
const body = await readJson(response);
|
|
37
|
+
return {
|
|
38
|
+
route: name,
|
|
39
|
+
model: model.id,
|
|
40
|
+
ok: response.ok,
|
|
41
|
+
status: response.status,
|
|
42
|
+
contentType: response.headers.get("content-type") || undefined,
|
|
43
|
+
usage: response.ok ? tokenSummary(body) : undefined,
|
|
44
|
+
error: response.ok ? undefined : body.error || body
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function summarizeModels(models) {
|
|
49
|
+
const summary = { responses: 0, messages: 0, chat: 0, websocketResponses: 0, embeddings: 0, unsupported: 0 };
|
|
50
|
+
for (const model of models) {
|
|
51
|
+
const routes = modelRoutes(model);
|
|
52
|
+
if (routes.includes("responses")) summary.responses += 1;
|
|
53
|
+
if (routes.includes("messages")) summary.messages += 1;
|
|
54
|
+
if (routes.includes("chat")) summary.chat += 1;
|
|
55
|
+
if (modelRoutes(model).includes("responses_websocket")) summary.websocketResponses += 1;
|
|
56
|
+
if (model.aerial?.notes?.includes("embeddings_not_implemented")) summary.embeddings += 1;
|
|
57
|
+
if (!model.aerial?.supported) summary.unsupported += 1;
|
|
58
|
+
}
|
|
59
|
+
return summary;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function runProbe({ live = false } = {}) {
|
|
63
|
+
const modelsResponse = await proxyModels(new Request("http://aerial.local/v1/models", { method: "GET" }));
|
|
64
|
+
const modelsPayload = await readJson(modelsResponse);
|
|
65
|
+
if (!modelsResponse.ok) {
|
|
66
|
+
return { ok: false, generatedAt: new Date().toISOString(), error: modelsPayload.error || modelsPayload };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const models = Array.isArray(modelsPayload.data) ? modelsPayload.data : [];
|
|
70
|
+
const report = {
|
|
71
|
+
ok: true,
|
|
72
|
+
generatedAt: new Date().toISOString(),
|
|
73
|
+
live,
|
|
74
|
+
summary: summarizeModels(models),
|
|
75
|
+
routes: [],
|
|
76
|
+
models: models.map((model) => ({
|
|
77
|
+
id: model.id,
|
|
78
|
+
routes: modelRoutes(model),
|
|
79
|
+
notes: model.aerial?.notes || [],
|
|
80
|
+
supported: Boolean(model.aerial?.supported)
|
|
81
|
+
}))
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (!live) return report;
|
|
85
|
+
|
|
86
|
+
const responsesModel = firstModel(models, "responses");
|
|
87
|
+
if (responsesModel) {
|
|
88
|
+
report.routes.push(await probeRoute("responses", responsesModel, proxyResponses, {
|
|
89
|
+
model: responsesModel.id,
|
|
90
|
+
input: "Return only: aerial-probe",
|
|
91
|
+
max_output_tokens: 16,
|
|
92
|
+
store: false
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const messagesModel = firstModel(models, "messages");
|
|
97
|
+
if (messagesModel) {
|
|
98
|
+
report.routes.push(await probeRoute("messages", messagesModel, proxyMessages, {
|
|
99
|
+
model: messagesModel.id,
|
|
100
|
+
max_tokens: 16,
|
|
101
|
+
messages: [{ role: "user", content: "Return only: aerial-probe" }]
|
|
102
|
+
}, { "anthropic-version": "2023-06-01" }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const chatModel = firstModel(models, "chat");
|
|
106
|
+
if (chatModel) {
|
|
107
|
+
report.routes.push(await probeRoute("chat", chatModel, proxyChatCompletions, {
|
|
108
|
+
model: chatModel.id,
|
|
109
|
+
messages: [{ role: "user", content: "Return only: aerial-probe" }],
|
|
110
|
+
max_completion_tokens: 16
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return report;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function formatProbeReport(report) {
|
|
118
|
+
if (!report.ok) return `Aerial probe failed: ${JSON.stringify(report.error)}`;
|
|
119
|
+
const lines = [
|
|
120
|
+
`Aerial probe (${report.live ? "live" : "models"}) at ${report.generatedAt}`,
|
|
121
|
+
`Models: ${report.models.length}`,
|
|
122
|
+
`Routes: responses=${report.summary.responses}, responsesWebSocket=${report.summary.websocketResponses}, messages=${report.summary.messages}, chat=${report.summary.chat}`,
|
|
123
|
+
`Unsupported: embeddings=${report.summary.embeddings}, noRoute=${report.summary.unsupported}`
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
if (report.routes.length) {
|
|
127
|
+
lines.push("", "Live route checks:");
|
|
128
|
+
for (const route of report.routes) {
|
|
129
|
+
const usage = route.usage ? ` input=${route.usage.input ?? "?"} output=${route.usage.output ?? "?"} cached=${route.usage.cached ?? 0}` : "";
|
|
130
|
+
lines.push(`- ${route.route}: ${route.ok ? "ok" : "fail"} status=${route.status} model=${route.model}${usage}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lines.push("", "Model matrix:");
|
|
135
|
+
for (const model of report.models) {
|
|
136
|
+
const routes = model.routes.length ? model.routes.join(",") : "-";
|
|
137
|
+
const notes = model.notes.length ? ` notes=${model.notes.join(",")}` : "";
|
|
138
|
+
lines.push(`- ${model.id}: routes=${routes}${notes}`);
|
|
139
|
+
}
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { WebSocket } from "undici";
|
|
2
|
+
import { COPILOT_API_ORIGIN } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
const TERMINAL_RESPONSE_TYPES = new Set([
|
|
5
|
+
"response.completed",
|
|
6
|
+
"response.failed",
|
|
7
|
+
"response.incomplete",
|
|
8
|
+
"error"
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
function websocketUrl() {
|
|
12
|
+
const url = new URL(`${COPILOT_API_ORIGIN}/responses`);
|
|
13
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
14
|
+
return url.toString();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeMessageData(data) {
|
|
18
|
+
if (typeof data === "string") return data;
|
|
19
|
+
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
|
|
20
|
+
if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data);
|
|
21
|
+
if (data && typeof data.text === "function") return data.text();
|
|
22
|
+
return String(data);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toSseFrame(message) {
|
|
26
|
+
if (message === "[DONE]") return "data: [DONE]\n\n";
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(message);
|
|
29
|
+
const lines = [];
|
|
30
|
+
if (typeof parsed.id === "string") lines.push(`id: ${parsed.id}`);
|
|
31
|
+
if (typeof parsed.type === "string") lines.push(`event: ${parsed.type}`);
|
|
32
|
+
lines.push(`data: ${JSON.stringify(parsed)}`);
|
|
33
|
+
return `${lines.join("\n")}\n\n`;
|
|
34
|
+
} catch {
|
|
35
|
+
return `data: ${message}\n\n`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isTerminalMessage(message) {
|
|
40
|
+
if (!message || message === "[DONE]") return false;
|
|
41
|
+
try {
|
|
42
|
+
return TERMINAL_RESPONSE_TYPES.has(JSON.parse(message).type);
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function openWebSocket(headers) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const ws = new WebSocket(websocketUrl(), { headers });
|
|
51
|
+
const cleanup = () => {
|
|
52
|
+
ws.removeEventListener("open", onOpen);
|
|
53
|
+
ws.removeEventListener("error", onError);
|
|
54
|
+
};
|
|
55
|
+
const onOpen = () => {
|
|
56
|
+
cleanup();
|
|
57
|
+
resolve(ws);
|
|
58
|
+
};
|
|
59
|
+
const onError = (event) => {
|
|
60
|
+
cleanup();
|
|
61
|
+
reject(new Error(`Failed to create responses websocket: ${event?.message || event?.error?.message || "unknown error"}`));
|
|
62
|
+
};
|
|
63
|
+
ws.addEventListener("open", onOpen);
|
|
64
|
+
ws.addEventListener("error", onError);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function supportsResponsesWebSocket(model) {
|
|
69
|
+
return Array.isArray(model?.supported_endpoints) && model.supported_endpoints.includes("ws:/responses");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isResponsesWebSocketOptIn() {
|
|
73
|
+
return process.env.AERIAL_RESPONSES_WEBSOCKET === "on";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function shouldUseResponsesWebSocket(payload, model) {
|
|
77
|
+
if (!isResponsesWebSocketOptIn()) return false;
|
|
78
|
+
if (!payload?.stream) return false;
|
|
79
|
+
return supportsResponsesWebSocket(model);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function proxyResponsesWebSocket(payload, headers, { initiator = "user" } = {}) {
|
|
83
|
+
const ws = await openWebSocket(headers);
|
|
84
|
+
ws.send(JSON.stringify({ ...payload, type: "response.create", initiator }));
|
|
85
|
+
|
|
86
|
+
const encoder = new TextEncoder();
|
|
87
|
+
return new Response(new ReadableStream({
|
|
88
|
+
start(controller) {
|
|
89
|
+
const close = () => {
|
|
90
|
+
try { ws.close(); } catch {}
|
|
91
|
+
};
|
|
92
|
+
ws.addEventListener("message", async (event) => {
|
|
93
|
+
const message = await normalizeMessageData(event.data);
|
|
94
|
+
controller.enqueue(encoder.encode(toSseFrame(message)));
|
|
95
|
+
if (isTerminalMessage(message)) {
|
|
96
|
+
controller.close();
|
|
97
|
+
close();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
ws.addEventListener("close", () => {
|
|
101
|
+
try { controller.close(); } catch {}
|
|
102
|
+
});
|
|
103
|
+
ws.addEventListener("error", (event) => {
|
|
104
|
+
controller.error(new Error(event?.message || event?.error?.message || "Responses websocket stream error"));
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
cancel() {
|
|
108
|
+
try { ws.close(); } catch {}
|
|
109
|
+
}
|
|
110
|
+
}), { headers: { "content-type": "text/event-stream" } });
|
|
111
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { DEFAULT_HOST, DEFAULT_PORT } from "./constants.js";
|
|
3
|
+
import { loadConfig, validateLocalAuth } from "./config.js";
|
|
4
|
+
import { proxyChatCompletions, proxyMessages, proxyModels, proxyResponses, localCountTokens } from "./copilot.js";
|
|
5
|
+
import { logEvent } from "./log.js";
|
|
6
|
+
|
|
7
|
+
function json(status, body) {
|
|
8
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function nodeRequestToFetch(req, body, signal) {
|
|
12
|
+
return new Request(`http://${req.headers.host}${req.url}`, { method: req.method, headers: req.headers, body: body.length ? body : undefined, duplex: "half", signal });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function readBody(req) {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
18
|
+
return Buffer.concat(chunks);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function handle(fetchRequest, runtime = {}) {
|
|
22
|
+
const url = new URL(fetchRequest.url);
|
|
23
|
+
const config = loadConfig();
|
|
24
|
+
if (fetchRequest.method === "GET" && url.pathname === "/health") {
|
|
25
|
+
return Response.json({ ok: true, service: "aerial", host: runtime.host || config.host, port: runtime.port || config.port });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!validateLocalAuth(Object.fromEntries(fetchRequest.headers), config)) {
|
|
29
|
+
return json(401, { error: { type: "authentication_error", message: "Invalid or missing Aerial API key" } });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (fetchRequest.method === "GET" && url.pathname === "/v1/models") return proxyModels(fetchRequest);
|
|
33
|
+
if (fetchRequest.method === "POST" && url.pathname === "/v1/responses") return proxyResponses(fetchRequest);
|
|
34
|
+
if (fetchRequest.method === "POST" && url.pathname === "/v1/messages") return proxyMessages(fetchRequest);
|
|
35
|
+
if (fetchRequest.method === "POST" && url.pathname === "/v1/messages/count_tokens") return localCountTokens(fetchRequest);
|
|
36
|
+
if (fetchRequest.method === "POST" && url.pathname === "/v1/chat/completions") return proxyChatCompletions(fetchRequest);
|
|
37
|
+
return json(404, { error: { type: "not_found", message: `No route for ${fetchRequest.method} ${url.pathname}` } });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function writeNodeResponse(res, fetchResponse, signal) {
|
|
41
|
+
res.statusCode = fetchResponse.status;
|
|
42
|
+
fetchResponse.headers.forEach((value, key) => res.setHeader(key, value));
|
|
43
|
+
if (!fetchResponse.body) {
|
|
44
|
+
res.end();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const reader = fetchResponse.body.getReader();
|
|
48
|
+
try {
|
|
49
|
+
while (!signal?.aborted) {
|
|
50
|
+
const { done, value } = await reader.read();
|
|
51
|
+
if (done) break;
|
|
52
|
+
if (!res.write(Buffer.from(value))) {
|
|
53
|
+
await new Promise((resolve) => res.once("drain", resolve));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
if (signal?.aborted) await reader.cancel().catch(() => {});
|
|
58
|
+
}
|
|
59
|
+
if (!res.destroyed) res.end();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createServer(runtime = {}) {
|
|
63
|
+
const server = http.createServer(async (req, res) => {
|
|
64
|
+
const started = Date.now();
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
res.on("close", () => controller.abort());
|
|
67
|
+
try {
|
|
68
|
+
logEvent("request_start", { method: req.method, path: req.url });
|
|
69
|
+
const body = await readBody(req);
|
|
70
|
+
const fetchRequest = nodeRequestToFetch(req, body, controller.signal);
|
|
71
|
+
const fetchResponse = await handle(fetchRequest, runtime);
|
|
72
|
+
await writeNodeResponse(res, fetchResponse, controller.signal);
|
|
73
|
+
logEvent("request_end", { method: req.method, path: req.url, status: fetchResponse.status, ms: Date.now() - started });
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.name === "AbortError" || res.destroyed) {
|
|
76
|
+
logEvent("request_aborted", { method: req.method, path: req.url, ms: Date.now() - started });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
logEvent("request_end", { method: req.method, path: req.url, status: 500, ms: Date.now() - started, error: error.message });
|
|
80
|
+
res.statusCode = error.message?.includes("Missing GitHub token") ? 503 : 500;
|
|
81
|
+
res.setHeader("content-type", "application/json");
|
|
82
|
+
res.end(JSON.stringify({ error: { type: "aerial_error", message: error.message } }));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
server.on("upgrade", (req, socket) => {
|
|
87
|
+
logEvent("websocket_unsupported", { path: req.url });
|
|
88
|
+
socket.end("HTTP/1.1 501 Not Implemented\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{\"error\":{\"type\":\"not_implemented\",\"message\":\"Aerial does not expose a client WebSocket. Use HTTP POST /v1/responses; the upstream ws:/responses path is an internal opt-in transport (AERIAL_RESPONSES_WEBSOCKET=on) and is never proxied to clients.\"}}");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return server;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function startServer({ host, port } = {}) {
|
|
95
|
+
const config = loadConfig();
|
|
96
|
+
const bindHost = host || config.host || DEFAULT_HOST;
|
|
97
|
+
const bindPort = Number(port || config.port || DEFAULT_PORT);
|
|
98
|
+
const server = createServer({ host: bindHost, port: bindPort });
|
|
99
|
+
server.listen(bindPort, bindHost, () => logEvent("server_start", { host: bindHost, port: bindPort }));
|
|
100
|
+
return server;
|
|
101
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { ensureApiKey, loadConfig } from "./config.js";
|
|
6
|
+
import { logEvent } from "./log.js";
|
|
7
|
+
|
|
8
|
+
const AERIAL_ENV_KEY = "AERIAL_API_KEY";
|
|
9
|
+
|
|
10
|
+
function backupIfExists(file) {
|
|
11
|
+
if (!fs.existsSync(file)) return undefined;
|
|
12
|
+
const backup = `${file}.aerial-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
13
|
+
fs.copyFileSync(file, backup);
|
|
14
|
+
return backup;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureParent(file) {
|
|
18
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function setTomlString(content, key, value) {
|
|
22
|
+
const line = `${key} = "${value}"`;
|
|
23
|
+
const re = new RegExp(`^${key}\\s*=.*$`, "m");
|
|
24
|
+
return re.test(content) ? content.replace(re, line) : `${content.trimEnd()}\n${line}\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function upsertTomlSection(content, section, values) {
|
|
28
|
+
const heading = `[${section}]`;
|
|
29
|
+
const lines = Object.entries(values).map(([key, value]) => `${key} = "${value}"`).join("\n");
|
|
30
|
+
const block = `${heading}\n${lines}\n`;
|
|
31
|
+
const source = content.split(/\r?\n/);
|
|
32
|
+
const start = source.findIndex((line) => line.trim() === heading);
|
|
33
|
+
if (start === -1) return `${content.trimEnd()}\n\n${block}`;
|
|
34
|
+
let end = source.length;
|
|
35
|
+
for (let i = start + 1; i < source.length; i += 1) {
|
|
36
|
+
if (/^\s*\[.*\]\s*$/.test(source[i])) {
|
|
37
|
+
end = i;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
source.splice(start, end - start, ...block.trimEnd().split("\n"));
|
|
42
|
+
return `${source.join("\n").trimEnd()}\n`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function persistUserEnv(name, value) {
|
|
46
|
+
process.env[name] = value;
|
|
47
|
+
if (process.env.AERIAL_SKIP_ENV_PERSIST === "1") return { persisted: false, reason: "skipped" };
|
|
48
|
+
if (process.platform === "win32") {
|
|
49
|
+
const escaped = String(value).replace(/'/g, "''");
|
|
50
|
+
const command = `[Environment]::SetEnvironmentVariable('${name}', '${escaped}', 'User')`;
|
|
51
|
+
const { status, error } = spawnSync("powershell.exe", ["-NoProfile", "-Command", command], { stdio: "ignore" });
|
|
52
|
+
if (status === 0) return { persisted: true, target: "user" };
|
|
53
|
+
return { persisted: false, reason: error?.message || `powershell exited ${status}` };
|
|
54
|
+
}
|
|
55
|
+
return { persisted: false, reason: "unsupported_platform" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ensureClientApiKeyEnv() {
|
|
59
|
+
const result = ensureApiKey();
|
|
60
|
+
if (!result.apiKey) return { ...result, env: { name: AERIAL_ENV_KEY, persisted: false, reason: "raw_key_unavailable" } };
|
|
61
|
+
return { ...result, env: { name: AERIAL_ENV_KEY, ...persistUserEnv(AERIAL_ENV_KEY, result.apiKey) } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function claudeEnvForAerial(currentEnv, config) {
|
|
65
|
+
const {
|
|
66
|
+
ANTHROPIC_API_KEY,
|
|
67
|
+
ANTHROPIC_AUTH_TOKEN,
|
|
68
|
+
ANTHROPIC_MODEL,
|
|
69
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL,
|
|
70
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL,
|
|
71
|
+
...rest
|
|
72
|
+
} = currentEnv || {};
|
|
73
|
+
return {
|
|
74
|
+
...rest,
|
|
75
|
+
ANTHROPIC_BASE_URL: `http://${config.host}:${config.port}`,
|
|
76
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1"
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function setupCodex({ model } = {}) {
|
|
81
|
+
const apiKey = ensureClientApiKeyEnv();
|
|
82
|
+
const config = loadConfig();
|
|
83
|
+
const selectedModel = model || config.defaultModel || "gpt-4.1";
|
|
84
|
+
const file = path.join(os.homedir(), ".codex", "config.toml");
|
|
85
|
+
ensureParent(file);
|
|
86
|
+
const backup = backupIfExists(file);
|
|
87
|
+
let content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
88
|
+
content = setTomlString(content, "model_provider", "aerial");
|
|
89
|
+
content = setTomlString(content, "model", selectedModel);
|
|
90
|
+
content = upsertTomlSection(content, "model_providers.aerial", {
|
|
91
|
+
name: "Aerial Copilot Local",
|
|
92
|
+
base_url: `http://${config.host}:${config.port}/v1`,
|
|
93
|
+
wire_api: "responses",
|
|
94
|
+
env_key: AERIAL_ENV_KEY
|
|
95
|
+
});
|
|
96
|
+
content = upsertTomlSection(content, "profiles.aerial", { model_provider: "aerial", model: selectedModel });
|
|
97
|
+
fs.writeFileSync(file, content, "utf8");
|
|
98
|
+
logEvent("setup_write", { target: "codex", file, backup, env: apiKey.env });
|
|
99
|
+
return { file, backup, model: selectedModel, env: apiKey.env };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function setupClaude({ model } = {}) {
|
|
103
|
+
ensureApiKey();
|
|
104
|
+
const config = loadConfig();
|
|
105
|
+
const selectedModel = model || config.defaultModel;
|
|
106
|
+
const dir = path.join(os.homedir(), ".claude");
|
|
107
|
+
const file = path.join(dir, "settings.json");
|
|
108
|
+
ensureParent(file);
|
|
109
|
+
const backup = backupIfExists(file);
|
|
110
|
+
const current = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
|
|
111
|
+
const next = {
|
|
112
|
+
...current,
|
|
113
|
+
apiKeyHelper: "aerial key print",
|
|
114
|
+
env: claudeEnvForAerial(current.env, config)
|
|
115
|
+
};
|
|
116
|
+
if (selectedModel) next.model = selectedModel;
|
|
117
|
+
fs.writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
118
|
+
logEvent("setup_write", { target: "claude", file, backup, model: selectedModel });
|
|
119
|
+
return { file, backup, model: selectedModel };
|
|
120
|
+
}
|
|
121
|
+
|
package/src/version.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
const PACKAGE_JSON_URL = new URL("../package.json", import.meta.url);
|
|
4
|
+
|
|
5
|
+
export function readPackageVersion(packageJsonUrl = PACKAGE_JSON_URL) {
|
|
6
|
+
try {
|
|
7
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonUrl, "utf8"));
|
|
8
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0) return pkg.version;
|
|
9
|
+
throw new Error("missing version field");
|
|
10
|
+
} catch (error) {
|
|
11
|
+
const reason = error?.message || String(error);
|
|
12
|
+
console.warn(`aerial: cannot read package version (${reason}); reporting "unknown"`);
|
|
13
|
+
return "unknown";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function printVersion(packageJsonUrl = PACKAGE_JSON_URL) {
|
|
18
|
+
console.log(readPackageVersion(packageJsonUrl));
|
|
19
|
+
}
|