agent-accelerator 0.5.2 → 0.5.4
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 +117 -32
- package/examples/prompts/SYSTEM_PROMPT_AGENT.md +14 -20
- package/package.json +1 -1
- package/src/agent/agent.ts +355 -38
- package/src/agent/context.ts +3 -1
- package/src/agent/delegation.ts +208 -524
- package/src/agent/loop.ts +139 -18
- package/src/agent/subagents-tool.ts +87 -0
- package/src/agent/subagents.md +31 -0
- package/src/agent/turns.ts +60 -0
- package/src/agent/worker.ts +38 -30
- package/src/agent/workers.ts +405 -0
- package/src/index.ts +5 -3
- package/src/models/catalog-cache.ts +3 -10
- package/src/scheduler/scheduler.ts +2 -0
- package/src/scheduler/store.ts +34 -21
- package/src/session/journal.ts +212 -0
- package/src/session/store.ts +203 -90
- package/src/session/tool-log.ts +62 -0
- package/src/session/usage.ts +201 -0
- package/src/telemetry-file.ts +4 -1
- package/src/tools/executor.ts +72 -15
- package/src/tools/subagent-result.ts +1 -1
- package/src/tools/tool.ts +0 -6
- package/src/types/agent.ts +45 -6
- package/src/types/message.ts +4 -0
- package/src/types/response.ts +2 -0
- package/src/types/tool.ts +19 -7
- package/src/utils/durable.ts +218 -0
- package/src/utils/headers.ts +1 -1
- package/src/utils/retry.ts +15 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only step log for a persisted session (dir mode). `session.json` stays the snapshot; every step after it
|
|
3
|
+
* adds ONE short line to `journal.ndjson` instead of rewriting the whole file, so a long chat costs the same per step
|
|
4
|
+
* as a short one. When history changes any other way (rollback, repair, reset, import) or the journal grows past a
|
|
5
|
+
* limit, a fresh snapshot replaces both. The snapshot carries an `epoch` id and the journal header repeats it, so a
|
|
6
|
+
* journal left over from before a snapshot is ignored on load (see `replayJournal` in `store.ts`).
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import type { Message, ContentPart } from "../types/message.ts";
|
|
12
|
+
import { writeFileDurable } from "../utils/durable.ts";
|
|
13
|
+
import {
|
|
14
|
+
JOURNAL_FILE,
|
|
15
|
+
buildSessionData,
|
|
16
|
+
embeddedMedia,
|
|
17
|
+
quarantineIfCorrupt,
|
|
18
|
+
serializeSession,
|
|
19
|
+
stripRedundantRawArguments,
|
|
20
|
+
type SessionAgentLike,
|
|
21
|
+
type SessionPending,
|
|
22
|
+
type SessionPendingPrompt,
|
|
23
|
+
type SessionToolRecord,
|
|
24
|
+
} from "./store.ts";
|
|
25
|
+
|
|
26
|
+
/** A journal past this many lines is folded into a fresh snapshot. */
|
|
27
|
+
const MAX_JOURNAL_LINES = 500;
|
|
28
|
+
/** A journal past this many bytes is folded into a fresh snapshot. */
|
|
29
|
+
const MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
const EMPTY_PENDING = JSON.stringify({ steer: [], queue: [] });
|
|
32
|
+
|
|
33
|
+
// One `tool` journal line; the result is saved as given (tool results are JSON-safe by the time the executor returns them); in: a finished tool call, out: the journal.
|
|
34
|
+
function toolLine(t: SessionToolRecord): string {
|
|
35
|
+
return JSON.stringify({ t: "tool", call: t.id, name: t.name, result: t.result, ...(t.isError ? { isError: true } : {}), ...(t.stopped ? { stopped: true } : {}) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Persists one session folder (`sessions/<id>/`) step by step. */
|
|
39
|
+
export class SessionJournal {
|
|
40
|
+
/** Epoch of the snapshot the journal belongs to; unset until the first snapshot (and after a failed append). */
|
|
41
|
+
private epoch: string | undefined;
|
|
42
|
+
/** How many messages are on disk, and the last one (identity check: history only grew since the last sync). */
|
|
43
|
+
private count = 0;
|
|
44
|
+
private last: Message | undefined;
|
|
45
|
+
private lines = 0;
|
|
46
|
+
private bytes = 0;
|
|
47
|
+
/** JSON of the last written non-message state (config, system prompt, worker traces). */
|
|
48
|
+
private meta = "";
|
|
49
|
+
/** How many run marks are on disk, and the last one. */
|
|
50
|
+
private runCount = 0;
|
|
51
|
+
private lastRun: unknown;
|
|
52
|
+
/** JSON of the last journaled waiting prompts. */
|
|
53
|
+
private pending = EMPTY_PENDING;
|
|
54
|
+
/** JSON of the last journaled spending totals. */
|
|
55
|
+
private usage = "";
|
|
56
|
+
/** Run ids whose ending is on disk. */
|
|
57
|
+
private readonly endsOnDisk = new Set<string>();
|
|
58
|
+
/** How many of the current run's finished tool results are on disk, and the last one. */
|
|
59
|
+
private toolCount = 0;
|
|
60
|
+
private lastTool: unknown;
|
|
61
|
+
/** Disk form of each message already prepared (media replaced by `media/` files, redundant raw arguments dropped). */
|
|
62
|
+
private readonly onDisk = new WeakMap<Message, Message>();
|
|
63
|
+
/** Same for waiting prompts made of content parts. */
|
|
64
|
+
private readonly promptOnDisk = new WeakMap<ContentPart[], ContentPart[]>();
|
|
65
|
+
|
|
66
|
+
constructor(readonly dir: string) {}
|
|
67
|
+
|
|
68
|
+
// Brings the folder up to date with the agent: appends new messages and changed state, or writes a fresh snapshot when history changed another way; in: Agent.doPersist after every step, out: files in this.dir.
|
|
69
|
+
sync(agent: SessionAgentLike): void {
|
|
70
|
+
const messages = agent.context.messages;
|
|
71
|
+
const runs = agent.runMarks ?? [];
|
|
72
|
+
const grew =
|
|
73
|
+
this.epoch !== undefined &&
|
|
74
|
+
messages.length >= this.count &&
|
|
75
|
+
(this.count === 0 || messages[this.count - 1] === this.last) &&
|
|
76
|
+
runs.length >= this.runCount &&
|
|
77
|
+
(this.runCount === 0 || runs[this.runCount - 1] === this.lastRun);
|
|
78
|
+
if (!grew) {
|
|
79
|
+
this.snapshot(agent);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const records: string[] = [];
|
|
83
|
+
for (let i = this.count; i < messages.length; i++) records.push(JSON.stringify({ t: "msg", i, m: this.toDisk(messages[i]!) }));
|
|
84
|
+
for (let r = this.runCount; r < runs.length; r++) records.push(JSON.stringify({ t: "run", id: runs[r]!.id, i: runs[r]!.i, at: runs[r]!.at }));
|
|
85
|
+
for (const mark of runs) {
|
|
86
|
+
if (mark.end && !this.endsOnDisk.has(mark.id)) records.push(JSON.stringify({ t: "end", id: mark.id, status: mark.end }));
|
|
87
|
+
}
|
|
88
|
+
const tools = agent.toolRecords ?? [];
|
|
89
|
+
const firstNewTool = this.toolCount > 0 && tools[this.toolCount - 1] === this.lastTool ? this.toolCount : 0; // a new run starts the list over
|
|
90
|
+
for (let k = firstNewTool; k < tools.length; k++) records.push(toolLine(tools[k]!));
|
|
91
|
+
const meta = this.metaOf(agent);
|
|
92
|
+
if (meta !== this.meta) records.push(`{"t":"meta","m":${meta}}`);
|
|
93
|
+
const pending = this.pendingOf(agent);
|
|
94
|
+
if (pending !== this.pending) records.push(`{"t":"pending","p":${pending}}`);
|
|
95
|
+
const usage = this.usageOf(agent);
|
|
96
|
+
if (usage !== this.usage) records.push(`{"t":"usage","u":${usage}}`);
|
|
97
|
+
if (records.length === 0) return;
|
|
98
|
+
const text = records.join("\n") + "\n";
|
|
99
|
+
try {
|
|
100
|
+
const fd = fs.openSync(path.join(this.dir, JOURNAL_FILE), "a", 0o600);
|
|
101
|
+
try {
|
|
102
|
+
fs.writeFileSync(fd, text);
|
|
103
|
+
fs.fsyncSync(fd);
|
|
104
|
+
} finally {
|
|
105
|
+
fs.closeSync(fd);
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
this.epoch = undefined; // a half-written line may be on disk: the next sync starts over with a snapshot
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
this.count = messages.length;
|
|
112
|
+
this.last = messages[messages.length - 1];
|
|
113
|
+
this.meta = meta;
|
|
114
|
+
this.runCount = runs.length;
|
|
115
|
+
this.lastRun = runs[runs.length - 1];
|
|
116
|
+
this.pending = pending;
|
|
117
|
+
this.usage = usage;
|
|
118
|
+
this.toolCount = tools.length;
|
|
119
|
+
this.lastTool = tools[tools.length - 1];
|
|
120
|
+
for (const mark of runs) if (mark.end) this.endsOnDisk.add(mark.id);
|
|
121
|
+
this.lines += records.length;
|
|
122
|
+
this.bytes += text.length;
|
|
123
|
+
if (this.lines >= MAX_JOURNAL_LINES || this.bytes >= MAX_JOURNAL_BYTES) this.snapshot(agent);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Writes `session.json` (new epoch) and then restarts the journal under that epoch; a crash in between leaves a journal with the old epoch, which load ignores; in: sync, out: both files.
|
|
127
|
+
private snapshot(agent: SessionAgentLike): void {
|
|
128
|
+
const messages = agent.context.messages;
|
|
129
|
+
const epoch = randomUUID();
|
|
130
|
+
const target = path.join(this.dir, "session.json");
|
|
131
|
+
if (this.epoch === undefined) quarantineIfCorrupt(target);
|
|
132
|
+
const data = buildSessionData({ ...agent, context: { ...agent.context, messages: messages.map((m) => this.toDisk(m)) } }, null);
|
|
133
|
+
writeFileDurable(target, serializeSession({ ...data, epoch }) + "\n");
|
|
134
|
+
const pending = this.pendingOf(agent);
|
|
135
|
+
const tools = agent.toolRecords ?? [];
|
|
136
|
+
// Waiting prompts and the current run's finished tool results live only in the journal, so a new journal starts with them.
|
|
137
|
+
const header =
|
|
138
|
+
JSON.stringify({ t: "base", e: epoch }) + "\n" + (pending === EMPTY_PENDING ? "" : `{"t":"pending","p":${pending}}\n`) + tools.map((t) => toolLine(t) + "\n").join("");
|
|
139
|
+
writeFileDurable(path.join(this.dir, JOURNAL_FILE), header, { backup: false });
|
|
140
|
+
this.epoch = epoch;
|
|
141
|
+
this.pending = pending;
|
|
142
|
+
this.usage = this.usageOf(agent); // the snapshot holds it
|
|
143
|
+
this.runs(agent);
|
|
144
|
+
this.count = messages.length;
|
|
145
|
+
this.last = messages[messages.length - 1];
|
|
146
|
+
this.lines = (pending === EMPTY_PENDING ? 0 : 1) + tools.length;
|
|
147
|
+
this.toolCount = tools.length;
|
|
148
|
+
this.lastTool = tools[tools.length - 1];
|
|
149
|
+
this.endsOnDisk.clear();
|
|
150
|
+
for (const mark of agent.runMarks ?? []) if (mark.end) this.endsOnDisk.add(mark.id); // the snapshot holds them
|
|
151
|
+
this.bytes = 0;
|
|
152
|
+
this.meta = this.metaOf(agent);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Remembers which run marks the snapshot already holds; in: snapshot, out: the next sync's "which marks are new".
|
|
156
|
+
private runs(agent: SessionAgentLike): void {
|
|
157
|
+
const runs = agent.runMarks ?? [];
|
|
158
|
+
this.runCount = runs.length;
|
|
159
|
+
this.lastRun = runs[runs.length - 1];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// JSON of the waiting prompts with media moved to files; in: the agent's steer inbox + follow-up queue, out: change detection + the `pending` record.
|
|
163
|
+
private pendingOf(agent: SessionAgentLike): string {
|
|
164
|
+
const waiting = agent.pendingPrompts?.();
|
|
165
|
+
if (!waiting || waiting.steer.length + waiting.queue.length === 0) return EMPTY_PENDING;
|
|
166
|
+
const parts = (prompt: ContentPart[]): ContentPart[] => {
|
|
167
|
+
let disk = this.promptOnDisk.get(prompt);
|
|
168
|
+
if (!disk) this.promptOnDisk.set(prompt, (disk = this.toDisk({ role: "user", content: prompt }).content as ContentPart[]));
|
|
169
|
+
return disk;
|
|
170
|
+
};
|
|
171
|
+
const slim = (list: SessionPendingPrompt[]): SessionPendingPrompt[] =>
|
|
172
|
+
list.map((w) => ({ ...w, prompt: typeof w.prompt === "string" ? w.prompt : parts(w.prompt) }));
|
|
173
|
+
return JSON.stringify({ steer: slim(waiting.steer), queue: slim(waiting.queue) } satisfies SessionPending);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// JSON of the agent's spending totals (`null` before its first model call); in: the live agent, out: change detection + the `usage` record.
|
|
177
|
+
private usageOf(agent: SessionAgentLike): string {
|
|
178
|
+
return agent.usageTotals && agent.usageTotals.turns > 0 ? JSON.stringify(agent.usageTotals) : "null";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// JSON of everything in the snapshot that is not a message, identity or totals; in: the live agent, out: change detection + the `meta` record.
|
|
182
|
+
private metaOf(agent: SessionAgentLike): string {
|
|
183
|
+
const { version: _v, sessionId: _s, messages: _m, totals: _t, runs: _r, usage: _u, ...rest } = buildSessionData(
|
|
184
|
+
{ ...agent, context: { ...agent.context, messages: [] } },
|
|
185
|
+
null
|
|
186
|
+
);
|
|
187
|
+
return JSON.stringify(rest);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The form of a message that goes to disk: embedded media written once as `media/<kind>-h<hash>.<ext>` (content-addressed, never deleted or renumbered), redundant raw arguments dropped; in: a live message, out: journal lines and snapshots.
|
|
191
|
+
private toDisk(message: Message): Message {
|
|
192
|
+
const known = this.onDisk.get(message);
|
|
193
|
+
if (known) return known;
|
|
194
|
+
const slim = stripRedundantRawArguments([message])[0]!;
|
|
195
|
+
let out = slim;
|
|
196
|
+
if (Array.isArray(slim.content)) {
|
|
197
|
+
let changed = false;
|
|
198
|
+
const content = (slim.content as ContentPart[]).map((part) => {
|
|
199
|
+
const found = embeddedMedia(part);
|
|
200
|
+
if (!found) return part;
|
|
201
|
+
const file = `${found.key}-h${createHash("sha256").update(found.bytes).digest("hex").slice(0, 16)}.${found.ext}`;
|
|
202
|
+
const dest = path.join(this.dir, "media", file);
|
|
203
|
+
if (!fs.existsSync(dest)) writeFileDurable(dest, found.bytes, { backup: false });
|
|
204
|
+
changed = true;
|
|
205
|
+
return { ...(part as unknown as Record<string, unknown>), [found.key]: `media/${file}`, ...(found.mimeType ? { mimeType: found.mimeType } : {}) } as unknown as ContentPart;
|
|
206
|
+
});
|
|
207
|
+
if (changed) out = { ...slim, content };
|
|
208
|
+
}
|
|
209
|
+
this.onDisk.set(message, out);
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/session/store.ts
CHANGED
|
@@ -32,6 +32,8 @@ import { getModelFromCatalog } from "../models/catalog.ts";
|
|
|
32
32
|
import type { SubAgentStep } from "../types/response.ts";
|
|
33
33
|
import { SessionLoadError } from "../types/errors.ts";
|
|
34
34
|
import { createSessionId } from "../utils/session.ts";
|
|
35
|
+
import { writeFileDurable, readWithBackup } from "../utils/durable.ts";
|
|
36
|
+
import type { UsageTotals } from "./usage.ts";
|
|
35
37
|
|
|
36
38
|
/** Cumulative per-session token + cost totals (JSON-safe). */
|
|
37
39
|
export interface SessionTotals {
|
|
@@ -61,6 +63,40 @@ export interface PersistedSubAgentTrace {
|
|
|
61
63
|
error?: string;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/** How a run ended; a run with no ending was cut off (a crash). `abandoned` = a newer run started instead of resuming it. */
|
|
67
|
+
export type SessionRunEnd = "done" | "failed" | "aborted" | "stopped" | "abandoned";
|
|
68
|
+
|
|
69
|
+
/** Where one run began: `i` is the index of its user message in `messages` (turn boundaries for later phases). */
|
|
70
|
+
export interface SessionRunMark {
|
|
71
|
+
id: string;
|
|
72
|
+
i: number;
|
|
73
|
+
at: number;
|
|
74
|
+
/** Unset while the run is active or when it was cut off. */
|
|
75
|
+
end?: SessionRunEnd;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The result of a tool call that finished, saved the moment it finished so a crash never repeats it. */
|
|
79
|
+
export interface SessionToolRecord {
|
|
80
|
+
id: string;
|
|
81
|
+
name: string;
|
|
82
|
+
result: unknown;
|
|
83
|
+
isError?: boolean;
|
|
84
|
+
/** The tool ended the run with `ctx.stop`. */
|
|
85
|
+
stopped?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A prompt that was waiting when the process stopped (its options other than `additionalContext` are not saved). */
|
|
89
|
+
export interface SessionPendingPrompt {
|
|
90
|
+
prompt: string | ContentPart[];
|
|
91
|
+
additionalContext?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Steers not yet injected into the active run, and queued follow-ups, as last journaled. */
|
|
95
|
+
export interface SessionPending {
|
|
96
|
+
steer: SessionPendingPrompt[];
|
|
97
|
+
queue: SessionPendingPrompt[];
|
|
98
|
+
}
|
|
99
|
+
|
|
64
100
|
/** Single-file session snapshot (v1). Written as one JSON object. */
|
|
65
101
|
export interface PersistedAgentSession {
|
|
66
102
|
version: 1;
|
|
@@ -79,6 +115,16 @@ export interface PersistedAgentSession {
|
|
|
79
115
|
parentSessionId?: string;
|
|
80
116
|
/** Dedicated sub-agent traces, keyed by worker TrackingID (32-char hex). */
|
|
81
117
|
subagents?: Record<string, PersistedSubAgentTrace>;
|
|
118
|
+
/** Written with a journal (dir mode): only a `journal.ndjson` whose header names this id belongs to this snapshot. */
|
|
119
|
+
epoch?: string;
|
|
120
|
+
/** Where each run began, oldest first. */
|
|
121
|
+
runs?: SessionRunMark[];
|
|
122
|
+
/** What this agent spent on its own model calls (see `usage.ts`); the journal keeps it current with `usage` records. */
|
|
123
|
+
usage?: UsageTotals;
|
|
124
|
+
/** Read from the journal only (never in `session.json`): prompts that were still waiting. */
|
|
125
|
+
pending?: SessionPending;
|
|
126
|
+
/** Read from the journal only: results of tool calls finished in the current run (used to resume without repeating them). */
|
|
127
|
+
tools?: SessionToolRecord[];
|
|
82
128
|
}
|
|
83
129
|
|
|
84
130
|
export function emptyTotals(): SessionTotals {
|
|
@@ -286,6 +332,14 @@ export interface SessionAgentLike {
|
|
|
286
332
|
instructions?: string;
|
|
287
333
|
parentSessionId?: unknown;
|
|
288
334
|
subagentTraces?: Record<string, PersistedSubAgentTrace>;
|
|
335
|
+
/** Run boundaries (see {@link SessionRunMark}). */
|
|
336
|
+
runMarks?: SessionRunMark[];
|
|
337
|
+
/** Prompts waiting in the steer inbox / follow-up queue; called by the journal on every sync. */
|
|
338
|
+
pendingPrompts?: () => SessionPending;
|
|
339
|
+
/** Tool calls finished in the current run (cleared when the next run starts). */
|
|
340
|
+
toolRecords?: SessionToolRecord[];
|
|
341
|
+
/** What this agent spent on its own model calls. */
|
|
342
|
+
usageTotals?: UsageTotals;
|
|
289
343
|
context: { systemPrompt?: string; messages: Message[] };
|
|
290
344
|
}
|
|
291
345
|
|
|
@@ -354,7 +408,7 @@ function stableStringify(value: unknown): string {
|
|
|
354
408
|
* stored `arguments` (pure duplicate, the common case). Divergent or
|
|
355
409
|
* unparseable wire strings are kept — they carry info parsing lost.
|
|
356
410
|
*/
|
|
357
|
-
function stripRedundantRawArguments(messages: Message[]): Message[] {
|
|
411
|
+
export function stripRedundantRawArguments(messages: Message[]): Message[] {
|
|
358
412
|
return messages.map((m) => {
|
|
359
413
|
if (!Array.isArray((m as Message).content)) return m;
|
|
360
414
|
let changed = false;
|
|
@@ -432,6 +486,8 @@ export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTel
|
|
|
432
486
|
messages,
|
|
433
487
|
...(parentSessionId ? { parentSessionId } : {}),
|
|
434
488
|
...(subagents ? { subagents } : {}),
|
|
489
|
+
...(agent.runMarks && agent.runMarks.length > 0 ? { runs: agent.runMarks.map((r) => ({ ...r })) } : {}),
|
|
490
|
+
...(agent.usageTotals && agent.usageTotals.turns > 0 ? { usage: JSON.parse(JSON.stringify(agent.usageTotals)) as UsageTotals } : {}),
|
|
435
491
|
};
|
|
436
492
|
}
|
|
437
493
|
|
|
@@ -619,6 +675,9 @@ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSessio
|
|
|
619
675
|
totals: obj.totals,
|
|
620
676
|
parentSessionId: obj.parentSessionId,
|
|
621
677
|
subagents: obj.subagents,
|
|
678
|
+
...(typeof obj.epoch === "string" ? { epoch: obj.epoch } : {}),
|
|
679
|
+
...(Array.isArray(obj.runs) ? { runs: obj.runs as SessionRunMark[] } : {}),
|
|
680
|
+
...(obj.usage && typeof obj.usage === "object" ? { usage: obj.usage as UsageTotals } : {}),
|
|
622
681
|
};
|
|
623
682
|
} catch {
|
|
624
683
|
return null;
|
|
@@ -654,14 +713,14 @@ export function inspectSessionPayload(
|
|
|
654
713
|
/** Loads a session file (pretty `.json` or legacy `.jsonl`). Returns null when missing/corrupt. */
|
|
655
714
|
export function loadSessionFile(filePath: string, limits?: SessionLimits): PersistedAgentSession | null {
|
|
656
715
|
const resolved = path.resolve(filePath);
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
return
|
|
716
|
+
const read = readWithBackup(resolved, isLoadableSession);
|
|
717
|
+
return read ? sessionFromPayload(read.text, resolved, limits) : null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// True when a stored payload is a usable snapshot (a valid one over the load limits counts); in: raw file text, out: readWithBackup's fallback decision.
|
|
721
|
+
function isLoadableSession(raw: string): boolean {
|
|
722
|
+
const status = inspectSessionPayload(raw).status;
|
|
723
|
+
return status === "ok" || status === "over_limit";
|
|
665
724
|
}
|
|
666
725
|
|
|
667
726
|
/**
|
|
@@ -680,25 +739,8 @@ function sessionFromPayload(raw: string, file: string, limits?: SessionLimits):
|
|
|
680
739
|
return res.session ?? null;
|
|
681
740
|
}
|
|
682
741
|
|
|
683
|
-
/**
|
|
684
|
-
export
|
|
685
|
-
const dir = path.dirname(filePath);
|
|
686
|
-
try {
|
|
687
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
688
|
-
} catch {}
|
|
689
|
-
let tag = "tmp";
|
|
690
|
-
try {
|
|
691
|
-
const pid = (globalThis as any)?.process?.pid;
|
|
692
|
-
const rand = (globalThis as any)?.crypto?.randomUUID?.().slice(0, 8) ?? Math.random().toString(36).slice(2, 8);
|
|
693
|
-
tag = `${typeof pid === "number" ? pid : "np"}-${rand}`;
|
|
694
|
-
} catch {
|
|
695
|
-
tag = Math.random().toString(36).slice(2, 8);
|
|
696
|
-
}
|
|
697
|
-
const tmp = `${filePath}.tmp-${tag}`;
|
|
698
|
-
fs.writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
|
|
699
|
-
try { fs.chmodSync(tmp, 0o600); } catch {}
|
|
700
|
-
fs.renameSync(tmp, filePath);
|
|
701
|
-
}
|
|
742
|
+
/** Public name of the durable writer (temp file, fsync, `.bak`, atomic rename): a crash or power cut never leaves half-written JSON. */
|
|
743
|
+
export const writeFileAtomic = writeFileDurable;
|
|
702
744
|
|
|
703
745
|
/** Session id grammar: no traversal, no slashes, no hidden files. */
|
|
704
746
|
export function assertValidSessionId(sessionId: string): void {
|
|
@@ -707,6 +749,15 @@ export function assertValidSessionId(sessionId: string): void {
|
|
|
707
749
|
}
|
|
708
750
|
}
|
|
709
751
|
|
|
752
|
+
/** Renames a genuinely corrupt snapshot to `*.corrupt-<ts>` before it is overwritten (a healthy one over the load limits is our own prior save and is just rewritten). */
|
|
753
|
+
export function quarantineIfCorrupt(file: string): void {
|
|
754
|
+
try {
|
|
755
|
+
if (inspectSessionPayload(fs.readFileSync(file, "utf8")).status === "corrupt") fs.renameSync(file, `${file}.corrupt-${Date.now()}`);
|
|
756
|
+
} catch {
|
|
757
|
+
// why: no file yet, or it cannot be read: nothing to quarantine.
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
710
761
|
/** Saves an agent + telemetry snapshot as a pretty-printed `.json` document (2-space indent). */
|
|
711
762
|
export function saveSessionFile(
|
|
712
763
|
filePath: string,
|
|
@@ -714,20 +765,7 @@ export function saveSessionFile(
|
|
|
714
765
|
telemetry?: SessionTelemetry | SessionTotals | null
|
|
715
766
|
): void {
|
|
716
767
|
const resolved = path.resolve(filePath);
|
|
717
|
-
|
|
718
|
-
// as `*.corrupt-<ts>` first (load returns null for corrupt payloads).
|
|
719
|
-
try {
|
|
720
|
-
if (fs.existsSync(resolved)) {
|
|
721
|
-
try {
|
|
722
|
-
const raw = fs.readFileSync(resolved, "utf8");
|
|
723
|
-
// Only genuinely corrupt payloads are quarantined; a healthy snapshot
|
|
724
|
-
// over the load limits is simply rewritten (it is our own prior save).
|
|
725
|
-
if (inspectSessionPayload(raw).status === "corrupt") {
|
|
726
|
-
try { fs.renameSync(resolved, `${resolved}.corrupt-${Date.now()}`); } catch {}
|
|
727
|
-
}
|
|
728
|
-
} catch {}
|
|
729
|
-
}
|
|
730
|
-
} catch {}
|
|
768
|
+
quarantineIfCorrupt(resolved);
|
|
731
769
|
const data = buildSessionData(agent, telemetry);
|
|
732
770
|
try {
|
|
733
771
|
writeFileAtomic(resolved, serializeSession(data) + "\n");
|
|
@@ -790,6 +828,28 @@ function extForMedia(mimeType?: string, filename?: string): string {
|
|
|
790
828
|
return "bin";
|
|
791
829
|
}
|
|
792
830
|
|
|
831
|
+
// Finds embedded media bytes in a part (binary, `data:` URL or raw base64); remote URLs and local paths are references and give undefined; in: any content part, out: extractMediaToDir and the session journal.
|
|
832
|
+
export function embeddedMedia(part: ContentPart): { key: (typeof MEDIA_PART_KEYS)[number]; bytes: Uint8Array; mimeType?: string; ext: string } | undefined {
|
|
833
|
+
const p = part as unknown as Record<string, unknown> | null;
|
|
834
|
+
if (!p || typeof p["type"] !== "string" || !(MEDIA_PART_KEYS as readonly string[]).includes(p["type"])) return undefined;
|
|
835
|
+
const key = p["type"] as (typeof MEDIA_PART_KEYS)[number];
|
|
836
|
+
const raw = p[key];
|
|
837
|
+
let bytes: Uint8Array;
|
|
838
|
+
let mimeType = typeof p["mimeType"] === "string" ? (p["mimeType"] as string) : undefined;
|
|
839
|
+
if (raw instanceof Uint8Array) bytes = raw;
|
|
840
|
+
else if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw);
|
|
841
|
+
else if (typeof raw === "string") {
|
|
842
|
+
if (isHttpRef(raw)) return undefined; // remote reference: the provider fetches it per turn
|
|
843
|
+
const dataUrl = raw.startsWith("data:") ? parseDataUrl(raw) : undefined;
|
|
844
|
+
if (dataUrl) {
|
|
845
|
+
bytes = base64ToBytes(dataUrl.base64);
|
|
846
|
+
mimeType = mimeType ?? dataUrl.mimeType;
|
|
847
|
+
} else if (looksLikeEmbeddedBase64(raw)) bytes = base64ToBytes(raw.replace(/\s/g, ""));
|
|
848
|
+
else return undefined; // local path or short label: keep as reference
|
|
849
|
+
} else return undefined;
|
|
850
|
+
return { key, bytes, ...(mimeType ? { mimeType } : {}), ext: extForMedia(mimeType, typeof p["filename"] === "string" ? (p["filename"] as string) : undefined) };
|
|
851
|
+
}
|
|
852
|
+
|
|
793
853
|
function cloneMessages(messages: Message[]): Message[] {
|
|
794
854
|
try {
|
|
795
855
|
const g: any = globalThis as any;
|
|
@@ -830,35 +890,15 @@ export function extractMediaToDir(
|
|
|
830
890
|
} catch {}
|
|
831
891
|
const wrote: string[] = [];
|
|
832
892
|
const counters: Record<string, number> = {};
|
|
833
|
-
let hasBinary = false;
|
|
834
893
|
|
|
835
894
|
for (const msg of cloned) {
|
|
836
895
|
if (!Array.isArray((msg as Message).content)) continue;
|
|
837
896
|
for (const part of (msg as Message).content as ContentPart[]) {
|
|
838
|
-
const
|
|
839
|
-
if (!
|
|
840
|
-
|
|
841
|
-
const key = p.type as (typeof MEDIA_PART_KEYS)[number];
|
|
842
|
-
const raw = p[key] as unknown;
|
|
843
|
-
let bytes: Uint8Array | undefined;
|
|
844
|
-
let mimeType: string | undefined = typeof p.mimeType === "string" ? p.mimeType : undefined;
|
|
845
|
-
if (raw instanceof Uint8Array) bytes = raw;
|
|
846
|
-
else if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw);
|
|
847
|
-
else if (typeof raw === "string") {
|
|
848
|
-
if (isHttpRef(raw)) continue; // remote reference — provider fetches per turn
|
|
849
|
-
const dataUrl = raw.startsWith("data:") ? parseDataUrl(raw) : undefined;
|
|
850
|
-
if (dataUrl) {
|
|
851
|
-
bytes = base64ToBytes(dataUrl.base64);
|
|
852
|
-
mimeType = mimeType ?? dataUrl.mimeType;
|
|
853
|
-
} else if (looksLikeEmbeddedBase64(raw)) {
|
|
854
|
-
bytes = base64ToBytes(raw.replace(/\s/g, ""));
|
|
855
|
-
} else continue; // local path or short label — keep as reference
|
|
856
|
-
} else continue;
|
|
857
|
-
|
|
858
|
-
hasBinary = true;
|
|
897
|
+
const found = embeddedMedia(part);
|
|
898
|
+
if (!found) continue;
|
|
899
|
+
const { key, bytes, mimeType } = found;
|
|
859
900
|
const n = (counters[key] = (counters[key] ?? 0) + 1);
|
|
860
|
-
const
|
|
861
|
-
const file = `${key}-${String(n).padStart(3, "0")}.${ext}`;
|
|
901
|
+
const file = `${key}-${String(n).padStart(3, "0")}.${found.ext}`;
|
|
862
902
|
try {
|
|
863
903
|
fs.mkdirSync(mediaDir, { recursive: true, mode: 0o700 });
|
|
864
904
|
const dest = path.resolve(mediaDir, file);
|
|
@@ -868,8 +908,9 @@ export function extractMediaToDir(
|
|
|
868
908
|
} catch {
|
|
869
909
|
continue;
|
|
870
910
|
}
|
|
911
|
+
const p = part as unknown as Record<string, unknown>;
|
|
871
912
|
p[key] = `media/${file}`;
|
|
872
|
-
if (mimeType) p
|
|
913
|
+
if (mimeType) p["mimeType"] = mimeType;
|
|
873
914
|
wrote.push(`media/${file}`);
|
|
874
915
|
}
|
|
875
916
|
}
|
|
@@ -937,17 +978,7 @@ export function saveSessionDir(
|
|
|
937
978
|
);
|
|
938
979
|
try {
|
|
939
980
|
const target = path.join(sessionDir, "session.json");
|
|
940
|
-
|
|
941
|
-
try {
|
|
942
|
-
if (fs.existsSync(target)) {
|
|
943
|
-
try {
|
|
944
|
-
const raw = fs.readFileSync(target, "utf8");
|
|
945
|
-
if (inspectSessionPayload(raw).status === "corrupt") {
|
|
946
|
-
try { fs.renameSync(target, `${target}.corrupt-${Date.now()}`); } catch {}
|
|
947
|
-
}
|
|
948
|
-
} catch {}
|
|
949
|
-
}
|
|
950
|
-
} catch {}
|
|
981
|
+
quarantineIfCorrupt(target);
|
|
951
982
|
writeFileAtomic(target, serializeSession(data) + "\n");
|
|
952
983
|
} catch {
|
|
953
984
|
// persistence must never crash a chat turn
|
|
@@ -955,6 +986,69 @@ export function saveSessionDir(
|
|
|
955
986
|
return sessionDir;
|
|
956
987
|
}
|
|
957
988
|
|
|
989
|
+
/** Name of the append-only step log next to `session.json` (see `session/journal.ts`). */
|
|
990
|
+
export const JOURNAL_FILE = "journal.ndjson";
|
|
991
|
+
|
|
992
|
+
// Snapshot fields a `meta` journal record replaces (everything except messages, identity and totals).
|
|
993
|
+
const JOURNAL_META_KEYS = ["model", "subagentModel", "thinkingLevel", "cache", "systemPrompt", "parentSessionId", "subagents"] as const;
|
|
994
|
+
|
|
995
|
+
// Applies a journal to its snapshot: records of the snapshot's own epoch only, `msg` by index (already-present ones skipped), `run` adds a run boundary, `end` closes one, `usage` replaces the agent's spending totals, `tool` keeps a finished tool result, `pending` replaces the waiting prompts, `meta` replaces config fields, unknown record types ignored; a torn last line is dropped, damage earlier stops the replay with a bus warning; in: the parsed snapshot + journal text, out: loadSessionDir.
|
|
996
|
+
function replayJournal(snapshot: PersistedAgentSession, text: string, file: string): PersistedAgentSession {
|
|
997
|
+
if (!snapshot.epoch) return snapshot;
|
|
998
|
+
const lines = text.split("\n");
|
|
999
|
+
const session: PersistedAgentSession = { ...snapshot, messages: [...snapshot.messages] };
|
|
1000
|
+
let sawHeader = false;
|
|
1001
|
+
const stop = (reason: string, line: number): void => {
|
|
1002
|
+
telemetryBus.emitWarning("session.journal_corrupt", `[Agent Accelerator] ${path.basename(file)} line ${line + 1}: ${reason}; the rest of the journal is ignored.`, { file, line: line + 1 });
|
|
1003
|
+
};
|
|
1004
|
+
for (let k = 0; k < lines.length; k++) {
|
|
1005
|
+
const line = lines[k]!.trim();
|
|
1006
|
+
if (!line) continue;
|
|
1007
|
+
let rec: { t?: unknown; e?: unknown; i?: unknown; m?: unknown; p?: unknown; id?: unknown; at?: unknown; u?: unknown; status?: unknown; call?: unknown; name?: unknown; result?: unknown; isError?: unknown; stopped?: unknown };
|
|
1008
|
+
try {
|
|
1009
|
+
rec = JSON.parse(line);
|
|
1010
|
+
} catch {
|
|
1011
|
+
if (lines.slice(k + 1).some((l) => l.trim())) stop("not valid JSON", k); // a torn LAST line is just a cut-off write
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
if (!sawHeader) {
|
|
1015
|
+
sawHeader = true;
|
|
1016
|
+
if (rec.t !== "base" || rec.e !== snapshot.epoch) return snapshot; // a journal from before this snapshot
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
if (rec.t === "msg" && typeof rec.i === "number") {
|
|
1020
|
+
if (rec.i < session.messages.length) continue; // already in the snapshot
|
|
1021
|
+
if (rec.i > session.messages.length || findInvalidMessage([rec.m]) !== -1) {
|
|
1022
|
+
stop(rec.i > session.messages.length ? "a message is missing before this one" : "invalid message", k);
|
|
1023
|
+
break;
|
|
1024
|
+
}
|
|
1025
|
+
session.messages.push(rec.m as Message);
|
|
1026
|
+
} else if (rec.t === "run" && typeof rec.id === "string" && typeof rec.i === "number" && typeof rec.at === "number") {
|
|
1027
|
+
if (!session.runs?.some((r) => r.id === rec.id)) session.runs = [...(session.runs ?? []), { id: rec.id, i: rec.i, at: rec.at }];
|
|
1028
|
+
} else if (rec.t === "end" && typeof rec.id === "string" && typeof rec.status === "string") {
|
|
1029
|
+
const mark = session.runs?.find((r) => r.id === rec.id);
|
|
1030
|
+
if (mark) session.runs = session.runs!.map((r) => (r === mark ? { ...r, end: rec.status as SessionRunEnd } : r));
|
|
1031
|
+
} else if (rec.t === "tool" && typeof rec.call === "string" && typeof rec.name === "string") {
|
|
1032
|
+
session.tools = [...(session.tools ?? []).filter((x) => x.id !== rec.call), { id: rec.call, name: rec.name, result: rec.result, ...(rec.isError ? { isError: true } : {}), ...(rec.stopped ? { stopped: true } : {}) }];
|
|
1033
|
+
} else if (rec.t === "usage" && rec.u && typeof rec.u === "object") {
|
|
1034
|
+
session.usage = rec.u as UsageTotals; // latest record wins
|
|
1035
|
+
} else if (rec.t === "pending" && rec.p && typeof rec.p === "object") {
|
|
1036
|
+
const p = rec.p as SessionPending; // latest record wins
|
|
1037
|
+
if (Array.isArray(p.steer) && Array.isArray(p.queue)) {
|
|
1038
|
+
if (p.steer.length + p.queue.length > 0) session.pending = p;
|
|
1039
|
+
else delete session.pending;
|
|
1040
|
+
}
|
|
1041
|
+
} else if (rec.t === "meta" && rec.m && typeof rec.m === "object") {
|
|
1042
|
+
const meta = rec.m as Record<string, unknown>;
|
|
1043
|
+
for (const key of JOURNAL_META_KEYS) {
|
|
1044
|
+
if (key in meta) (session as unknown as Record<string, unknown>)[key] = meta[key];
|
|
1045
|
+
else delete session[key];
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return session;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
958
1052
|
/** A loaded session folder: snapshot (media paths resolved) + folder location. */
|
|
959
1053
|
export interface LoadedSessionDir {
|
|
960
1054
|
session: PersistedAgentSession;
|
|
@@ -985,9 +1079,17 @@ export function loadSessionDir(sessionPath: string, limits?: SessionLimits): Loa
|
|
|
985
1079
|
// path does not exist yet — fall through to session.json lookup
|
|
986
1080
|
}
|
|
987
1081
|
const file = path.join(dir, "session.json");
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1082
|
+
const read = readWithBackup(file, isLoadableSession);
|
|
1083
|
+
if (!read) return null;
|
|
1084
|
+
const snapshot = sessionFromPayload(read.text, file, limits);
|
|
1085
|
+
if (!snapshot) return null;
|
|
1086
|
+
let journal: string | undefined;
|
|
1087
|
+
try {
|
|
1088
|
+
journal = fs.readFileSync(path.join(dir, JOURNAL_FILE), "utf8");
|
|
1089
|
+
} catch {
|
|
1090
|
+
journal = undefined; // no journal: the snapshot is the whole session
|
|
1091
|
+
}
|
|
1092
|
+
const data = journal ? replayJournal(snapshot, journal, path.join(dir, JOURNAL_FILE)) : snapshot;
|
|
991
1093
|
data.messages = resolveMediaPaths(data.messages, dir);
|
|
992
1094
|
return { session: data, dir };
|
|
993
1095
|
} catch (err) {
|
|
@@ -1007,7 +1109,13 @@ export function findLatestSessionDir(rootDir: string): string | null {
|
|
|
1007
1109
|
try {
|
|
1008
1110
|
const st = fs.statSync(file);
|
|
1009
1111
|
if (!st.isFile()) continue;
|
|
1010
|
-
|
|
1112
|
+
let mtime = st.mtimeMs;
|
|
1113
|
+
try {
|
|
1114
|
+
mtime = Math.max(mtime, fs.statSync(path.join(root, entry, JOURNAL_FILE)).mtimeMs); // steps land in the journal
|
|
1115
|
+
} catch {
|
|
1116
|
+
// why: no journal yet.
|
|
1117
|
+
}
|
|
1118
|
+
if (!best || mtime > best.mtime) best = { dir: path.join(root, entry), mtime };
|
|
1011
1119
|
} catch {
|
|
1012
1120
|
continue;
|
|
1013
1121
|
}
|
|
@@ -1229,15 +1337,16 @@ function toDollars(microUSD: number): number {
|
|
|
1229
1337
|
* `cost.context_over_200k`, the over-200k tier prices apply; otherwise base
|
|
1230
1338
|
* `pricing.*` (falling back to legacy `cost.*`) applies.
|
|
1231
1339
|
*/
|
|
1340
|
+
// Cache prices stay undefined when the catalog has none: priceUsage then bills those tokens at the input rate (never free).
|
|
1232
1341
|
function resolveEffectivePricing(
|
|
1233
1342
|
spec: ModelSpec | undefined,
|
|
1234
1343
|
inputTokens: number
|
|
1235
|
-
): { input: number; output: number; cacheRead: number; cacheWrite: number; audio: number; tier: string } {
|
|
1344
|
+
): { input: number; output: number; cacheRead: number | undefined; cacheWrite: number | undefined; audio: number; tier: string } {
|
|
1236
1345
|
const base = {
|
|
1237
1346
|
input: spec?.pricing?.inputPerMillion ?? (spec?.cost?.input as number | undefined) ?? 0,
|
|
1238
1347
|
output: spec?.pricing?.outputPerMillion ?? (spec?.cost?.output as number | undefined) ?? 0,
|
|
1239
|
-
cacheRead: spec?.pricing?.cacheReadPerMillion ?? (spec?.cost?.cache_read as number | undefined)
|
|
1240
|
-
cacheWrite: spec?.pricing?.cacheWritePerMillion ?? (spec?.cost?.cache_write as number | undefined)
|
|
1348
|
+
cacheRead: spec?.pricing?.cacheReadPerMillion ?? (spec?.cost?.cache_read as number | undefined),
|
|
1349
|
+
cacheWrite: spec?.pricing?.cacheWritePerMillion ?? (spec?.cost?.cache_write as number | undefined),
|
|
1241
1350
|
audio:
|
|
1242
1351
|
spec?.pricing?.inputAudioPerMillion ?? (spec?.cost as { input_audio?: number } | undefined)?.input_audio ?? 0,
|
|
1243
1352
|
};
|
|
@@ -1289,8 +1398,9 @@ function resolveEffectivePricing(
|
|
|
1289
1398
|
* - Audio tokens (`usage.audioTokens` / `usage.inputAudioTokens` when present)
|
|
1290
1399
|
* price at `pricing.inputAudioPerMillion` / `cost.input_audio` and fold into
|
|
1291
1400
|
* the total (separately itemized in `breakdown.audioMicroUSD`).
|
|
1292
|
-
* - `opts.serviceTier` (`flex`
|
|
1401
|
+
* - `opts.serviceTier` (`flex` 0.5x, `priority` 2x, estimates) scales the
|
|
1293
1402
|
* catalog total; provider-reported costs are never rescaled.
|
|
1403
|
+
* - Cache reads and writes are subsets of the input tokens; a missing cache price bills at the input rate.
|
|
1294
1404
|
*/
|
|
1295
1405
|
export function priceUsage(
|
|
1296
1406
|
usage: TokenUsage,
|
|
@@ -1339,12 +1449,15 @@ export function priceUsage(
|
|
|
1339
1449
|
return undefined;
|
|
1340
1450
|
}
|
|
1341
1451
|
const { multiplier, tier: serviceTier } = serviceTierMultiplier(opts?.serviceTier, opts?.modelId ?? spec?.id);
|
|
1342
|
-
|
|
1452
|
+
// Cache reads and writes are both a SUBSET of the input tokens (OpenRouter `prompt_tokens` includes them), so the
|
|
1453
|
+
// plain input rate applies only to what is neither read from nor written to the cache.
|
|
1454
|
+
const freshInput = Math.max(0, inputTokens - cachedTokens - cacheWriteTokens);
|
|
1343
1455
|
// Integer micro-USD accumulation: microUSD = tokens * pricePerMillion
|
|
1344
1456
|
// ($P per 1M tokens == $P/1e6 per token == P micro-USD per token).
|
|
1345
|
-
|
|
1346
|
-
const
|
|
1347
|
-
const
|
|
1457
|
+
// A catalog entry without a cache price bills those tokens at the input rate: cache reads/writes are never free.
|
|
1458
|
+
const inputMicroUSD = Math.round(freshInput * pricing.input * multiplier);
|
|
1459
|
+
const cacheReadMicroUSD = Math.round(cachedTokens * (pricing.cacheRead ?? pricing.input) * multiplier);
|
|
1460
|
+
const cacheWriteMicroUSD = Math.round(cacheWriteTokens * (pricing.cacheWrite ?? pricing.input) * multiplier);
|
|
1348
1461
|
const outputMicroUSD = Math.round(outputTokens * pricing.output * multiplier);
|
|
1349
1462
|
const audioMicroUSD = Math.round(audioCount * pricing.audio * multiplier);
|
|
1350
1463
|
const totalMicroUSD = inputMicroUSD + cacheReadMicroUSD + cacheWriteMicroUSD + outputMicroUSD + audioMicroUSD;
|