directionallylocal 0.3.0 → 0.4.1
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 +202 -0
- package/README.md +4 -0
- package/dist/cli.js +33 -11
- package/dist/doctor.js +6 -3
- package/dist/keys.js +188 -0
- package/dist/laptop/commands.js +130 -25
- package/dist/laptop/config.js +108 -22
- package/dist/laptop/credentials.js +2 -2
- package/dist/laptop/keys.js +133 -0
- package/dist/laptop/setup.js +101 -53
- package/dist/laptop/ssh.js +11 -6
- package/dist/layout.js +4 -0
- package/dist/local/cli.js +4 -1
- package/dist/local/keys.js +100 -0
- package/dist/local/layout.js +3 -0
- package/dist/local/setup.js +13 -3
- package/dist/local/test-support/fake-computer.js +27 -3
- package/dist/model-prices.js +119 -0
- package/dist/up.js +16 -5
- package/package.json +2 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
2
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
3
|
+
/** Older than this, `dl doctor` suggests refreshing it. */
|
|
4
|
+
export const MODEL_PRICES_MAX_AGE_DAYS = 7;
|
|
5
|
+
/** USD per token as OpenRouter gives it ("0.0000001"), or undefined when it isn't a usable price. */
|
|
6
|
+
function perToken(value) {
|
|
7
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
|
|
8
|
+
// OpenRouter marks variable pricing with "-1"; genuine 0 is a free model and is kept.
|
|
9
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
10
|
+
}
|
|
11
|
+
/** Per million tokens, without float noise (0.0000001 × 1e6 is 0.09999999999999999). */
|
|
12
|
+
function perMillion(price) {
|
|
13
|
+
return Math.round(price * 1e12) / 1e6;
|
|
14
|
+
}
|
|
15
|
+
/** OpenRouter's catalog response to a price table; entries without usable prices are skipped. */
|
|
16
|
+
export function pricesFromCatalog(catalog) {
|
|
17
|
+
const data = catalog?.data;
|
|
18
|
+
if (!Array.isArray(data))
|
|
19
|
+
throw new Error("OpenRouter's model list had no models");
|
|
20
|
+
const table = {};
|
|
21
|
+
for (const entry of data) {
|
|
22
|
+
const id = typeof entry?.id === "string" ? entry.id.trim().toLowerCase() : "";
|
|
23
|
+
if (!id)
|
|
24
|
+
continue;
|
|
25
|
+
const input = perToken(entry?.pricing?.prompt);
|
|
26
|
+
const output = perToken(entry?.pricing?.completion);
|
|
27
|
+
if (input === undefined || output === undefined)
|
|
28
|
+
continue;
|
|
29
|
+
table[id] = { inputPerMTok: perMillion(input), outputPerMTok: perMillion(output) };
|
|
30
|
+
}
|
|
31
|
+
return table;
|
|
32
|
+
}
|
|
33
|
+
/** Downloads OpenRouter's public model list (no key needed) as a price table. */
|
|
34
|
+
export async function fetchModelPrices(fetchImpl = fetch) {
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await fetchImpl(OPENROUTER_MODELS_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { "User-Agent": "directionallylocal" } });
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new Error(`couldn't reach openrouter.ai: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
if (!response.ok)
|
|
43
|
+
throw new Error(`openrouter.ai answered HTTP ${response.status}`);
|
|
44
|
+
const table = pricesFromCatalog(await response.json().catch(() => undefined));
|
|
45
|
+
const count = Object.keys(table).length;
|
|
46
|
+
if (count === 0)
|
|
47
|
+
throw new Error("OpenRouter's model list had no prices");
|
|
48
|
+
return { table, count };
|
|
49
|
+
}
|
|
50
|
+
export function renderModelPrices(table) {
|
|
51
|
+
const sorted = Object.fromEntries(Object.keys(table).sort().map((id) => [id, table[id]]));
|
|
52
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
53
|
+
}
|
|
54
|
+
/** How many models a written price file holds, or undefined when it isn't one. */
|
|
55
|
+
export function countModelPrices(text) {
|
|
56
|
+
if (text === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(text);
|
|
60
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
61
|
+
return undefined;
|
|
62
|
+
return Object.values(parsed).filter((price) => typeof price?.inputPerMTok === "number" && typeof price?.outputPerMTok === "number").length;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Refreshes the price file. When OpenRouter can't be reached, an existing file is kept;
|
|
70
|
+
* either way it warns rather than failing the install. Returns a step detail, if any.
|
|
71
|
+
*/
|
|
72
|
+
export async function refreshModelPrices(system, input) {
|
|
73
|
+
if (system.dryRun) {
|
|
74
|
+
system.log(` would fetch: ${OPENROUTER_MODELS_URL} -> ${input.path}`);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const { table, count } = await fetchModelPrices(input.fetchImpl);
|
|
79
|
+
await system.writeFile(input.path, renderModelPrices(table), input.write);
|
|
80
|
+
return `prices for ${count} models`;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const reason = error.message;
|
|
84
|
+
const kept = countModelPrices(await system.readFile(input.path));
|
|
85
|
+
if (kept !== undefined && kept > 0) {
|
|
86
|
+
input.warn(`Couldn't refresh model prices (${reason}); kept the last list (${kept} models).`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
input.warn(`Couldn't download model prices (${reason}). Tasks will fail with "model not priced" until ${input.retry} can reach openrouter.ai.`);
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** For `dl doctor` and `dl local status`: the file's contents and its age (mtime, seconds). */
|
|
95
|
+
export function modelPricesHealth(text, mtimeSeconds, now, refresh) {
|
|
96
|
+
if (text === undefined)
|
|
97
|
+
return { ok: false, warn: false, detail: `missing; tasks fail with "model not priced"` };
|
|
98
|
+
const count = countModelPrices(text);
|
|
99
|
+
if (count === undefined || count === 0)
|
|
100
|
+
return { ok: false, warn: false, detail: "unreadable; tasks may fail with \"model not priced\"" };
|
|
101
|
+
const models = `${count} model${count === 1 ? "" : "s"}`;
|
|
102
|
+
if (mtimeSeconds === undefined)
|
|
103
|
+
return { ok: true, warn: false, detail: models };
|
|
104
|
+
const days = Math.max(0, Math.floor((now.getTime() / 1000 - mtimeSeconds) / 86_400));
|
|
105
|
+
const age = days === 0 ? "updated today" : `${days} day${days === 1 ? "" : "s"} old`;
|
|
106
|
+
if (days > MODEL_PRICES_MAX_AGE_DAYS)
|
|
107
|
+
return { ok: true, warn: true, detail: `${models} · ${age}; ${refresh} refreshes it` };
|
|
108
|
+
return { ok: true, warn: false, detail: `${models} · ${age}` };
|
|
109
|
+
}
|
|
110
|
+
/** A file's mtime in seconds via stat (GNU on Linux, BSD on macOS); undefined when unknown. */
|
|
111
|
+
export async function fileMtime(system, path, platform = "linux") {
|
|
112
|
+
const result = await system.probe("stat", platform === "darwin" ? ["-f", "%m", path] : ["-c", "%Y", path]);
|
|
113
|
+
const seconds = Number(result.stdout.trim());
|
|
114
|
+
return result.code === 0 && result.stdout.trim() !== "" && Number.isFinite(seconds) ? seconds : undefined;
|
|
115
|
+
}
|
|
116
|
+
/** A failed run's reason when Hotcell refused the model for lack of a price. */
|
|
117
|
+
export function isModelNotPriced(text) {
|
|
118
|
+
return /model not priced/i.test(text);
|
|
119
|
+
}
|
package/dist/up.js
CHANGED
|
@@ -5,6 +5,7 @@ import { AUTOPILOT_PORT, CELL_MEMORY_MB, GITHUB_PROXY_PORT, HOTCELL_EGRESS_PORT,
|
|
|
5
5
|
import { ACCESS_MODES, readState, writeState } from "./state.js";
|
|
6
6
|
import { PlainUi } from "./ui.js";
|
|
7
7
|
import { ownerLink } from "./server-api.js";
|
|
8
|
+
import { refreshModelPrices } from "./model-prices.js";
|
|
8
9
|
const DRY_RUN_BRIDGE_IP = "172.17.0.1";
|
|
9
10
|
const DRY_RUN_URL = "https://<this-machine>.<tailnet>.ts.net";
|
|
10
11
|
/** `dl up`: install or repair Hotcell + Autopilot as services. Safe to re-run. */
|
|
@@ -35,7 +36,7 @@ export async function up(system, options) {
|
|
|
35
36
|
{ id: "software", label: "Installing Hotcell + Autopilot" },
|
|
36
37
|
{ id: "image", label: "Building the sandbox image" },
|
|
37
38
|
{ id: "services", label: "Starting services" },
|
|
38
|
-
{ id: "access", label: access === "tailscale" ? "Opening private access" : "Starting the
|
|
39
|
+
{ id: "access", label: access === "tailscale" ? "Opening private access" : "Starting the server" },
|
|
39
40
|
]);
|
|
40
41
|
const step = async (id, work) => {
|
|
41
42
|
ui.start(id);
|
|
@@ -103,6 +104,14 @@ export async function up(system, options) {
|
|
|
103
104
|
const hotcellValues = parseEnvFile(await system.readFile(PATHS.hotcellEnv));
|
|
104
105
|
const apiKey = hotcellValues.HOTCELL_API_KEY || randomBytes(24).toString("hex");
|
|
105
106
|
await system.mkdir(PATHS.hotcellEtc, { mode: 0o750, owner: `root:${USERS.hotcell}` });
|
|
107
|
+
// Read once at Hotcell's start, so it's in place before the restart below.
|
|
108
|
+
const prices = await refreshModelPrices(system, {
|
|
109
|
+
path: PATHS.modelPrices,
|
|
110
|
+
write: { mode: 0o644, owner: `${USERS.hotcell}:${USERS.hotcell}` },
|
|
111
|
+
fetchImpl,
|
|
112
|
+
warn: (text) => ui.warn(text),
|
|
113
|
+
retry: "dl up",
|
|
114
|
+
});
|
|
106
115
|
await system.writeFile(PATHS.hotcellEnv, hotcellEnv({ apiKey, bridgeIp }), { mode: 0o640, owner: `root:${USERS.hotcell}`, secret: true });
|
|
107
116
|
if (!(await system.readFile(PATHS.asterConfig))) {
|
|
108
117
|
await system.writeFile(PATHS.asterConfig, asterConfig(), { mode: 0o640, owner: `root:${USERS.autopilot}` });
|
|
@@ -118,7 +127,7 @@ export async function up(system, options) {
|
|
|
118
127
|
await system.run("systemctl", ["restart", SERVICES.hotcell]);
|
|
119
128
|
await waitUntil(system, sleep, "Hotcell", async () => (await system.probe("curl", ["-fsS", "-o", "/dev/null", `http://127.0.0.1:${HOTCELL_PORT}/`])).code === 0
|
|
120
129
|
|| (await system.probe("sh", ["-c", `ss -ltn | grep -q '127.0.0.1:${HOTCELL_PORT}'`])).code === 0);
|
|
121
|
-
return { value: apiKey, detail:
|
|
130
|
+
return { value: apiKey, detail: `sandboxes and keys ready${prices ? ` · ${prices}` : ""}` };
|
|
122
131
|
});
|
|
123
132
|
const url = await step("access", async () => {
|
|
124
133
|
// Moving off Tailscale: stop publishing on the tailnet, so the only way in is the new one.
|
|
@@ -131,7 +140,9 @@ export async function up(system, options) {
|
|
|
131
140
|
await system.run("systemctl", ["restart", SERVICES.autopilot]);
|
|
132
141
|
// Without a token the Aster API answers 401: the server is up and refusing strangers.
|
|
133
142
|
await waitUntil(system, sleep, "Autopilot", async () => (await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `http://127.0.0.1:${AUTOPILOT_PORT}/api/v1/health`])).stdout.trim() === "401");
|
|
134
|
-
|
|
143
|
+
// The API only listens on this machine: say who can reach it rather than a loopback address.
|
|
144
|
+
const reach = access === "tailscale" ? address.replace(/^https?:\/\//, "") : access === "ssh" ? "private · reached over SSH" : "private · this machine only";
|
|
145
|
+
return { value: address, detail: reach };
|
|
135
146
|
});
|
|
136
147
|
const now = (options.now ?? (() => new Date()))().toISOString();
|
|
137
148
|
const state = {
|
|
@@ -390,7 +401,7 @@ async function latestVersion(system, pkg) {
|
|
|
390
401
|
throw new Error(`Couldn't look up the latest ${pkg} on npm${result.stderr ? `: ${result.stderr.trim()}` : ""}`);
|
|
391
402
|
return version;
|
|
392
403
|
}
|
|
393
|
-
function asHotcell(system, args, options = {}) {
|
|
404
|
+
export function asHotcell(system, args, options = {}) {
|
|
394
405
|
return system.run("runuser", [
|
|
395
406
|
"-u", USERS.hotcell, "--", "env", `HOTCELL_HOME=${PATHS.hotcellHome}`, "HOTCELL_KEYSTORE=file",
|
|
396
407
|
"/usr/bin/node", hotcellBin(), ...args,
|
|
@@ -492,7 +503,7 @@ async function serveOverTailscale(system) {
|
|
|
492
503
|
throw new Error("Tailscale is up but reported no DNS name for this machine; is MagicDNS on?");
|
|
493
504
|
return `https://${dnsName}`;
|
|
494
505
|
}
|
|
495
|
-
async function waitUntil(system, sleep, what, ready, unit = what.toLowerCase()) {
|
|
506
|
+
export async function waitUntil(system, sleep, what, ready, unit = what.toLowerCase()) {
|
|
496
507
|
if (system.dryRun)
|
|
497
508
|
return;
|
|
498
509
|
for (let attempt = 0; attempt < 60; attempt++) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "directionallylocal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Directionally Local: background coding agents on your own server. One-line server install, and the dl command for your server and laptop.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
5
6
|
"homepage": "https://directionallylocal.com",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|