Spaces:
Sleeping
Sleeping
Parse reasoning, tool calls, route and usage; live OpenRouter pricing
Browse files- 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:
|
| 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:
|
| 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(
|
| 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"
|
| 518 |
<span>RAW</span>
|
| 519 |
</label>
|
| 520 |
-
<label title="
|
| 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 |
-
//
|
| 612 |
-
const
|
| 613 |
|
| 614 |
function collectResults() {
|
| 615 |
const results = {
|
|
@@ -632,11 +662,8 @@
|
|
| 632 |
},
|
| 633 |
prompts: [],
|
| 634 |
responses: [],
|
| 635 |
-
|
| 636 |
-
|
| 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=${
|
| 680 |
|
| 681 |
lane.prompts.forEach((prompt, i) => {
|
| 682 |
text += `[Prompt ${i+1}]: ${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}\n`;
|
| 683 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1290 |
-
|
| 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 |
-
|
| 1331 |
-
|
| 1332 |
-
|
| 1333 |
-
|
| 1334 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 1433 |
}
|
| 1434 |
|
| 1435 |
-
|
| 1436 |
-
|
| 1437 |
-
|
| 1438 |
-
|
| 1439 |
-
|
| 1440 |
-
|
| 1441 |
-
|
| 1442 |
-
|
| 1443 |
-
|
| 1444 |
-
|
| 1445 |
-
|
| 1446 |
-
|
| 1447 |
-
|
| 1448 |
-
|
| 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 |
-
|
| 1504 |
-
|
| 1505 |
-
|
| 1506 |
}
|
| 1507 |
} else {
|
| 1508 |
-
// Non-streaming response
|
| 1509 |
const data = await response.json();
|
| 1510 |
-
|
| 1511 |
-
|
| 1512 |
-
|
| 1513 |
-
|
| 1514 |
-
if (
|
| 1515 |
-
|
| 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
|
| 1533 |
-
|
|
|
|
|
|
|
| 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>
|