daemon-cost.mjs
171 lines 7.3 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Cost tracking for the daemon: token estimation, daily cost accumulation,
3 * and cap-enforcement helpers. Phase F of the Daemon Consolidation Spec.
4 *
5 * The cost record lives in {data_dir}/daemon-cost.json, keyed by UTC date
6 * (YYYY-MM-DD). The daily counter resets automatically each calendar day
7 * because only the key matching today's date is ever read. Old keys are
8 * ignored (they remain in the file but are never summed).
9 *
10 * Default model rates (gpt-4o-mini):
11 * $0.15 / 1M input tokens
12 * $0.60 / 1M output tokens
13 *
14 * All public functions accept an optional `rates` parameter so that tests
15 * can use exact, deterministic values without depending on specific dollar
16 * amounts tied to any particular model.
17 *
18 * Note on concurrency: reads and writes to daemon-cost.json are synchronous
19 * and sequential within a single Node.js process (the daemon is single-
20 * threaded). No locking is needed.
21 *
22 * Exports:
23 * DEFAULT_RATES — default per-token USD rates
24 * estimateTokens — char-count / 4 heuristic (swap for exact counter)
25 * computeCallCost — USD cost for one LLM call (opts + raw response)
26 * getCostFilePath — resolve {data_dir}/daemon-cost.json from config
27 * utcDateString — injectable today-UTC helper (YYYY-MM-DD)
28 * getDailyCost — read accumulated cost for a UTC date
29 * recordCallCost — add a cost amount to today's running total
30 * resetDailyCost — write an empty cost record (used by tests / manual resets)
31 */
32
33 import fs from 'fs';
34 import path from 'path';
35
36 // ── Default rates ─────────────────────────────────────────────────────────────
37
38 export const DEFAULT_RATES = {
39 /** USD per input token (gpt-4o-mini: $0.15 / 1M) */
40 input_per_token: 0.15 / 1_000_000,
41 /** USD per output token (gpt-4o-mini: $0.60 / 1M) */
42 output_per_token: 0.60 / 1_000_000,
43 };
44
45 // ── Token estimation ──────────────────────────────────────────────────────────
46
47 /**
48 * Estimate the number of tokens in a text string using a 4-chars-per-token
49 * heuristic. Designed as a thin wrapper so it can be replaced with an exact
50 * counter (e.g. tiktoken) without changing any call sites.
51 *
52 * @param {string} text
53 * @returns {number} estimated token count (>= 0, integer)
54 */
55 export function estimateTokens(text) {
56 if (!text || typeof text !== 'string') return 0;
57 return Math.ceil(text.length / 4);
58 }
59
60 // ── Per-call cost computation ─────────────────────────────────────────────────
61
62 /**
63 * Compute the USD cost for a single LLM call from the call options and
64 * the raw response string.
65 *
66 * Input tokens = estimateTokens(opts.system + opts.user)
67 * Output tokens = estimateTokens(rawResponse)
68 *
69 * @param {{ system?: string, user?: string }} opts — LLM call options
70 * @param {string} rawResponse — raw LLM response text
71 * @param {{ input_per_token?: number, output_per_token?: number }} [rates]
72 * — overrides DEFAULT_RATES; callers may supply any finite positive values
73 * @returns {number} USD cost >= 0
74 */
75 export function computeCallCost(opts, rawResponse, rates) {
76 const r = { ...DEFAULT_RATES, ...(rates ?? {}) };
77 const inputText = (opts?.system ?? '') + (opts?.user ?? '');
78 const inputTokens = estimateTokens(inputText);
79 const outputTokens = estimateTokens(String(rawResponse ?? ''));
80 return inputTokens * r.input_per_token + outputTokens * r.output_per_token;
81 }
82
83 // ── File path helper ──────────────────────────────────────────────────────────
84
85 /**
86 * Absolute path of the cost tracking file.
87 * @param {object} config — loadConfig() result
88 * @returns {string}
89 */
90 export function getCostFilePath(config) {
91 return path.join(config.data_dir, 'daemon-cost.json');
92 }
93
94 // ── UTC date helper ───────────────────────────────────────────────────────────
95
96 /**
97 * Return today's UTC date as a YYYY-MM-DD string.
98 * Exported so callers can inject a different `now` for deterministic tests.
99 *
100 * @param {Date} [now] — defaults to new Date()
101 * @returns {string} e.g. "2026-04-05"
102 */
103 export function utcDateString(now = new Date()) {
104 return now.toISOString().slice(0, 10);
105 }
106
107 // ── Daily cost read ───────────────────────────────────────────────────────────
108
109 /**
110 * Read the accumulated USD cost for a given UTC calendar date from the cost
111 * file. Returns 0 when the file is missing, cannot be parsed, or has no
112 * entry for the requested date.
113 *
114 * @param {object} config — loadConfig() result
115 * @param {string} [date] — YYYY-MM-DD; defaults to today UTC
116 * @returns {number} total USD cost for that date (>= 0)
117 */
118 export function getDailyCost(config, date) {
119 const key = date ?? utcDateString();
120 try {
121 const raw = fs.readFileSync(getCostFilePath(config), 'utf8');
122 const data = JSON.parse(raw);
123 return typeof data[key] === 'number' && data[key] >= 0 ? data[key] : 0;
124 } catch {
125 return 0;
126 }
127 }
128
129 // ── Daily cost write ──────────────────────────────────────────────────────────
130
131 /**
132 * Add `costUsd` to the running total for the given date in the cost file.
133 * Creates parent directories and the file itself if they do not exist.
134 * Silently ignores zero or negative amounts (not an error, just a no-op).
135 *
136 * @param {object} config — loadConfig() result
137 * @param {number} costUsd — amount to add (ignored if <= 0)
138 * @param {string} [date] — YYYY-MM-DD; defaults to today UTC
139 */
140 export function recordCallCost(config, costUsd, date) {
141 if (typeof costUsd !== 'number' || costUsd <= 0) return;
142
143 const key = date ?? utcDateString();
144 const filePath = getCostFilePath(config);
145 fs.mkdirSync(path.dirname(filePath), { recursive: true });
146
147 let data = {};
148 try {
149 data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
150 if (typeof data !== 'object' || data === null || Array.isArray(data)) data = {};
151 } catch {
152 // file missing or corrupt — start fresh
153 }
154
155 data[key] = (typeof data[key] === 'number' && data[key] >= 0 ? data[key] : 0) + costUsd;
156 fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
157 }
158
159 // ── Daily cost reset ──────────────────────────────────────────────────────────
160
161 /**
162 * Reset the daily cost record by writing an empty JSON object to the cost
163 * file. Intended for tests and manual operator resets.
164 *
165 * @param {object} config — loadConfig() result
166 */
167 export function resetDailyCost(config) {
168 const filePath = getCostFilePath(config);
169 fs.mkdirSync(path.dirname(filePath), { recursive: true });
170 fs.writeFileSync(filePath, JSON.stringify({}), 'utf8');
171 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago