@evoclock/pi-agentic-driver 0.9.2 → 0.9.3
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 +78 -54
- package/janus/README.draft.md +210 -0
- package/janus/README.md +196 -0
- package/janus/janus/audit.ts +213 -0
- package/janus/janus/budget.ts +101 -0
- package/janus/janus/budget_breaker.ts +105 -0
- package/janus/janus/config.ts +332 -0
- package/janus/janus/errors.ts +90 -0
- package/janus/janus/evaluate.ts +230 -0
- package/janus/janus/keychain.ts +119 -0
- package/janus/janus/providers.ts +544 -0
- package/janus/janus/schema.ts +344 -0
- package/janus/janus/server.ts +267 -0
- package/janus/janus/single_flight.ts +99 -0
- package/janus/janus/verdict.ts +192 -0
- package/janus/package-lock.json +616 -0
- package/janus/package.json +29 -0
- package/janus/tests/accounting.test.ts +229 -0
- package/janus/tests/allowlist.test.ts +97 -0
- package/janus/tests/config.test.ts +151 -0
- package/janus/tests/errors.test.ts +147 -0
- package/janus/tests/evaluate_pipeline.test.ts +358 -0
- package/janus/tests/helpers.ts +217 -0
- package/janus/tests/providers.test.ts +292 -0
- package/janus/tests/redaction_budget.test.ts +227 -0
- package/janus/tests/schema.test.ts +356 -0
- package/janus/tests/server.test.ts +288 -0
- package/janus/tests/single_flight.test.ts +84 -0
- package/janus/tsconfig.json +19 -0
- package/janus/user.janus.plist +51 -0
- package/package.json +3 -2
- package/scripts/enforcement/router_schemas_pi.js +63 -13
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Audit log (§5, M9 + round-2 retention rule): append-only local log of
|
|
5
|
+
// request id, timestamps, question NAMES (non-sensitive identifiers, never
|
|
6
|
+
// question text or state contents), verdict probabilities, and token usage.
|
|
7
|
+
// 90-day default retention (config-adjustable), size-capped with
|
|
8
|
+
// oldest-first rotation.
|
|
9
|
+
//
|
|
10
|
+
// Retention (review fix #7): enforced for the ACTIVE file as well as rotated
|
|
11
|
+
// files. On start, after each rotation, and on a periodic sweep, records
|
|
12
|
+
// older than the retention window are purged from the active file by
|
|
13
|
+
// rewriting it without them, and rotated files older than the window are
|
|
14
|
+
// deleted. Records older than retention never survive indefinitely.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
closeSync,
|
|
18
|
+
existsSync,
|
|
19
|
+
fstatSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
openSync,
|
|
22
|
+
readSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
statSync,
|
|
25
|
+
unlinkSync,
|
|
26
|
+
writeSync,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import { dirname } from "node:path";
|
|
29
|
+
|
|
30
|
+
export interface AuditEntry {
|
|
31
|
+
requestId: string;
|
|
32
|
+
timestamp: string;
|
|
33
|
+
event: "evaluate" | "evaluate_result" | "evaluate_error";
|
|
34
|
+
/** Non-sensitive question identifiers — never question text. */
|
|
35
|
+
questionNames: string[];
|
|
36
|
+
verdictProbabilities?: Record<string, number>;
|
|
37
|
+
tokenUsage?: { inputTokens?: number | undefined; outputTokens?: number | undefined; totalTokens?: number | undefined };
|
|
38
|
+
errorCode?: string;
|
|
39
|
+
durationMs?: number;
|
|
40
|
+
coalesced?: boolean;
|
|
41
|
+
/** How many provider attempts this request consumed (1 = no failover). */
|
|
42
|
+
providerAttempts?: number;
|
|
43
|
+
/** Provider id that produced the result (or last attempted). */
|
|
44
|
+
provider?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MAX_ROTATED_FILES = 5;
|
|
48
|
+
/** How often the active-file retention sweep runs while the log is open. */
|
|
49
|
+
const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
function entryTimestampMs(line: string): number | undefined {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(line) as { timestamp?: unknown };
|
|
54
|
+
if (typeof parsed.timestamp !== "string") return undefined;
|
|
55
|
+
const ms = Date.parse(parsed.timestamp);
|
|
56
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class AuditLog {
|
|
63
|
+
private fd: number | null = null;
|
|
64
|
+
private readonly activePath: string;
|
|
65
|
+
private lastSweepAt = 0;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly basePath: string,
|
|
69
|
+
private readonly maxBytes: number,
|
|
70
|
+
private readonly retentionDays: number,
|
|
71
|
+
private readonly now: () => Date = () => new Date(),
|
|
72
|
+
) {
|
|
73
|
+
this.activePath = basePath === "stdout" ? "stdout" : `${basePath}.jsonl`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Open (or reopen) the log and apply retention. Call once at startup. */
|
|
77
|
+
start(): void {
|
|
78
|
+
if (this.activePath === "stdout") return;
|
|
79
|
+
mkdirSync(dirname(this.activePath), { recursive: true });
|
|
80
|
+
this.applyRetention();
|
|
81
|
+
this.openFd();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
stop(): void {
|
|
85
|
+
if (this.fd !== null) {
|
|
86
|
+
closeSync(this.fd);
|
|
87
|
+
this.fd = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private openFd(): void {
|
|
92
|
+
this.fd = openSync(this.activePath, "a");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Purge records older than the retention window from the ACTIVE file by
|
|
97
|
+
* rewriting it without them. Rotated-file deletion is handled separately
|
|
98
|
+
* by mtime in applyRetention(). This guarantees active-file records do not
|
|
99
|
+
* survive past retention (review fix #7).
|
|
100
|
+
*/
|
|
101
|
+
private sweepActiveFile(): void {
|
|
102
|
+
if (this.activePath === "stdout") return;
|
|
103
|
+
const cutoff = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
|
|
104
|
+
|
|
105
|
+
let size = 0;
|
|
106
|
+
try {
|
|
107
|
+
size = statSync(this.activePath).size;
|
|
108
|
+
} catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (size === 0) return;
|
|
112
|
+
|
|
113
|
+
// Read the whole active file (bounded by maxBytes + slack), filter, and
|
|
114
|
+
// rewrite in place if anything expired.
|
|
115
|
+
let raw: string;
|
|
116
|
+
const fd = openSync(this.activePath, "r");
|
|
117
|
+
try {
|
|
118
|
+
const buffer = Buffer.alloc(size);
|
|
119
|
+
const read = readSync(fd, buffer, 0, size, 0);
|
|
120
|
+
raw = buffer.toString("utf8", 0, read);
|
|
121
|
+
} finally {
|
|
122
|
+
closeSync(fd);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const lines = raw.split("\n");
|
|
126
|
+
const kept: string[] = [];
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
if (line.length === 0) continue;
|
|
129
|
+
const ts = entryTimestampMs(line);
|
|
130
|
+
if (ts === undefined || ts >= cutoff) kept.push(line);
|
|
131
|
+
}
|
|
132
|
+
if (kept.length === lines.filter((l) => l.length > 0).length) return; // nothing expired
|
|
133
|
+
|
|
134
|
+
const replacement = `${kept.join("\n")}\n`;
|
|
135
|
+
// Rewrite via a temp file + rename for atomicity, then reopen for append.
|
|
136
|
+
const tempPath = `${this.activePath}.sweep`;
|
|
137
|
+
const tempFd = openSync(tempPath, "w");
|
|
138
|
+
try {
|
|
139
|
+
writeSync(tempFd, replacement);
|
|
140
|
+
} finally {
|
|
141
|
+
closeSync(tempFd);
|
|
142
|
+
}
|
|
143
|
+
if (this.fd !== null) {
|
|
144
|
+
closeSync(this.fd);
|
|
145
|
+
this.fd = null;
|
|
146
|
+
}
|
|
147
|
+
renameSync(tempPath, this.activePath);
|
|
148
|
+
this.openFd();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private applyRetention(): void {
|
|
152
|
+
const cutoff = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
|
|
153
|
+
for (let i = 1; i <= MAX_ROTATED_FILES; i += 1) {
|
|
154
|
+
const rotated = `${this.activePath}.${i}`;
|
|
155
|
+
if (!existsSync(rotated)) continue;
|
|
156
|
+
try {
|
|
157
|
+
if (statSync(rotated).mtimeMs < cutoff) unlinkSync(rotated);
|
|
158
|
+
} catch {
|
|
159
|
+
// A missing file mid-scan is fine; retention is best-effort.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this.sweepActiveFile();
|
|
163
|
+
this.lastSweepAt = this.now().getTime();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private rotateIfNeeded(): void {
|
|
167
|
+
if (this.activePath === "stdout" || this.fd === null) return;
|
|
168
|
+
let size = 0;
|
|
169
|
+
try {
|
|
170
|
+
size = fstatSync(this.fd).size;
|
|
171
|
+
} catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (size < this.maxBytes) return;
|
|
175
|
+
|
|
176
|
+
// Oldest-first rotation: .4→.5, .3→.4, … .1→.2, active→.1.
|
|
177
|
+
for (let i = MAX_ROTATED_FILES - 1; i >= 1; i -= 1) {
|
|
178
|
+
const from = `${this.activePath}.${i}`;
|
|
179
|
+
const to = `${this.activePath}.${i + 1}`;
|
|
180
|
+
if (existsSync(from)) {
|
|
181
|
+
try {
|
|
182
|
+
unlinkSync(to);
|
|
183
|
+
} catch {
|
|
184
|
+
/* first shift may have no target */
|
|
185
|
+
}
|
|
186
|
+
renameSync(from, to);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
closeSync(this.fd);
|
|
190
|
+
this.fd = null;
|
|
191
|
+
renameSync(this.activePath, `${this.activePath}.1`);
|
|
192
|
+
this.applyRetention();
|
|
193
|
+
this.openFd();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
append(entry: AuditEntry): void {
|
|
197
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
198
|
+
if (this.activePath === "stdout") {
|
|
199
|
+
process.stdout.write(line);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (this.fd === null) this.openFd();
|
|
203
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
204
|
+
writeSync(this.fd!, line);
|
|
205
|
+
this.rotateIfNeeded();
|
|
206
|
+
// Periodic active-file retention sweep (review fix #7).
|
|
207
|
+
const nowMs = this.now().getTime();
|
|
208
|
+
if (nowMs - this.lastSweepAt >= RETENTION_SWEEP_INTERVAL_MS) {
|
|
209
|
+
this.sweepActiveFile();
|
|
210
|
+
this.lastSweepAt = nowMs;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Budget accounting (§2, §5): the authoritative request budget is 30k tokens
|
|
5
|
+
// for state + questions + framing. Anything exceeding the budget after
|
|
6
|
+
// shaping is rejected with state_too_large — never truncated silently.
|
|
7
|
+
//
|
|
8
|
+
// Conservative token bound (review fix #3): the estimate is byte-based with
|
|
9
|
+
// an explicit worst-case floor of 1 token per 2 UTF-8 bytes. This dominates
|
|
10
|
+
// chars/4 for every encoding shape:
|
|
11
|
+
// - ASCII prose: 1 byte/char → 2 bytes/token (≥ chars/4)
|
|
12
|
+
// - CJK / emoji: 3–4 bytes/char → 1.5–2 bytes/token (chars/4 would
|
|
13
|
+
// undercount a 4-byte emoji as 0.25 tokens; this bound charges ≥ 2)
|
|
14
|
+
// - dense/base64 JSON: 1 byte/char → 2 bytes/token (chars/4 undercounts
|
|
15
|
+
// by 2×; dense JSON tokenizes at ~1–2 bytes/token in practice)
|
|
16
|
+
// The bound can over-reject (e.g. very compressive scripts) but never
|
|
17
|
+
// materially undercounts. It runs BEFORE any external call so oversized
|
|
18
|
+
// requests never reach a provider.
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { stateTooLarge } from "./errors.js";
|
|
22
|
+
import type { EnforcedQuestion } from "./schema.js";
|
|
23
|
+
|
|
24
|
+
/** Conservative worst-case bytes per token (finding #3): 1 token per 2 UTF-8 bytes. */
|
|
25
|
+
export const BYTES_PER_TOKEN_CONSERVATIVE = 2;
|
|
26
|
+
|
|
27
|
+
/** Canonical JSON with object keys sorted (stable digests). */
|
|
28
|
+
export function canonicalJson(value: unknown): string {
|
|
29
|
+
return JSON.stringify(canonicalize(value));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canonicalize(value: unknown): unknown {
|
|
33
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
34
|
+
if (value !== null && typeof value === "object") {
|
|
35
|
+
const sorted: Record<string, unknown> = {};
|
|
36
|
+
for (const k of Object.keys(value).sort()) {
|
|
37
|
+
sorted[k] = canonicalize((value as Record<string, unknown>)[k]);
|
|
38
|
+
}
|
|
39
|
+
return sorted;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Conservative token estimate for a string: UTF-8 bytes / 2, rounded up.
|
|
46
|
+
* Dominates chars/4 for CJK, emoji, and dense encoded content; can
|
|
47
|
+
* over-reject but never materially undercounts.
|
|
48
|
+
*/
|
|
49
|
+
export function estimateStringTokens(text: string): number {
|
|
50
|
+
return Math.ceil(Buffer.byteLength(text, "utf8") / BYTES_PER_TOKEN_CONSERVATIVE);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RequestSize {
|
|
54
|
+
estimatedTokens: number;
|
|
55
|
+
stateBytes: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function estimateRequestTokens(
|
|
59
|
+
state: unknown,
|
|
60
|
+
questions: readonly EnforcedQuestion[],
|
|
61
|
+
budgetTokens: number,
|
|
62
|
+
maxStateBytes: number,
|
|
63
|
+
): RequestSize {
|
|
64
|
+
const stateJson = canonicalJson(state);
|
|
65
|
+
const stateBytes = Buffer.byteLength(stateJson, "utf8");
|
|
66
|
+
if (stateBytes > maxStateBytes) {
|
|
67
|
+
throw stateTooLarge(
|
|
68
|
+
`state is ${stateBytes} bytes; the hard byte ceiling is ${maxStateBytes} — shape to digests/summaries and retry`,
|
|
69
|
+
{ stateBytes, maxStateBytes },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let tokens = estimateStringTokens(stateJson);
|
|
74
|
+
for (const question of questions) {
|
|
75
|
+
tokens += estimateStringTokens(question.question);
|
|
76
|
+
tokens += estimateStringTokens(question.instructions);
|
|
77
|
+
if (question.kind === "choice") {
|
|
78
|
+
tokens += question.choices.reduce((sum, c) => sum + estimateStringTokens(c), 0);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (tokens > budgetTokens) {
|
|
83
|
+
throw stateTooLarge(
|
|
84
|
+
`estimated request size ${tokens} tokens exceeds the ${budgetTokens}-token budget ` +
|
|
85
|
+
`(state + questions + framing); never truncated silently — shape the state down`,
|
|
86
|
+
{ estimatedTokens: tokens, budgetTokens },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { estimatedTokens: tokens, stateBytes };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** evalInputDigest (M9): stable digest of the exact evaluation input. */
|
|
94
|
+
export function evalInputDigest(
|
|
95
|
+
state: unknown,
|
|
96
|
+
questions: readonly EnforcedQuestion[],
|
|
97
|
+
): string {
|
|
98
|
+
const hash = createHash("sha256");
|
|
99
|
+
hash.update(canonicalJson({ state, questions }));
|
|
100
|
+
return hash.digest("hex");
|
|
101
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Daily cost breaker (§5, M10): a daily token/cost ceiling in config; on
|
|
5
|
+
// exceed, /evaluate returns busy with budget_exhausted detail until the
|
|
6
|
+
// window resets. Windows are UTC calendar days (the audit log uses the same
|
|
7
|
+
// day key). Usage arrives from the SDK result after each evaluation; the
|
|
8
|
+
// breaker is updated by the evaluate path.
|
|
9
|
+
|
|
10
|
+
import { busyError } from "./errors.js";
|
|
11
|
+
|
|
12
|
+
export interface UsageRecord {
|
|
13
|
+
inputTokens?: number | undefined;
|
|
14
|
+
outputTokens?: number | undefined;
|
|
15
|
+
totalTokens?: number | undefined;
|
|
16
|
+
costUsd?: number | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BudgetStatus {
|
|
20
|
+
dayKey: string;
|
|
21
|
+
tokensUsed: number;
|
|
22
|
+
costUsdUsed: number;
|
|
23
|
+
tokenCeiling: number;
|
|
24
|
+
costCeilingUsd: number;
|
|
25
|
+
exhausted: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DailyBudget {
|
|
29
|
+
private dayKey = "";
|
|
30
|
+
private tokensUsed = 0;
|
|
31
|
+
private costUsdUsed = 0;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly tokenCeiling: number,
|
|
35
|
+
private readonly costCeilingUsd: number,
|
|
36
|
+
private readonly costPerMtokenUsd: number,
|
|
37
|
+
private readonly now: () => Date = () => new Date(),
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
static dayKeyFor(date: Date): string {
|
|
41
|
+
return date.toISOString().slice(0, 10);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private rollIfNeeded(): void {
|
|
45
|
+
const key = DailyBudget.dayKeyFor(this.now());
|
|
46
|
+
if (key !== this.dayKey) {
|
|
47
|
+
this.dayKey = key;
|
|
48
|
+
this.tokensUsed = 0;
|
|
49
|
+
this.costUsdUsed = 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Record usage after an evaluation completes (success or failure after the
|
|
55
|
+
* gateway accepted the call — retries consume budget too).
|
|
56
|
+
*/
|
|
57
|
+
record(usage: UsageRecord): void {
|
|
58
|
+
this.rollIfNeeded();
|
|
59
|
+
const total =
|
|
60
|
+
usage.totalTokens ??
|
|
61
|
+
(usage.inputTokens !== undefined || usage.outputTokens !== undefined
|
|
62
|
+
? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
|
63
|
+
: 0);
|
|
64
|
+
this.tokensUsed += total;
|
|
65
|
+
this.costUsdUsed +=
|
|
66
|
+
usage.costUsd ?? (total / 1_000_000) * this.costPerMtokenUsd;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Check before dispatch. Throws busy/budget_exhausted when over either ceiling. */
|
|
70
|
+
assertWithinBudget(): void {
|
|
71
|
+
this.rollIfNeeded();
|
|
72
|
+
if (this.tokenCeiling > 0 && this.tokensUsed >= this.tokenCeiling) {
|
|
73
|
+
throw busyError(
|
|
74
|
+
`daily token ceiling of ${this.tokenCeiling} reached; resets at 00:00 UTC`,
|
|
75
|
+
{ budget_exhausted: { kind: "tokens", dayKey: this.dayKey, used: this.tokensUsed } },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (this.costCeilingUsd > 0 && this.costUsdUsed >= this.costCeilingUsd) {
|
|
79
|
+
throw busyError(
|
|
80
|
+
`daily cost ceiling of $${this.costCeilingUsd} reached; resets at 00:00 UTC`,
|
|
81
|
+
{
|
|
82
|
+
budget_exhausted: {
|
|
83
|
+
kind: "cost",
|
|
84
|
+
dayKey: this.dayKey,
|
|
85
|
+
usedUsd: Number(this.costUsdUsed.toFixed(4)),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
status(): BudgetStatus {
|
|
93
|
+
this.rollIfNeeded();
|
|
94
|
+
return {
|
|
95
|
+
dayKey: this.dayKey,
|
|
96
|
+
tokensUsed: this.tokensUsed,
|
|
97
|
+
costUsdUsed: Number(this.costUsdUsed.toFixed(4)),
|
|
98
|
+
tokenCeiling: this.tokenCeiling,
|
|
99
|
+
costCeilingUsd: this.costCeilingUsd,
|
|
100
|
+
exhausted:
|
|
101
|
+
(this.tokenCeiling > 0 && this.tokensUsed >= this.tokenCeiling) ||
|
|
102
|
+
(this.costCeilingUsd > 0 && this.costUsdUsed >= this.costCeilingUsd),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|