ravensight-playtest 0.2.8 → 0.3.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/README.md +98 -9
- package/docs/ADAPTERS.md +2 -0
- package/docs/NATIVE_RUNNER.md +243 -0
- package/package.json +2 -1
- package/src/cli.js +6 -0
- package/src/commands/check.js +330 -106
- package/src/commands/run.js +104 -8
- package/src/run/aggregate.js +97 -33
- package/src/run/args.js +2 -0
- package/src/run/drivers/driver.js +7 -0
- package/src/run/drivers/web.js +559 -34
- package/src/run/drivers/webdriver-probe.js +353 -0
- package/src/run/dropped.js +7 -7
- package/src/run/heartbeat.js +8 -1
- package/src/run/personaLoop.js +326 -62
- package/src/run/regenerate.js +24 -4
- package/src/run/report.js +93 -2
- package/src/run/resume.js +16 -1
- package/src/run/state.js +4 -1
- package/src/run/tools.js +31 -10
- package/src/run/usage.js +19 -1
package/src/run/regenerate.js
CHANGED
|
@@ -47,7 +47,7 @@ export async function readTranscript(runDirectory) {
|
|
|
47
47
|
try {
|
|
48
48
|
text = await readFile(path.join(runDirectory, RUN_FILES.transcript), 'utf8');
|
|
49
49
|
} catch {
|
|
50
|
-
return { header: null, notes: [], screenshots: [], observations: 0, actions: 0 };
|
|
50
|
+
return { header: null, notes: [], screenshots: [], observations: 0, actions: 0, firstT: null, lastT: null };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
let header = null;
|
|
@@ -55,6 +55,12 @@ export async function readTranscript(runDirectory) {
|
|
|
55
55
|
const screenshots = [];
|
|
56
56
|
let observations = 0;
|
|
57
57
|
let actions = 0;
|
|
58
|
+
// Notes written on the tool call the last `action` line closed, so a
|
|
59
|
+
// `driver_error` after it can mark them as the live run did.
|
|
60
|
+
let callNotes = [];
|
|
61
|
+
let openNotes = [];
|
|
62
|
+
let firstT = null;
|
|
63
|
+
let lastT = null;
|
|
58
64
|
for (const line of text.split('\n')) {
|
|
59
65
|
if (!line.trim()) continue;
|
|
60
66
|
let entry;
|
|
@@ -65,13 +71,27 @@ export async function readTranscript(runDirectory) {
|
|
|
65
71
|
// it rather than refusing to read the whole transcript.
|
|
66
72
|
continue;
|
|
67
73
|
}
|
|
74
|
+
if (Number.isFinite(Number(entry.t))) {
|
|
75
|
+
if (firstT === null) firstT = Number(entry.t);
|
|
76
|
+
lastT = Number(entry.t);
|
|
77
|
+
}
|
|
68
78
|
if (entry.kind === 'header') header = entry;
|
|
69
|
-
else if (entry.kind === 'note' && entry.note)
|
|
79
|
+
else if (entry.kind === 'note' && entry.note) {
|
|
80
|
+
const note = { ...entry.note };
|
|
81
|
+
notes.push(note);
|
|
82
|
+
if (entry.tool) openNotes.push(note);
|
|
83
|
+
} else if (entry.kind === 'driver_error') {
|
|
84
|
+
for (const note of callNotes) note.driver_error = true;
|
|
85
|
+
}
|
|
70
86
|
else if (entry.kind === 'screenshot' && entry.path) screenshots.push({ relativePath: entry.path, reason: entry.reason || '' });
|
|
71
87
|
else if (entry.kind === 'observation') observations += 1;
|
|
72
|
-
else if (entry.kind === 'action'
|
|
88
|
+
else if (entry.kind === 'action') {
|
|
89
|
+
callNotes = openNotes;
|
|
90
|
+
openNotes = [];
|
|
91
|
+
if (Number.isFinite(Number(entry.actions))) actions = Math.max(actions, Number(entry.actions));
|
|
92
|
+
}
|
|
73
93
|
}
|
|
74
|
-
return { header, notes, screenshots, observations, actions };
|
|
94
|
+
return { header, notes, screenshots, observations, actions, firstT, lastT };
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
/**
|
package/src/run/report.js
CHANGED
|
@@ -127,6 +127,86 @@ export function stampReport(report, facts) {
|
|
|
127
127
|
return stamped;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** Keys a model has been seen to put an evidence path under instead of `ref`. */
|
|
131
|
+
const REF_ALIASES = ['path', 'file', 'screenshot', 'src', 'href', 'location', 'value'];
|
|
132
|
+
|
|
133
|
+
/** The four evidence types the schema allows. */
|
|
134
|
+
const EVIDENCE_TYPES = new Set(['screenshot', 'video_ts', 'transcript_step', 'file_line']);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One evidence entry in the schema's shape, `{type, ref}` and nothing else,
|
|
138
|
+
* when the model's own words say unambiguously what it meant.
|
|
139
|
+
*
|
|
140
|
+
* The 0.2.8 report that failed upload wrote
|
|
141
|
+
* `{"type": "screenshot", "path": "screenshots/04-04-school-selected.png"}`
|
|
142
|
+
* five times: a real file, under the wrong key. The schema refuses the extra
|
|
143
|
+
* key and the missing `ref`, so the whole report was refused and the run
|
|
144
|
+
* failed over a key name. This moves the value, never invents one: a path
|
|
145
|
+
* still has to name a file in the run directory (`checkEvidence`), and the
|
|
146
|
+
* server still checks it against what was uploaded.
|
|
147
|
+
*
|
|
148
|
+
* Also: a bare string becomes a screenshot or a transcript step by its look;
|
|
149
|
+
* a missing type on a `screenshots/...` ref becomes `screenshot`; a ref
|
|
150
|
+
* written `./screenshots/x.png` or as an absolute path inside the run
|
|
151
|
+
* directory becomes the run relative path the server's manifest uses.
|
|
152
|
+
*
|
|
153
|
+
* @param {any} item
|
|
154
|
+
* @param {string|null} runDirectory
|
|
155
|
+
* @returns {any} the normalised entry, or the input unchanged when it is not an object it can read
|
|
156
|
+
*/
|
|
157
|
+
export function normaliseEvidenceItem(item, runDirectory = null) {
|
|
158
|
+
if (typeof item === 'string') {
|
|
159
|
+
const text = item.trim();
|
|
160
|
+
if (/^(?:\.\/)?screenshots\//.test(text)) return normaliseEvidenceItem({ type: 'screenshot', ref: text }, runDirectory);
|
|
161
|
+
if (/^(?:observation|note|step|action)\s*#?\s*\d+$/i.test(text)) return { type: 'transcript_step', ref: text };
|
|
162
|
+
return item;
|
|
163
|
+
}
|
|
164
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
|
|
165
|
+
let ref = typeof item.ref === 'string' && item.ref.trim() ? item.ref : null;
|
|
166
|
+
if (ref === null) {
|
|
167
|
+
const alias = REF_ALIASES.find(key => typeof item[key] === 'string' && item[key].trim());
|
|
168
|
+
if (alias) ref = item[alias];
|
|
169
|
+
}
|
|
170
|
+
let type = typeof item.type === 'string' ? item.type : null;
|
|
171
|
+
if (!type && ref && /^(?:\.\/)?screenshots\//.test(ref.trim())) type = 'screenshot';
|
|
172
|
+
if (type === null || ref === null || !EVIDENCE_TYPES.has(type)) return item;
|
|
173
|
+
ref = ref.trim();
|
|
174
|
+
if (type === 'screenshot') ref = runRelative(ref, runDirectory);
|
|
175
|
+
return { type, ref };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function runRelative(ref, runDirectory) {
|
|
179
|
+
let value = ref.replace(/\\/g, '/');
|
|
180
|
+
if (runDirectory && path.isAbsolute(ref)) {
|
|
181
|
+
const relative = path.relative(path.resolve(runDirectory), path.resolve(ref));
|
|
182
|
+
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) value = relative.split(path.sep).join('/');
|
|
183
|
+
}
|
|
184
|
+
while (value.startsWith('./')) value = value.slice(2);
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Every finding's evidence through {@link normaliseEvidenceItem}.
|
|
190
|
+
*
|
|
191
|
+
* @param {Object} report
|
|
192
|
+
* @param {string|null} [runDirectory]
|
|
193
|
+
* @returns {{report: Object, changed: number}} a new report, and how many entries were rewritten
|
|
194
|
+
*/
|
|
195
|
+
export function normaliseEvidence(report, runDirectory = null) {
|
|
196
|
+
if (!report || typeof report !== 'object' || !Array.isArray(report.findings)) return { report, changed: 0 };
|
|
197
|
+
let changed = 0;
|
|
198
|
+
const findings = report.findings.map(finding => {
|
|
199
|
+
if (!finding || typeof finding !== 'object' || !Array.isArray(finding.evidence)) return finding;
|
|
200
|
+
const evidence = finding.evidence.map(item => {
|
|
201
|
+
const out = normaliseEvidenceItem(item, runDirectory);
|
|
202
|
+
if (JSON.stringify(out) !== JSON.stringify(item)) changed += 1;
|
|
203
|
+
return out;
|
|
204
|
+
});
|
|
205
|
+
return { ...finding, evidence };
|
|
206
|
+
});
|
|
207
|
+
return { report: { ...report, findings }, changed };
|
|
208
|
+
}
|
|
209
|
+
|
|
130
210
|
/**
|
|
131
211
|
* Check every `screenshot` evidence ref against the files actually on disk.
|
|
132
212
|
*
|
|
@@ -151,8 +231,17 @@ export async function checkEvidence(report, runDirectory) {
|
|
|
151
231
|
continue;
|
|
152
232
|
}
|
|
153
233
|
for (const item of evidence) {
|
|
154
|
-
if (!item || item
|
|
155
|
-
|
|
234
|
+
if (!item || typeof item !== 'object') {
|
|
235
|
+
problems.push(`${finding.id}: an evidence entry is not an object; write {"type": ..., "ref": ...}`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (typeof item.ref !== 'string' || !item.ref.trim()) {
|
|
239
|
+
const keys = Object.keys(item).filter(key => key !== 'type');
|
|
240
|
+
problems.push(`${finding.id}: a ${item.type || 'typeless'} evidence entry has no "ref"${keys.length > 0 ? ` (it has ${keys.map(key => `"${key}"`).join(', ')})` : ''}; every entry is exactly {"type": ..., "ref": ...}`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (item.type !== 'screenshot') continue;
|
|
244
|
+
const ref = String(item.ref);
|
|
156
245
|
const candidate = path.resolve(runDirectory, ref);
|
|
157
246
|
// Refuse a ref that climbs out of the run directory before touching
|
|
158
247
|
// the filesystem with it.
|
|
@@ -214,6 +303,8 @@ export default {
|
|
|
214
303
|
createReportValidator,
|
|
215
304
|
stampReport,
|
|
216
305
|
checkEvidence,
|
|
306
|
+
normaliseEvidence,
|
|
307
|
+
normaliseEvidenceItem,
|
|
217
308
|
writeReportFiles,
|
|
218
309
|
repairInstruction,
|
|
219
310
|
lintMarkdownSections,
|
package/src/run/resume.js
CHANGED
|
@@ -62,6 +62,15 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
62
62
|
return EXIT.ENVIRONMENT;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// A run whose report the server refused is done here, whatever the server
|
|
66
|
+
// says its state is: it is never finalised from its transcript (which would
|
|
67
|
+
// rewrite the kept report.json and report.md) and never replayed.
|
|
68
|
+
for (const [runId, entry] of Object.entries(journal.runs || {})) {
|
|
69
|
+
if (entry && entry.report_refused === true) {
|
|
70
|
+
log(`run ${runId}: the server refused its report, so it is not replayed or rebuilt. Its report.json, report.md and screenshots are in ${runDirFor(repoRoot, jobId, runId)}.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
65
74
|
// Reconcile the journal with what the server actually thinks: a run the sweep
|
|
66
75
|
// marked interrupted, or one that finished after the journal was last
|
|
67
76
|
// written, is the server's word against a stale file, and the server wins.
|
|
@@ -235,7 +244,13 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
|
|
|
235
244
|
actions_taken: report.actions_taken,
|
|
236
245
|
quit_reason: 'error',
|
|
237
246
|
report,
|
|
238
|
-
report_md: markdown
|
|
247
|
+
report_md: markdown,
|
|
248
|
+
// The transcript's own span: its first and last timestamps. No model
|
|
249
|
+
// time is known for a run rebuilt after the process died.
|
|
250
|
+
duration: {
|
|
251
|
+
wall_seconds: transcript.firstT !== null && transcript.lastT !== null ? Number(((transcript.lastT - transcript.firstT) / 1000).toFixed(3)) : null,
|
|
252
|
+
model_seconds: null
|
|
253
|
+
}
|
|
239
254
|
}
|
|
240
255
|
});
|
|
241
256
|
if (answer && answer.dropped_findings > 0) {
|
package/src/run/state.js
CHANGED
|
@@ -192,7 +192,10 @@ export async function patchRun(repoRoot, state, runId, patch) {
|
|
|
192
192
|
* @returns {boolean}
|
|
193
193
|
*/
|
|
194
194
|
export function isRunDone(entry) {
|
|
195
|
-
|
|
195
|
+
// A run whose report the server refused was played to the end: replaying
|
|
196
|
+
// it would spend a unit again, and rebuilding its report from the
|
|
197
|
+
// transcript would overwrite the one the developer is meant to read.
|
|
198
|
+
return Boolean(entry) && (entry.completed === true || entry.report_refused === true || RUN_DONE_STATES.includes(entry.state));
|
|
196
199
|
}
|
|
197
200
|
|
|
198
201
|
export default { SCHEMA_VERSION, newState, newRunEntry, save, load, patchRun, isRunDone, stateFile };
|
package/src/run/tools.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { UNCHANGED_OBSERVATION_TEXT } from './context.js';
|
|
2
|
-
import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js';
|
|
2
|
+
import { OBSERVATION_MAX_CHARS, STALE_REF_MESSAGE, truncateObservation } from './drivers/driver.js';
|
|
3
|
+
|
|
4
|
+
export { STALE_REF_MESSAGE };
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* The client tool set for `persona_playtest.act`.
|
|
@@ -23,8 +25,12 @@ import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js'
|
|
|
23
25
|
* `snapshot`, `screenshot` and `wait` do not.
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Note kinds, from spec 03's `log_note`, plus `waiting`: the persona is
|
|
30
|
+
* waiting for something to finish (a load, an animation, a turn). A waiting
|
|
31
|
+
* note is context for the report and never becomes a finding.
|
|
32
|
+
*/
|
|
33
|
+
export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion', 'reaction', 'waiting']);
|
|
28
34
|
|
|
29
35
|
/** Severities, from the report schema's finding severity enum. */
|
|
30
36
|
export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
|
|
@@ -52,7 +58,7 @@ const note = {
|
|
|
52
58
|
additionalProperties: false,
|
|
53
59
|
required: ['kind', 'text'],
|
|
54
60
|
properties: {
|
|
55
|
-
kind: { type: 'string', enum: [...NOTE_KINDS] },
|
|
61
|
+
kind: { type: 'string', enum: [...NOTE_KINDS], description: 'waiting means you are waiting for something to finish; it is recorded as context and never reported as a finding.' },
|
|
56
62
|
text: { type: 'string' },
|
|
57
63
|
severity: { type: 'string', enum: [...SEVERITIES] }
|
|
58
64
|
}
|
|
@@ -71,12 +77,13 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
71
77
|
},
|
|
72
78
|
{
|
|
73
79
|
name: 'click',
|
|
74
|
-
description: 'Click one thing. Spends an action.
|
|
80
|
+
description: 'Click one thing. Spends an action. On playwright_web, use the ref the screen shows next to the element, such as e12: it names exactly one element, even when two share a name. Otherwise name the target: on playwright_web an accessibility node such as button "Start" or a visible text fragment; on godot_driver a node path from the scene tree you were shown. Targets are refs or visible text, never locator code such as getByRole(...) or page.locator(...). Avoid x and y coordinates on playwright_web: they are blind to the page, and are only for a canvas with no refs, taken from the @(x,y) the screen shows or from the screenshot.',
|
|
75
81
|
input_schema: {
|
|
76
82
|
type: 'object',
|
|
77
83
|
additionalProperties: false,
|
|
78
84
|
properties: {
|
|
79
|
-
|
|
85
|
+
ref: { type: 'string', description: 'playwright_web only, the preferred target: the ref shown on the element, such as e12.' },
|
|
86
|
+
target: { type: 'string', description: 'web: button "Start" or Start Game. Godot: a node path such as /root/Main/UI/StartButton.' },
|
|
80
87
|
x: { type: 'number' },
|
|
81
88
|
y: { type: 'number' },
|
|
82
89
|
thought_in_character: thought,
|
|
@@ -95,7 +102,8 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
95
102
|
text: { type: 'string' },
|
|
96
103
|
submit: { type: 'boolean' },
|
|
97
104
|
replace: { type: 'boolean', description: 'Empty the field first, so it holds only this text. playwright_web only; other drivers refuse it.' },
|
|
98
|
-
|
|
105
|
+
ref: { type: 'string', description: 'playwright_web only, the preferred way to name the field: its ref, such as e12.' },
|
|
106
|
+
target: { type: 'string', description: 'playwright_web only, other drivers refuse it: the field to type into, named like a click target, such as textbox "Email". Without it or a ref, the focused field.' },
|
|
99
107
|
thought_in_character: thought,
|
|
100
108
|
note
|
|
101
109
|
}
|
|
@@ -186,6 +194,7 @@ export function actionFor(toolUse) {
|
|
|
186
194
|
const input = toolUse.input || {};
|
|
187
195
|
switch (toolUse.name) {
|
|
188
196
|
case 'click':
|
|
197
|
+
if (typeof input.ref === 'string' && input.ref.trim()) return { kind: 'click', ref: input.ref.trim() };
|
|
189
198
|
return Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y))
|
|
190
199
|
? { kind: 'click', target: { x: Number(input.x), y: Number(input.y) } }
|
|
191
200
|
: { kind: 'click', target: String(input.target || '') };
|
|
@@ -193,6 +202,7 @@ export function actionFor(toolUse) {
|
|
|
193
202
|
const action = { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
|
|
194
203
|
if (input.replace === true) action.replace = true;
|
|
195
204
|
if (typeof input.target === 'string' && input.target.trim()) action.target = input.target;
|
|
205
|
+
if (typeof input.ref === 'string' && input.ref.trim()) action.ref = input.ref.trim();
|
|
196
206
|
return action;
|
|
197
207
|
}
|
|
198
208
|
case 'key':
|
|
@@ -220,9 +230,11 @@ export function actionFor(toolUse) {
|
|
|
220
230
|
* @param {Object} args.toolUse
|
|
221
231
|
* @param {(note: Object) => void} [args.onNote]
|
|
222
232
|
* @param {(shot: Object) => void} [args.onScreenshot]
|
|
233
|
+
* @param {boolean} [args.staleRefs] an earlier action in this turn changed the
|
|
234
|
+
* screen, so a ref in this call was read off a screen that is gone
|
|
223
235
|
* @returns {Promise<{result: Object, observation: Object|null, spentAction: boolean, changed: boolean, error: string|null}>}
|
|
224
236
|
*/
|
|
225
|
-
export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
|
|
237
|
+
export async function executeTool({ driver, toolUse, onNote, onScreenshot, staleRefs = false }) {
|
|
226
238
|
const input = toolUse.input || {};
|
|
227
239
|
if (toolUse.malformed) {
|
|
228
240
|
return {
|
|
@@ -280,7 +292,15 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
|
|
|
280
292
|
};
|
|
281
293
|
}
|
|
282
294
|
|
|
283
|
-
|
|
295
|
+
// A ref is a playwright_web target. Every other driver is told so by name,
|
|
296
|
+
// before anything reaches the game.
|
|
297
|
+
// A ref in a later tool call of a turn whose earlier call already changed
|
|
298
|
+
// the screen was read off a screen that is gone.
|
|
299
|
+
const outcome = action.ref && !driver.supportsRefs
|
|
300
|
+
? { ok: false, changed: false, error: `ref targets are only available on playwright_web; ${driver.name || 'this driver'} takes a named target or coordinates`, driverError: true }
|
|
301
|
+
: action.ref && staleRefs
|
|
302
|
+
? { ok: false, changed: false, error: STALE_REF_MESSAGE, driverError: true }
|
|
303
|
+
: await driver.act(action);
|
|
284
304
|
const observation = await driver.observe();
|
|
285
305
|
const spentAction = ACTION_TOOLS.includes(toolUse.name) && outcome.ok;
|
|
286
306
|
const prefix = outcome.ok
|
|
@@ -291,7 +311,8 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
|
|
|
291
311
|
observation,
|
|
292
312
|
spentAction,
|
|
293
313
|
changed: Boolean(outcome.changed),
|
|
294
|
-
error: outcome.ok ? null : String(outcome.error || 'act failed')
|
|
314
|
+
error: outcome.ok ? null : String(outcome.error || 'act failed'),
|
|
315
|
+
driverError: !outcome.ok && Boolean(outcome.driverError)
|
|
295
316
|
};
|
|
296
317
|
}
|
|
297
318
|
|
package/src/run/usage.js
CHANGED
|
@@ -29,6 +29,7 @@ export function createUsageLedger() {
|
|
|
29
29
|
const byModel = new Map();
|
|
30
30
|
const started = Date.now();
|
|
31
31
|
let calls = 0;
|
|
32
|
+
let modelMs = 0;
|
|
32
33
|
let serverTotalUsd = null;
|
|
33
34
|
let serverCalls = null;
|
|
34
35
|
|
|
@@ -36,8 +37,10 @@ export function createUsageLedger() {
|
|
|
36
37
|
/**
|
|
37
38
|
* @param {{model: string|null, usage: Object}} turn
|
|
38
39
|
*/
|
|
39
|
-
record({ model, usage }) {
|
|
40
|
+
record({ model, usage, ms }) {
|
|
40
41
|
calls += 1;
|
|
42
|
+
// Time spent waiting on the model, when the caller measured it.
|
|
43
|
+
if (Number.isFinite(Number(ms)) && Number(ms) > 0) modelMs += Number(ms);
|
|
41
44
|
const key = model || 'unknown';
|
|
42
45
|
const entry = byModel.get(key) || { model: key, input_tokens: 0, cache_read_tokens: 0, output_tokens: 0, usd: 0 };
|
|
43
46
|
// The report schema has no cache-creation field, so creation tokens
|
|
@@ -64,6 +67,20 @@ export function createUsageLedger() {
|
|
|
64
67
|
return calls;
|
|
65
68
|
},
|
|
66
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Wall time since the ledger was made, and time spent waiting on model
|
|
72
|
+
* calls, in seconds. What `complete` sends as `duration`, so the run card
|
|
73
|
+
* does not read 0s for a run that played for minutes.
|
|
74
|
+
*
|
|
75
|
+
* @returns {{wall_seconds: number, model_seconds: number}}
|
|
76
|
+
*/
|
|
77
|
+
duration() {
|
|
78
|
+
return {
|
|
79
|
+
wall_seconds: Number(((Date.now() - started) / 1000).toFixed(3)),
|
|
80
|
+
model_seconds: Number((modelMs / 1000).toFixed(3))
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
|
|
67
84
|
/**
|
|
68
85
|
* The `usage` object as the report schema wants it.
|
|
69
86
|
*
|
|
@@ -87,6 +104,7 @@ export function createUsageLedger() {
|
|
|
87
104
|
const report = this.toReportUsage();
|
|
88
105
|
return {
|
|
89
106
|
...report,
|
|
107
|
+
model_seconds: Number((modelMs / 1000).toFixed(3)),
|
|
90
108
|
local_model_calls: calls,
|
|
91
109
|
server_model_calls: serverCalls,
|
|
92
110
|
usd_source: serverTotalUsd === null ? 'unavailable' : 'server',
|