decision-gate 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/lib/cache.mjs ADDED
@@ -0,0 +1,95 @@
1
+ import { closeSync, fsyncSync, linkSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { createHmac, randomBytes, randomUUID } from "node:crypto";
4
+ import { appendRecords, clock, locked, privateDir, replaceFile } from "./state.mjs";
5
+
6
+ export const TTL = 30 * 86400000;
7
+ const MAX_BYTES = 50 * 1024 * 1024;
8
+
9
+ function hashKey(stateDir) {
10
+ privateDir(stateDir);
11
+ const file = path.join(stateDir, "cache-key");
12
+ try {
13
+ const key = readFileSync(file);
14
+ if (key.length !== 32 || (statSync(file).mode & 0o777) !== 0o600) throw new Error("invalid cache key");
15
+ return key;
16
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
17
+ const temp = `${file}.${randomUUID()}`;
18
+ const fd = openSync(temp, "wx", 0o600);
19
+ try { writeFileSync(fd, randomBytes(32)); fsyncSync(fd); }
20
+ finally { closeSync(fd); }
21
+ try { linkSync(temp, file); }
22
+ catch (error) { if (error.code !== "EEXIST") throw error; }
23
+ finally { rmSync(temp, { force: true }); }
24
+ return hashKey(stateDir);
25
+ }
26
+
27
+ function liveRows(text, now, ttl) {
28
+ const rows = new Map();
29
+ let lines = 0;
30
+ for (const line of text.split("\n")) {
31
+ if (!line) continue;
32
+ lines++;
33
+ try {
34
+ const row = JSON.parse(line);
35
+ if (/^[a-f0-9]{64}$/.test(row.h) && Number.isFinite(row.p) && row.p >= 0 && row.p <= 1 && Number.isFinite(row.t) && row.t <= now && row.t > now - ttl) rows.set(row.h, row);
36
+ } catch { /* A partial or corrupt entry is a miss, never an answer */ }
37
+ }
38
+ return { rows, stale: lines - rows.size };
39
+ }
40
+
41
+ export function answerCache({ stateDir, cacheDir, scope, ttl = TTL, enabled = true, notice = () => {}, time = clock }) {
42
+ let key, file;
43
+ const warn = () => { notice("answer cache unavailable; continuing without caching"); enabled = false; };
44
+ const hash = (value) => createHmac("sha256", key).update(value).digest("hex");
45
+ if (enabled) {
46
+ try {
47
+ key = hashKey(stateDir);
48
+ privateDir(cacheDir);
49
+ file = path.join(cacheDir, `${hash(JSON.stringify(scope))}.jsonl`);
50
+ } catch { warn(); }
51
+ }
52
+ let entries = new Map(), stale = 0;
53
+ if (enabled) {
54
+ try { ({ rows: entries, stale } = liveRows(readFileSync(file, "utf8"), time.now(), ttl)); }
55
+ catch (error) { if (error.code !== "ENOENT") warn(); }
56
+ }
57
+ return {
58
+ get(name) { return enabled ? entries.get(hash(name))?.p : undefined; },
59
+ // One locked write per batch of [key, probability] pairs
60
+ async put(pairs) {
61
+ // Only a key's keyed hash and a number are stored, so nothing else is accepted
62
+ if (!Array.isArray(pairs) || pairs.some((pair) => typeof pair?.[0] !== "string" || !Number.isFinite(pair[1]) || pair[1] < 0 || pair[1] > 1)) throw new TypeError("cache entries must be [string key, probability] pairs");
63
+ if (!enabled || !pairs.length) return;
64
+ try {
65
+ const now = time.now();
66
+ const rows = pairs.map(([name, p]) => ({ h: hash(name), p, t: now }));
67
+ await locked(path.join(cacheDir, "maintenance"), () => {
68
+ // Expired and superseded rows would otherwise keep a busy query's file growing forever
69
+ if (stale > Math.max(256, entries.size)) {
70
+ let text = "";
71
+ try { text = readFileSync(file, "utf8"); }
72
+ catch (error) { if (error.code !== "ENOENT") throw error; }
73
+ const current = liveRows(text, now, ttl).rows;
74
+ replaceFile(file, [...current.values()].map((row) => `${JSON.stringify(row)}\n`).join(""));
75
+ entries = current;
76
+ stale = 0;
77
+ }
78
+ let total = 0;
79
+ const files = readdirSync(cacheDir).filter((name) => /^[a-f0-9]{64}\.jsonl$/.test(name)).map((name) => {
80
+ const full = path.join(cacheDir, name), stat = statSync(full);
81
+ total += stat.size;
82
+ return { full, stat };
83
+ }).sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs);
84
+ const bytes = rows.reduce((sum, row) => sum + Buffer.byteLength(JSON.stringify(row)) + 1, 0);
85
+ for (const entry of files) {
86
+ if (total + bytes <= MAX_BYTES && entry.stat.mtimeMs > now - TTL) continue;
87
+ rmSync(entry.full); total -= entry.stat.size;
88
+ }
89
+ appendRecords(file, rows);
90
+ }, { time });
91
+ for (const row of rows) entries.set(row.h, row);
92
+ } catch { warn(); }
93
+ }
94
+ };
95
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,113 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { ConfigError } from "./errors.mjs";
6
+ import { DEFAULT_PROVIDER, providers } from "./providers/index.mjs";
7
+
8
+ const NAME = "decision-gate";
9
+ export const expandHome = (value) => value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
10
+
11
+ export function amount(value, name, { positive = false } = {}) {
12
+ if (typeof value !== "number" || !Number.isFinite(value) || (positive ? value <= 0 : value < 0)) throw new ConfigError(`${name} must be a ${positive ? "positive" : "nonnegative"} number`);
13
+ return value;
14
+ }
15
+
16
+ export function loadConfig(env = process.env) {
17
+ const file = env.DECISION_GATE_CONFIG || path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), NAME, "config.json");
18
+ let user = {};
19
+ try { user = JSON.parse(readFileSync(file, "utf8")); }
20
+ catch (error) {
21
+ if (error.code !== "ENOENT" || env.DECISION_GATE_CONFIG) throw new ConfigError("cannot read config as JSON");
22
+ }
23
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value);
24
+ if (!object(user) || (user.spend !== undefined && !object(user.spend)) || (user.limits !== undefined && !object(user.limits))) throw new ConfigError("config, spend and limits must be objects");
25
+ // The provider is only ever chosen by name, never from whichever key variable happens to be set,
26
+ // since other tools export those keys for their own use
27
+ const provider = env.DECISION_GATE_PROVIDER ?? user.provider ?? DEFAULT_PROVIDER;
28
+ if (!Object.hasOwn(providers, provider)) throw new ConfigError(`provider must be one of ${Object.keys(providers).join(", ")}`);
29
+ const nested = Object.entries(user.limits ?? {}).filter(([, value]) => object(value));
30
+ if (nested.some(([name]) => !Object.hasOwn(providers, name))) throw new ConfigError("limits may nest only a known provider's own limits");
31
+ // Unsectioned limits describe a TypeSafe account, so another provider keeps its own defaults
32
+ // unless its own section overrides them
33
+ const shared = Object.fromEntries(Object.entries(user.limits ?? {}).filter(([, value]) => !object(value)));
34
+ const limits = { ...(provider === DEFAULT_PROVIDER ? shared : {}), ...user.limits?.[provider] };
35
+ // A key belongs to one provider: the top-level key_file is TypeSafe's, and any other provider's
36
+ // lives in a section named after it
37
+ const own = Object.keys(providers).filter((name) => name !== DEFAULT_PROVIDER && user[name] !== undefined);
38
+ if (own.some((name) => !object(user[name]) || Object.keys(user[name]).some((field) => field !== "key_file"))) throw new ConfigError("a provider's config section holds only its key_file");
39
+ const keyFile = provider === DEFAULT_PROVIDER ? user.key_file : user[provider]?.key_file;
40
+ const defaults = providers[provider].limits;
41
+ const number = (name, fallback, label, positive = false) => amount(env[name] !== undefined ? (env[name].trim() ? Number(env[name]) : NaN) : fallback, label, { positive });
42
+ const location = (value, fromEnv, label) => {
43
+ if (value === null) return null;
44
+ if (typeof value !== "string" || !value.trim()) throw new ConfigError(`${label} must be a path`);
45
+ return path.resolve(fromEnv ? process.cwd() : path.dirname(file), expandHome(value));
46
+ };
47
+ const share = amount(limits.share ?? 0.8, "limits.share", { positive: true });
48
+ if (share > 1) throw new ConfigError("limits.share must not exceed 1");
49
+ const whole = (value, label) => {
50
+ if (!Number.isInteger(value)) throw new ConfigError(`${label} must be a positive whole number`);
51
+ return value;
52
+ };
53
+ return {
54
+ provider,
55
+ key_file: location(keyFile ?? null, false, "key_file"),
56
+ never_send_file: location(env.DECISION_GATE_NEVER_SEND_FILE || user.never_send_file || null, !!env.DECISION_GATE_NEVER_SEND_FILE, "never_send_file"),
57
+ spend: {
58
+ perRunUsd: number("DECISION_GATE_PER_RUN_USD", user.spend?.per_run_usd ?? 0.02, "per-run ceiling"),
59
+ perDayUsd: number("DECISION_GATE_PER_DAY_USD", user.spend?.per_day_usd ?? 0.2, "daily ceiling")
60
+ },
61
+ limits: {
62
+ requestsPerMinute: number("DECISION_GATE_RPM", limits.requests_per_minute ?? defaults.requestsPerMinute, "requests per minute", true),
63
+ tokensPerSecond: number("DECISION_GATE_TPS", limits.tokens_per_second ?? defaults.tokensPerSecond, "tokens per second", true),
64
+ share,
65
+ // Measured on one account: requests of about 6,100 tokens finished fastest at two to four in
66
+ // flight, while at about 50,000 tokens one at a time was as fast as two or four
67
+ inFlight: whole(number("DECISION_GATE_IN_FLIGHT", limits.in_flight ?? defaults.inFlight, "limits.in_flight", true), "limits.in_flight"),
68
+ largeInFlight: 1,
69
+ largeRequestTokens: 32000
70
+ },
71
+ stateDir: location(path.join(env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state"), NAME), true, "state directory"),
72
+ cacheDir: location(path.join(env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), NAME, "answers"), true, "cache directory")
73
+ };
74
+ }
75
+
76
+ function privateFile(file) {
77
+ const stat = statSync(file);
78
+ if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) throw new ConfigError("key file must be a private regular file (chmod 600)");
79
+ if (!stat.size) throw new ConfigError("key file must contain one nonempty line");
80
+ }
81
+
82
+ // A caller's key names the provider it belongs to, and is used only when that provider is selected,
83
+ // so a key never goes to another provider's endpoint
84
+ export function keySource(explicit, config, env, provider) {
85
+ const kinds = explicit && typeof explicit === "object" ? Object.keys(explicit).filter((name) => name !== "provider") : [];
86
+ if (explicit != null && (!Object.hasOwn(providers, explicit.provider) || kinds.length !== 1 || !["file", "env", "value"].includes(kinds[0]) || typeof explicit[kinds[0]] !== "string")) throw new ConfigError("key needs its provider and exactly one file, env or value source");
87
+ const own = explicit?.provider === provider.name ? { [kinds[0]]: explicit[kinds[0]] } : null;
88
+ const source = own ?? (env[provider.keyEnv]?.trim() ? { env: provider.keyEnv } : config.key_file ? { file: config.key_file } : null);
89
+ const where = provider.name === DEFAULT_PROVIDER ? "key_file" : `key_file in the config's ${provider.name} section`;
90
+ const status = () => {
91
+ try {
92
+ if (!source) return { ok: false, missing: true, label: provider.label, keyEnv: provider.keyEnv, reason: `a ${provider.label} API key is needed: export ${provider.keyEnv} or set ${where}; nothing was sent` };
93
+ if (source.file !== undefined) privateFile(expandHome(source.file));
94
+ else if (!(source.value ?? env[source.env])?.trim()) throw new ConfigError("configured key source is empty");
95
+ return { ok: true };
96
+ } catch (error) { return { ok: false, reason: error instanceof ConfigError ? error.message : "cannot read the configured key file" }; }
97
+ };
98
+ return Object.freeze({
99
+ status,
100
+ read() {
101
+ const state = status();
102
+ if (!state.ok) throw new ConfigError(state.reason);
103
+ let value;
104
+ try { value = (source.file !== undefined ? readFileSync(expandHome(source.file), "utf8") : source.value ?? env[source.env]).trim(); }
105
+ catch { throw new ConfigError("cannot read the configured key source"); }
106
+ if (!value || /[\s\x00-\x1f\x7f]/.test(value)) throw new ConfigError("configured key must contain one nonempty token");
107
+ return Object.freeze(Object.defineProperties({}, {
108
+ authorization: { value: `Bearer ${value}` },
109
+ fingerprint: { value: createHash("sha256").update(value).digest("hex").slice(0, 16), enumerable: true }
110
+ }));
111
+ }
112
+ });
113
+ }
package/lib/errors.mjs ADDED
@@ -0,0 +1,27 @@
1
+ // describeError passes these messages through, so none may ever carry a key, request text or a
2
+ // provider response body
3
+ export class ConfigError extends Error {}
4
+ export class StateError extends Error {}
5
+ export class RedactionError extends Error {}
6
+
7
+ export class SpendCapError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "SpendCapError";
11
+ }
12
+ }
13
+
14
+ export class ServiceError extends Error {
15
+ constructor(message, { status } = {}) {
16
+ super(message);
17
+ this.name = "ServiceError";
18
+ this.status = status;
19
+ }
20
+ }
21
+
22
+ export class RequestSizeError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = "RequestSizeError";
26
+ }
27
+ }
package/lib/index.mjs ADDED
@@ -0,0 +1,115 @@
1
+ import path from "node:path";
2
+ import { amount, loadConfig, keySource } from "./config.mjs";
3
+ import { createSpendBudget } from "./budget.mjs";
4
+ import { TTL, answerCache } from "./cache.mjs";
5
+ import { ConfigError, RedactionError, RequestSizeError, ServiceError, SpendCapError, StateError } from "./errors.mjs";
6
+ import { createLedger } from "./ledger.mjs";
7
+ import { createLimiter } from "./limits.mjs";
8
+ import { providers } from "./providers/index.mjs";
9
+ import { createRedactor, loadRedactor } from "./redaction.mjs";
10
+ import { clock } from "./state.mjs";
11
+
12
+ export { ConfigError, RedactionError, RequestSizeError, ServiceError, SpendCapError, StateError };
13
+ export { privateKeyLines } from "./redaction.mjs";
14
+ export { PINNED_MODEL, MAX_INPUT_TOKENS, MAX_STATE_QUESTION_TOKENS, usdFor, estimateUsd } from "./providers/typesafe.mjs";
15
+
16
+ export function openJev(options = {}) {
17
+ const { env = process.env, notice = () => {}, time = clock, tool } = options;
18
+ if (typeof tool !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(tool)) throw new ConfigError("tool must be a short identifier");
19
+ const config = loadConfig(env);
20
+ const provider = providers[config.provider];
21
+ const toolPerDayUsd = options.spend?.perDayUsd === undefined ? Infinity : amount(options.spend.perDayUsd, "daily ceiling");
22
+ const spend = {
23
+ perRunUsd: amount(options.spend?.perRunUsd ?? config.spend.perRunUsd, "per-run ceiling"),
24
+ perDayUsd: Math.min(toolPerDayUsd, config.spend.perDayUsd)
25
+ };
26
+ const source = keySource(options.key, config, env, provider);
27
+ const neverSend = options.neverSend ?? config.never_send_file;
28
+ const redactor = Object.freeze(neverSend ? loadRedactor(neverSend) : createRedactor());
29
+ const endpoint = provider.endpoint(env);
30
+ const limiter = createLimiter({ dir: path.join(config.stateDir, "limits", provider.name), limits: config.limits, notice, time });
31
+ let identity;
32
+ const credentials = () => {
33
+ if (!identity) {
34
+ const key = source.read();
35
+ identity = {
36
+ key,
37
+ ledger: createLedger({ dir: path.join(config.stateDir, "spend", provider.name), fingerprint: key.fingerprint, tool, perDayUsd: config.spend.perDayUsd, toolPerDayUsd, time })
38
+ };
39
+ }
40
+ return identity;
41
+ };
42
+ const jev = Object.freeze({
43
+ status: source.status,
44
+ config: Object.freeze({ ...config, spend, provider: provider.name, model: provider.model, pinned: provider.pinned, endpoint }),
45
+ redactor,
46
+ notice,
47
+ time,
48
+ remaining: () => credentials().ledger.remaining(),
49
+ // Answers are filed per provider, model, endpoint and never-send list as well as the caller's
50
+ // scope, so a change to any of them starts a fresh file instead of reusing stale answers
51
+ // A floating model can change behind its id, so its answers expire within a day
52
+ cache({ scope, enabled = true, notice: warn = notice } = {}) {
53
+ return answerCache({
54
+ ttl: provider.pinned ? TTL : 86400000,
55
+ stateDir: config.stateDir,
56
+ cacheDir: config.cacheDir,
57
+ scope: { provider: provider.name, model: provider.model, endpoint, neverSend: redactor.fingerprint, caller: scope ?? null },
58
+ enabled,
59
+ notice: warn,
60
+ time
61
+ });
62
+ },
63
+ run({ capUsd = spend.perRunUsd } = {}) {
64
+ amount(capUsd, "run ceiling");
65
+ const budgetCap = Math.min(capUsd, spend.perRunUsd);
66
+ let budget, lease, responder, ready, closing;
67
+ const pending = new Set();
68
+ const initialize = () => ready ??= (async () => {
69
+ const { key, ledger } = credentials();
70
+ lease = await ledger.open(budgetCap);
71
+ budget = createSpendBudget({ capUsd: lease.capUsd, price: provider.price });
72
+ // The ledger reads the committed total inside its lock, so concurrent attempts never
73
+ // persist an older total over a newer one
74
+ const book = () => lease.book(() => budget.committedUsd());
75
+ responder = provider.respond({ key, budget, limiter, book, assertSafe: (body) => redactor.check(body), endpoint, fetchImpl: options.fetch, maxRetries: options.maxRetries, timeoutMs: options.timeoutMs });
76
+ })().catch((error) => { ready = undefined; throw error; });
77
+ return Object.freeze({
78
+ ask(request, { signal } = {}) {
79
+ if (closing) return Promise.reject(new StateError("run is closed"));
80
+ // Own the serialized snapshot before async work can observe caller mutations
81
+ let snapshot;
82
+ try {
83
+ snapshot = JSON.stringify(request);
84
+ redactor.check(snapshot);
85
+ provider.checkSize(JSON.parse(snapshot));
86
+ } catch (error) { return Promise.reject(error); }
87
+ const job = (async () => {
88
+ signal?.throwIfAborted();
89
+ await initialize();
90
+ return responder(JSON.parse(snapshot), { signal });
91
+ })();
92
+ pending.add(job);
93
+ job.then(() => pending.delete(job), () => pending.delete(job));
94
+ return job;
95
+ },
96
+ // Which ceiling bounds this run: its own, or what is left of today's once the run has opened
97
+ summary: () => ({ ...(budget?.summary() ?? { cap_usd: budgetCap, committed_usd: 0, billed_input_tokens: 0, attempts_booked_at_reservation: 0 }), ceiling: lease && lease.capUsd < budgetCap ? "day" : "run" }),
98
+ close() {
99
+ // Closing waits for asks already made, so the ledger records their final cost
100
+ closing ??= (async () => {
101
+ await Promise.allSettled([...pending]);
102
+ if (lease) await lease.close(budget.committedUsd());
103
+ })();
104
+ return closing;
105
+ }
106
+ });
107
+ }
108
+ });
109
+ return jev;
110
+ }
111
+
112
+ export function describeError(error) {
113
+ const safe = [ConfigError, ServiceError, SpendCapError, RedactionError, RequestSizeError, StateError].some((Type) => error instanceof Type);
114
+ return safe ? error.message.replace(/[\r\x1b]/g, " ") : "request failed; check file access and network connectivity (no input or key logged)";
115
+ }
package/lib/ledger.mjs ADDED
@@ -0,0 +1,73 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { SpendCapError, StateError } from "./errors.mjs";
5
+ import { appendRecord, clock, locked, replaceFile } from "./state.mjs";
6
+
7
+ const HOLD_MS = 10 * 60000;
8
+ const day = (now) => new Date(now).toISOString().slice(0, 10);
9
+
10
+ function records(file, now) {
11
+ let text;
12
+ try { text = readFileSync(file, "utf8"); }
13
+ catch (error) { if (error.code === "ENOENT") return new Map(); throw error; }
14
+ const latest = new Map();
15
+ const lines = text.split("\n").filter(Boolean);
16
+ try {
17
+ for (const line of lines) {
18
+ const row = JSON.parse(line);
19
+ if (typeof row.id !== "string" || typeof row.tool !== "string" || typeof row.day !== "string" || !Number.isFinite(row.at) || !Number.isFinite(row.usd) || row.usd < 0 || !Number.isFinite(row.hold) || row.hold < row.usd || typeof row.closed !== "boolean") throw new Error();
20
+ latest.set(row.id, row);
21
+ }
22
+ } catch { throw new StateError("invalid spend ledger; refusing to spend"); }
23
+ // Only today's rows count and only yesterday's runs may still close, so compact the rest away
24
+ // rather than re-reading every past run under the lock
25
+ const live = new Set([day(now), day(now - 86400000)]);
26
+ const kept = [...latest.values()].filter((row) => live.has(row.day));
27
+ if (lines.length - kept.length > Math.max(256, kept.length)) {
28
+ replaceFile(file, kept.map((row) => `${JSON.stringify(row)}\n`).join(""));
29
+ return new Map(kept.map((row) => [row.id, row]));
30
+ }
31
+ return latest;
32
+ }
33
+
34
+ function used(rows, today, now, except, tool) {
35
+ return [...rows.values()].filter((r) => (tool === undefined || r.tool === tool) && r.day === today && r.id !== except).reduce((sum, r) => sum + (!r.closed && now - r.at < HOLD_MS ? r.hold : r.usd), 0);
36
+ }
37
+
38
+ // The key's ceiling counts every tool on the key; a tool's own ceiling can only lower it
39
+ export function createLedger({ dir, fingerprint, tool, perDayUsd, toolPerDayUsd = Infinity, time = clock }) {
40
+ const file = path.join(dir, `${fingerprint}.jsonl`);
41
+ const available = (rows, today, now, except) => Math.min(perDayUsd - used(rows, today, now, except), toolPerDayUsd - used(rows, today, now, except, tool));
42
+ return {
43
+ remaining() {
44
+ return locked(file, () => {
45
+ const now = time.now();
46
+ return Math.max(0, available(records(file, now), day(now), now));
47
+ }, { time });
48
+ },
49
+ async open(capUsd, signal) {
50
+ let row;
51
+ await locked(file, () => {
52
+ const now = time.now();
53
+ row = { id: randomUUID(), tool, day: day(now), at: now, usd: 0, hold: Math.min(capUsd, Math.max(0, available(records(file, now), day(now), now))), closed: false };
54
+ appendRecord(file, row);
55
+ }, { signal, time });
56
+ const update = (total, closed = false) => locked(file, () => {
57
+ const usd = typeof total === "function" ? total() : total;
58
+ const now = time.now();
59
+ const rows = records(file, now);
60
+ if (row.closed) throw new StateError("run is closed");
61
+ // A renewed lease or midnight boundary must re-check other processes' holds
62
+ if (!closed && (day(now) !== row.day || now - row.at >= HOLD_MS)) {
63
+ if (day(now) !== row.day) throw new SpendCapError("UTC day changed; start a new run");
64
+ row.hold = Math.min(row.hold, Math.max(row.usd, available(rows, row.day, now, row.id)));
65
+ }
66
+ if (!closed && usd > row.hold + 1e-12) throw new SpendCapError("daily spend ceiling reached");
67
+ row = { ...row, at: now, usd, hold: closed ? usd : Math.max(usd, row.hold), closed };
68
+ appendRecord(file, row);
69
+ }, { time });
70
+ return { capUsd: row.hold, book: (usd) => update(usd), close: (usd) => update(usd, true) };
71
+ }
72
+ };
73
+ }
package/lib/limits.mjs ADDED
@@ -0,0 +1,101 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { StateError } from "./errors.mjs";
5
+ import { clock, dead, locked, readJson, writeJson } from "./state.mjs";
6
+
7
+ // A slot is held across the SDK's retries and refreshed at each attempt; an attempt lasts at most
8
+ // 30 seconds and the SDK waits at most a minute before the next, so a slot left this long belongs
9
+ // to a process that stopped without releasing it
10
+ const SLOT_MS = 120000;
11
+ // Another process frees a slot without telling this one, so a waiter looks again this often
12
+ const POLL_MS = 50;
13
+
14
+ const localWindows = new Map();
15
+ const mine = new Set();
16
+
17
+ const valid = (state) => Array.isArray(state.starts) && Number.isFinite(state.pausedUntil) && Array.isArray(state.inFlight)
18
+ && state.starts.every((e) => Number.isFinite(e.at) && Number.isFinite(e.tokens) && e.tokens >= 0)
19
+ && state.inFlight.every((e) => Number.isFinite(e.at) && typeof e.token === "string" && typeof e.host === "string" && Number.isInteger(e.pid));
20
+
21
+ // The service counts rate limits per account, not per key, and a key does not reveal its account,
22
+ // so every key on this machine shares one window, one pause and one set of in-flight slots
23
+ export function createLimiter({ dir, limits, notice = () => {}, time = clock }) {
24
+ const file = path.join(dir, "accounts", "default.json");
25
+ const rpm = Math.floor(limits.requestsPerMinute * limits.share);
26
+ const tps = Math.floor(limits.tokensPerSecond * limits.share);
27
+ let local = false;
28
+ const transaction = async (job, signal) => {
29
+ if (!local) {
30
+ try {
31
+ return await locked(file, () => {
32
+ const state = readJson(file, { starts: [], pausedUntil: 0, inFlight: [] });
33
+ if (!valid(state)) throw new StateError("invalid rate-limit state");
34
+ const result = job(state);
35
+ writeJson(file, state);
36
+ return result;
37
+ }, { signal, time });
38
+ } catch (error) {
39
+ if (!["EACCES", "EPERM", "EROFS", "ENOSPC", "ENOTDIR"].includes(error.code)) throw error;
40
+ local = true;
41
+ notice("shared rate-limit state is unwritable; using in-process limits (spend ceilings still apply)");
42
+ }
43
+ }
44
+ if (!localWindows.has(file)) localWindows.set(file, { starts: [], pausedUntil: 0, inFlight: [] });
45
+ return job(localWindows.get(file));
46
+ };
47
+ // Waits for the account's window, then starts one attempt of `tokens` counted tokens. Without
48
+ // `held`, it also waits for an in-flight slot and takes it
49
+ const admit = async (tokens, { held, large = false, signal } = {}) => {
50
+ if (rpm < 1 || tokens > tps) throw new StateError("configured rate limits cannot fit one reserved request; increase DECISION_GATE_RPM or DECISION_GATE_TPS");
51
+ const slot = held ?? { token: randomUUID(), host: os.hostname(), pid: process.pid, large };
52
+ for (;;) {
53
+ signal?.throwIfAborted();
54
+ const wait = await transaction((state) => {
55
+ const now = time.now();
56
+ state.starts = state.starts.filter((e) => e.at > now - 60000);
57
+ state.inFlight = state.inFlight.filter((e) => e.at > now - SLOT_MS && !dead(e, mine));
58
+ if (state.pausedUntil > now) return state.pausedUntil - now;
59
+ let until = now;
60
+ if (state.starts.length >= rpm) until = Math.max(until, state.starts[state.starts.length - rpm].at + 60000);
61
+ const recent = state.starts.filter((e) => e.at > now - 1000);
62
+ let total = recent.reduce((sum, e) => sum + e.tokens, tokens);
63
+ for (const entry of recent) {
64
+ if (total <= tps) break;
65
+ total -= entry.tokens;
66
+ until = Math.max(until, entry.at + 1000);
67
+ }
68
+ if (until > now) return until - now;
69
+ if (!held && (state.inFlight.length >= limits.inFlight || (large && state.inFlight.filter((e) => e.large).length >= limits.largeInFlight))) return POLL_MS;
70
+ state.starts.push({ at: now, tokens });
71
+ state.inFlight = [...state.inFlight.filter((e) => e.token !== slot.token), { ...slot, at: now }];
72
+ mine.add(slot.token);
73
+ return 0;
74
+ }, signal);
75
+ if (!wait) return slot;
76
+ await time.sleep(wait, signal);
77
+ }
78
+ };
79
+ return {
80
+ // Takes an in-flight slot and starts the first attempt of `tokens` counted tokens; `estimated`
81
+ // is its likely real size, which decides whether it is a large request. The slot is held
82
+ // until `release`, and `again` starts each retry on it without waiting for a slot
83
+ async take(tokens, { estimated = tokens, signal } = {}) {
84
+ const slot = await admit(tokens, { large: estimated >= limits.largeRequestTokens, signal });
85
+ let released;
86
+ return {
87
+ again: (retryTokens, { signal: retrySignal } = {}) => admit(retryTokens, { held: slot, signal: retrySignal }),
88
+ release() {
89
+ released ??= transaction((state) => { state.inFlight = state.inFlight.filter((e) => e.token !== slot.token); })
90
+ // A slot that cannot be released expires on its own, so the answer is not lost to it
91
+ .catch(() => {})
92
+ .finally(() => mine.delete(slot.token));
93
+ return released;
94
+ }
95
+ };
96
+ },
97
+ async pause(ms) {
98
+ await transaction((state) => { state.pausedUntil = Math.max(state.pausedUntil, time.now() + ms); });
99
+ }
100
+ };
101
+ }
@@ -0,0 +1,8 @@
1
+ import { typesafe } from "./typesafe.mjs";
2
+ import { vercelAiGateway } from "./vercel-ai-gateway.mjs";
3
+
4
+ // A provider must have a known price and typed probabilities before it can own requests, since
5
+ // the ceilings and callers' thresholds depend on both; state is filed under its name so adding
6
+ // one needs no migration
7
+ export const providers = Object.freeze({ [typesafe.name]: typesafe, [vercelAiGateway.name]: vercelAiGateway });
8
+ export const DEFAULT_PROVIDER = typesafe.name;