@jiayunxie/aerial 0.2.1 → 0.2.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/README.md +1 -1
- package/docs/usage.md +2 -2
- package/package.json +5 -5
- package/src/cli/args.js +28 -0
- package/src/cli/config-command.js +39 -0
- package/src/cli/disable-command.js +28 -0
- package/src/{doctor.js → cli/doctor.js} +3 -3
- package/src/cli/help.js +23 -0
- package/src/cli/index.js +92 -0
- package/src/cli/key-command.js +20 -0
- package/src/cli/login-command.js +28 -0
- package/src/{model-selection.js → cli/model-selection.js} +5 -5
- package/src/cli/output.js +75 -0
- package/src/{probe.js → cli/probe.js} +3 -3
- package/src/cli/proxy-command.js +120 -0
- package/src/cli/runtime-auth.js +21 -0
- package/src/cli/service-command.js +120 -0
- package/src/cli/setup-command.js +122 -0
- package/src/{setup-selection.js → cli/setup-selection.js} +3 -20
- package/src/cli/start-command.js +11 -0
- package/src/cli/status-command.js +26 -0
- package/src/{version.js → cli/version.js} +1 -1
- package/src/proxy/cache-policy.js +89 -0
- package/src/proxy/cache-telemetry.js +172 -0
- package/src/proxy/effort.js +120 -0
- package/src/proxy/headers.js +33 -0
- package/src/proxy/index.js +85 -0
- package/src/proxy/models.js +27 -0
- package/src/{responses-websocket.js → proxy/responses-websocket.js} +2 -2
- package/src/{server.js → proxy/server.js} +5 -5
- package/src/proxy/transport.js +55 -0
- package/src/service/health.js +54 -0
- package/src/service/index.js +38 -0
- package/src/service/lifecycle.js +301 -0
- package/src/service/platform.js +227 -0
- package/src/service/runner.js +45 -0
- package/src/service/status.js +296 -0
- package/src/service/wrapper-render.js +228 -0
- package/src/setup/backup.js +38 -0
- package/src/{setup.js → setup/clients.js} +11 -198
- package/src/setup/index.js +4 -0
- package/src/setup/restore.js +90 -0
- package/src/setup/status.js +24 -0
- package/src/setup/toml.js +41 -0
- package/src/{auth.js → shared/auth.js} +1 -1
- package/src/{config.js → shared/config.js} +2 -2
- package/src/shared/effort.js +19 -0
- package/src/shared/file-utils.js +31 -0
- package/src/{paths.js → shared/paths.js} +2 -3
- package/src/{upstream-fetch.js → upstream/fetch.js} +1 -1
- package/src/cli.js +0 -684
- package/src/copilot.js +0 -572
- package/src/service.js +0 -1182
- /package/src/{app-status.js → cli/app-status.js} +0 -0
- /package/src/{model-catalog.js → proxy/model-catalog.js} +0 -0
- /package/src/{model-utils.js → proxy/model-utils.js} +0 -0
- /package/src/{constants.js → shared/constants.js} +0 -0
- /package/src/{crypto.js → shared/crypto.js} +0 -0
- /package/src/{http-utils.js → shared/http-utils.js} +0 -0
- /package/src/{log.js → shared/log.js} +0 -0
- /package/src/{prompt-utils.js → shared/prompt-utils.js} +0 -0
- /package/src/{proxy-config.js → upstream/proxy-config.js} +0 -0
- /package/src/{socks5-bridge.js → upstream/socks5-bridge.js} +0 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { serviceInstall, serviceRestart, serviceStart, serviceStatus, serviceStop, serviceUninstall } from "../service/index.js";
|
|
2
|
+
import { printServiceDiagnostics, printServiceWarning } from "./output.js";
|
|
3
|
+
|
|
4
|
+
export function printServiceUninstallResult(r, { prefix = "Service uninstall" } = {}) {
|
|
5
|
+
if (r.note === "no service installed") console.log(`${prefix}: no service installed`);
|
|
6
|
+
else if (r.ok) console.log(`${prefix}: ok (${r.platform})`);
|
|
7
|
+
else {
|
|
8
|
+
console.log(`${prefix}: FAILED (${r.reason || "see stderr"})`);
|
|
9
|
+
if (r.message) console.log(` ${r.message}`);
|
|
10
|
+
else console.log(` Retry with: aerial service uninstall`);
|
|
11
|
+
}
|
|
12
|
+
if (r.delete?.stderr) console.log(` schtasks stderr: ${r.delete.stderr.trim()}`);
|
|
13
|
+
if (r.bootout?.stderr) console.log(` bootout stderr: ${r.bootout.stderr.trim()}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function runServiceCliAction(action, render) {
|
|
17
|
+
try {
|
|
18
|
+
const result = await action();
|
|
19
|
+
render(result);
|
|
20
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error(err.message);
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printServiceStatus(status) {
|
|
28
|
+
console.log(`Aerial: http://${status.config.host}:${status.config.port} (platform: ${status.platform})`);
|
|
29
|
+
if (status.supported === false) {
|
|
30
|
+
console.log(`service: unsupported on ${status.platform}`);
|
|
31
|
+
console.log(`summary: ${status.summary}`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const svc = status.service;
|
|
36
|
+
const stateLine = svc.loaded ? "running" : (svc.installed ? "installed (not running)" : "not installed");
|
|
37
|
+
console.log(`service: ${stateLine}`);
|
|
38
|
+
if (svc.pid) console.log(` pid: ${svc.pid}`);
|
|
39
|
+
if (svc.status) console.log(` win status: ${svc.status}`);
|
|
40
|
+
if (svc.lastExitStatus !== undefined) console.log(` last exit: ${svc.lastExitStatus}`);
|
|
41
|
+
const h = status.health;
|
|
42
|
+
let healthLine;
|
|
43
|
+
if (h.aerial && h.supervisor === "service-managed") healthLine = "ok (Aerial, service-managed)";
|
|
44
|
+
else if (h.aerial && h.supervisor === "foreground") healthLine = "ok (Aerial, foreground)";
|
|
45
|
+
else if (h.portConflict) healthLine = `port conflict (${h.conflictReason})`;
|
|
46
|
+
else if (h.ok) healthLine = "ok";
|
|
47
|
+
else healthLine = `unreachable (${h.error || `http ${h.status}`})`;
|
|
48
|
+
console.log(`health: ${healthLine}`);
|
|
49
|
+
console.log(`summary: ${status.summary}`);
|
|
50
|
+
console.log(`logs: ${status.logs.dir}`);
|
|
51
|
+
console.log(` primary: ${status.logs.primary.exists ? `${status.logs.primary.size} bytes` : "missing"} ${status.logs.primary.file}`);
|
|
52
|
+
console.log(` stdio: ${status.logs.stdio.exists ? `${status.logs.stdio.size} bytes` : "missing"} ${status.logs.stdio.file}`);
|
|
53
|
+
console.log(`auth: api_key=${status.auth.api_key.state} github_token=${status.auth.github_token.state}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function runServiceCli(subcommand, rest) {
|
|
57
|
+
if (subcommand === "install") {
|
|
58
|
+
await runServiceCliAction(serviceInstall, (r) => {
|
|
59
|
+
if (r.ok && r.note) console.log(`Service install: ${r.note} (${r.platform}).`);
|
|
60
|
+
else if (r.ok) console.log(`Service installed (${r.platform}).`);
|
|
61
|
+
else console.log(`Service install: FAILED (${r.reason || "unknown"}): ${r.message || ""}`);
|
|
62
|
+
if (r.file) console.log(` unit: ${r.file}`);
|
|
63
|
+
if (r.taskName) console.log(` task: ${r.taskName}`);
|
|
64
|
+
if (r.wrapper) console.log(` wrapper: ${r.wrapper}`);
|
|
65
|
+
if (r.bootstrap?.stderr) console.log(` bootstrap stderr: ${r.bootstrap.stderr.trim()}`);
|
|
66
|
+
if (r.create?.stderr) console.log(` schtasks stderr: ${r.create.stderr.trim()}`);
|
|
67
|
+
if (r.run?.stderr) console.log(` schtasks /Run stderr: ${r.run.stderr.trim()}`);
|
|
68
|
+
printServiceDiagnostics(r.diagnostics);
|
|
69
|
+
printServiceWarning(r);
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (subcommand === "start") {
|
|
74
|
+
await runServiceCliAction(serviceStart, (r) => {
|
|
75
|
+
if (r.ok && r.note) console.log(`Service start: ${r.note} (${r.platform})`);
|
|
76
|
+
else if (r.ok) console.log(`Service start: ok (${r.platform})`);
|
|
77
|
+
else console.log(`Service start: FAILED (${r.reason || `status=${r.status}`})${r.message ? ": " + r.message : ""}`);
|
|
78
|
+
if (r.stderr) console.log(` ${r.stderr.trim()}`);
|
|
79
|
+
printServiceDiagnostics(r.diagnostics);
|
|
80
|
+
printServiceWarning(r);
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (subcommand === "stop") {
|
|
85
|
+
await runServiceCliAction(serviceStop, (r) => {
|
|
86
|
+
if (r.note) console.log(`Service stop: ${r.note} (${r.platform})`);
|
|
87
|
+
else if (r.ok) console.log(`Service stop: ok (${r.platform})`);
|
|
88
|
+
else console.log(`Service stop: FAILED (status=${r.status})`);
|
|
89
|
+
if (r.stderr) console.log(` ${r.stderr.trim()}`);
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (subcommand === "restart") {
|
|
94
|
+
await runServiceCliAction(serviceRestart, (r) => {
|
|
95
|
+
if (!r.ok && r.reason === "stop_failed") console.log(`Service restart: FAILED on stop; start not attempted`);
|
|
96
|
+
else if (r.ok) console.log(`Service restart: ok`);
|
|
97
|
+
else console.log(`Service restart: FAILED`);
|
|
98
|
+
if (r.stop?.stderr) console.log(` stop: ${r.stop.stderr.trim()}`);
|
|
99
|
+
if (r.start?.stderr) console.log(` start: ${r.start.stderr.trim()}`);
|
|
100
|
+
printServiceWarning(r);
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (subcommand === "uninstall") {
|
|
105
|
+
await runServiceCliAction(serviceUninstall, printServiceUninstallResult);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (subcommand === "status") {
|
|
109
|
+
const status = await serviceStatus();
|
|
110
|
+
if (rest.includes("--json")) {
|
|
111
|
+
console.log(JSON.stringify(status, null, 2));
|
|
112
|
+
process.exitCode = status.supported === false ? 1 : 0;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
printServiceStatus(status);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
console.error(`Unknown service subcommand: ${subcommand}. Use install, start, stop, restart, status, or uninstall.`);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { loadConfig } from "../shared/config.js";
|
|
2
|
+
import { configPath } from "../shared/paths.js";
|
|
3
|
+
import { restoreAllClients, restoreClient, setupClaude, setupCodex, setupStatus } from "../setup/index.js";
|
|
4
|
+
import { chooseSetupModel, formatModelChoices } from "./model-selection.js";
|
|
5
|
+
import { assertValidEffort, chooseSetupEffort, formatEffortSelection } from "./setup-selection.js";
|
|
6
|
+
import { requiredArgValue } from "./args.js";
|
|
7
|
+
import { claudeApiKeyHelper, codexAuthCommand } from "./runtime-auth.js";
|
|
8
|
+
import { printRestoreResults, printSetupCompletionSummary } from "./output.js";
|
|
9
|
+
|
|
10
|
+
async function selectSetupOptions(target, route, args) {
|
|
11
|
+
const explicitEffort = requiredArgValue(args, "--effort");
|
|
12
|
+
if (explicitEffort !== undefined) assertValidEffort(explicitEffort);
|
|
13
|
+
const selected = await chooseSetupModel({ target, route, explicitModel: requiredArgValue(args, "--model") });
|
|
14
|
+
if (!selected.displayed) {
|
|
15
|
+
for (const line of formatModelChoices({ target, route, choices: selected.choices, selectedModel: selected.model, source: selected.source, recommended: selected.recommended })) {
|
|
16
|
+
console.log(line);
|
|
17
|
+
}
|
|
18
|
+
} else {
|
|
19
|
+
console.log(`Selected ${target} model: ${selected.model}`);
|
|
20
|
+
}
|
|
21
|
+
const effortChoice = await chooseSetupEffort({ target, explicitEffort });
|
|
22
|
+
console.log(formatEffortSelection({ target, effort: effortChoice.effort, source: effortChoice.source }));
|
|
23
|
+
return {
|
|
24
|
+
model: selected.model,
|
|
25
|
+
effort: effortChoice.effort,
|
|
26
|
+
modelSource: selected.source,
|
|
27
|
+
effortSource: effortChoice.source,
|
|
28
|
+
modelDisplayed: Boolean(selected.displayed),
|
|
29
|
+
effortDisplayed: Boolean(effortChoice.displayed)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function printSetupStatus(status) {
|
|
34
|
+
console.log(`Aerial: http://${status.config.host}:${status.config.port} (platform: ${status.platform})`);
|
|
35
|
+
console.log(`API key file: ${status.auth.api_key.file} (${status.auth.api_key.exists ? "present" : "missing"})`);
|
|
36
|
+
const ghSourceText = status.auth.github_token.source === "missing"
|
|
37
|
+
? `(missing)`
|
|
38
|
+
: status.auth.github_token.source === "env"
|
|
39
|
+
? `(present, source=env; file path ${status.auth.github_token.file} is not consulted while AERIAL_GITHUB_TOKEN is set)`
|
|
40
|
+
: `${status.auth.github_token.file} (present, source=file)`;
|
|
41
|
+
console.log(`GitHub token: ${ghSourceText}`);
|
|
42
|
+
for (const cs of Object.values(status.clients)) {
|
|
43
|
+
const head = `${cs.target.padEnd(7)} state=${cs.state}`;
|
|
44
|
+
const effortText = ` effort=${cs.effort || "missing"}`;
|
|
45
|
+
console.log(`${head}${effortText} file=${cs.file}`);
|
|
46
|
+
if (cs.backups.length) console.log(` backups=${cs.backups.length}`);
|
|
47
|
+
if (cs.error) console.log(` error=${cs.error}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runSetupCli(subcommand, rest) {
|
|
52
|
+
if (subcommand === "codex") {
|
|
53
|
+
const options = await selectSetupOptions("Codex", "responses", rest);
|
|
54
|
+
const result = setupCodex({ model: options.model, effort: options.effort, authCommand: codexAuthCommand() });
|
|
55
|
+
const config = loadConfig();
|
|
56
|
+
printSetupCompletionSummary({
|
|
57
|
+
heading: "Configured Codex",
|
|
58
|
+
cli: "Codex",
|
|
59
|
+
model: result.model,
|
|
60
|
+
effort: result.effort || "missing",
|
|
61
|
+
proxy: `http://${config.host}:${config.port}/v1`,
|
|
62
|
+
configFile: result.file,
|
|
63
|
+
aerialConfigFile: configPath(),
|
|
64
|
+
aerialDefaultEffort: config.defaultEffort || "missing",
|
|
65
|
+
backup: result.backup,
|
|
66
|
+
auth: "command-backed local Aerial key",
|
|
67
|
+
notes: ["restart Codex if it was already running so it reloads the profile."]
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (subcommand === "claude") {
|
|
72
|
+
const options = await selectSetupOptions("Claude Code", "messages", rest);
|
|
73
|
+
const result = setupClaude({ model: options.model, effort: options.effort, apiKeyHelper: claudeApiKeyHelper() });
|
|
74
|
+
const config = loadConfig();
|
|
75
|
+
printSetupCompletionSummary({
|
|
76
|
+
heading: "Configured Claude Code",
|
|
77
|
+
cli: "Claude Code",
|
|
78
|
+
model: result.model || "preserved",
|
|
79
|
+
effort: result.effort || config.defaultEffort || "missing",
|
|
80
|
+
proxy: `http://${config.host}:${config.port}`,
|
|
81
|
+
configFile: result.file,
|
|
82
|
+
aerialConfigFile: configPath(),
|
|
83
|
+
aerialDefaultEffort: config.defaultEffort || "missing",
|
|
84
|
+
backup: result.backup,
|
|
85
|
+
auth: "apiKeyHelper local Aerial key",
|
|
86
|
+
notes: ["effort is applied via Aerial defaultEffort and proxy fallback; Claude settings.json does not store an effort value."]
|
|
87
|
+
});
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (subcommand === "all") {
|
|
91
|
+
throw new Error("aerial setup all has been removed. Run `aerial setup codex` and/or `aerial setup claude` instead.");
|
|
92
|
+
}
|
|
93
|
+
if (subcommand === "status") {
|
|
94
|
+
const status = setupStatus();
|
|
95
|
+
if (rest.includes("--json")) {
|
|
96
|
+
console.log(JSON.stringify(status, null, 2));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
printSetupStatus(status);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (subcommand === "restore") {
|
|
103
|
+
const which = rest[0];
|
|
104
|
+
if (!which) throw new Error("Usage: aerial setup restore <codex|claude|all> --latest");
|
|
105
|
+
if (!rest.includes("--latest")) throw new Error("aerial setup restore: only --latest is supported in this release");
|
|
106
|
+
if (which === "all") {
|
|
107
|
+
const { ok, results } = restoreAllClients();
|
|
108
|
+
printRestoreResults(results);
|
|
109
|
+
process.exitCode = ok ? 0 : 1;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (which !== "codex" && which !== "claude") throw new Error(`Unknown restore target: ${which}. Use codex, claude, or all.`);
|
|
113
|
+
const r = restoreClient(which);
|
|
114
|
+
if (r.restored) {
|
|
115
|
+
console.log(`Restored ${which}: ${r.file} <- ${r.from}`);
|
|
116
|
+
if (r.snapshot) console.log(` pre-restore snapshot: ${r.snapshot}`);
|
|
117
|
+
} else if (r.reason === "no_backup") {
|
|
118
|
+
console.log(`Restored ${which}: no backup to restore`);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -1,26 +1,9 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
-
import { parseNumberChoice } from "
|
|
3
|
+
import { parseNumberChoice } from "../shared/prompt-utils.js";
|
|
4
|
+
import { DEFAULT_EFFORT, EFFORT_VALUES, assertValidEffort } from "../shared/effort.js";
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
export const DEFAULT_EFFORT = "medium";
|
|
7
|
-
|
|
8
|
-
export function normalizeEffort(value) {
|
|
9
|
-
if (value === undefined || value === null) return undefined;
|
|
10
|
-
const trimmed = String(value).trim().toLowerCase();
|
|
11
|
-
if (!trimmed) return undefined;
|
|
12
|
-
if (trimmed === "max") return "xhigh";
|
|
13
|
-
if (EFFORT_VALUES.includes(trimmed)) return trimmed;
|
|
14
|
-
return undefined;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function assertValidEffort(raw) {
|
|
18
|
-
const normalized = normalizeEffort(raw);
|
|
19
|
-
if (!normalized) {
|
|
20
|
-
throw new Error(`Invalid --effort ${JSON.stringify(raw)}. Allowed: ${EFFORT_VALUES.join(", ")} (or alias 'max' for xhigh).`);
|
|
21
|
-
}
|
|
22
|
-
return normalized;
|
|
23
|
-
}
|
|
6
|
+
export { DEFAULT_EFFORT, EFFORT_VALUES, normalizeEffort, assertValidEffort } from "../shared/effort.js";
|
|
24
7
|
|
|
25
8
|
export async function chooseSetupEffort({
|
|
26
9
|
target,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ensureApiKey, loadConfig } from "../shared/config.js";
|
|
2
|
+
import { startServer } from "../proxy/server.js";
|
|
3
|
+
import { argValue } from "./args.js";
|
|
4
|
+
|
|
5
|
+
export function runStartCli(args) {
|
|
6
|
+
const config = loadConfig();
|
|
7
|
+
const host = argValue(args, "--host") || config.host;
|
|
8
|
+
const port = Number(argValue(args, "--port") || config.port);
|
|
9
|
+
ensureApiKey();
|
|
10
|
+
startServer({ host, port });
|
|
11
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { setupStatus } from "../setup/index.js";
|
|
2
|
+
import { serviceStatus } from "../service/index.js";
|
|
3
|
+
import { computeAppStatus } from "./app-status.js";
|
|
4
|
+
import { printServiceSummary, printSetupSummary } from "./output.js";
|
|
5
|
+
|
|
6
|
+
export async function appStatus({ json = false } = {}) {
|
|
7
|
+
const setup = setupStatus();
|
|
8
|
+
const service = await serviceStatus();
|
|
9
|
+
const status = computeAppStatus(setup, service);
|
|
10
|
+
if (json) {
|
|
11
|
+
console.log(JSON.stringify(status, null, 2));
|
|
12
|
+
return status;
|
|
13
|
+
}
|
|
14
|
+
console.log("Aerial status");
|
|
15
|
+
printSetupSummary(setup);
|
|
16
|
+
printServiceSummary(service);
|
|
17
|
+
if (status.nextSteps.length) {
|
|
18
|
+
console.log("next:");
|
|
19
|
+
for (const step of status.nextSteps) console.log(` - ${step}`);
|
|
20
|
+
}
|
|
21
|
+
if (status.hints.length) {
|
|
22
|
+
console.log("hints:");
|
|
23
|
+
for (const hint of status.hints) console.log(` - ${hint}`);
|
|
24
|
+
}
|
|
25
|
+
return status;
|
|
26
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { loadConfig } from "../shared/config.js";
|
|
3
|
+
|
|
4
|
+
export function configuredPromptCacheRetention(config = loadConfig()) {
|
|
5
|
+
const value = config.promptCacheRetention;
|
|
6
|
+
if (!value || value === "off") return undefined;
|
|
7
|
+
if (!["in_memory", "24h"].includes(value)) return undefined;
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function stableCacheKeyPart(value) {
|
|
12
|
+
if (value === undefined || value === null) return undefined;
|
|
13
|
+
if (typeof value === "string") return value.trim() || undefined;
|
|
14
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
15
|
+
try {
|
|
16
|
+
return JSON.stringify(value);
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function metadataCacheKey(metadata) {
|
|
23
|
+
if (!metadata || typeof metadata !== "object") return undefined;
|
|
24
|
+
for (const key of ["prompt_cache_key", "session_id", "conversation_id", "thread_id", "user_id"]) {
|
|
25
|
+
const value = stableCacheKeyPart(metadata[key]);
|
|
26
|
+
if (value) return value;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function configuredPromptCacheKey(payload, config = loadConfig()) {
|
|
32
|
+
const value = config.promptCacheKey;
|
|
33
|
+
if (!value || value === "off") return undefined;
|
|
34
|
+
if (value !== "auto") return String(value);
|
|
35
|
+
const basis = [
|
|
36
|
+
stableCacheKeyPart(payload?.model),
|
|
37
|
+
metadataCacheKey(payload?.metadata),
|
|
38
|
+
stableCacheKeyPart(payload?.conversation_id),
|
|
39
|
+
stableCacheKeyPart(payload?.thread_id),
|
|
40
|
+
stableCacheKeyPart(process.cwd())
|
|
41
|
+
].filter(Boolean).join(":");
|
|
42
|
+
if (!basis) return undefined;
|
|
43
|
+
return "aerial:" + crypto.createHash("sha256").update(basis).digest("hex").slice(0, 32);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function withDefaultPromptCache(payload) {
|
|
47
|
+
const config = loadConfig();
|
|
48
|
+
const retention = configuredPromptCacheRetention(config);
|
|
49
|
+
const promptCacheKey = configuredPromptCacheKey(payload, config);
|
|
50
|
+
const next = { ...payload };
|
|
51
|
+
if (retention && next.prompt_cache_retention === undefined) next.prompt_cache_retention = retention;
|
|
52
|
+
if (promptCacheKey && next.prompt_cache_key === undefined) next.prompt_cache_key = promptCacheKey;
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function countCacheControlBlocks(value) {
|
|
57
|
+
if (!value || typeof value !== "object") return 0;
|
|
58
|
+
if (Array.isArray(value)) return value.reduce((total, item) => total + countCacheControlBlocks(item), 0);
|
|
59
|
+
return Object.entries(value).reduce((total, [key, item]) => total + (key === "cache_control" ? 1 : 0) + countCacheControlBlocks(item), 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function addEphemeralCacheControl(value) {
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
return value.trim() ? { value: [{ type: "text", text: value, cache_control: { type: "ephemeral" } }], changed: true } : { value, changed: false };
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(value) || value.length === 0) return { value, changed: false };
|
|
67
|
+
for (let i = value.length - 1; i >= 0; i -= 1) {
|
|
68
|
+
const item = value[i];
|
|
69
|
+
if (item && typeof item === "object" && !Array.isArray(item) && item.cache_control === undefined) {
|
|
70
|
+
const next = [...value];
|
|
71
|
+
next[i] = { ...item, cache_control: { type: "ephemeral" } };
|
|
72
|
+
return { value: next, changed: true };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { value, changed: false };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function withDefaultAnthropicCache(payload) {
|
|
79
|
+
const retention = configuredPromptCacheRetention();
|
|
80
|
+
if (!retention || countCacheControlBlocks(payload) > 0) return payload;
|
|
81
|
+
const next = { ...payload };
|
|
82
|
+
const system = addEphemeralCacheControl(next.system);
|
|
83
|
+
if (system.changed) return { ...next, system: system.value };
|
|
84
|
+
if (Array.isArray(next.tools) && next.tools.length > 0) {
|
|
85
|
+
const tools = addEphemeralCacheControl(next.tools);
|
|
86
|
+
if (tools.changed) return { ...next, tools: tools.value };
|
|
87
|
+
}
|
|
88
|
+
return next;
|
|
89
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { logEvent } from "../shared/log.js";
|
|
2
|
+
import { copyResponseHeaders } from "./headers.js";
|
|
3
|
+
import { countCacheControlBlocks } from "./cache-policy.js";
|
|
4
|
+
|
|
5
|
+
export function parseJsonBody(body, contentType) {
|
|
6
|
+
if (!body || !contentType.includes("json")) return undefined;
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(Buffer.from(body).toString("utf8"));
|
|
9
|
+
} catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function cacheRequestFields(payload) {
|
|
15
|
+
if (!payload || typeof payload !== "object") return {};
|
|
16
|
+
const fields = {
|
|
17
|
+
model: typeof payload.model === "string" ? payload.model : undefined,
|
|
18
|
+
promptCacheRetention: payload.prompt_cache_retention,
|
|
19
|
+
hasPromptCacheKey: payload.prompt_cache_key !== undefined,
|
|
20
|
+
cacheControlBlocks: countCacheControlBlocks(payload)
|
|
21
|
+
};
|
|
22
|
+
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined && value !== false && value !== 0));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function responseItems(payload) {
|
|
26
|
+
return Array.isArray(payload?.input) ? payload.input : [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function responseHasVision(payload) {
|
|
30
|
+
const visit = (value) => {
|
|
31
|
+
if (Array.isArray(value)) return value.some(visit);
|
|
32
|
+
if (!value || typeof value !== "object") return false;
|
|
33
|
+
if (value.type === "input_image") return true;
|
|
34
|
+
return Array.isArray(value.content) && value.content.some(visit);
|
|
35
|
+
};
|
|
36
|
+
return responseItems(payload).some(visit);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function responseInitiator(payload) {
|
|
40
|
+
const last = responseItems(payload).at(-1);
|
|
41
|
+
if (!last) return "user";
|
|
42
|
+
if (!last.role) return "agent";
|
|
43
|
+
return String(last.role).toLowerCase() === "assistant" ? "agent" : "user";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hasExplicitCacheRequest(fields) {
|
|
47
|
+
return fields.promptCacheRetention !== undefined || fields.hasPromptCacheKey === true || Number(fields.cacheControlBlocks || 0) > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sumCopilotCacheDetails(details, kind) {
|
|
51
|
+
if (!Array.isArray(details)) return undefined;
|
|
52
|
+
const total = details.reduce((sum, detail) => {
|
|
53
|
+
const label = String(detail.type || detail.name || detail.kind || detail.cache || "");
|
|
54
|
+
if (!label.includes(kind)) return sum;
|
|
55
|
+
return sum + Number(detail.tokens || detail.count || detail.token_count || 0);
|
|
56
|
+
}, 0);
|
|
57
|
+
return total || undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function usageFields(payload) {
|
|
61
|
+
const usage = payload?.usage || payload?.response?.usage;
|
|
62
|
+
if (!usage || typeof usage !== "object") return {};
|
|
63
|
+
const copilotDetails = usage.copilot_usage?.token_details || payload?.copilot_usage?.token_details;
|
|
64
|
+
const fields = {
|
|
65
|
+
input: usage.input_tokens ?? usage.prompt_tokens,
|
|
66
|
+
output: usage.output_tokens ?? usage.completion_tokens,
|
|
67
|
+
cached: usage.input_tokens_details?.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens,
|
|
68
|
+
cacheCreation: usage.cache_creation_input_tokens,
|
|
69
|
+
cacheRead: usage.cache_read_input_tokens,
|
|
70
|
+
copilotCacheRead: sumCopilotCacheDetails(copilotDetails, "cache_read"),
|
|
71
|
+
copilotCacheWrite: sumCopilotCacheDetails(copilotDetails, "cache_write")
|
|
72
|
+
};
|
|
73
|
+
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hasCacheUsage(fields) {
|
|
77
|
+
return fields.cached !== undefined || fields.cacheCreation !== undefined || fields.cacheRead !== undefined || fields.copilotCacheRead !== undefined || fields.copilotCacheWrite !== undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sseJsonPayloads(text) {
|
|
81
|
+
return text.split(/\r?\n/)
|
|
82
|
+
.filter((line) => line.startsWith("data:"))
|
|
83
|
+
.map((line) => line.slice("data:".length).trim())
|
|
84
|
+
.filter((line) => line && line !== "[DONE]")
|
|
85
|
+
.flatMap((line) => {
|
|
86
|
+
try {
|
|
87
|
+
return [JSON.parse(line)];
|
|
88
|
+
} catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function shouldEmitCacheObserve(requestCache, usage) {
|
|
95
|
+
return hasExplicitCacheRequest(requestCache) || hasCacheUsage(usage);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createSseCacheObserver(route, requestCache) {
|
|
99
|
+
const decoder = new TextDecoder();
|
|
100
|
+
let buffer = "";
|
|
101
|
+
let usage = {};
|
|
102
|
+
let logged = false;
|
|
103
|
+
|
|
104
|
+
const consume = () => {
|
|
105
|
+
let match;
|
|
106
|
+
const boundary = /\r?\n\r?\n/;
|
|
107
|
+
while ((match = boundary.exec(buffer)) !== null) {
|
|
108
|
+
const frame = buffer.slice(0, match.index);
|
|
109
|
+
buffer = buffer.slice(match.index + match[0].length);
|
|
110
|
+
for (const payload of sseJsonPayloads(frame)) {
|
|
111
|
+
const fields = usageFields(payload);
|
|
112
|
+
if (Object.keys(fields).length) usage = { ...usage, ...fields };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const emit = () => {
|
|
118
|
+
if (logged) return;
|
|
119
|
+
logged = true;
|
|
120
|
+
if (shouldEmitCacheObserve(requestCache, usage)) {
|
|
121
|
+
logEvent("cache_observe", { route, request: requestCache, usage });
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return new TransformStream({
|
|
126
|
+
transform(chunk, controller) {
|
|
127
|
+
controller.enqueue(chunk);
|
|
128
|
+
try {
|
|
129
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
130
|
+
consume();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
logEvent("cache_observe_error", { route, error: error.message });
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
flush() {
|
|
136
|
+
try {
|
|
137
|
+
buffer += decoder.decode();
|
|
138
|
+
consume();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logEvent("cache_observe_error", { route, error: error.message });
|
|
141
|
+
}
|
|
142
|
+
emit();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function logJsonCacheUsage(route, requestCache, clone) {
|
|
148
|
+
try {
|
|
149
|
+
const payload = await clone.json();
|
|
150
|
+
const usage = usageFields(payload);
|
|
151
|
+
if (shouldEmitCacheObserve(requestCache, usage)) {
|
|
152
|
+
logEvent("cache_observe", { route, request: requestCache, usage });
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
logEvent("cache_observe_error", { route, error: error.message });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function wrapResponseWithCacheObserver(upstream, route, requestCache) {
|
|
160
|
+
const headers = copyResponseHeaders(upstream);
|
|
161
|
+
const contentType = upstream.headers.get("content-type") || "";
|
|
162
|
+
if (!upstream.body) return new Response(upstream.body, { status: upstream.status, headers });
|
|
163
|
+
if (contentType.includes("text/event-stream")) {
|
|
164
|
+
const observer = createSseCacheObserver(route, requestCache);
|
|
165
|
+
return new Response(upstream.body.pipeThrough(observer), { status: upstream.status, headers });
|
|
166
|
+
}
|
|
167
|
+
if (contentType.includes("json")) {
|
|
168
|
+
void logJsonCacheUsage(route, requestCache, upstream.clone());
|
|
169
|
+
return new Response(upstream.body, { status: upstream.status, headers });
|
|
170
|
+
}
|
|
171
|
+
return new Response(upstream.body, { status: upstream.status, headers });
|
|
172
|
+
}
|