div0-space commited on
Commit
4bc1c60
·
verified ·
1 Parent(s): cd7eade

Parse reasoning, tool calls, route and usage; live OpenRouter pricing

Browse files
Files changed (1) hide show
  1. api-tester.html +639 -141
api-tester.html CHANGED
@@ -314,7 +314,7 @@
314
  flex-direction: column;
315
  gap: 0.5rem;
316
  min-height: 200px;
317
- max-height: 400px;
318
  overflow-y: auto;
319
  }
320
 
@@ -371,6 +371,7 @@
371
  }
372
 
373
  .status-dot.success { background: var(--success); }
 
374
  .status-dot.error { background: var(--error); }
375
 
376
  @keyframes pulse {
@@ -385,7 +386,7 @@
385
  line-height: 1.5;
386
  white-space: pre-wrap;
387
  word-break: break-word;
388
- max-height: 150px;
389
  overflow-y: auto;
390
  }
391
 
@@ -394,6 +395,30 @@
394
  overflow: hidden;
395
  }
396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  /* Stream Preview */
398
  .stream-preview {
399
  font-family: 'SF Mono', monospace;
@@ -414,7 +439,7 @@
414
  background: rgba(99, 102, 241, 0.05);
415
  border-top: 1px solid var(--border);
416
  display: grid;
417
- grid-template-columns: repeat(3, 1fr);
418
  gap: 0.5rem;
419
  font-size: 0.75rem;
420
  }
@@ -513,16 +538,21 @@
513
  <div class="field">
514
  <label>Output</label>
515
  <div class="mode-toggle">
516
- <label title="Show raw SSE events as-is">
517
- <input type="radio" name="outputMode" value="raw" checked>
518
  <span>RAW</span>
519
  </label>
520
- <label title="Parse and show only text content">
521
- <input type="radio" name="outputMode" value="parsed">
522
  <span>Parsed</span>
523
  </label>
524
  </div>
525
  </div>
 
 
 
 
 
526
  </div>
527
 
528
  <div class="toolbar">
@@ -608,8 +638,8 @@
608
  return div.innerHTML;
609
  }
610
 
611
- // Results storage for export
612
- const testResults = new Map();
613
 
614
  function collectResults() {
615
  const results = {
@@ -632,11 +662,8 @@
632
  },
633
  prompts: [],
634
  responses: [],
635
- stats: {
636
- ttft: document.getElementById(`${laneId}-ttft`)?.textContent || '-',
637
- tps: document.getElementById(`${laneId}-tps`)?.textContent || '-',
638
- total: document.getElementById(`${laneId}-total`)?.textContent || '-'
639
- }
640
  };
641
 
642
  // Collect prompts
@@ -644,10 +671,8 @@
644
  for (let i = 1; i <= chainLength; i++) {
645
  const promptEl = document.getElementById(`${laneId}-prompt-${i}`);
646
  if (promptEl) laneData.prompts.push(promptEl.value);
647
-
648
- const outputEl = document.getElementById(`${laneId}-output-${i}`);
649
- if (outputEl) laneData.responses.push(outputEl.textContent);
650
  }
 
651
 
652
  results.lanes.push(laneData);
653
  });
@@ -674,13 +699,23 @@
674
  text += `Stream Mode: ${results.globalConfig.streamMode}\n\n`;
675
 
676
  results.lanes.forEach((lane, idx) => {
 
677
  text += `═══ Lane ${idx + 1}: ${lane.config.model} ═══\n`;
678
  text += `Endpoint: ${lane.config.endpoint}\n`;
679
- text += `Stats: TTFT=${lane.stats.ttft} | tok/s=${lane.stats.tps} | Total=${lane.stats.total}\n\n`;
680
 
681
  lane.prompts.forEach((prompt, i) => {
682
  text += `[Prompt ${i+1}]: ${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}\n`;
683
- text += `[Response ${i+1}]: ${(lane.responses[i] || '').slice(0, 200)}${(lane.responses[i] || '').length > 200 ? '...' : ''}\n\n`;
 
 
 
 
 
 
 
 
 
684
  });
685
  text += '\n';
686
  });
@@ -779,6 +814,7 @@
779
  model: document.getElementById(`${laneId}-model`)?.value || 'chat',
780
  chain: chainCount,
781
  systemPrompt: document.getElementById(`${laneId}-system`)?.value || '',
 
782
  prompts: []
783
  };
784
 
@@ -844,6 +880,8 @@
844
  updatePrompts(laneId);
845
  }
846
  if (systemEl) systemEl.value = laneData.systemPrompt;
 
 
847
 
848
  // Set prompts (after updatePrompts created the fields)
849
  setTimeout(() => {
@@ -899,6 +937,7 @@
899
  updatePrompts('lane-1'); // Generate all 4 canonical prompts
900
  updateLogCount();
901
  updatePresetDropdown();
 
902
  });
903
 
904
  function addLane() {
@@ -939,6 +978,10 @@
939
  <input type="number" id="${laneId}-chain" value="4" min="1" max="10" onchange="updatePrompts('${laneId}')">
940
  </div>
941
  </div>
 
 
 
 
942
  <div class="field">
943
  <label>System Prompt (optional)</label>
944
  <textarea id="${laneId}-system" placeholder="You are a helpful assistant..."></textarea>
@@ -978,18 +1021,30 @@
978
  </div>
979
 
980
  <div class="lane-stats" id="${laneId}-stats" style="display: none;">
981
- <div class="stat">
982
  <div class="stat-value" id="${laneId}-ttft">-</div>
983
  <div class="stat-label">TTFT</div>
984
  </div>
985
- <div class="stat">
986
  <div class="stat-value" id="${laneId}-tps">-</div>
987
- <div class="stat-label">tok/s</div>
 
 
 
 
 
 
 
 
988
  </div>
989
  <div class="stat">
990
  <div class="stat-value" id="${laneId}-total">-</div>
991
  <div class="stat-label">Total</div>
992
  </div>
 
 
 
 
993
  </div>
994
  `;
995
 
@@ -1170,6 +1225,7 @@
1170
  outputSection.innerHTML = '<h4>Responses</h4>';
1171
  document.getElementById(`${laneId}-stats`).style.display = 'none';
1172
  });
 
1173
  }
1174
 
1175
  function handleAttachmentSelection(laneId) {
@@ -1286,9 +1342,8 @@
1286
  statsSection.style.display = 'grid';
1287
 
1288
  let previousResponseId = null;
1289
- let totalTokens = 0;
1290
- let totalTime = 0;
1291
- let firstTTFT = null;
1292
 
1293
  for (let step = 1; step <= chainCount; step++) {
1294
  const prompt = document.getElementById(`${laneId}-prompt-${step}`).value;
@@ -1327,17 +1382,16 @@
1327
  });
1328
 
1329
  previousResponseId = result.responseId;
1330
- totalTokens += result.tokens;
1331
- totalTime += result.time;
1332
- if (step === 1) firstTTFT = result.ttft;
1333
-
1334
- // Update stats
1335
- document.getElementById(`${laneId}-ttft`).textContent = `${firstTTFT}ms`;
1336
- document.getElementById(`${laneId}-tps`).textContent =
1337
- totalTime > 0 ? (totalTokens / (totalTime / 1000)).toFixed(1) : '-';
1338
- document.getElementById(`${laneId}-total`).textContent = `${(totalTime / 1000).toFixed(2)}s`;
1339
 
1340
- document.getElementById(`${laneId}-dot-${step}`).className = 'status-dot success';
 
 
 
1341
 
1342
  } catch (error) {
1343
  document.getElementById(`${laneId}-output-${step}`).textContent = `Error: ${error.message}`;
@@ -1347,18 +1401,529 @@
1347
  }
1348
  }
1349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1350
  async function executeRequest({ endpoint, model, prompt, imageUrl, imageDataUrls, systemPrompt, apiKey, isStream, previousResponseId, laneId, step }) {
1351
  const outputEl = document.getElementById(`${laneId}-output-${step}`);
1352
  const previewEl = document.getElementById(`${laneId}-preview-${step}`);
1353
 
1354
  // Detect endpoint type
1355
  const isResponsesAPI = endpoint.includes('/v1/responses');
1356
- const isChatAPI = endpoint.includes('/v1/chat/completions');
1357
 
1358
  // Build request body based on endpoint type
1359
  let body;
1360
  if (isResponsesAPI) {
1361
- // Responses API format
1362
  const input = [];
1363
  if (systemPrompt) {
1364
  input.push({ role: 'system', content: systemPrompt });
@@ -1406,6 +1971,7 @@
1406
  messages.push({ role: 'user', content: userContent });
1407
 
1408
  body = { model, messages, stream: isStream };
 
1409
  }
1410
 
1411
  const headers = {
@@ -1416,11 +1982,9 @@
1416
  headers['Accept'] = 'text/event-stream';
1417
  }
1418
 
 
 
1419
  const startTime = performance.now();
1420
- let ttft = null;
1421
- let tokens = 0;
1422
- let fullText = '';
1423
- let responseId = null;
1424
 
1425
  const response = await fetch(endpoint, {
1426
  method: 'POST',
@@ -1429,116 +1993,50 @@
1429
  });
1430
 
1431
  if (!response.ok) {
1432
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
 
 
 
1433
  }
1434
 
1435
- // Check output mode
1436
- const isRawMode = document.querySelector('input[name="outputMode"]:checked')?.value === 'raw';
1437
- let rawEvents = [];
1438
-
1439
- if (isStream) {
1440
- const reader = response.body.getReader();
1441
- const decoder = new TextDecoder();
1442
-
1443
- while (true) {
1444
- const { done, value } = await reader.read();
1445
- if (done) break;
1446
-
1447
- const chunk = decoder.decode(value);
1448
- const lines = chunk.split('\n');
1449
-
1450
- for (const line of lines) {
1451
- // RAW MODE: Show everything including event: lines
1452
- if (isRawMode && line.trim()) {
1453
- rawEvents.push(line);
1454
- outputEl.innerHTML = '<pre style="white-space: pre-wrap; font-size: 0.75rem; font-family: monospace;">' +
1455
- rawEvents.map(e => escapeHtml(e)).join('\n') + '</pre>';
1456
- if (ttft === null) {
1457
- ttft = Math.round(performance.now() - startTime);
1458
- }
1459
- tokens++;
1460
- }
1461
-
1462
- if (!line.startsWith('data: ')) continue;
1463
-
1464
- const data = line.slice(6);
1465
- if (data === '[DONE]') continue;
1466
-
1467
- try {
1468
- const parsed = JSON.parse(data);
1469
-
1470
- // Extract response ID
1471
- if (parsed.id) responseId = parsed.id;
1472
- if (parsed.response?.id) responseId = parsed.response.id;
1473
-
1474
- // PARSED MODE: Extract text delta only
1475
- if (!isRawMode) {
1476
- let delta = '';
1477
- if (parsed.type === 'response.output_text.delta') {
1478
- delta = parsed.delta || '';
1479
- } else if (parsed.delta?.text) {
1480
- delta = parsed.delta.text;
1481
- } else if (parsed.choices?.[0]?.delta?.content) {
1482
- delta = parsed.choices[0].delta.content;
1483
- }
1484
-
1485
- if (delta) {
1486
- if (ttft === null) {
1487
- ttft = Math.round(performance.now() - startTime);
1488
- }
1489
- tokens++;
1490
- fullText += delta;
1491
- outputEl.textContent = fullText;
1492
-
1493
- // Update preview (last 30 chars)
1494
- previewEl.textContent = fullText.slice(-30);
1495
- }
1496
- }
1497
- } catch (e) {
1498
- // Skip non-JSON lines
1499
  }
1500
  }
1501
- }
1502
-
1503
- // RAW mode: store full text from raw events
1504
- if (isRawMode) {
1505
- fullText = rawEvents.join('\n');
1506
  }
1507
  } else {
1508
- // Non-streaming response
1509
  const data = await response.json();
1510
- responseId = data.id || null;
1511
- ttft = Math.round(performance.now() - startTime);
1512
-
1513
- // Extract text based on format
1514
- if (data.output) {
1515
- for (const item of data.output) {
1516
- if (item.type === 'message' && item.content) {
1517
- for (const c of item.content) {
1518
- if (c.type === 'output_text') {
1519
- fullText += c.text;
1520
- }
1521
- }
1522
- }
1523
- }
1524
- } else if (data.choices?.[0]?.message?.content) {
1525
- fullText = data.choices[0].message.content;
1526
- }
1527
-
1528
- tokens = fullText.split(/\s+/).length; // Rough estimate
1529
- outputEl.textContent = fullText || JSON.stringify(data, null, 2);
1530
  }
1531
 
1532
- const totalTime = performance.now() - startTime;
1533
- previewEl.textContent = responseId ? responseId.slice(0, 16) + '...' : '';
 
 
1534
 
1535
- return {
1536
- responseId,
1537
- tokens,
1538
- time: totalTime,
1539
- ttft: ttft || Math.round(totalTime),
1540
- text: fullText
1541
- };
1542
  }
1543
  </script>
1544
  </body>
 
314
  flex-direction: column;
315
  gap: 0.5rem;
316
  min-height: 200px;
317
+ max-height: 720px;
318
  overflow-y: auto;
319
  }
320
 
 
371
  }
372
 
373
  .status-dot.success { background: var(--success); }
374
+ .status-dot.warning { background: var(--warning); }
375
  .status-dot.error { background: var(--error); }
376
 
377
  @keyframes pulse {
 
386
  line-height: 1.5;
387
  white-space: pre-wrap;
388
  word-break: break-word;
389
+ max-height: 360px;
390
  overflow-y: auto;
391
  }
392
 
 
395
  overflow: hidden;
396
  }
397
 
398
+ /* Parsed turn: answer, reasoning, tools, route, usage */
399
+ .turn { white-space: normal; font-family: inherit; }
400
+ .turn .dim { color: var(--text-dim); }
401
+ .turn-meta, .turn-metrics, .turn-route { font-size: 0.75rem; margin-bottom: 0.35rem; }
402
+ .turn-metrics { color: var(--accent-hover); }
403
+ .badge {
404
+ display: inline-block;
405
+ padding: 0 0.4rem;
406
+ border-radius: 4px;
407
+ font-size: 0.7rem;
408
+ font-weight: 700;
409
+ text-transform: uppercase;
410
+ }
411
+ .badge-ok { background: rgba(34, 197, 94, 0.15); color: var(--success); }
412
+ .badge-err { background: rgba(239, 68, 68, 0.15); color: var(--error); }
413
+ .badge-run { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
414
+ .turn-section { margin: 0.35rem 0; border: 1px solid var(--border); border-radius: 6px; }
415
+ .turn-section summary { cursor: pointer; padding: 0.25rem 0.5rem; font-size: 0.75rem; color: var(--text-dim); }
416
+ .section-body { padding: 0.35rem 0.5rem; white-space: pre-wrap; font-size: 0.75rem; }
417
+ .tool-row { display: flex; justify-content: space-between; gap: 0.75rem; padding: 0.15rem 0; }
418
+ .answer { white-space: pre-wrap; margin: 0.35rem 0; }
419
+ .turn-warning { color: var(--warning); font-size: 0.75rem; margin: 0.35rem 0; }
420
+ .price-status { font-size: 0.75rem; color: var(--text-dim); padding-top: 0.45rem; white-space: nowrap; }
421
+
422
  /* Stream Preview */
423
  .stream-preview {
424
  font-family: 'SF Mono', monospace;
 
439
  background: rgba(99, 102, 241, 0.05);
440
  border-top: 1px solid var(--border);
441
  display: grid;
442
+ grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
443
  gap: 0.5rem;
444
  font-size: 0.75rem;
445
  }
 
538
  <div class="field">
539
  <label>Output</label>
540
  <div class="mode-toggle">
541
+ <label title="Show raw SSE events as-is (stats are still parsed)">
542
+ <input type="radio" name="outputMode" value="raw">
543
  <span>RAW</span>
544
  </label>
545
+ <label title="Answer, reasoning, tool calls, route, usage and cost">
546
+ <input type="radio" name="outputMode" value="parsed" checked>
547
  <span>Parsed</span>
548
  </label>
549
  </div>
550
  </div>
551
+ <div class="field">
552
+ <label title="Live price list from openrouter.ai/api/v1/models; used when the API reports no cost">Prices (OpenRouter)</label>
553
+ <div class="price-status" id="openrouterStatus">loading…</div>
554
+ <datalist id="openrouterModels"></datalist>
555
+ </div>
556
  </div>
557
 
558
  <div class="toolbar">
 
638
  return div.innerHTML;
639
  }
640
 
641
+ // Parsed step summaries per lane: laneId -> { steps: [...] }
642
+ const laneRuns = new Map();
643
 
644
  function collectResults() {
645
  const results = {
 
662
  },
663
  prompts: [],
664
  responses: [],
665
+ steps: laneRuns.get(laneId)?.steps || [],
666
+ stats: aggregateLaneStats(laneRuns.get(laneId)?.steps || [])
 
 
 
667
  };
668
 
669
  // Collect prompts
 
671
  for (let i = 1; i <= chainLength; i++) {
672
  const promptEl = document.getElementById(`${laneId}-prompt-${i}`);
673
  if (promptEl) laneData.prompts.push(promptEl.value);
 
 
 
674
  }
675
+ laneData.responses = laneData.steps.map(step => step.raw ?? step.text);
676
 
677
  results.lanes.push(laneData);
678
  });
 
699
  text += `Stream Mode: ${results.globalConfig.streamMode}\n\n`;
700
 
701
  results.lanes.forEach((lane, idx) => {
702
+ const s = lane.stats;
703
  text += `═══ Lane ${idx + 1}: ${lane.config.model} ═══\n`;
704
  text += `Endpoint: ${lane.config.endpoint}\n`;
705
+ text += `Stats: TTFT=${s.ttft} | decode=${s.tps} tok/s | out=${s.out} | tools=${s.tools} | Total=${s.total} | cost=${s.cost}\n\n`;
706
 
707
  lane.prompts.forEach((prompt, i) => {
708
  text += `[Prompt ${i+1}]: ${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}\n`;
709
+ const step = lane.steps[i];
710
+ if (!step) {
711
+ text += `[Response ${i+1}]: (not run)\n\n`;
712
+ return;
713
+ }
714
+ text += `[Response ${i+1}]: ${describeStep(step)}\n`;
715
+ step.tools.forEach(tool => { text += ` tool: ${describeTool(tool)}\n`; });
716
+ if (step.reasoningChars) text += ` reasoning: ${step.reasoningChars} chars\n`;
717
+ step.warnings.forEach(warning => { text += ` ⚠ ${warning}\n`; });
718
+ text += ` answer: ${step.text ? truncate(step.text, 200) : '(no answer text)'}\n\n`;
719
  });
720
  text += '\n';
721
  });
 
814
  model: document.getElementById(`${laneId}-model`)?.value || 'chat',
815
  chain: chainCount,
816
  systemPrompt: document.getElementById(`${laneId}-system`)?.value || '',
817
+ priceAs: document.getElementById(`${laneId}-priceAs`)?.value || '',
818
  prompts: []
819
  };
820
 
 
880
  updatePrompts(laneId);
881
  }
882
  if (systemEl) systemEl.value = laneData.systemPrompt;
883
+ const priceAsEl = document.getElementById(`${laneId}-priceAs`);
884
+ if (priceAsEl) priceAsEl.value = laneData.priceAs || '';
885
 
886
  // Set prompts (after updatePrompts created the fields)
887
  setTimeout(() => {
 
937
  updatePrompts('lane-1'); // Generate all 4 canonical prompts
938
  updateLogCount();
939
  updatePresetDropdown();
940
+ loadOpenRouterCatalog();
941
  });
942
 
943
  function addLane() {
 
978
  <input type="number" id="${laneId}-chain" value="4" min="1" max="10" onchange="updatePrompts('${laneId}')">
979
  </div>
980
  </div>
981
+ <div class="field">
982
+ <label title="OpenRouter model whose live price estimates this lane's cost; empty = auto from the model name">Price as (OpenRouter model)</label>
983
+ <input type="text" id="${laneId}-priceAs" list="openrouterModels" placeholder="auto (e.g. qwen/qwen3.8-flash)">
984
+ </div>
985
  <div class="field">
986
  <label>System Prompt (optional)</label>
987
  <textarea id="${laneId}-system" placeholder="You are a helpful assistant..."></textarea>
 
1021
  </div>
1022
 
1023
  <div class="lane-stats" id="${laneId}-stats" style="display: none;">
1024
+ <div class="stat" title="First output of any kind (reasoning, text or tool) in step 1">
1025
  <div class="stat-value" id="${laneId}-ttft">-</div>
1026
  <div class="stat-label">TTFT</div>
1027
  </div>
1028
+ <div class="stat" title="usage.output_tokens ÷ time between the first and the last delta, all steps">
1029
  <div class="stat-value" id="${laneId}-tps">-</div>
1030
+ <div class="stat-label">decode tok/s</div>
1031
+ </div>
1032
+ <div class="stat" title="usage.output_tokens (of which reasoning), all steps">
1033
+ <div class="stat-value" id="${laneId}-out">-</div>
1034
+ <div class="stat-label">out tok (reason)</div>
1035
+ </div>
1036
+ <div class="stat" title="Tool calls and their wall time, all steps">
1037
+ <div class="stat-value" id="${laneId}-tools">-</div>
1038
+ <div class="stat-label">tools</div>
1039
  </div>
1040
  <div class="stat">
1041
  <div class="stat-value" id="${laneId}-total">-</div>
1042
  <div class="stat-label">Total</div>
1043
  </div>
1044
+ <div class="stat" title="Reported by the API, or estimated from the price fields">
1045
+ <div class="stat-value" id="${laneId}-cost">-</div>
1046
+ <div class="stat-label">cost</div>
1047
+ </div>
1048
  </div>
1049
  `;
1050
 
 
1225
  outputSection.innerHTML = '<h4>Responses</h4>';
1226
  document.getElementById(`${laneId}-stats`).style.display = 'none';
1227
  });
1228
+ laneRuns.clear();
1229
  }
1230
 
1231
  function handleAttachmentSelection(laneId) {
 
1342
  statsSection.style.display = 'grid';
1343
 
1344
  let previousResponseId = null;
1345
+ const laneRun = { steps: [] };
1346
+ laneRuns.set(laneId, laneRun);
 
1347
 
1348
  for (let step = 1; step <= chainCount; step++) {
1349
  const prompt = document.getElementById(`${laneId}-prompt-${step}`).value;
 
1382
  });
1383
 
1384
  previousResponseId = result.responseId;
1385
+ laneRun.steps.push(result.summary);
1386
+ const stats = aggregateLaneStats(laneRun.steps);
1387
+ for (const key of ['ttft', 'tps', 'out', 'tools', 'total', 'cost']) {
1388
+ document.getElementById(`${laneId}-${key}`).textContent = stats[key];
1389
+ }
 
 
 
 
1390
 
1391
+ const completed = result.summary.status === 'completed';
1392
+ document.getElementById(`${laneId}-dot-${step}`).className = !completed
1393
+ ? 'status-dot error'
1394
+ : (result.summary.warnings.length ? 'status-dot warning' : 'status-dot success');
1395
 
1396
  } catch (error) {
1397
  document.getElementById(`${laneId}-output-${step}`).textContent = `Error: ${error.message}`;
 
1401
  }
1402
  }
1403
 
1404
+ // ===== Responses / Chat Completions stream parsing =====
1405
+ const TERMINAL_TYPES = new Set([
1406
+ 'response.completed', 'response.failed', 'response.incomplete', 'response.cancelled'
1407
+ ]);
1408
+ const TOOL_ITEM_TYPES = new Set([
1409
+ 'function_call', 'web_search_call', 'file_search_call', 'code_interpreter_call',
1410
+ 'computer_call', 'image_generation_call', 'mcp_call', 'custom_tool_call', 'local_shell_call'
1411
+ ]);
1412
+ const HOSTED_TOOL_EVENT =
1413
+ /^response\.(web_search_call|file_search_call|code_interpreter_call|image_generation_call|mcp_call)\.([a-z_]+)$/;
1414
+
1415
+ function truncate(text, limit) {
1416
+ const value = String(text ?? '');
1417
+ return value.length > limit ? `${value.slice(0, limit)}…` : value;
1418
+ }
1419
+
1420
+ // ===== Live price list: openrouter.ai/api/v1/models (public, CORS *) =====
1421
+ const OPENROUTER_CATALOG_URL = 'https://openrouter.ai/api/v1/models';
1422
+ const OPENROUTER_CACHE_KEY = 'api-tester-openrouter-catalog';
1423
+ const OPENROUTER_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
1424
+ // Every gateway alias runs on the fused Qwen3.8-Flash-Next runtime, whose
1425
+ // hosted counterpart on OpenRouter is qwen/qwen3.8-flash.
1426
+ const FLASH_ALIASES = new Set([
1427
+ 'chat', 'buddy', 'programmer', 'master', 'vision', 'soap', 'ai-suggestions', 'svetliq', 'gpt-oss-20b'
1428
+ ]);
1429
+ const openRouterPricing = new Map();
1430
+
1431
+ function applyOpenRouterCatalog(models, fetchedAt) {
1432
+ openRouterPricing.clear();
1433
+ const options = [];
1434
+ for (const model of models) {
1435
+ if (!model?.id || !model.pricing) continue;
1436
+ openRouterPricing.set(model.id, model);
1437
+ options.push(`<option value="${escapeHtml(model.id)}">${escapeHtml(model.name || '')}</option>`);
1438
+ }
1439
+ const datalist = document.getElementById('openrouterModels');
1440
+ if (datalist) datalist.innerHTML = options.join('');
1441
+ const status = document.getElementById('openrouterStatus');
1442
+ if (status) status.textContent = `${openRouterPricing.size} models · ${new Date(fetchedAt).toLocaleString()}`;
1443
+ }
1444
+
1445
+ async function loadOpenRouterCatalog() {
1446
+ try {
1447
+ const cached = JSON.parse(localStorage.getItem(OPENROUTER_CACHE_KEY) || 'null');
1448
+ if (cached?.models?.length && Date.now() - cached.fetchedAt < OPENROUTER_CACHE_TTL_MS) {
1449
+ applyOpenRouterCatalog(cached.models, cached.fetchedAt);
1450
+ return;
1451
+ }
1452
+ } catch (e) {
1453
+ // Unreadable cache: fetch a fresh list below.
1454
+ }
1455
+ try {
1456
+ const response = await fetch(OPENROUTER_CATALOG_URL);
1457
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1458
+ const body = await response.json();
1459
+ const models = (body.data || []).map(model => ({ id: model.id, name: model.name, pricing: model.pricing }));
1460
+ const fetchedAt = Date.now();
1461
+ applyOpenRouterCatalog(models, fetchedAt);
1462
+ try {
1463
+ localStorage.setItem(OPENROUTER_CACHE_KEY, JSON.stringify({ fetchedAt, models }));
1464
+ } catch (e) {
1465
+ // Storage quota: keep the list in memory for this session only.
1466
+ }
1467
+ } catch (e) {
1468
+ const status = document.getElementById('openrouterStatus');
1469
+ if (status) status.textContent = `unavailable (${e.message})`;
1470
+ }
1471
+ }
1472
+
1473
+ function priceModelFor(laneId, model) {
1474
+ const explicit = document.getElementById(`${laneId}-priceAs`)?.value.trim();
1475
+ if (explicit) return explicit;
1476
+ const name = String(model || '').trim();
1477
+ const lower = name.toLowerCase();
1478
+ if (openRouterPricing.has(name)) return name;
1479
+ if (lower.includes('qwen3.8-27b')) return 'qwen/qwen3.8-27b';
1480
+ if (FLASH_ALIASES.has(lower) || lower.includes('qwen3.8-flash')) return 'qwen/qwen3.8-flash';
1481
+ return null;
1482
+ }
1483
+
1484
+ async function* readSseEvents(response) {
1485
+ // An event may straddle network chunks, and so may a multi-byte character.
1486
+ const reader = response.body.getReader();
1487
+ const decoder = new TextDecoder();
1488
+ let buffer = '';
1489
+ while (true) {
1490
+ const { done, value } = await reader.read();
1491
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
1492
+ buffer = buffer.replace(/\r\n/g, '\n');
1493
+ let boundary;
1494
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
1495
+ const block = buffer.slice(0, boundary);
1496
+ buffer = buffer.slice(boundary + 2);
1497
+ if (block.trim()) yield parseSseBlock(block);
1498
+ }
1499
+ if (done) {
1500
+ if (buffer.trim()) yield parseSseBlock(buffer);
1501
+ return;
1502
+ }
1503
+ }
1504
+ }
1505
+
1506
+ function parseSseBlock(block) {
1507
+ let event = null;
1508
+ const data = [];
1509
+ for (const line of block.split('\n')) {
1510
+ if (line.startsWith('event:')) event = line.slice(6).trim();
1511
+ else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
1512
+ }
1513
+ return { event, data: data.join('\n'), raw: block };
1514
+ }
1515
+
1516
+ function createTurnState() {
1517
+ return {
1518
+ responseId: null, model: null, status: null,
1519
+ text: '', reasoning: '',
1520
+ tools: new Map(), route: [],
1521
+ usage: null, metadata: null, error: null, incomplete: null, reportedCost: null,
1522
+ firstOutputAt: null, firstTextAt: null, lastDeltaAt: null,
1523
+ rawBlocks: []
1524
+ };
1525
+ }
1526
+
1527
+ function markDelta(state, now, isText) {
1528
+ if (state.firstOutputAt === null) state.firstOutputAt = now;
1529
+ if (isText && state.firstTextAt === null) state.firstTextAt = now;
1530
+ state.lastDeltaAt = now;
1531
+ }
1532
+
1533
+ function toolFor(state, itemId, outputIndex, type) {
1534
+ let key = itemId;
1535
+ if ((!key || !state.tools.has(key)) && outputIndex !== undefined && outputIndex !== null) {
1536
+ for (const [existingKey, tool] of state.tools) {
1537
+ if (tool.outputIndex === outputIndex) { key = existingKey; break; }
1538
+ }
1539
+ }
1540
+ key = key || `index-${outputIndex}`;
1541
+ if (!state.tools.has(key)) {
1542
+ state.tools.set(key, {
1543
+ type: type || 'tool', name: '', args: '', status: '', query: '', sources: null,
1544
+ outputIndex, startedAt: null, endedAt: null
1545
+ });
1546
+ }
1547
+ const tool = state.tools.get(key);
1548
+ if (type && tool.type === 'tool') tool.type = type;
1549
+ return tool;
1550
+ }
1551
+
1552
+ function recordToolItem(state, item, outputIndex, now, finished) {
1553
+ if (!item || !TOOL_ITEM_TYPES.has(item.type)) return false;
1554
+ const tool = toolFor(state, item.id, outputIndex, item.type);
1555
+ if (tool.startedAt === null) tool.startedAt = now;
1556
+ if (item.name) tool.name = item.name;
1557
+ if (typeof item.arguments === 'string' && item.arguments) tool.args = item.arguments;
1558
+ if (item.status) tool.status = item.status;
1559
+ const action = item.action || {};
1560
+ if (action.query) tool.query = action.query;
1561
+ if (Array.isArray(action.sources)) tool.sources = action.sources.length;
1562
+ if (finished && tool.endedAt === null) tool.endedAt = now;
1563
+ return true;
1564
+ }
1565
+
1566
+ function messageText(item) {
1567
+ return (item.content || [])
1568
+ .filter(part => part.type === 'output_text' || part.type === 'text')
1569
+ .map(part => part.text || '')
1570
+ .join('');
1571
+ }
1572
+
1573
+ function reasoningText(item) {
1574
+ return [...(item.summary || []), ...(item.content || [])].map(part => part.text || '').join('\n');
1575
+ }
1576
+
1577
+ function applyResponseObject(state, response, now) {
1578
+ if (!response || typeof response !== 'object') return;
1579
+ state.responseId = response.id || state.responseId;
1580
+ state.model = response.model || state.model;
1581
+ if (response.status) state.status = response.status;
1582
+ if (response.usage) state.usage = response.usage;
1583
+ if (response.metadata && Object.keys(response.metadata).length) state.metadata = response.metadata;
1584
+ if (response.error) state.error = response.error;
1585
+ if (response.incomplete_details) state.incomplete = response.incomplete_details;
1586
+ const cost = response.usage?.cost ?? response.usage?.total_cost ?? response.metadata?.cost
1587
+ ?? response.metadata?.price ?? response.cost;
1588
+ if (cost !== undefined && cost !== null) state.reportedCost = cost;
1589
+ // Deltas are the live truth; the terminal object only fills what never streamed.
1590
+ const hadText = Boolean(state.text);
1591
+ const hadReasoning = Boolean(state.reasoning);
1592
+ (Array.isArray(response.output) ? response.output : []).forEach((item, index) => {
1593
+ if (item.type === 'message') {
1594
+ if (!hadText) state.text += messageText(item);
1595
+ } else if (item.type === 'reasoning') {
1596
+ if (!hadReasoning) state.reasoning += reasoningText(item);
1597
+ } else {
1598
+ recordToolItem(state, item, index, now, true);
1599
+ }
1600
+ });
1601
+ }
1602
+
1603
+ function applyResponsesEvent(state, parsed, now) {
1604
+ const type = parsed.type || 'unknown';
1605
+ if (parsed.response?.id) state.responseId = parsed.response.id;
1606
+ if (parsed.response?.model) state.model = parsed.response.model;
1607
+ if (type === 'response.output_text.delta') {
1608
+ if (parsed.delta) { markDelta(state, now, true); state.text += parsed.delta; }
1609
+ } else if (type === 'response.reasoning_text.delta' || type === 'response.reasoning_summary_text.delta') {
1610
+ if (parsed.delta) { markDelta(state, now, false); state.reasoning += parsed.delta; }
1611
+ } else if (type === 'response.reasoning_summary_part.added') {
1612
+ if (state.reasoning) state.reasoning += '\n\n';
1613
+ } else if (type === 'response.function_call_arguments.delta') {
1614
+ const tool = toolFor(state, parsed.item_id, parsed.output_index, 'function_call');
1615
+ if (parsed.delta) { tool.args += parsed.delta; markDelta(state, now, false); }
1616
+ } else if (type === 'response.function_call_arguments.done') {
1617
+ const tool = toolFor(state, parsed.item_id, parsed.output_index, 'function_call');
1618
+ if (typeof parsed.arguments === 'string') tool.args = parsed.arguments;
1619
+ } else if (type === 'response.output_item.added') {
1620
+ recordToolItem(state, parsed.item, parsed.output_index, now, false);
1621
+ } else if (type === 'response.output_item.done') {
1622
+ if (!recordToolItem(state, parsed.item, parsed.output_index, now, true)) {
1623
+ if (parsed.item?.type === 'message' && !state.text) state.text = messageText(parsed.item);
1624
+ if (parsed.item?.type === 'reasoning' && !state.reasoning) state.reasoning = reasoningText(parsed.item);
1625
+ }
1626
+ } else if (type.startsWith('response.route.')) {
1627
+ state.route.push({
1628
+ type: type.slice('response.route.'.length),
1629
+ route: parsed.route, attempt: parsed.attempt, at: now
1630
+ });
1631
+ } else if (TERMINAL_TYPES.has(type)) {
1632
+ applyResponseObject(state, parsed.response, now);
1633
+ if (!state.status) state.status = type.slice('response.'.length);
1634
+ } else if (type === 'error') {
1635
+ state.error = parsed.error || { message: parsed.message || 'stream error', code: parsed.code };
1636
+ state.status = state.status || 'failed';
1637
+ } else {
1638
+ const hosted = type.match(HOSTED_TOOL_EVENT);
1639
+ if (hosted) {
1640
+ const tool = toolFor(state, parsed.item_id, parsed.output_index, hosted[1]);
1641
+ if (tool.startedAt === null) tool.startedAt = now;
1642
+ tool.status = hosted[2];
1643
+ if ((hosted[2] === 'completed' || hosted[2] === 'failed') && tool.endedAt === null) {
1644
+ tool.endedAt = now;
1645
+ }
1646
+ }
1647
+ }
1648
+ }
1649
+
1650
+ function chatUsage(usage) {
1651
+ if (!usage) return null;
1652
+ return {
1653
+ input_tokens: usage.prompt_tokens,
1654
+ output_tokens: usage.completion_tokens,
1655
+ input_tokens_details: { cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? 0 },
1656
+ output_tokens_details: { reasoning_tokens: usage.completion_tokens_details?.reasoning_tokens ?? 0 },
1657
+ total_tokens: usage.total_tokens,
1658
+ cost: usage.cost
1659
+ };
1660
+ }
1661
+
1662
+ function chatStatus(finishReason) {
1663
+ return ['stop', 'tool_calls'].includes(finishReason) ? 'completed' : finishReason;
1664
+ }
1665
+
1666
+ function applyChatChunk(state, chunk, now) {
1667
+ if (chunk.id && !state.responseId) state.responseId = chunk.id;
1668
+ if (chunk.model) state.model = chunk.model;
1669
+ if (chunk.usage) {
1670
+ state.usage = chatUsage(chunk.usage);
1671
+ if (chunk.usage.cost !== undefined) state.reportedCost = chunk.usage.cost;
1672
+ }
1673
+ const choice = chunk.choices?.[0];
1674
+ if (!choice) return;
1675
+ const delta = choice.delta || {};
1676
+ if (delta.content) { markDelta(state, now, true); state.text += delta.content; }
1677
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
1678
+ if (typeof reasoning === 'string' && reasoning) { markDelta(state, now, false); state.reasoning += reasoning; }
1679
+ (delta.tool_calls || []).forEach(call => {
1680
+ const tool = toolFor(state, `chat-call-${call.index ?? 0}`, undefined, 'function_call');
1681
+ if (tool.startedAt === null) tool.startedAt = now;
1682
+ if (call.function?.name) tool.name = call.function.name;
1683
+ if (call.function?.arguments) { tool.args += call.function.arguments; markDelta(state, now, false); }
1684
+ });
1685
+ if (choice.finish_reason) {
1686
+ state.status = chatStatus(choice.finish_reason);
1687
+ for (const tool of state.tools.values()) if (tool.endedAt === null) tool.endedAt = now;
1688
+ }
1689
+ }
1690
+
1691
+ function applyChatCompletion(state, data, now) {
1692
+ const choice = data.choices?.[0] || {};
1693
+ const message = choice.message || {};
1694
+ state.responseId = data.id || state.responseId;
1695
+ state.model = data.model || state.model;
1696
+ state.text = message.content || '';
1697
+ state.reasoning = message.reasoning_content || message.reasoning || '';
1698
+ (message.tool_calls || []).forEach((call, index) => {
1699
+ const tool = toolFor(state, `chat-call-${index}`, undefined, 'function_call');
1700
+ Object.assign(tool, {
1701
+ name: call.function?.name || '', args: call.function?.arguments || '',
1702
+ status: 'completed', startedAt: now, endedAt: now
1703
+ });
1704
+ });
1705
+ if (choice.finish_reason) state.status = chatStatus(choice.finish_reason);
1706
+ if (data.usage) {
1707
+ state.usage = chatUsage(data.usage);
1708
+ if (data.usage.cost !== undefined) state.reportedCost = data.usage.cost;
1709
+ }
1710
+ }
1711
+
1712
+ function resolveCost(state, priceModel) {
1713
+ const reported = Number(state.reportedCost);
1714
+ if (state.reportedCost !== null && state.reportedCost !== undefined && Number.isFinite(reported)) {
1715
+ return { value: reported, source: 'reported' };
1716
+ }
1717
+ const pricing = priceModel ? openRouterPricing.get(priceModel)?.pricing : null;
1718
+ const usage = state.usage || {};
1719
+ if (!pricing || !Number.isFinite(usage.input_tokens) || !Number.isFinite(usage.output_tokens)) return null;
1720
+ // OpenRouter prices are USD per token, as strings.
1721
+ const rate = key => {
1722
+ const value = Number(pricing[key]);
1723
+ return Number.isFinite(value) ? value : null;
1724
+ };
1725
+ const prompt = rate('prompt') ?? 0;
1726
+ const completion = rate('completion') ?? 0;
1727
+ const cached = Math.min(usage.input_tokens_details?.cached_tokens || 0, usage.input_tokens);
1728
+ const reasoning = Math.min(usage.output_tokens_details?.reasoning_tokens || 0, usage.output_tokens);
1729
+ const value = (usage.input_tokens - cached) * prompt
1730
+ + cached * (rate('input_cache_read') ?? prompt)
1731
+ + (usage.output_tokens - reasoning) * completion
1732
+ + reasoning * (rate('internal_reasoning') ?? completion)
1733
+ + (rate('request') ?? 0);
1734
+ return { value, source: 'estimate', model: priceModel };
1735
+ }
1736
+
1737
+ function toolSummary(tool, requestStartedAt) {
1738
+ return {
1739
+ type: tool.type, name: tool.name, query: tool.query, args: tool.args,
1740
+ status: tool.status, sources: tool.sources,
1741
+ startedAtSeconds: tool.startedAt !== null ? (tool.startedAt - requestStartedAt) / 1000 : null,
1742
+ seconds: tool.startedAt !== null && tool.endedAt !== null ? (tool.endedAt - tool.startedAt) / 1000 : null
1743
+ };
1744
+ }
1745
+
1746
+ function summarizeTurn(state, startedAt, finishedAt, streamed, priceModel) {
1747
+ const usage = state.usage || {};
1748
+ const outputTokens = Number.isFinite(usage.output_tokens) ? usage.output_tokens : null;
1749
+ const decodeSpan = streamed && state.firstOutputAt !== null && state.lastDeltaAt !== null
1750
+ ? (state.lastDeltaAt - state.firstOutputAt) / 1000
1751
+ : 0;
1752
+ const tools = [...state.tools.values()].map(tool => toolSummary(tool, startedAt));
1753
+ const lastRoute = state.route[state.route.length - 1];
1754
+ const warnings = [];
1755
+ if (state.status === 'completed' && !state.text && !tools.length) {
1756
+ warnings.push(state.reasoning ? 'reasoning only, no answer text' : 'completed with empty output');
1757
+ }
1758
+ if (!state.status) {
1759
+ warnings.push(lastRoute && lastRoute.type !== 'terminal'
1760
+ ? `stream ended while the route was "${lastRoute.type}" — no upstream output`
1761
+ : 'stream ended without a terminal event');
1762
+ }
1763
+ if (state.error) warnings.push(`error: ${state.error.code || ''} ${state.error.message || ''}`.trim());
1764
+ if (state.incomplete) warnings.push(`incomplete: ${state.incomplete.reason || JSON.stringify(state.incomplete)}`);
1765
+ return {
1766
+ responseId: state.responseId, model: state.model, status: state.status || 'no terminal',
1767
+ ttftMs: state.firstOutputAt !== null ? Math.round(state.firstOutputAt - startedAt) : null,
1768
+ firstTextMs: state.firstTextAt !== null ? Math.round(state.firstTextAt - startedAt) : null,
1769
+ totalSeconds: (finishedAt - startedAt) / 1000,
1770
+ decodeSpan,
1771
+ decodeTps: outputTokens !== null && decodeSpan > 0 ? outputTokens / decodeSpan : null,
1772
+ inputTokens: Number.isFinite(usage.input_tokens) ? usage.input_tokens : null,
1773
+ outputTokens,
1774
+ reasoningTokens: usage.output_tokens_details?.reasoning_tokens ?? null,
1775
+ cachedTokens: usage.input_tokens_details?.cached_tokens ?? null,
1776
+ tools,
1777
+ route: state.route.map(entry => ({
1778
+ type: entry.type, route: entry.route, attempt: entry.attempt,
1779
+ atSeconds: (entry.at - startedAt) / 1000
1780
+ })),
1781
+ cost: resolveCost(state, priceModel),
1782
+ priceModel,
1783
+ usage: state.usage, metadata: state.metadata, error: state.error, incomplete: state.incomplete,
1784
+ text: state.text,
1785
+ reasoningChars: state.reasoning.length,
1786
+ warnings
1787
+ };
1788
+ }
1789
+
1790
+ function fmtSeconds(ms) {
1791
+ return ms === null || ms === undefined ? '-' : `${(ms / 1000).toFixed(2)}s`;
1792
+ }
1793
+
1794
+ function fmtUsd(value) {
1795
+ return `$${value < 0.01 ? value.toFixed(6) : value.toFixed(4)}`;
1796
+ }
1797
+
1798
+ function fmtCost(cost) {
1799
+ if (!cost) return '-';
1800
+ return cost.source === 'reported' ? fmtUsd(cost.value) : `≈${fmtUsd(cost.value)} (${cost.model})`;
1801
+ }
1802
+
1803
+ function describeTool(tool) {
1804
+ const subject = tool.type === 'web_search_call'
1805
+ ? `web_search "${truncate(tool.query || '?', 80)}"`
1806
+ : `${tool.name || tool.type}(${truncate(tool.args, 80)})`;
1807
+ const parts = [tool.status || 'pending'];
1808
+ if (tool.sources !== null && tool.sources !== undefined) parts.push(`${tool.sources} sources`);
1809
+ if (tool.startedAtSeconds !== null && tool.startedAtSeconds !== undefined) {
1810
+ parts.push(`@+${tool.startedAtSeconds.toFixed(1)}s`);
1811
+ }
1812
+ if (tool.seconds !== null && tool.seconds !== undefined) parts.push(`${tool.seconds.toFixed(1)}s`);
1813
+ return `${subject} · ${parts.join(' · ')}`;
1814
+ }
1815
+
1816
+ function stepMetricsLine(step) {
1817
+ const tokens = step.outputTokens !== null
1818
+ ? `out ${step.outputTokens}${step.reasoningTokens ? ` (reasoning ${step.reasoningTokens})` : ''}` +
1819
+ ` · in ${step.inputTokens ?? '?'}${step.cachedTokens ? ` (cached ${step.cachedTokens})` : ''}`
1820
+ : 'no usage reported';
1821
+ const rate = step.decodeTps !== null ? `${step.decodeTps.toFixed(1)} tok/s` : '- tok/s';
1822
+ return `TTFT ${fmtSeconds(step.ttftMs)} · first text ${fmtSeconds(step.firstTextMs)} · ${rate} · ` +
1823
+ `${tokens} · tools ${step.tools.length} · total ${step.totalSeconds.toFixed(2)}s · cost ${fmtCost(step.cost)}`;
1824
+ }
1825
+
1826
+ function describeStep(step) {
1827
+ return `${step.status} · ${step.responseId || '?'} · ${stepMetricsLine(step)}`;
1828
+ }
1829
+
1830
+ function aggregateLaneStats(steps) {
1831
+ if (!steps.length) return { ttft: '-', tps: '-', out: '-', tools: '-', total: '-', cost: '-' };
1832
+ const decodeSteps = steps.filter(step => step.decodeTps !== null);
1833
+ const decodeTokens = decodeSteps.reduce((sum, step) => sum + step.outputTokens, 0);
1834
+ const decodeSpan = decodeSteps.reduce((sum, step) => sum + step.decodeSpan, 0);
1835
+ const out = steps.reduce((sum, step) => sum + (step.outputTokens || 0), 0);
1836
+ const reasoning = steps.reduce((sum, step) => sum + (step.reasoningTokens || 0), 0);
1837
+ const total = steps.reduce((sum, step) => sum + step.totalSeconds, 0);
1838
+ const toolCount = steps.reduce((sum, step) => sum + step.tools.length, 0);
1839
+ const toolSeconds = steps.reduce(
1840
+ (sum, step) => sum + step.tools.reduce((inner, tool) => inner + (tool.seconds || 0), 0), 0
1841
+ );
1842
+ const costs = steps.map(step => step.cost).filter(Boolean);
1843
+ const estimated = costs.some(cost => cost.source === 'estimate');
1844
+ let tps = '-';
1845
+ if (decodeSpan > 0) tps = (decodeTokens / decodeSpan).toFixed(1);
1846
+ else if (total > 0 && out > 0) tps = `${(out / total).toFixed(1)}*`; // sync: whole-request rate
1847
+ return {
1848
+ ttft: fmtSeconds(steps[0].ttftMs),
1849
+ tps,
1850
+ out: out ? `${out} (${reasoning})` : '-',
1851
+ tools: toolCount ? `${toolCount} · ${toolSeconds.toFixed(1)}s` : '0',
1852
+ total: `${total.toFixed(2)}s`,
1853
+ cost: costs.length
1854
+ ? `${estimated ? '≈' : ''}${fmtUsd(costs.reduce((sum, cost) => sum + cost.value, 0))}`
1855
+ : '-'
1856
+ };
1857
+ }
1858
+
1859
+ function sectionOpen(outputEl, key, openByDefault) {
1860
+ const existing = outputEl.querySelector(`details[data-section="${key}"]`);
1861
+ return existing ? existing.open : openByDefault;
1862
+ }
1863
+
1864
+ function renderTurn(outputEl, state, summary, isRawMode, startedAt) {
1865
+ const status = summary ? summary.status : 'streaming';
1866
+ const badge = !summary ? 'badge-run'
1867
+ : (summary.status === 'completed' && !summary.warnings.length ? 'badge-ok' : 'badge-err');
1868
+ const identity = [state.responseId, state.model].filter(Boolean).map(escapeHtml).join(' · ');
1869
+ let html = `<div class="turn-meta"><span class="badge ${badge}">${escapeHtml(status)}</span> ` +
1870
+ `<span class="dim">${identity}</span></div>`;
1871
+ if (summary) html += `<div class="turn-metrics">${escapeHtml(stepMetricsLine(summary))}</div>`;
1872
+ if (state.route.length) {
1873
+ const route = state.route.map(entry => {
1874
+ const where = entry.route ? ` ${entry.route}${entry.attempt !== undefined ? `#${entry.attempt}` : ''}` : '';
1875
+ return `${entry.type}${where} +${((entry.at - startedAt) / 1000).toFixed(1)}s`;
1876
+ }).join(' → ');
1877
+ html += `<div class="turn-route dim">route: ${escapeHtml(route)}</div>`;
1878
+ }
1879
+ (summary?.warnings || []).forEach(warning => {
1880
+ html += `<div class="turn-warning">⚠ ${escapeHtml(warning)}</div>`;
1881
+ });
1882
+ if (isRawMode) {
1883
+ const raw = state.rawBlocks.map(block => escapeHtml(block)).join('\n\n');
1884
+ outputEl.innerHTML = `<div class="turn">${html}</div>` +
1885
+ `<pre style="white-space: pre-wrap; font-size: 0.75rem; font-family: monospace;">${raw}</pre>`;
1886
+ return;
1887
+ }
1888
+ const tools = [...state.tools.values()].map(tool => toolSummary(tool, startedAt));
1889
+ if (tools.length) {
1890
+ const rows = tools.map(tool => {
1891
+ const [subject, ...details] = describeTool(tool).split(' · ');
1892
+ return `<div class="tool-row"><span>${escapeHtml(subject)}</span>` +
1893
+ `<span class="dim">${escapeHtml(details.join(' · '))}</span></div>`;
1894
+ }).join('');
1895
+ html += `<details class="turn-section" data-section="tools" ${sectionOpen(outputEl, 'tools', true) ? 'open' : ''}>` +
1896
+ `<summary>🔧 Tools · ${tools.length}</summary><div class="section-body">${rows}</div></details>`;
1897
+ }
1898
+ if (state.reasoning) {
1899
+ const tokens = summary?.reasoningTokens ? ` · ${summary.reasoningTokens} tok` : '';
1900
+ html += `<details class="turn-section" data-section="reasoning" ${sectionOpen(outputEl, 'reasoning', false) ? 'open' : ''}>` +
1901
+ `<summary>🧠 Reasoning · ${state.reasoning.length} chars${tokens}</summary>` +
1902
+ `<div class="section-body">${escapeHtml(state.reasoning)}</div></details>`;
1903
+ }
1904
+ if (state.text) html += `<div class="answer">${escapeHtml(state.text)}</div>`;
1905
+ if (summary && (summary.usage || summary.metadata || summary.error || summary.incomplete)) {
1906
+ const payload = {
1907
+ usage: summary.usage, metadata: summary.metadata,
1908
+ error: summary.error, incomplete_details: summary.incomplete
1909
+ };
1910
+ html += `<details class="turn-section" data-section="meta" ${sectionOpen(outputEl, 'meta', false) ? 'open' : ''}>` +
1911
+ `<summary>📊 Usage & metadata</summary>` +
1912
+ `<div class="section-body">${escapeHtml(JSON.stringify(payload, null, 2))}</div></details>`;
1913
+ }
1914
+ outputEl.innerHTML = `<div class="turn">${html}</div>`;
1915
+ }
1916
+
1917
  async function executeRequest({ endpoint, model, prompt, imageUrl, imageDataUrls, systemPrompt, apiKey, isStream, previousResponseId, laneId, step }) {
1918
  const outputEl = document.getElementById(`${laneId}-output-${step}`);
1919
  const previewEl = document.getElementById(`${laneId}-preview-${step}`);
1920
 
1921
  // Detect endpoint type
1922
  const isResponsesAPI = endpoint.includes('/v1/responses');
 
1923
 
1924
  // Build request body based on endpoint type
1925
  let body;
1926
  if (isResponsesAPI) {
 
1927
  const input = [];
1928
  if (systemPrompt) {
1929
  input.push({ role: 'system', content: systemPrompt });
 
1971
  messages.push({ role: 'user', content: userContent });
1972
 
1973
  body = { model, messages, stream: isStream };
1974
+ if (isStream) body.stream_options = { include_usage: true };
1975
  }
1976
 
1977
  const headers = {
 
1982
  headers['Accept'] = 'text/event-stream';
1983
  }
1984
 
1985
+ const isRawMode = document.querySelector('input[name="outputMode"]:checked')?.value === 'raw';
1986
+ const state = createTurnState();
1987
  const startTime = performance.now();
 
 
 
 
1988
 
1989
  const response = await fetch(endpoint, {
1990
  method: 'POST',
 
1993
  });
1994
 
1995
  if (!response.ok) {
1996
+ const detail = await response.text().catch(() => '');
1997
+ let message = detail;
1998
+ try { message = JSON.parse(detail).error?.message || detail; } catch (e) { /* plain text body */ }
1999
+ throw new Error(`HTTP ${response.status}: ${truncate(message || response.statusText, 300)}`);
2000
  }
2001
 
2002
+ const streamed = isStream && (response.headers.get('content-type') || '').includes('text/event-stream');
2003
+ let lastRender = 0;
2004
+
2005
+ if (streamed) {
2006
+ for await (const block of readSseEvents(response)) {
2007
+ const now = performance.now();
2008
+ if (isRawMode) state.rawBlocks.push(block.raw);
2009
+ if (block.data && block.data !== '[DONE]') {
2010
+ let parsed = null;
2011
+ try { parsed = JSON.parse(block.data); } catch (e) { /* non-JSON data line */ }
2012
+ if (parsed && typeof parsed === 'object') {
2013
+ if (!parsed.type && block.event) parsed.type = block.event;
2014
+ if (parsed.choices || parsed.object === 'chat.completion.chunk') applyChatChunk(state, parsed, now);
2015
+ else applyResponsesEvent(state, parsed, now);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2016
  }
2017
  }
2018
+ if (now - lastRender > 120) {
2019
+ lastRender = now;
2020
+ renderTurn(outputEl, state, null, isRawMode, startTime);
2021
+ previewEl.textContent = (state.text || state.reasoning).slice(-30);
2022
+ }
2023
  }
2024
  } else {
 
2025
  const data = await response.json();
2026
+ const now = performance.now();
2027
+ if (data.choices) applyChatCompletion(state, data, now);
2028
+ else applyResponseObject(state, data, now);
2029
+ state.firstOutputAt = now;
2030
+ if (state.text) state.firstTextAt = now;
2031
+ if (isRawMode) state.rawBlocks.push(JSON.stringify(data, null, 2));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2032
  }
2033
 
2034
+ const summary = summarizeTurn(state, startTime, performance.now(), streamed, priceModelFor(laneId, model));
2035
+ if (isRawMode) summary.raw = state.rawBlocks.join('\n\n');
2036
+ renderTurn(outputEl, state, summary, isRawMode, startTime);
2037
+ previewEl.textContent = state.responseId ? state.responseId.slice(0, 16) + '...' : '';
2038
 
2039
+ return { responseId: state.responseId, summary };
 
 
 
 
 
 
2040
  }
2041
  </script>
2042
  </body>