@bitkyc08/opencodex 2.7.27 → 2.7.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,8 @@ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
30
30
  import { clearThreadAccountMap } from "../codex/routing";
31
31
  import { primeCodexPoolQuotas } from "../codex/auth-api";
32
32
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../providers/context-cap";
33
+ import { resolveCodexHomeDir } from "../codex/home";
34
+ import { scanStorage } from "../storage/scanner";
33
35
  import { readUsageEntries } from "../usage/log";
34
36
  import { getUsageDebugLogEntries } from "../usage/debug";
35
37
  import { parseRange, parseUsageSurface, summarizeUsage } from "../usage/summary";
@@ -46,7 +48,9 @@ import {
46
48
  } from "../lib/debug-settings";
47
49
  import type { OcxClaudeCodeConfig, OcxConfig, OcxProviderConfig } from "../types";
48
50
  import { drainAndShutdown } from "./lifecycle";
49
- import { filterRequestLogs, getRequestLogEntries } from "./request-log";
51
+ import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "./request-log";
52
+ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../usage/cost";
53
+ import type { PersistedUsageAttempt } from "../usage/log";
50
54
  import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
51
55
  import { applySystemEnvToggle } from "./system-env";
52
56
 
@@ -82,6 +86,96 @@ function parseDebugLogQuery(url: URL): { after: number; limit: number } {
82
86
  };
83
87
  }
84
88
 
89
+ // ---- /api/logs display metrics (devlog/_plan/260720_toks_speed_price_columns/020) ----
90
+ // Derived at response time only; NEVER persisted to the request log or usage.jsonl.
91
+
92
+ type MetricUnavailableReason =
93
+ | "usage_missing" | "usage_unsupported" | "output_missing" | "invalid_duration"
94
+ | "price_unmatched" | "invalid_cache_breakdown"
95
+ | "invalid_usage" | "combo_attempt_unavailable";
96
+
97
+ type TokPerSecondResult =
98
+ | { kind: "value"; value: number; estimated: boolean }
99
+ | { kind: "unavailable"; reason: MetricUnavailableReason };
100
+
101
+ type CostEstimateReason = "usage_estimated" | "cache_detail_missing" | "expected_price_overlay";
102
+
103
+ type CostResult =
104
+ | { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
105
+ | { kind: "unavailable"; reason: MetricUnavailableReason };
106
+
107
+ type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage"> & {
108
+ attempts?: readonly PersistedUsageAttempt[];
109
+ };
110
+
111
+ function tokPerSecondResult(entry: Pick<MetricSource, "durationMs" | "usageStatus" | "usage">): TokPerSecondResult {
112
+ if (!entry.usage) return { kind: "unavailable", reason: "usage_missing" };
113
+ if (entry.usageStatus === "unsupported") return { kind: "unavailable", reason: "usage_unsupported" };
114
+ const value = tokensPerSecond(entry.usage.outputTokens, entry.durationMs);
115
+ if (value === null) {
116
+ return {
117
+ kind: "unavailable",
118
+ reason: entry.usage.outputTokens <= 0 ? "output_missing" : "invalid_duration",
119
+ };
120
+ }
121
+ return { kind: "value", value, estimated: entry.usageStatus === "estimated" || entry.usage.estimated === true };
122
+ }
123
+
124
+ function unavailableCostReason(entry: MetricSource): MetricUnavailableReason {
125
+ // Normalizer-first classification: the landed normalizer recovers legacy
126
+ // cachedInputTokens=read+write rows via retry, so a raw read+write>input
127
+ // pre-check would misclassify recoverable rows (020 audit blocker #2).
128
+ if (!entry.usage && !entry.attempts?.length) return "usage_missing";
129
+ if (entry.usageStatus === "unsupported") return "usage_unsupported";
130
+ if (entry.attempts?.length) return "combo_attempt_unavailable";
131
+ if (!entry.usage) return "usage_missing";
132
+ if (!normalizeCostTokens(entry.usage)) {
133
+ const effectiveRead = entry.usage.cacheReadInputTokens ?? entry.usage.cachedInputTokens ?? 0;
134
+ const effectiveWrite = entry.usage.cacheCreationInputTokens ?? 0;
135
+ const finite = [entry.usage.inputTokens, entry.usage.outputTokens, effectiveRead, effectiveWrite]
136
+ .every(v => Number.isFinite(v) && v >= 0);
137
+ return finite ? "invalid_cache_breakdown" : "invalid_usage";
138
+ }
139
+ return "price_unmatched";
140
+ }
141
+
142
+ function costResult(entry: MetricSource): CostResult {
143
+ const estimate = entry.attempts?.length
144
+ ? estimateComboCost(entry.attempts)
145
+ : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus });
146
+ if (!estimate) return { kind: "unavailable", reason: unavailableCostReason(entry) };
147
+ const estimateReasons = [
148
+ entry.usageStatus === "estimated" || entry.usage?.estimated ? "usage_estimated" as const : undefined,
149
+ entry.usage && entry.usage.cachedInputTokens === undefined
150
+ && entry.usage.cacheReadInputTokens === undefined
151
+ && entry.usage.cacheCreationInputTokens === undefined ? "cache_detail_missing" as const : undefined,
152
+ estimate.price?.source === "expected" || estimate.attempts?.some(a => a.price.source === "expected")
153
+ ? "expected_price_overlay" as const : undefined,
154
+ ].filter((reason): reason is CostEstimateReason => reason !== undefined);
155
+ return { kind: "value", estimate, estimateReasons };
156
+ }
157
+
158
+ function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
159
+ return {
160
+ ...entry,
161
+ displayMetrics: {
162
+ tokPerSecond: tokPerSecondResult(entry),
163
+ cost: costResult(entry),
164
+ },
165
+ ...(entry.attempts?.length
166
+ ? {
167
+ attempts: entry.attempts.map(attempt => ({
168
+ ...attempt,
169
+ displayMetrics: {
170
+ tokPerSecond: tokPerSecondResult(attempt),
171
+ cost: costResult({ ...attempt, attempts: undefined }),
172
+ },
173
+ })),
174
+ }
175
+ : {}),
176
+ };
177
+ }
178
+
85
179
  export async function handleManagementAPI(req: Request, url: URL, config: OcxConfig, deps: ManagementApiDeps = {}): Promise<Response | null> {
86
180
  if (!isAllowedRequestOrigin(req, config)) {
87
181
  return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
@@ -311,7 +405,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
311
405
  }
312
406
 
313
407
  if (url.pathname === "/api/logs" && req.method === "GET") {
314
- return jsonResponse(filterRequestLogs(getRequestLogEntries(), url.searchParams));
408
+ const logs = filterRequestLogs(getRequestLogEntries(), url.searchParams);
409
+ return jsonResponse(logs.map(requestLogDto));
315
410
  }
316
411
 
317
412
  if (url.pathname === "/api/debug" && req.method === "GET") {
@@ -378,6 +473,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
378
473
  generatedAt: now,
379
474
  summary: {
380
475
  requests: 0,
476
+ attemptCount: 0,
381
477
  measuredRequests: 0,
382
478
  reportedRequests: 0,
383
479
  unreportedRequests: 0,
@@ -391,6 +487,10 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
391
487
  reasoningOutputTokens: 0,
392
488
  totalTokens: 0,
393
489
  coverageRatio: 0,
490
+ estimatedCostUsd: 0,
491
+ pricedRequests: 0,
492
+ unpricedRequests: 0,
493
+ unmeteredRequests: 0,
394
494
  },
395
495
  days: [],
396
496
  models: [],
@@ -400,6 +500,20 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
400
500
  }
401
501
  }
402
502
 
503
+ if (url.pathname === "/api/storage" && req.method === "GET") {
504
+ try {
505
+ return jsonResponse(scanStorage());
506
+ } catch {
507
+ return jsonResponse({
508
+ codexHome: resolveCodexHomeDir(),
509
+ generatedAt: Date.now(),
510
+ total: { bytes: 0, fileCount: 0 },
511
+ buckets: [],
512
+ error: "scan_failed",
513
+ });
514
+ }
515
+ }
516
+
403
517
  if (url.pathname === "/api/provider-quotas" && req.method === "GET") {
404
518
  const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
405
519
  return jsonResponse(await fetchProviderQuotaReports(config, forceRefresh));
@@ -543,13 +657,19 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
543
657
  return jsonResponse({ error: "authMode must be key, forward, oauth, or local" }, 400);
544
658
  }
545
659
  }
546
- if (Object.hasOwn(rawBody, "note")) {
547
- if (typeof rawBody.note !== "string") return jsonResponse({ error: "note must be a string" }, 400);
548
- const note = rawBody.note.trim();
549
- if (note) next.note = note;
550
- else delete next.note;
551
- touched = true;
552
- }
660
+ if (Object.hasOwn(rawBody, "note")) {
661
+ if (typeof rawBody.note !== "string") return jsonResponse({ error: "note must be a string" }, 400);
662
+ const note = rawBody.note.trim();
663
+ if (note) next.note = note;
664
+ else delete next.note;
665
+ touched = true;
666
+ }
667
+
668
+ if (Object.hasOwn(rawBody, "allowPrivateNetwork")) {
669
+ if (typeof rawBody.allowPrivateNetwork !== "boolean") return jsonResponse({ error: "allowPrivateNetwork must be a boolean" }, 400);
670
+ next.allowPrivateNetwork = rawBody.allowPrivateNetwork;
671
+ touched = true;
672
+ }
553
673
 
554
674
  if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400);
555
675
 
@@ -1241,13 +1361,24 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1241
1361
  // the provider's loopback callback server (inside this process) captures the redirect in the
1242
1362
  // background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
1243
1363
  if (url.pathname === "/api/oauth/login" && req.method === "POST") {
1244
- const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean };
1364
+ const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean };
1245
1365
  const provider = (body.provider ?? "").trim().toLowerCase();
1246
1366
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1367
+ const accountId = body.accountId?.trim();
1368
+ const reauth = body.reauth === true || Boolean(accountId);
1247
1369
  try {
1248
- // addAccount forces a fresh browser identity (skips local-CLI token import) so a
1249
- // SECOND account can be added instead of re-importing the first one.
1250
- const { url: authUrl, instructions } = await startLoginFlow(provider, body.addAccount ? { forceLogin: true } : undefined);
1370
+ if (accountId) {
1371
+ const { getAccountSet } = await import("../oauth/store");
1372
+ const set = getAccountSet(provider);
1373
+ if (!set?.accounts.some(a => a.id === accountId)) {
1374
+ return jsonResponse({ error: "Unknown account for reauth" }, 404);
1375
+ }
1376
+ }
1377
+ // addAccount / reauth forces a fresh browser identity (skips local-CLI token import).
1378
+ const { url: authUrl, instructions } = await startLoginFlow(provider, {
1379
+ forceLogin: body.addAccount === true || reauth,
1380
+ ...(accountId ? { reauthAccountId: accountId } : {}),
1381
+ });
1251
1382
  upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart
1252
1383
  if (authUrl) {
1253
1384
  // Open the browser server-side (the proxy runs on the user's machine) — the GUI's
@@ -6,6 +6,7 @@ import {
6
6
  httpStatusForRequestLogTerminal,
7
7
  inspectResponseLogJson,
8
8
  inspectResponseLogSsePayload,
9
+ recordFirstOutput,
9
10
  type RequestLogContext,
10
11
  type RequestLogEntry,
11
12
  } from "./request-log";
@@ -107,6 +108,34 @@ export function sseDataPayload(block: string): string | null {
107
108
  }
108
109
 
109
110
  export function terminalStatusFromSsePayload(payload: string): ResponsesTerminalStatus | null {
111
+ return terminalStatusFromSsePayloadInner(payload);
112
+ }
113
+
114
+ /** True when a native Responses SSE payload carries the FIRST kind of non-empty model output. */
115
+ export function isFirstOutputSsePayload(payload: string | null): boolean {
116
+ if (!payload || payload === "[DONE]") return false;
117
+ try {
118
+ const event = JSON.parse(payload) as { type?: unknown; delta?: unknown };
119
+ return (event.type === "response.output_text.delta"
120
+ || event.type === "response.reasoning_summary_text.delta"
121
+ || event.type === "response.reasoning_text.delta")
122
+ && typeof event.delta === "string"
123
+ && event.delta.length > 0;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ function createFirstOutputReporter(onFirstOutput?: () => void): (payload: string | null) => void {
130
+ let reported = false;
131
+ return payload => {
132
+ if (reported || !isFirstOutputSsePayload(payload)) return;
133
+ reported = true;
134
+ try { onFirstOutput?.(); } catch { /* metrics must not break the stream */ }
135
+ };
136
+ }
137
+
138
+ function terminalStatusFromSsePayloadInner(payload: string): ResponsesTerminalStatus | null {
110
139
  if (payload === "[DONE]") return null;
111
140
  try {
112
141
  const json = JSON.parse(payload) as { type?: unknown };
@@ -144,11 +173,13 @@ export function trackSseForRequestLog(
144
173
  onTerminal: (status: ResponsesTerminalStatus) => void,
145
174
  onCancel: () => void,
146
175
  logCtx?: RequestLogContext,
176
+ onFirstOutput?: () => void,
147
177
  ): ReadableStream<Uint8Array> {
148
178
  const reader = body.getReader();
149
179
  const decoder = new TextDecoder();
150
180
  let buffer = "";
151
181
  let terminalReported = false;
182
+ const reportFirstOutput = createFirstOutputReporter(onFirstOutput);
152
183
 
153
184
  const reportTerminal = (status: ResponsesTerminalStatus) => {
154
185
  if (terminalReported) return;
@@ -159,6 +190,7 @@ export function trackSseForRequestLog(
159
190
  const inspectPayload = (payload: string | null) => {
160
191
  if (!payload) return;
161
192
  if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
193
+ reportFirstOutput(payload);
162
194
  const status = terminalStatusFromSsePayload(payload);
163
195
  if (status) reportTerminal(status);
164
196
  };
@@ -264,6 +296,7 @@ export function responseWithDeferredRequestLog(
264
296
  addFinalRequestLog(requestId, start, logCtx, 499, { closeReason: "client_cancel" }, addLog);
265
297
  },
266
298
  logCtx,
299
+ () => recordFirstOutput(logCtx, start),
267
300
  );
268
301
  return new Response(body, {
269
302
  status: response.status,
@@ -379,12 +412,14 @@ export function consumeForInspection(
379
412
  logCtx?: RequestLogContext,
380
413
  onCancel?: () => void,
381
414
  onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void,
415
+ onFirstOutput?: () => void,
382
416
  ): void {
383
417
  const reader = body.getReader();
384
418
  const decoder = new TextDecoder();
385
419
  let buffer = "";
386
420
  let reported = false;
387
421
  let cancelled = false;
422
+ const reportFirstOutput = createFirstOutputReporter(onFirstOutput);
388
423
  if (signal) {
389
424
  if (signal.aborted) {
390
425
  // Aborted before we could read anything (Codex disconnects the instant it finishes reading).
@@ -413,6 +448,7 @@ export function consumeForInspection(
413
448
  if (buffer.trim() && !reported) {
414
449
  const payload = sseDataPayload(buffer);
415
450
  if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
451
+ reportFirstOutput(payload);
416
452
  if (payload) {
417
453
  const status = terminalStatusFromSsePayload(payload);
418
454
  if (status) { reported = true; onTerminal(status); }
@@ -432,6 +468,7 @@ export function consumeForInspection(
432
468
  if (reported && !onCompletedResponse) continue;
433
469
  const payload = sseDataPayload(next.block);
434
470
  if (!reported && logCtx) inspectResponseLogSsePayload(logCtx, payload);
471
+ reportFirstOutput(payload);
435
472
  if (!payload) continue;
436
473
  if (!reported) {
437
474
  const status = terminalStatusFromSsePayload(payload);
@@ -458,10 +495,12 @@ export function consumeForResponseLogMetadata(
458
495
  signal?: AbortSignal,
459
496
  onDone?: () => void,
460
497
  onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void,
498
+ onFirstOutput?: () => void,
461
499
  ): void {
462
500
  const reader = body.getReader();
463
501
  const decoder = new TextDecoder();
464
502
  let buffer = "";
503
+ const reportFirstOutput = createFirstOutputReporter(onFirstOutput);
465
504
  if (signal) {
466
505
  if (signal.aborted) {
467
506
  reader.cancel(signal.reason).catch(() => {});
@@ -481,6 +520,7 @@ export function consumeForResponseLogMetadata(
481
520
  if (buffer.trim()) {
482
521
  const payload = sseDataPayload(buffer);
483
522
  inspectResponseLogSsePayload(logCtx, payload);
523
+ reportFirstOutput(payload);
484
524
  if (payload && onCompletedResponse) {
485
525
  const response = completedResponseFromSsePayload(payload);
486
526
  if (response) onCompletedResponse(response);
@@ -494,6 +534,7 @@ export function consumeForResponseLogMetadata(
494
534
  buffer = next.rest;
495
535
  const payload = sseDataPayload(next.block);
496
536
  inspectResponseLogSsePayload(logCtx, payload);
537
+ reportFirstOutput(payload);
497
538
  if (payload && onCompletedResponse) {
498
539
  const response = completedResponseFromSsePayload(payload);
499
540
  if (response) onCompletedResponse(response);
@@ -29,6 +29,8 @@ import {
29
29
  export interface RequestLogContext {
30
30
  model: string;
31
31
  provider: string;
32
+ /** TTFT: ms from request start to the first non-empty model output delta (WP4, devlog 040). */
33
+ firstOutputMs?: number;
32
34
  surface?: "claude";
33
35
  requestedModel?: string;
34
36
  requestedEffort?: string;
@@ -65,6 +67,8 @@ export interface RequestLogEntry {
65
67
  timestamp: number;
66
68
  model: string;
67
69
  provider: string;
70
+ /** TTFT: ms from request start to the first non-empty model output delta; unset for non-streaming/tool-only. */
71
+ firstOutputMs?: number;
68
72
  surface?: "claude";
69
73
  requestedModel?: string;
70
74
  requestedEffort?: string;
@@ -117,6 +121,7 @@ export function addRequestLog(entry: RequestLogEntry) {
117
121
  ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
118
122
  status: entry.status,
119
123
  durationMs: entry.durationMs,
124
+ ...(entry.firstOutputMs !== undefined ? { firstOutputMs: entry.firstOutputMs } : {}),
120
125
  usageStatus: entry.usageStatus,
121
126
  ...(entry.usage ? { usage: entry.usage } : {}),
122
127
  ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
@@ -133,6 +138,26 @@ export function nextRequestLogId(timestamp = Date.now()): string {
133
138
  return `ocx-${timestamp.toString(36)}-${requestLogSeq.toString(36)}`;
134
139
  }
135
140
 
141
+ /**
142
+ * One-shot TTFT recorder (WP4). Records the first non-empty model output moment
143
+ * relative to the request start, and — when a combo attempt is in flight —
144
+ * relative to that attempt's start as well. Later calls are no-ops, so both the
145
+ * bridge callback and the deferred SSE tap may fire without double-recording.
146
+ */
147
+ export function recordFirstOutput(
148
+ logCtx: RequestLogContext,
149
+ requestStartedAt: number,
150
+ now = Date.now(),
151
+ ): void {
152
+ if (!Number.isFinite(requestStartedAt) || !Number.isFinite(now)) return;
153
+ const requestElapsed = Math.max(0, now - requestStartedAt);
154
+ if (logCtx.firstOutputMs === undefined) logCtx.firstOutputMs = requestElapsed;
155
+ if (logCtx.activeAttempt && logCtx.activeAttempt.firstOutputMs === undefined) {
156
+ const attemptStartedAt = logCtx.activeAttemptStartedAt ?? requestStartedAt;
157
+ logCtx.activeAttempt.firstOutputMs = Math.max(0, now - attemptStartedAt);
158
+ }
159
+ }
160
+
136
161
  export function requestLogErrorCode(status: number, upstreamError?: string): string | undefined {
137
162
  if (status >= 200 && status < 400) return undefined;
138
163
  // Defense in depth: mid-stream web-search aborts used to land as 502 with this message.
@@ -456,6 +481,7 @@ export function addFinalRequestLog(
456
481
  ...(logCtx.resolvedModel ? { resolvedModel: logCtx.resolvedModel } : {}),
457
482
  status: effectiveStatus,
458
483
  durationMs: Date.now() - start,
484
+ ...(logCtx.firstOutputMs !== undefined ? { firstOutputMs: logCtx.firstOutputMs } : {}),
459
485
  ...(errorCode ? { errorCode } : {}),
460
486
  ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
461
487
  ...(closeReason ? { closeReason } : {}),
@@ -462,6 +462,8 @@ interface ConsumedComboFailure {
462
462
  interface HandleResponsesOptions {
463
463
  forceEmptyResponseId?: boolean;
464
464
  abortSignal?: AbortSignal;
465
+ /** One-shot TTFT callback: first non-empty model output observed (WP4). */
466
+ onFirstOutput?: () => void;
465
467
  onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void;
466
468
  recordTerminalOutcomes?: boolean;
467
469
  setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus) => void) | undefined) => void;
@@ -634,6 +636,14 @@ async function handleComboResponses(
634
636
  response = await handleResponses(childRequest, config, childLog, {
635
637
  ...options,
636
638
  comboAttempt: true,
639
+ // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
640
+ // Object.assign(logCtx, childLog) would overwrite the request-relative value).
641
+ onFirstOutput: () => {
642
+ if (attempt.firstOutputMs === undefined) {
643
+ attempt.firstOutputMs = Math.max(0, Date.now() - started);
644
+ }
645
+ options.onFirstOutput?.();
646
+ },
637
647
  onCodexAuthContextResolved: value => { resolvedAuth = value; },
638
648
  setTerminalOutcomeRecorder: value => { terminalRecorder = value; },
639
649
  onConsumedComboFailure: value => { consumedChildFailure = value; },
@@ -1176,9 +1186,17 @@ export async function handleResponses(
1176
1186
  logCtx,
1177
1187
  () => options.onNativePassthroughCancel?.(),
1178
1188
  rememberPassthroughResponse,
1189
+ options.onFirstOutput,
1179
1190
  );
1180
1191
  } else {
1181
- consumeForResponseLogMetadata(inspectBody, logCtx, turnAc.signal, () => unregisterTurn(turnAc), rememberPassthroughResponse);
1192
+ consumeForResponseLogMetadata(
1193
+ inspectBody,
1194
+ logCtx,
1195
+ turnAc.signal,
1196
+ () => unregisterTurn(turnAc),
1197
+ rememberPassthroughResponse,
1198
+ options.onFirstOutput,
1199
+ );
1182
1200
  }
1183
1201
  if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
1184
1202
  // win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
@@ -1266,6 +1284,7 @@ export async function handleResponses(
1266
1284
  ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
1267
1285
  stallTimeoutSec: config.stallTimeoutSec,
1268
1286
  hideThinkingSummary: parsed.options.hideThinkingSummary,
1287
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
1269
1288
  ...(routedCompaction ? { compaction: true } : {}),
1270
1289
  ...(routedCompaction ? {} : { onCompletedResponse: (response: Record<string, unknown>) => rememberResponseState(parsed._rawBody, response, parsed._cursorConversationId) }),
1271
1290
  },
@@ -1317,6 +1336,7 @@ export async function handleResponses(
1317
1336
  maxSearches: wsPlan.maxSearches,
1318
1337
  forceEmptyResponseId: true,
1319
1338
  abortSignal: options.abortSignal,
1339
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
1320
1340
  recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
1321
1341
  connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
1322
1342
  routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
@@ -1529,6 +1549,7 @@ export async function handleResponses(
1529
1549
  ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
1530
1550
  stallTimeoutSec: config.stallTimeoutSec,
1531
1551
  hideThinkingSummary: parsed.options.hideThinkingSummary,
1552
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
1532
1553
  ...(routedCompaction ? { compaction: true } : {}),
1533
1554
  // Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
1534
1555
  // PRE-compaction history, and a later previous_response_id expansion would rehydrate the
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Bridge upstream stall budget: seconds of silence (no adapter events) before the
3
+ * Responses bridge emits `response.incomplete` / `upstream_stall_timeout`.
4
+ *
5
+ * Raised from 90s so long reasoning + large tool writes are not cut mid-turn.
6
+ * Hung streams still die; they just get a more realistic window.
7
+ */
8
+ export const DEFAULT_STALL_TIMEOUT_SEC = 300;
9
+
10
+ /**
11
+ * Resolve the effective bridge stall deadline for a turn.
12
+ * - unset / non-finite config → {@link DEFAULT_STALL_TIMEOUT_SEC}
13
+ * - finite config → ceil, minimum 1
14
+ */
15
+ export function resolveStallTimeoutSec(configuredSec: number | undefined): number {
16
+ if (typeof configuredSec === "number" && Number.isFinite(configuredSec)) {
17
+ return Math.max(1, Math.ceil(configuredSec));
18
+ }
19
+ return DEFAULT_STALL_TIMEOUT_SEC;
20
+ }