benchmark-opencode-cache.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  1. #!/usr/bin/env bun
  2. export {};
  3. /**
  4. * Example: bun scripts/benchmark-opencode-cache.ts --server http://127.0.0.1:4096 --provider anthropic --model claude-sonnet-4-5 --runs 10 --output /tmp/cache.json --arm plugin-on --plugin-build $(git rev-parse HEAD)
  5. * Direct HTTP only: POST /session, POST/GET/DELETE /session/:id/message, GET /event, GET /global/health.
  6. */
  7. type Json = Record<string, unknown>;
  8. type Event = {
  9. at: number;
  10. type: string;
  11. sessionID?: string;
  12. messageID?: string;
  13. partID?: string;
  14. field?: string;
  15. partType?: string;
  16. status?: string;
  17. retryAttempt?: number;
  18. };
  19. type RequestObservation = {
  20. position: number;
  21. warmup: boolean;
  22. input: number | null;
  23. output: number | null;
  24. cacheRead: number | null;
  25. cacheWrite: number | null;
  26. totalInput: number | null;
  27. };
  28. type PromptLoopObservation = {
  29. turn: number;
  30. warmup: boolean;
  31. promptLoopTtftMs: number | null;
  32. promptLoopLatencyMs: number | null;
  33. };
  34. const HELP = `Usage: bun scripts/benchmark-opencode-cache.ts [options]
  35. Required:
  36. --server URL --provider ID --model ID --runs N --output PATH
  37. --arm ID Comparison arm identifier (for example plugin-on)
  38. --plugin-build ID Plugin build/commit identifier
  39. Options:
  40. --timeout-ms N Per-request timeout (default: 120000)
  41. --retries N Retry transient GET/create/probe failures (default: 2)
  42. --retry-delay-ms N Initial exponential-backoff delay (default: 500)
  43. --header NAME:VALUE Extra HTTP header; repeatable, never persisted
  44. --overwrite Replace an existing output file
  45. --help Show this help
  46. Each independent session explicitly targets agent "orchestrator" and the supplied
  47. provider/model. It requests Read(package.json), TodoWrite, a final answer, then a
  48. second user follow-up. Incomplete, retrying, compacted, or misrouted sessions are
  49. recorded as discards, never resampled. Output contains only normalized/redacted
  50. telemetry: no prompts, model text, tool I/O, workspace paths, headers, or event
  51. properties. Prompt-loop TTFT/latency are one observation per submitted user
  52. prompt, never copied to provider steps. SSE coverage must be established before
  53. prompts; an unavailable, malformed, or closed stream discards the session. A
  54. unique per-session nonce is kept only in live prompts.
  55. Sessions are deleted after collection.
  56. `;
  57. function fail(message: string): never {
  58. console.error(`Error: ${message}\n\n${HELP}`);
  59. process.exit(2);
  60. }
  61. function isRecord(value: unknown): value is Json {
  62. return !!value && typeof value === 'object' && !Array.isArray(value);
  63. }
  64. function string(value: unknown) {
  65. return typeof value === 'string' ? value : undefined;
  66. }
  67. function number(value: unknown) {
  68. return typeof value === 'number' && Number.isFinite(value)
  69. ? value
  70. : undefined;
  71. }
  72. function urlPath(base: string, route: string) {
  73. return `${base}${route.startsWith('/') ? route : `/${route}`}`;
  74. }
  75. const now = () => performance.timeOrigin + performance.now();
  76. const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
  77. function parseArgs(argv: string[]) {
  78. const values = new Map<string, string[]>();
  79. const flags = new Set<string>();
  80. const names = [
  81. '--server',
  82. '--provider',
  83. '--model',
  84. '--runs',
  85. '--output',
  86. '--arm',
  87. '--plugin-build',
  88. '--timeout-ms',
  89. '--retries',
  90. '--retry-delay-ms',
  91. '--header',
  92. ];
  93. for (let i = 0; i < argv.length; i += 1) {
  94. if (argv[i] === '--help') {
  95. console.log(HELP);
  96. process.exit(0);
  97. }
  98. if (argv[i] === '--overwrite') {
  99. flags.add(argv[i]);
  100. continue;
  101. }
  102. if (!names.includes(argv[i])) fail(`unknown argument ${argv[i]}`);
  103. const value = argv[i + 1];
  104. if (!value || value.startsWith('--')) fail(`missing value for ${argv[i]}`);
  105. values.set(argv[i], [...(values.get(argv[i]) ?? []), value]);
  106. i += 1;
  107. }
  108. const required = (name: string) => {
  109. const value = values.get(name)?.at(-1);
  110. if (!value) fail(`missing required ${name}`);
  111. return value;
  112. };
  113. const integer = (name: string, fallback?: number) => {
  114. const value = Number(values.get(name)?.at(-1) ?? fallback);
  115. if (
  116. !Number.isSafeInteger(value) ||
  117. value < 0 ||
  118. (name === '--runs' && value === 0)
  119. )
  120. fail(
  121. `${name} must be a ${name === '--runs' ? 'positive' : 'non-negative'} integer`,
  122. );
  123. return value;
  124. };
  125. let server: URL;
  126. try {
  127. server = new URL(required('--server'));
  128. } catch {
  129. fail('--server must be an absolute http(s) URL');
  130. }
  131. if (!['http:', 'https:'].includes(server.protocol))
  132. fail('--server must be an http(s) URL');
  133. const headers = new Headers();
  134. for (const value of values.get('--header') ?? []) {
  135. const split = value.indexOf(':');
  136. if (split < 1) fail('--header must be NAME:VALUE');
  137. headers.append(value.slice(0, split), value.slice(split + 1).trim());
  138. }
  139. return {
  140. server: server.toString().replace(/\/$/, ''),
  141. provider: required('--provider'),
  142. model: required('--model'),
  143. runs: integer('--runs'),
  144. output: required('--output'),
  145. arm: required('--arm'),
  146. pluginBuild: required('--plugin-build'),
  147. timeoutMs: integer('--timeout-ms', 120_000),
  148. retries: integer('--retries', 2),
  149. retryDelayMs: integer('--retry-delay-ms', 500),
  150. headers,
  151. overwrite: flags.has('--overwrite'),
  152. };
  153. }
  154. const args = parseArgs(process.argv.slice(2));
  155. if ((await Bun.file(args.output).exists()) && !args.overwrite)
  156. fail(`output already exists: ${args.output} (use --overwrite)`);
  157. async function request(
  158. method: string,
  159. route: string,
  160. body?: Json,
  161. retry = false,
  162. ) {
  163. let last = 'request failed';
  164. for (let attempt = 0; attempt <= args.retries; attempt += 1) {
  165. const controller = new AbortController();
  166. const timeout = setTimeout(() => controller.abort(), args.timeoutMs);
  167. try {
  168. const headers = new Headers(args.headers);
  169. if (body) headers.set('content-type', 'application/json');
  170. const response = await fetch(urlPath(args.server, route), {
  171. method,
  172. headers,
  173. body: body ? JSON.stringify(body) : undefined,
  174. signal: controller.signal,
  175. });
  176. const text = await response.text();
  177. if (response.ok)
  178. return text.trim() ? (JSON.parse(text) as unknown) : undefined;
  179. last = `${method} ${route} returned HTTP ${response.status}`;
  180. if (!retry || response.status < 500 || attempt === args.retries)
  181. throw new Error(last);
  182. } catch (error) {
  183. last = error instanceof Error ? error.message : String(error);
  184. if (!retry || attempt === args.retries) throw new Error(last);
  185. } finally {
  186. clearTimeout(timeout);
  187. }
  188. await sleep(args.retryDelayMs * 2 ** attempt);
  189. }
  190. throw new Error(last);
  191. }
  192. function normalizeEvent(value: unknown): Event | undefined {
  193. if (!isRecord(value) || !isRecord(value.properties)) return undefined;
  194. const properties = value.properties;
  195. const type = string(value.type);
  196. const sessionID = string(properties.sessionID);
  197. if (!type || !sessionID) return undefined;
  198. if (type === 'message.part.delta')
  199. return {
  200. at: now(),
  201. type,
  202. sessionID,
  203. messageID: string(properties.messageID),
  204. partID: string(properties.partID),
  205. field: string(properties.field),
  206. };
  207. if (type === 'message.part.updated' && isRecord(properties.part))
  208. return {
  209. at: now(),
  210. type,
  211. sessionID,
  212. messageID: string(properties.part.messageID),
  213. partID: string(properties.part.id),
  214. partType: string(properties.part.type),
  215. status: isRecord(properties.part.state)
  216. ? string(properties.part.state.status)
  217. : undefined,
  218. };
  219. if (type === 'message.updated' && isRecord(properties.info))
  220. return {
  221. at: now(),
  222. type,
  223. sessionID,
  224. messageID: string(properties.info.id),
  225. };
  226. if (type === 'session.status' && isRecord(properties.status))
  227. return {
  228. at: now(),
  229. type,
  230. sessionID,
  231. status: string(properties.status.type),
  232. retryAttempt: number(properties.status.attempt),
  233. };
  234. if (type.toLowerCase().includes('compaction'))
  235. return { at: now(), type, sessionID };
  236. return undefined;
  237. }
  238. type StreamCoverage = {
  239. ready: Promise<void>;
  240. readyResolve: () => void;
  241. issue: string | null;
  242. };
  243. function streamCoverage(): StreamCoverage {
  244. let readyResolve = () => {};
  245. const ready = new Promise<void>((resolve) => {
  246. readyResolve = resolve;
  247. });
  248. return { ready, readyResolve, issue: null };
  249. }
  250. async function awaitSseReadiness(coverage: StreamCoverage) {
  251. let timeout: ReturnType<typeof setTimeout> | undefined;
  252. try {
  253. await Promise.race([
  254. coverage.ready,
  255. new Promise<void>((_, reject) => {
  256. timeout = setTimeout(
  257. () => reject(new Error('SSE subscription readiness timed out')),
  258. Math.min(args.timeoutMs, 10_000),
  259. );
  260. }),
  261. ]);
  262. } finally {
  263. if (timeout) clearTimeout(timeout);
  264. }
  265. if (coverage.issue) throw new Error(coverage.issue);
  266. }
  267. async function eventStream(
  268. events: Event[],
  269. controller: AbortController,
  270. coverage: StreamCoverage,
  271. ) {
  272. try {
  273. const response = await fetch(urlPath(args.server, '/event'), {
  274. headers: args.headers,
  275. signal: controller.signal,
  276. });
  277. if (!response.ok || !response.body)
  278. throw new Error(`GET /event returned HTTP ${response.status}`);
  279. const reader = response.body.getReader();
  280. const decoder = new TextDecoder();
  281. let buffer = '';
  282. while (!controller.signal.aborted) {
  283. const chunk = await reader.read();
  284. if (chunk.done) break;
  285. buffer += decoder.decode(chunk.value, { stream: true });
  286. const frames = buffer.split(/\r?\n\r?\n/);
  287. buffer = frames.pop() ?? '';
  288. for (const frame of frames) {
  289. const data = frame
  290. .split(/\r?\n/)
  291. .find((line) => line.startsWith('data:'))
  292. ?.slice(5)
  293. .trim();
  294. if (!data) continue;
  295. try {
  296. const parsed = JSON.parse(data) as unknown;
  297. if (isRecord(parsed) && parsed.type === 'server.connected') {
  298. coverage.readyResolve();
  299. continue;
  300. }
  301. const event = normalizeEvent(parsed);
  302. if (event) events.push(event);
  303. } catch {
  304. coverage.issue ??= 'SSE delivered a malformed frame';
  305. coverage.readyResolve();
  306. }
  307. }
  308. }
  309. if (!controller.signal.aborted) {
  310. coverage.issue ??= 'SSE stream closed before collection completed';
  311. coverage.readyResolve();
  312. }
  313. } catch {
  314. if (!controller.signal.aborted) {
  315. coverage.issue ??= 'SSE stream connection failed';
  316. coverage.readyResolve();
  317. }
  318. }
  319. }
  320. function normalizeMessage(value: unknown) {
  321. if (!isRecord(value) || !isRecord(value.info)) return undefined;
  322. const info = value.info;
  323. const parts = Array.isArray(value.parts) ? value.parts : [];
  324. return {
  325. id: string(info.id),
  326. parentID: string(info.parentID),
  327. role: string(info.role),
  328. agent: string(info.agent),
  329. providerID: string(info.providerID),
  330. modelID: string(info.modelID),
  331. tokens: isRecord(info.tokens) ? info.tokens : undefined,
  332. parts: parts.filter(isRecord).map((part) => ({
  333. type: string(part.type),
  334. tool: string(part.tool),
  335. status: isRecord(part.state) ? string(part.state.status) : undefined,
  336. tokens: isRecord(part.tokens) ? part.tokens : undefined,
  337. })),
  338. };
  339. }
  340. function tokenObservation(
  341. tokens: unknown,
  342. position: number,
  343. warmup: boolean,
  344. ): RequestObservation {
  345. const tokenRecord = isRecord(tokens) ? tokens : {};
  346. const cache = isRecord(tokenRecord.cache) ? tokenRecord.cache : undefined;
  347. const input = number(tokenRecord.input) ?? null;
  348. const cacheRead = cache ? (number(cache.read) ?? null) : null;
  349. const cacheWrite = cache ? (number(cache.write) ?? null) : null;
  350. const totalInput =
  351. input === null || cacheRead === null || cacheWrite === null
  352. ? null
  353. : input + cacheRead + cacheWrite;
  354. return {
  355. position,
  356. warmup,
  357. input,
  358. output: number(tokenRecord.output) ?? null,
  359. cacheRead,
  360. cacheWrite,
  361. totalInput,
  362. };
  363. }
  364. function percentile(values: number[], p: number) {
  365. if (!values.length) return null;
  366. const sorted = [...values].sort((a, b) => a - b);
  367. return sorted[
  368. Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)
  369. ];
  370. }
  371. function boundedCoverage(observations: RequestObservation[]) {
  372. const measured = observations.filter(
  373. (item) => item.cacheRead !== null && item.totalInput !== null,
  374. );
  375. const total = measured.reduce((sum, item) => sum + (item.totalInput ?? 0), 0);
  376. return total > 0
  377. ? Math.min(
  378. 1,
  379. Math.max(
  380. 0,
  381. measured.reduce((sum, item) => sum + (item.cacheRead ?? 0), 0) /
  382. total,
  383. ),
  384. )
  385. : null;
  386. }
  387. function bootstrapCI<T>(
  388. sessions: T[][],
  389. metric: (items: T[]) => number | null,
  390. ) {
  391. const observed = metric(sessions.flat());
  392. if (!sessions.length || observed === null)
  393. return { estimate: observed, lower: null, upper: null, samples: 0 };
  394. let state = 0x9e3779b9;
  395. const random = () => {
  396. state = (state * 1664525 + 1013904223) >>> 0;
  397. return state / 2 ** 32;
  398. };
  399. const values: number[] = [];
  400. for (let i = 0; i < 2000; i += 1) {
  401. const sampled = Array.from(
  402. { length: sessions.length },
  403. () => sessions[Math.floor(random() * sessions.length)],
  404. ).flat();
  405. const value = metric(sampled);
  406. if (value !== null) values.push(value);
  407. }
  408. return {
  409. estimate: observed,
  410. lower: percentile(values, 2.5),
  411. upper: percentile(values, 97.5),
  412. samples: values.length,
  413. };
  414. }
  415. function firstPrompt(nonce: string) {
  416. return `Session marker: ${nonce}. Use the Read tool to inspect package.json. Then use the TodoWrite tool to create one completed, low-priority todo named "cache benchmark trace". Do not modify files. After both tools finish, reply with exactly: benchmark complete.`;
  417. }
  418. function followUp(nonce: string) {
  419. return `Session marker: ${nonce}. Without using tools, reply with exactly: follow-up complete.`;
  420. }
  421. async function run(index: number) {
  422. const events: Event[] = [];
  423. const controller = new AbortController();
  424. const coverage = streamCoverage();
  425. const stream = eventStream(events, controller, coverage);
  426. let sessionID: string | undefined;
  427. const record: Json = {
  428. runIndex: index,
  429. runOrder: index + 1,
  430. requestedRouting: {
  431. agent: 'orchestrator',
  432. providerID: args.provider,
  433. modelID: args.model,
  434. },
  435. };
  436. try {
  437. await awaitSseReadiness(coverage);
  438. const created = await request('POST', '/session', {}, true);
  439. sessionID = isRecord(created) ? string(created.id) : undefined;
  440. if (!sessionID) throw new Error('POST /session returned no session id');
  441. record.sessionID = sessionID;
  442. const nonce = crypto.randomUUID();
  443. const prompts: {
  444. parentMessageID?: string;
  445. startedAt: number;
  446. completedAt: number;
  447. }[] = [];
  448. for (const text of [firstPrompt(nonce), followUp(nonce)]) {
  449. const startedAt = now();
  450. const response = await request(
  451. 'POST',
  452. `/session/${encodeURIComponent(sessionID)}/message`,
  453. {
  454. agent: 'orchestrator',
  455. model: { providerID: args.provider, modelID: args.model },
  456. parts: [{ type: 'text', text }],
  457. },
  458. );
  459. const normalized = normalizeMessage(response);
  460. prompts.push({
  461. // POST /message returns the assistant message; its parent is this turn's user message.
  462. parentMessageID: normalized?.parentID,
  463. startedAt,
  464. completedAt: now(),
  465. });
  466. }
  467. const [rawMessages, rawSession] = await Promise.all([
  468. request(
  469. 'GET',
  470. `/session/${encodeURIComponent(sessionID)}/message`,
  471. undefined,
  472. true,
  473. ),
  474. request(
  475. 'GET',
  476. `/session/${encodeURIComponent(sessionID)}`,
  477. undefined,
  478. true,
  479. ),
  480. ]);
  481. const messages = Array.isArray(rawMessages)
  482. ? rawMessages
  483. .map(normalizeMessage)
  484. .filter((item): item is NonNullable<typeof item> => !!item)
  485. : [];
  486. const session = isRecord(rawSession) ? rawSession : {};
  487. const sessionModel = isRecord(session.model) ? session.model : {};
  488. const sessionEvents = events.filter(
  489. (event) => event.sessionID === sessionID,
  490. );
  491. const assistants = messages.filter(
  492. (message) => message.role === 'assistant',
  493. );
  494. const tools = assistants.flatMap((message) =>
  495. message.parts
  496. .filter((part) => part.type === 'tool')
  497. .map((part) => ({ tool: part.tool, status: part.status })),
  498. );
  499. const observations: RequestObservation[] = [];
  500. const promptLoops: PromptLoopObservation[] = [];
  501. let position = 0;
  502. for (const prompt of prompts) {
  503. const assistantIDs = new Set(
  504. assistants
  505. .filter((message) => message.parentID === prompt.parentMessageID)
  506. .map((message) => message.id)
  507. .filter((id): id is string => !!id),
  508. );
  509. promptLoops.push({
  510. turn: promptLoops.length + 1,
  511. warmup: promptLoops.length === 0,
  512. promptLoopTtftMs: (() => {
  513. const event = sessionEvents.find(
  514. (item) =>
  515. item.at >= prompt.startedAt &&
  516. item.at <= prompt.completedAt &&
  517. item.messageID &&
  518. assistantIDs.has(item.messageID) &&
  519. item.type === 'message.part.delta' &&
  520. ['text', 'reasoning'].includes(item.field ?? ''),
  521. );
  522. return event ? event.at - prompt.startedAt : null;
  523. })(),
  524. promptLoopLatencyMs: prompt.completedAt - prompt.startedAt,
  525. });
  526. const perPrompt = assistants
  527. .filter((message) => message.parentID === prompt.parentMessageID)
  528. .flatMap((message) =>
  529. message.parts
  530. .filter((part) => part.type === 'step-finish')
  531. .map((part) => part.tokens),
  532. );
  533. const tokenSets = perPrompt.length
  534. ? perPrompt
  535. : assistants
  536. .filter((message) => message.parentID === prompt.parentMessageID)
  537. .map((message) => message.tokens);
  538. for (const tokens of tokenSets) {
  539. position += 1;
  540. observations.push(tokenObservation(tokens, position, position === 1));
  541. }
  542. }
  543. const retries = sessionEvents.filter(
  544. (event) => event.type === 'session.status' && event.status === 'retry',
  545. ).length;
  546. const historyRetry = messages.some((message) =>
  547. message.parts.some((part) => part.type === 'retry'),
  548. );
  549. const historyCompaction =
  550. string(session.agent) === 'compaction' ||
  551. messages.some(
  552. (message) =>
  553. message.agent === 'compaction' ||
  554. message.parts.some((part) => part.type === 'compaction'),
  555. ) ||
  556. (isRecord(session.time) && number(session.time.compacting) !== undefined);
  557. const compaction =
  558. historyCompaction ||
  559. sessionEvents.some((event) =>
  560. event.type.toLowerCase().includes('compaction'),
  561. );
  562. const routingMatches =
  563. string(session.agent) === 'orchestrator' &&
  564. string(sessionModel.providerID) === args.provider &&
  565. string(sessionModel.id) === args.model &&
  566. assistants.length > 0 &&
  567. assistants.every(
  568. (message) =>
  569. message.agent === 'orchestrator' &&
  570. message.providerID === args.provider &&
  571. message.modelID === args.model,
  572. );
  573. const reasons = [
  574. ...(tools.some(
  575. (tool) =>
  576. tool.tool?.toLowerCase() === 'read' && tool.status === 'completed',
  577. )
  578. ? []
  579. : ['missing completed Read tool']),
  580. ...(tools.some(
  581. (tool) =>
  582. tool.tool?.toLowerCase() === 'todowrite' &&
  583. tool.status === 'completed',
  584. )
  585. ? []
  586. : ['missing completed harmless TodoWrite tool']),
  587. ...(assistants.some((message) =>
  588. message.parts.some((part) => part.type === 'text'),
  589. )
  590. ? []
  591. : ['missing assistant final response']),
  592. ...(messages.filter((message) => message.role === 'user').length >= 2
  593. ? []
  594. : ['missing second user follow-up']),
  595. ...(routingMatches
  596. ? []
  597. : [
  598. 'observed session/assistant routing does not match orchestrator/provider/model',
  599. ]),
  600. ...(coverage.issue ? [coverage.issue] : []),
  601. ...(retries || historyRetry ? ['session entered retry state'] : []),
  602. ...(compaction ? ['session compaction observed'] : []),
  603. ];
  604. record.observation = {
  605. routing: {
  606. sessionAgent: string(session.agent) ?? null,
  607. sessionProviderID: string(sessionModel.providerID) ?? null,
  608. sessionModelID: string(sessionModel.id) ?? null,
  609. assistantCount: assistants.length,
  610. },
  611. events: sessionEvents,
  612. tools,
  613. requests: observations,
  614. promptLoops,
  615. retries,
  616. compaction,
  617. historyRetry,
  618. historyCompaction,
  619. };
  620. record.discarded = reasons.length > 0;
  621. if (reasons.length) record.discardReasons = reasons;
  622. } catch (error) {
  623. record.discarded = true;
  624. record.discardReasons = [
  625. error instanceof Error ? error.message : String(error),
  626. ];
  627. record.observation = { events };
  628. } finally {
  629. if (sessionID) {
  630. try {
  631. await request('DELETE', `/session/${encodeURIComponent(sessionID)}`);
  632. } catch {
  633. record.cleanupError = 'session deletion failed';
  634. }
  635. }
  636. controller.abort();
  637. await stream;
  638. }
  639. return record;
  640. }
  641. let hostVersion: string | null = null;
  642. try {
  643. const health = await request('GET', '/global/health', undefined, true);
  644. hostVersion = isRecord(health) ? (string(health.version) ?? null) : null;
  645. } catch {
  646. /* probe is optional */
  647. }
  648. const runs: Json[] = [];
  649. for (let index = 0; index < args.runs; index += 1) {
  650. console.error(`Running session ${index + 1}/${args.runs}`);
  651. runs.push(await run(index));
  652. }
  653. const eligible = runs.filter((run) => !run.discarded);
  654. const aggregateSessions = eligible.map((run) =>
  655. isRecord(run.observation) && Array.isArray(run.observation.requests)
  656. ? run.observation.requests
  657. .filter(isRecord)
  658. .map((item) => item as unknown as RequestObservation)
  659. .filter((item) => !item.warmup)
  660. : [],
  661. );
  662. const aggregate = aggregateSessions.flat();
  663. const promptLoopSessions = eligible.map((run) =>
  664. isRecord(run.observation) && Array.isArray(run.observation.promptLoops)
  665. ? run.observation.promptLoops
  666. .filter(isRecord)
  667. .map((item) => item as unknown as PromptLoopObservation)
  668. .filter((item) => !item.warmup)
  669. : [],
  670. );
  671. const promptLoops = promptLoopSessions.flat();
  672. const cacheMiss = (items: RequestObservation[]) => {
  673. const measured = items.filter((item) => item.cacheRead !== null);
  674. return measured.length
  675. ? measured.filter((item) => item.cacheRead === 0).length / measured.length
  676. : null;
  677. };
  678. const ttftP50 = (items: PromptLoopObservation[]) =>
  679. percentile(
  680. items
  681. .map((item) => item.promptLoopTtftMs)
  682. .filter((value): value is number => value !== null),
  683. 50,
  684. );
  685. const latencyP50 = (items: PromptLoopObservation[]) =>
  686. percentile(
  687. items
  688. .map((item) => item.promptLoopLatencyMs)
  689. .filter((value): value is number => value !== null),
  690. 50,
  691. );
  692. const report = {
  693. schemaVersion: 2,
  694. generatedAt: new Date().toISOString(),
  695. comparison: {
  696. arm: args.arm,
  697. pluginBuild: args.pluginBuild,
  698. hostOpenCodeVersion: hostVersion,
  699. runOrder: 'sequential independent sessions',
  700. routingFingerprint: {
  701. agent: 'orchestrator',
  702. providerID: args.provider,
  703. modelID: args.model,
  704. },
  705. },
  706. controls: {
  707. runs: args.runs,
  708. timeoutMs: args.timeoutMs,
  709. retries: args.retries,
  710. retryDelayMs: args.retryDelayMs,
  711. warmupRule:
  712. 'exclude request position 1 in each eligible session from aggregates',
  713. },
  714. metrics: {
  715. sessionsRequested: args.runs,
  716. sessionsEligible: eligible.length,
  717. sessionsDiscarded: runs.length - eligible.length,
  718. requestObservationsRaw: eligible.flatMap((run) =>
  719. isRecord(run.observation) && Array.isArray(run.observation.requests)
  720. ? run.observation.requests
  721. : [],
  722. ).length,
  723. requestObservationsAggregated: aggregate.length,
  724. promptLoopObservationsRaw: eligible.flatMap((run) =>
  725. isRecord(run.observation) && Array.isArray(run.observation.promptLoops)
  726. ? run.observation.promptLoops
  727. : [],
  728. ).length,
  729. promptLoopObservationsAggregated: promptLoops.length,
  730. primaryMetricsSessionBootstrap95CI: {
  731. zeroCacheMissRate: bootstrapCI(aggregateSessions, cacheMiss),
  732. cacheCoverage: bootstrapCI(aggregateSessions, boundedCoverage),
  733. promptLoopTtftMsP50: bootstrapCI(promptLoopSessions, ttftP50),
  734. promptLoopLatencyMsP50: bootstrapCI(promptLoopSessions, latencyP50),
  735. },
  736. },
  737. runs,
  738. };
  739. await Bun.write(args.output, `${JSON.stringify(report, null, 2)}\n`);
  740. console.error(
  741. `Wrote ${args.output}: ${eligible.length}/${args.runs} eligible sessions`,
  742. );