daemon-cost.test.mjs
345 lines 12.6 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tests for lib/daemon-cost.mjs — Phase F: Cost Tracking and Guardrails.
3 *
4 * Covers:
5 * 1. estimateTokens — empty input, short text, long text, non-string
6 * 2. computeCallCost — default rates, custom rates, empty inputs, zero response
7 * 3. getCostFilePath — correct path resolution
8 * 4. utcDateString — returns YYYY-MM-DD format, injectable date
9 * 5. getDailyCost — missing file, missing date key, existing entry, other-date isolation
10 * 6. recordCallCost — creates file, accumulates, ignores zero/negative, handles corrupt file
11 * 7. resetDailyCost — writes empty object, subsequent getDailyCost returns 0
12 *
13 * All filesystem I/O uses a temp directory. No LLM calls. No network access.
14 */
15
16 import { describe, it, before, after } from 'node:test';
17 import assert from 'node:assert';
18 import fs from 'fs';
19 import path from 'path';
20 import os from 'os';
21
22 import {
23 DEFAULT_RATES,
24 estimateTokens,
25 computeCallCost,
26 getCostFilePath,
27 utcDateString,
28 getDailyCost,
29 recordCallCost,
30 resetDailyCost,
31 } from '../lib/daemon-cost.mjs';
32
33 // ── Test fixtures ─────────────────────────────────────────────────────────────
34
35 let tmpDir;
36
37 before(() => {
38 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-cost-test-'));
39 });
40
41 after(() => {
42 fs.rmSync(tmpDir, { recursive: true, force: true });
43 });
44
45 function makeConfig(suffix = '') {
46 const dataDir = path.join(
47 tmpDir,
48 `data-${Date.now()}-${suffix || Math.random().toString(36).slice(2)}`,
49 );
50 fs.mkdirSync(dataDir, { recursive: true });
51 return { data_dir: dataDir };
52 }
53
54 // ── 1. estimateTokens ─────────────────────────────────────────────────────────
55
56 describe('estimateTokens', () => {
57 it('returns 0 for empty string', () => {
58 assert.strictEqual(estimateTokens(''), 0);
59 });
60
61 it('returns 0 for null', () => {
62 assert.strictEqual(estimateTokens(null), 0);
63 });
64
65 it('returns 0 for undefined', () => {
66 assert.strictEqual(estimateTokens(undefined), 0);
67 });
68
69 it('returns 0 for non-string number', () => {
70 assert.strictEqual(estimateTokens(42), 0);
71 });
72
73 it('returns ceil(length / 4) for a 4-char string', () => {
74 assert.strictEqual(estimateTokens('abcd'), 1);
75 });
76
77 it('returns ceil(length / 4) for a 5-char string', () => {
78 assert.strictEqual(estimateTokens('abcde'), 2);
79 });
80
81 it('returns ceil(length / 4) for a 16-char string', () => {
82 assert.strictEqual(estimateTokens('abcdefghijklmnop'), 4);
83 });
84
85 it('returns ceil(length / 4) for a 1-char string', () => {
86 assert.strictEqual(estimateTokens('x'), 1);
87 });
88
89 it('result is always a positive integer for non-empty strings', () => {
90 const text = 'Hello, world! This is a test of the token estimator.';
91 const result = estimateTokens(text);
92 assert.strictEqual(typeof result, 'number');
93 assert(Number.isInteger(result));
94 assert(result > 0);
95 });
96 });
97
98 // ── 2. computeCallCost ────────────────────────────────────────────────────────
99
100 describe('computeCallCost', () => {
101 it('returns a non-negative number', () => {
102 const cost = computeCallCost(
103 { system: 'You are a helper.', user: 'What is 2+2?' },
104 '4',
105 );
106 assert(typeof cost === 'number');
107 assert(cost >= 0);
108 });
109
110 it('returns 0 for empty opts and empty response', () => {
111 assert.strictEqual(computeCallCost({}, ''), 0);
112 });
113
114 it('returns 0 for null opts and null response', () => {
115 assert.strictEqual(computeCallCost(null, null), 0);
116 });
117
118 it('uses DEFAULT_RATES when no rates supplied', () => {
119 const system = 'a'.repeat(4); // 1 input token
120 const user = '';
121 const response = 'b'.repeat(4); // 1 output token
122 const cost = computeCallCost({ system, user }, response);
123 const expected =
124 1 * DEFAULT_RATES.input_per_token + 1 * DEFAULT_RATES.output_per_token;
125 assert(Math.abs(cost - expected) < 1e-12);
126 });
127
128 it('respects custom rates and produces exact values', () => {
129 // system = 8 chars → 2 input tokens; response = 4 chars → 1 output token
130 const system = 'a'.repeat(8);
131 const response = 'b'.repeat(4);
132 const rates = { input_per_token: 0.01, output_per_token: 0.02 };
133 const cost = computeCallCost({ system }, response, rates);
134 // 2 * 0.01 + 1 * 0.02 = 0.04
135 assert(Math.abs(cost - 0.04) < 1e-10);
136 });
137
138 it('handles opts with only system (no user)', () => {
139 const cost = computeCallCost({ system: 'test' }, 'ok');
140 assert(cost > 0);
141 });
142
143 it('handles opts with only user (no system)', () => {
144 const cost = computeCallCost({ user: 'hello' }, 'response');
145 assert(cost > 0);
146 });
147
148 it('partial rate override keeps the un-overridden default', () => {
149 // Override only output rate; input should remain DEFAULT_RATES.input_per_token
150 const system = 'a'.repeat(4); // 1 input token
151 const response = 'b'.repeat(4); // 1 output token
152 const rates = { output_per_token: 1.0 }; // $1 per output token (absurd, but deterministic)
153 const cost = computeCallCost({ system }, response, rates);
154 const expected = 1 * DEFAULT_RATES.input_per_token + 1 * 1.0;
155 assert(Math.abs(cost - expected) < 1e-12);
156 });
157 });
158
159 // ── 3. getCostFilePath ────────────────────────────────────────────────────────
160
161 describe('getCostFilePath', () => {
162 it('returns {data_dir}/daemon-cost.json', () => {
163 const config = makeConfig();
164 assert.strictEqual(
165 getCostFilePath(config),
166 path.join(config.data_dir, 'daemon-cost.json'),
167 );
168 });
169 });
170
171 // ── 4. utcDateString ──────────────────────────────────────────────────────────
172
173 describe('utcDateString', () => {
174 it('returns a YYYY-MM-DD formatted string', () => {
175 const d = utcDateString();
176 assert.match(d, /^\d{4}-\d{2}-\d{2}$/);
177 });
178
179 it('accepts an injectable Date for deterministic tests', () => {
180 const d = utcDateString(new Date('2026-04-05T15:30:00Z'));
181 assert.strictEqual(d, '2026-04-05');
182 });
183
184 it('handles UTC midnight correctly', () => {
185 const d = utcDateString(new Date('2026-01-01T00:00:00Z'));
186 assert.strictEqual(d, '2026-01-01');
187 });
188 });
189
190 // ── 5. getDailyCost ───────────────────────────────────────────────────────────
191
192 describe('getDailyCost', () => {
193 it('returns 0 when cost file does not exist', () => {
194 const config = makeConfig('no-file');
195 assert.strictEqual(getDailyCost(config), 0);
196 });
197
198 it('returns 0 when the file has no entry for the requested date', () => {
199 const config = makeConfig('missing-key');
200 resetDailyCost(config);
201 assert.strictEqual(getDailyCost(config, '2020-01-01'), 0);
202 });
203
204 it('returns the stored value for the requested date', () => {
205 const config = makeConfig('existing-entry');
206 const date = '2026-04-05';
207 recordCallCost(config, 0.0123, date);
208 assert(Math.abs(getDailyCost(config, date) - 0.0123) < 1e-10);
209 });
210
211 it('does NOT return cost recorded for a different date', () => {
212 const config = makeConfig('date-isolation');
213 recordCallCost(config, 0.50, '2026-04-04'); // yesterday
214 assert.strictEqual(getDailyCost(config, '2026-04-05'), 0);
215 });
216
217 it('defaults to today UTC when no date argument is supplied', () => {
218 const config = makeConfig('today-default');
219 const today = utcDateString();
220 recordCallCost(config, 0.007, today);
221 assert(getDailyCost(config) > 0);
222 });
223
224 it('returns 0 for a corrupt cost file', () => {
225 const config = makeConfig('corrupt');
226 fs.writeFileSync(getCostFilePath(config), 'NOT VALID JSON', 'utf8');
227 assert.strictEqual(getDailyCost(config, '2026-04-05'), 0);
228 });
229
230 it('returns 0 when the file contains a non-numeric value for the date key', () => {
231 const config = makeConfig('non-numeric');
232 fs.writeFileSync(
233 getCostFilePath(config),
234 JSON.stringify({ '2026-04-05': 'oops' }),
235 'utf8',
236 );
237 assert.strictEqual(getDailyCost(config, '2026-04-05'), 0);
238 });
239 });
240
241 // ── 6. recordCallCost ─────────────────────────────────────────────────────────
242
243 describe('recordCallCost', () => {
244 it('creates the cost file when it does not exist', () => {
245 const config = makeConfig('create-file');
246 const filePath = getCostFilePath(config);
247 assert(!fs.existsSync(filePath));
248 recordCallCost(config, 0.001, '2026-04-05');
249 assert(fs.existsSync(filePath));
250 });
251
252 it('creates parent directories as needed', () => {
253 const nested = path.join(tmpDir, 'deep', 'nested', 'data-dir');
254 fs.mkdirSync(nested, { recursive: true });
255 const config = { data_dir: nested };
256 recordCallCost(config, 0.005, '2026-04-05');
257 assert(fs.existsSync(getCostFilePath(config)));
258 });
259
260 it('accumulates cost across multiple calls on the same date', () => {
261 const config = makeConfig('accumulate');
262 const date = '2026-04-05';
263 recordCallCost(config, 0.010, date);
264 recordCallCost(config, 0.005, date);
265 recordCallCost(config, 0.003, date);
266 assert(Math.abs(getDailyCost(config, date) - 0.018) < 1e-10);
267 });
268
269 it('records costs for different dates independently', () => {
270 const config = makeConfig('multi-date');
271 recordCallCost(config, 0.10, '2026-04-04');
272 recordCallCost(config, 0.20, '2026-04-05');
273 assert(Math.abs(getDailyCost(config, '2026-04-04') - 0.10) < 1e-10);
274 assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.20) < 1e-10);
275 });
276
277 it('ignores zero cost (no-op)', () => {
278 const config = makeConfig('zero-cost');
279 recordCallCost(config, 0.05, '2026-04-05');
280 recordCallCost(config, 0, '2026-04-05');
281 assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.05) < 1e-10);
282 });
283
284 it('ignores negative cost (no-op)', () => {
285 const config = makeConfig('negative-cost');
286 recordCallCost(config, 0.05, '2026-04-05');
287 recordCallCost(config, -0.01, '2026-04-05');
288 assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.05) < 1e-10);
289 });
290
291 it('ignores non-numeric cost (no-op)', () => {
292 const config = makeConfig('non-numeric-cost');
293 recordCallCost(config, 0.05, '2026-04-05');
294 recordCallCost(config, 'abc', '2026-04-05');
295 assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.05) < 1e-10);
296 });
297
298 it('recovers gracefully from a corrupt cost file by starting fresh', () => {
299 const config = makeConfig('corrupt-recover');
300 fs.writeFileSync(getCostFilePath(config), '{ broken json', 'utf8');
301 assert.doesNotThrow(() => recordCallCost(config, 0.01, '2026-04-05'));
302 assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.01) < 1e-10);
303 });
304
305 it('defaults to today UTC when no date argument is supplied', () => {
306 const config = makeConfig('today-default-record');
307 const today = utcDateString();
308 recordCallCost(config, 0.042);
309 assert(Math.abs(getDailyCost(config, today) - 0.042) < 1e-10);
310 });
311 });
312
313 // ── 7. resetDailyCost ─────────────────────────────────────────────────────────
314
315 describe('resetDailyCost', () => {
316 it('writes an empty JSON object to the cost file', () => {
317 const config = makeConfig('reset-writes');
318 recordCallCost(config, 0.99, '2026-04-05');
319 resetDailyCost(config);
320 const raw = fs.readFileSync(getCostFilePath(config), 'utf8');
321 assert.deepStrictEqual(JSON.parse(raw), {});
322 });
323
324 it('getDailyCost returns 0 after reset', () => {
325 const config = makeConfig('reset-zero');
326 recordCallCost(config, 0.123, '2026-04-05');
327 resetDailyCost(config);
328 assert.strictEqual(getDailyCost(config, '2026-04-05'), 0);
329 });
330
331 it('creates the file if it does not exist', () => {
332 const config = makeConfig('reset-create');
333 assert(!fs.existsSync(getCostFilePath(config)));
334 assert.doesNotThrow(() => resetDailyCost(config));
335 assert(fs.existsSync(getCostFilePath(config)));
336 });
337
338 it('subsequent recordCallCost works correctly after reset', () => {
339 const config = makeConfig('reset-then-record');
340 recordCallCost(config, 10.0, '2026-04-05');
341 resetDailyCost(config);
342 recordCallCost(config, 0.007, '2026-04-05');
343 assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.007) < 1e-10);
344 });
345 });
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