testronaut 1.11.0 → 1.12.1

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.
Files changed (37) hide show
  1. package/README.md +226 -0
  2. package/TODO.md +5 -0
  3. package/bin/cli.js +232 -5
  4. package/bin/terminalUi.js +9 -1
  5. package/core/agent.js +7 -2
  6. package/core/browserEvidence.js +184 -0
  7. package/core/jevRouting.js +77 -0
  8. package/core/jevShadow.js +325 -0
  9. package/core/jevSuiteSelection.js +39 -0
  10. package/core/jevUsage.js +62 -0
  11. package/core/missionDiscovery.js +56 -6
  12. package/core/optimization.js +98 -0
  13. package/core/turnLoop.js +232 -11
  14. package/experiments/CROSS_PROVIDER.md +19 -0
  15. package/experiments/README.md +62 -0
  16. package/experiments/buildExperimentPublicationPackage.js +219 -0
  17. package/experiments/buildOptimizationDataPackage.js +390 -0
  18. package/experiments/crossProviderBenchmark.js +227 -0
  19. package/experiments/optimizationBenchmark.js +339 -0
  20. package/package.json +5 -1
  21. package/tests/binTests/cli.helpers.test.js +16 -0
  22. package/tests/binTests/cli.integration.test.js +18 -1
  23. package/tests/binTests/terminalUi.test.js +2 -1
  24. package/tests/coreTests/agent.test.js +2 -0
  25. package/tests/coreTests/browserEvidence.test.js +70 -0
  26. package/tests/coreTests/jevRouting.test.js +57 -0
  27. package/tests/coreTests/jevShadow.test.js +158 -0
  28. package/tests/coreTests/jevSuiteSelection.test.js +24 -0
  29. package/tests/coreTests/jevUsage.test.js +58 -0
  30. package/tests/coreTests/missionDiscovery.test.js +44 -0
  31. package/tests/coreTests/optimization.test.js +80 -0
  32. package/tests/coreTests/turnLoop.test.js +351 -1
  33. package/tests/experimentTests/buildOptimizationDataPackage.test.js +25 -0
  34. package/tests/experimentTests/crossProviderBenchmark.test.js +37 -0
  35. package/tests/experimentTests/optimizationBenchmark.test.js +49 -0
  36. package/tests/toolsTests/generateHtmlReport.test.js +100 -0
  37. package/tools/generateHtmlReport.js +67 -10
@@ -18,6 +18,7 @@
18
18
  import fs from 'fs';
19
19
  import path from 'path';
20
20
  import { normalizeTags } from '../core/tags.js';
21
+ import { summarizeJevUsage } from '../core/jevUsage.js';
21
22
 
22
23
  /**
23
24
  * Render and write a Testronaut run report to disk.
@@ -27,10 +28,11 @@ import { normalizeTags } from '../core/tags.js';
27
28
  * @returns {string} absolute path to the written HTML file
28
29
  */
29
30
  export function generateHtmlReport(report, outputPath) {
30
- const { runId, startTime, endTime, missions = [], summary = {}, llm = {}, cli = {} } = report;
31
+ const { runId, startTime, endTime, missions = [], summary = {}, llm = {}, cli = {}, optimization } = report;
31
32
  const reportTags = normalizeTags(report.tags ?? missions.flatMap(m => m.submissionType === 'mission' ? (m.tags ?? []) : []));
32
- const durationSec =
33
- (startTime && endTime)
33
+ const durationSec = Number.isFinite(report.durationMs)
34
+ ? (report.durationMs / 1000).toFixed(2)
35
+ : (startTime && endTime)
34
36
  ? ((new Date(endTime) - new Date(startTime)) / 1000).toFixed(2)
35
37
  : '—';
36
38
 
@@ -47,6 +49,7 @@ export function generateHtmlReport(report, outputPath) {
47
49
  : String(llm.provider ?? '').trim().toLowerCase();
48
50
  const provider = {
49
51
  openai: { name: 'OpenAI', logo: '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"/></svg>' },
52
+ gemini: { name: 'Google Gemini', logo: '<svg viewBox="0 0 24 24" aria-hidden="true"><defs><linearGradient id="gemini-gradient" x1="4" y1="20" x2="20" y2="4"><stop stop-color="#3186ff"/><stop offset=".5" stop-color="#8e68ff"/><stop offset="1" stop-color="#ef78b5"/></linearGradient></defs><path fill="url(#gemini-gradient)" d="M12 2c.7 5.8 4.2 9.3 10 10-5.8.7-9.3 4.2-10 10-.7-5.8-4.2-9.3-10-10 5.8-.7 9.3-4.2 10-10z"/></svg>' },
50
53
  anthropic: { name: 'Anthropic', logo: '<img src="data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8dGl0bGU+Q2xhdWRlPC90aXRsZT4KICA8cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHJ4PSI1IiBmaWxsPSIjRDk3NzU3Ii8+CiAgPHBhdGggZmlsbD0iI2ZmZiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMyAzKSBzY2FsZSguNzUpIiBkPSJNNC43MDkgMTUuOTU1bDQuNzItMi42NDcuMDgtLjIzLS4wOC0uMTI4SDkuMmwtLjc5LS4wNDgtMi42OTgtLjA3My0yLjMzOS0uMDk3LTIuMjY2LS4xMjItLjU3MS0uMTIxTDAgMTEuNzg0bC4wNTUtLjM1Mi40OC0uMzIxLjY4Ni4wNiAxLjUyLjEwMyAyLjI3OC4xNTggMS42NTIuMDk3IDIuNDQ5LjI1NWguMzg5bC4wNTUtLjE1Ny0uMTM0LS4wOTgtLjEwMy0uMDk3LTIuMzU4LTEuNTk2LTIuNTUyLTEuNjg4LTEuMzM2LS45NzItLjcyNC0uNDkxLS4zNjQtLjQ2Mi0uMTU4LTEuMDA4LjY1Ni0uNzIyLjg4MS4wNi4yMjUuMDYxLjg5My42ODYgMS45MDggMS40NzYgMi40OTEgMS44MzMuMzY1LjMwNC4xNDUtLjEwMy4wMTktLjA3My0uMTY0LS4yNzQtMS4zNTUtMi40NDYtMS40NDYtMi40OS0uNjQ0LTEuMDMyLS4xNy0uNjE5YTIuOTcgMi45NyAwIDAxLS4xMDQtLjcyOUw2LjI4My4xMzQgNi42OTYgMGwuOTk2LjEzNC40Mi4zNjQuNjIgMS40MTQgMS4wMDIgMi4yMjkgMS41NTUgMy4wMy40NTYuODk4LjI0My44MzIuMDkxLjI1NWguMTU4VjkuMDFsLjEyOC0xLjcwNi4yMzctMi4wOTUuMjMtMi42OTUuMDgtLjc2LjM3Ni0uOTEuNzQ3LS40OTIuNTg0LjI4LjQ4LjY4NS0uMDY3LjQ0NC0uMjg2IDEuODUxLS41NTkgMi45MDMtLjM2NCAxLjk0MmguMjEybC4yNDMtLjI0Mi45ODUtMS4zMDYgMS42NTItMi4wNjQuNzMtLjgyLjg1LS45MDQuNTQ3LS40MzFoMS4wMzNsLjc2IDEuMTI5LS4zNCAxLjE2Ni0xLjA2NCAxLjM0Ny0uODgxIDEuMTQyLTEuMjY0IDEuNy0uNzkgMS4zNi4wNzMuMTEuMTg4LS4wMiAyLjg1Ni0uNjA2IDEuNTQzLS4yOCAxLjg0MS0uMzE1LjgzMy4zODguMDkxLjM5NS0uMzI4LjgwNy0xLjk2OS40ODYtMi4zMDkuNDYyLTMuNDM5LjgxMy0uMDQyLjAzLjA0OS4wNjEgMS41NDkuMTQ2LjY2Mi4wMzZoMS42MjJsMy4wMi4yMjUuNzkuNTIyLjQ3NC42MzgtLjA3OS40ODUtMS4yMTUuNjItMS42NC0uMzg5LTMuODI5LS45MS0xLjMxMi0uMzI5aC0uMTgydi4xMWwxLjA5MyAxLjA2OCAyLjAwNiAxLjgxIDIuNTA5IDIuMzMuMTI3LjU3OC0uMzIyLjQ1NS0uMzQtLjA0OS0yLjIwNS0xLjY1Ny0uODUxLS43NDctMS45MjYtMS42MmgtLjEyOHYuMTdsLjQ0NC42NDkgMi4zNDUgMy41MjEuMTIyIDEuMDgtLjE3LjM1My0uNjA4LjIxMy0uNjY4LS4xMjItMS4zNzQtMS45MjUtMS40MTUtMi4xNjctMS4xNDMtMS45NDMtLjE0LjA4LS42NzQgNy4yNTQtLjMxNi4zNy0uNzI5LjI4LS42MDctLjQ2MS0uMzIyLS43NDcuMzIyLTEuNDc2LjM4OS0xLjkyNC4zMTUtMS41My4yODYtMS45LjE3LS42MzItLjAxMi0uMDQyLS4xNC4wMTgtMS40MzQgMS45NjctMi4xOCAyLjk0NS0xLjcyNiAxLjg0NS0uNDE0LjE2NC0uNzE3LS4zNy4wNjctLjY2Mi40MDEtLjU4OSAyLjM4OC0zLjAzNiAxLjQ0LTEuODgyLjkzLTEuMDg2LS4wMDYtLjE1OGgtLjA1NUw0LjEzMiAxOC41NmwtMS4xMy4xNDYtLjQ4Ny0uNDU2LjA2MS0uNzQ2LjIzMS0uMjQzIDEuOTA4LTEuMzEyLS4wMDYuMDA2eiIvPgo8L3N2Zz4K" alt="" />' },
51
54
  }[providerKey] ?? { name: llm.provider || 'Unknown provider', logo: '' };
52
55
  const tagPalette = [
@@ -59,14 +62,45 @@ export function generateHtmlReport(report, outputPath) {
59
62
  const [color, background] = tagPalette[index];
60
63
  return `color:${color};background:${background};border-color:${color}66`;
61
64
  };
65
+ const formatTokens = value => Number(value || 0).toLocaleString('en-US');
66
+ const summarizeModelTokens = (steps) => steps.reduce((usage, step) => {
67
+ const input = Number(step.inputTokens || 0);
68
+ const output = Number(step.outputTokens || 0);
69
+ const model = String(step.model || llm.model || 'Model');
70
+ usage.input += input;
71
+ usage.output += output;
72
+ usage.total += Number(step.tokensUsed ?? (input + output));
73
+ usage.byModel[model] ||= { input: 0, output: 0, total: 0, turns: 0 };
74
+ usage.byModel[model].input += input;
75
+ usage.byModel[model].output += output;
76
+ usage.byModel[model].total += Number(step.tokensUsed ?? (input + output));
77
+ usage.byModel[model].turns += 1;
78
+ return usage;
79
+ }, { input: 0, output: 0, total: 0, byModel: {} });
80
+ const tokenMeta = (modelUsage, jevUsage) => [
81
+ `Model tokens: ${formatTokens(modelUsage.total)} (${formatTokens(modelUsage.input)} in / ${formatTokens(modelUsage.output)} out)`,
82
+ Object.keys(modelUsage.byModel).length > 1
83
+ ? `By model: ${Object.entries(modelUsage.byModel).map(([model, usage]) => `${model} ${formatTokens(usage.total)}`).join(' / ')}`
84
+ : '',
85
+ jevUsage?.completed ? `Jev tokens: ${formatTokens(jevUsage.inputTokens)} in / ${formatTokens(jevUsage.outputTokens)} out` : '',
86
+ ].filter(Boolean).join(' • ');
87
+ const reportJevUsage = report.jevShadow?.evaluations
88
+ ? report.jevShadow
89
+ : summarizeJevUsage(missions.flatMap(m => Array.isArray(m.steps) ? m.steps : []));
90
+ const reportModelUsage = summarizeModelTokens(missions.flatMap(m => Array.isArray(m.steps) ? m.steps : []));
91
+ const reportModels = Object.keys(reportModelUsage.byModel);
92
+ const jevLogo = '<svg viewBox="0 0 24 24" role="img" aria-hidden="true"><rect width="24" height="24" rx="4" fill="#e547b5"/><g fill="none" stroke="#181a1b" stroke-width="2.25" stroke-linecap="square" stroke-linejoin="round"><path d="M12 2.5 6.1 6v10.1l3.2 1.9 2.7-1.6V9.6l3-1.8V4.3z"/><path d="m9.3 9.6 2.7 1.6 3-1.8 3 1.8v7L12 21.5l-3-1.8V13z"/><path d="m12 11.2 3 1.8 3-1.8M15 13v5.2l-6 3.3M9.3 9.6 12 8l3 1.8"/></g></svg>';
62
93
 
63
94
  const submissionBlock = (m) => {
64
- const mDurationSec =
65
- m.endTime && m.startTime
95
+ const mDurationSec = Number.isFinite(m.durationMs)
96
+ ? (m.durationMs / 1000).toFixed(2)
97
+ : m.endTime && m.startTime
66
98
  ? ((m.endTime - m.startTime) / 1000).toFixed(2)
67
99
  : (typeof m.durationSeconds === 'number' ? m.durationSeconds.toFixed(2) : '—');
68
100
 
69
101
  const steps = Array.isArray(m.steps) ? m.steps : [];
102
+ const missionJevUsage = m.jevShadow?.evaluations ? m.jevShadow : summarizeJevUsage(steps);
103
+ const missionModelUsage = summarizeModelTokens(steps);
70
104
  const stepItems = steps.map((step, idx) => {
71
105
  const events = Array.isArray(step.events) ? step.events : [];
72
106
  const ok = /✅|Passed|Mission Success/i.test(step.result || '');
@@ -87,6 +121,19 @@ export function generateHtmlReport(report, outputPath) {
87
121
  const imgTag = step.screenshotPath
88
122
  ? `<img src="${esc(step.screenshotPath)}" alt="screenshot turn ${esc(step.turn ?? idx)}">`
89
123
  : '';
124
+ const stepJev = step.jevShadow?.status === 'ok'
125
+ ? ` • Jev: ${formatTokens(step.jevShadow.usage?.input_tokens)} in / ${formatTokens(step.jevShadow.usage?.output_tokens)} out`
126
+ : step.jevShadow
127
+ ? ` • Jev: ${esc(step.jevShadow.status)}${step.jevShadow.reason ? ` (${esc(step.jevShadow.reason)})` : ''}`
128
+ : '';
129
+ const routeLabel = step.modelRouting?.fallback
130
+ ? ' • Jev route fallback'
131
+ : step.modelRouting?.applied
132
+ ? ' • routed by Jev'
133
+ : '';
134
+ const stepDuration = Number.isFinite(step.durationMs)
135
+ ? ` • ${(step.durationMs / 1000).toFixed(2)}s`
136
+ : '';
90
137
 
91
138
  const plan = (typeof step.summary === 'string' && step.summary.trim())
92
139
  ? step.summary.trim()
@@ -98,11 +145,11 @@ export function generateHtmlReport(report, outputPath) {
98
145
  return `
99
146
  <details class="step" ${ok ? '' : 'open'}>
100
147
  <summary>
101
- <span class="turn">Turn ${esc((step.turn ?? idx) + 1)}${esc(attemptLabel)}</span>
148
+ <span class="turn">${step.isClosureTurn ? 'Closure' : `Turn ${esc((step.turn ?? idx) + 1)}`}${esc(attemptLabel)}${stepDuration}</span>
102
149
  ${humanInput}
103
150
  ${planSpan}
104
151
  <span class="step-result ${ok ? 'ok' : 'bad'}" ${resultTooltip ? `title="${esc(resultTooltip)}"` : ''}>${esc(resultRaw)}</span>
105
- <span class="tokens">tokens: ${esc(step.tokensUsed ?? '—')} (in: ${esc(step.inputTokens ?? '—')}, out: ${esc(step.outputTokens ?? '—')}) / rolling: ${esc(step.totalTokensUsed ?? '—')}</span>
152
+ <span class="tokens">${esc(step.model || llm.model || 'Model')} tokens: ${esc(step.tokensUsed ?? '—')} (${esc(step.inputTokens ?? '—')} in / ${esc(step.outputTokens ?? '—')} out)${routeLabel}${stepJev}</span>
106
153
  </summary>
107
154
  <pre class="events">${esc(events.join('\n')) || '(no events)'}</pre>
108
155
  ${imgTag}
@@ -120,7 +167,7 @@ export function generateHtmlReport(report, outputPath) {
120
167
  <summary>
121
168
  <span class="name">${prettyTitle}</span>
122
169
  <span class="status ${statusClass}">${badge(m.status)}</span>
123
- <span class="meta">steps: ${steps.length} • duration: ${mDurationSec}s</span>
170
+ <span class="meta">steps: ${steps.length} • duration: ${mDurationSec}s • ${esc(tokenMeta(missionModelUsage, missionJevUsage))}</span>
124
171
  <span class="toolbar">
125
172
  <button class="btn-mini toggle" data-scope="submission" aria-label="Expand">▼</button>
126
173
  </span>
@@ -151,6 +198,8 @@ export function generateHtmlReport(report, outputPath) {
151
198
  const groupDur = (firstStart != null && lastEnd != null) ? ((lastEnd - firstStart) / 1000).toFixed(2) : '—';
152
199
  const tags = normalizeTags(subs.flatMap(s => s.submissionType === 'mission' ? (s.tags ?? []) : []));
153
200
  const sourceFiles = [...new Set(subs.map(s => s.file).filter(Boolean))];
201
+ const groupJevUsage = summarizeJevUsage(subs.flatMap(s => Array.isArray(s.steps) ? s.steps : []));
202
+ const groupModelUsage = summarizeModelTokens(subs.flatMap(s => Array.isArray(s.steps) ? s.steps : []));
154
203
 
155
204
  // pre → mission → post
156
205
  const order = { premission: 0, mission: 1, postmission: 2 };
@@ -165,7 +214,7 @@ export function generateHtmlReport(report, outputPath) {
165
214
  <span class="name">${esc(missionName)}</span>
166
215
  <span class="mission-tags">${tags.map(tag => `<span class="tag-small" style="${tagStyle(tag)}">${esc(tag)}</span>`).join('')}</span>
167
216
  <span class="status ${status === 'failed' ? 'bad' : 'ok'}">${badge(status)}</span>
168
- <span class="meta">${sourceFiles.length ? `${sourceFiles.map(esc).join(', ')} • ` : ''}submissions: ${subs.length} • steps: ${totalSteps} • duration: ${groupDur}s</span>
217
+ <span class="meta">${sourceFiles.length ? `${sourceFiles.map(esc).join(', ')} • ` : ''}submissions: ${subs.length} • steps: ${totalSteps} • duration: ${groupDur}s • ${esc(tokenMeta(groupModelUsage, groupJevUsage))}</span>
169
218
  <span class="toolbar">
170
219
  <button class="btn-mini toggle" data-scope="mission" aria-label="Expand">▼</button>
171
220
  </span>
@@ -277,6 +326,7 @@ export function generateHtmlReport(report, outputPath) {
277
326
  .pill:first-child{border-color:rgba(255,138,67,.5);box-shadow:inset 3px 0 var(--orange);}
278
327
  .pill.ok{ background: var(--chip-ok-bg); color: var(--ok); border-color: var(--chip-ok-border); font-weight:700; }
279
328
  .pill.bad{ background: var(--chip-bad-bg); color: var(--bad); border-color: var(--chip-bad-border); font-weight:700; }
329
+ .pill.warn{ background:rgba(251,191,36,.14);color:#fbbf24;border-color:rgba(251,191,36,.4);font-weight:700; }
280
330
  .tag-filter{max-width:928px;margin:0 auto 20px;padding:18px 16px;background:rgba(2,6,23,.30);}
281
331
  .tag-filter-heading{display:flex;align-items:center;gap:7px;margin-bottom:12px;flex-wrap:wrap;}
282
332
  .tag-filter-title{font-size:14px;color:var(--text);font-weight:800;}
@@ -377,7 +427,7 @@ export function generateHtmlReport(report, outputPath) {
377
427
  <div class="eyebrow">Mission Control · Flight Report</div>
378
428
  <h1>🧑‍🚀 <span class="gradient-text">Testronaut Report</span></h1>
379
429
  <div class="run-meta">
380
- <div class="provider-meta"><span class="provider-logo" title="${esc(provider.name)}" aria-label="${esc(provider.name)}">${provider.logo}</span><strong>${esc(llm.model ?? '—')}</strong></div>
430
+ <div class="provider-meta"><span class="provider-logo" title="${esc(provider.name)}" aria-label="${esc(provider.name)}">${provider.logo}</span><strong>${esc((reportModels.length ? reportModels : [llm.model ?? '—']).join(' + '))}</strong>${reportJevUsage.evaluations ? ` <span aria-hidden="true">+</span> <span class="provider-logo" title="Jev" aria-label="Jev">${jevLogo}</span><strong>Jev</strong>` : ''}</div>
381
431
  <div><strong>Run ID:</strong> ${esc(runId ?? '—')}</div>
382
432
  <div><strong>Start:</strong> ${esc(startTime ?? '—')}</div>
383
433
  <div><strong>End:</strong> ${esc(endTime ?? '—')}</div>
@@ -390,6 +440,13 @@ export function generateHtmlReport(report, outputPath) {
390
440
  <div class="pill">Missions: ${esc(summary.totalMissions ?? totals.total)}</div>
391
441
  <div class="pill ok">Passed: ${esc(summary.passed ?? totals.passed)}</div>
392
442
  <div class="pill bad">Failed: ${esc(summary.failed ?? totals.failed)}</div>
443
+ ${optimization?.configured ? `<div class="pill">Optimization: ${esc(optimization.objective)} (${esc(optimization.mode)})</div>` : ''}
444
+ ${Object.entries(reportModelUsage.byModel).length > 1
445
+ ? Object.entries(reportModelUsage.byModel).map(([model, usage]) => `<div class="pill">${esc(model)} tokens: ${esc(formatTokens(usage.total))} (${esc(formatTokens(usage.input))} in / ${esc(formatTokens(usage.output))} out)</div>`).join('')
446
+ : `<div class="pill">Model tokens: ${esc(formatTokens(reportModelUsage.total))} (${esc(formatTokens(reportModelUsage.input))} in / ${esc(formatTokens(reportModelUsage.output))} out)</div>`}
447
+ ${reportJevUsage.completed ? `<div class="pill">Jev tokens: ${esc(formatTokens(reportJevUsage.inputTokens))} in / ${esc(formatTokens(reportJevUsage.outputTokens))} out</div>` : ''}
448
+ ${reportJevUsage.skipped ? `<div class="pill warn">Jev skipped: ${esc(formatTokens(reportJevUsage.skipped))}</div>` : ''}
449
+ ${reportJevUsage.errors ? `<div class="pill warn">Jev errors: ${esc(formatTokens(reportJevUsage.errors))}</div>` : ''}
393
450
  </div>
394
451
 
395
452
  <div class="tag-filter glass">