benchmark-opencode-cache.ts 26 KB

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