@bitkyc08/opencodex 2.13.0 → 2.14.0

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.
Files changed (55) hide show
  1. package/gui/dist/assets/index-Co12XTT-.js +76 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/cursor/discovery.ts +4 -1
  5. package/src/adapters/cursor/effort-map.ts +5 -1
  6. package/src/adapters/cursor/request-builder.ts +3 -3
  7. package/src/adapters/google.ts +25 -5
  8. package/src/adapters/openai-chat.ts +182 -6
  9. package/src/adapters/openai-responses.ts +17 -7
  10. package/src/codex/catalog/bundled.ts +16 -0
  11. package/src/codex/catalog/metadata.ts +180 -5
  12. package/src/codex/catalog/parsing.ts +7 -6
  13. package/src/codex/catalog/sync.ts +73 -6
  14. package/src/codex/catalog.ts +1 -1
  15. package/src/codex/convergence.ts +20 -0
  16. package/src/codex/prompt-journal.ts +50 -13
  17. package/src/codex/prompt-layers.ts +1 -1
  18. package/src/config.ts +57 -0
  19. package/src/generated/compatibility-version.json +64 -48
  20. package/src/generated/model-metadata.ts +3 -3
  21. package/src/lib/local-provider-reload-contract.ts +100 -0
  22. package/src/oauth/login-cli.ts +52 -26
  23. package/src/providers/derive.ts +2 -0
  24. package/src/providers/openai-sidecar.ts +9 -2
  25. package/src/providers/quota.ts +57 -0
  26. package/src/providers/registry.ts +53 -25
  27. package/src/responses/state.ts +22 -0
  28. package/src/router.ts +1 -0
  29. package/src/server/claude-messages.ts +57 -11
  30. package/src/server/direct-local-http.ts +7 -3
  31. package/src/server/images.ts +6 -0
  32. package/src/server/index.ts +51 -11
  33. package/src/server/live.ts +117 -13
  34. package/src/server/local-provider-reload-client.ts +137 -0
  35. package/src/server/management/config-routes.ts +20 -3
  36. package/src/server/management/logs-usage-routes.ts +28 -0
  37. package/src/server/management/model-routes.ts +11 -3
  38. package/src/server/management/model-rows.ts +18 -3
  39. package/src/server/management/provider-routes.ts +107 -3
  40. package/src/server/management-auth.ts +65 -1
  41. package/src/server/proxy-liveness.ts +1 -0
  42. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  43. package/src/server/responses/agent-task-recovery.ts +460 -0
  44. package/src/server/responses/compact.ts +4 -2
  45. package/src/server/responses/core.ts +142 -6
  46. package/src/server/responses/encrypted-payload.ts +4 -1
  47. package/src/server/search.ts +4 -0
  48. package/src/types.ts +27 -0
  49. package/src/usage/expected-prices.ts +11 -0
  50. package/src/vision/describe.ts +4 -0
  51. package/src/web-search/anthropic-executor.ts +5 -1
  52. package/src/web-search/executor.ts +9 -1
  53. package/src/web-search/index.ts +5 -0
  54. package/src/web-search/loop.ts +42 -5
  55. package/gui/dist/assets/index-BHldBl6_.js +0 -76
@@ -19,6 +19,7 @@ import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractC
19
19
  import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
20
20
  import {
21
21
  expandPreviousResponseInput,
22
+ markBodyNonPersistable,
22
23
  previousResponseProviderState,
23
24
  previousResponseReplayFailure,
24
25
  rememberResponseState,
@@ -177,6 +178,10 @@ import {
177
178
  relayWithAbort,
178
179
  sanitizePassthroughHeaders,
179
180
  } from "../relay";
181
+ import {
182
+ agentTaskRecoveryConfig,
183
+ recoverEncryptedAgentTask,
184
+ } from "./agent-task-recovery";
180
185
  import { relaySseEagerBounded } from "../relay-eager";
181
186
  import {
182
187
  relayResponsesSseWithTerminalRepair,
@@ -1484,6 +1489,7 @@ async function handleResponsesInner(
1484
1489
  // so an omitted value means a genuine Responses inbound.
1485
1490
  const inboundWire = options.inboundWire ?? "responses";
1486
1491
  const translatorBudget = options.translatorBudget;
1492
+ const agentTaskRecovery = agentTaskRecoveryConfig(config);
1487
1493
  let body: unknown;
1488
1494
  try {
1489
1495
  body = await readJsonRequestBody(req, translatorBudget);
@@ -1494,7 +1500,7 @@ async function handleResponsesInner(
1494
1500
  if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
1495
1501
  return handleComboResponses(req, body, comboId, config, logCtx, options);
1496
1502
  }
1497
- const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
1503
+ let unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
1498
1504
  (body as { input?: unknown } | undefined)?.input,
1499
1505
  );
1500
1506
  const originalBody = body;
@@ -1621,7 +1627,9 @@ async function handleResponsesInner(
1621
1627
  };
1622
1628
  let selectedForwardHeaders = req.headers;
1623
1629
  let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
1630
+ let subagentFallbackPreviewAccountId: string | null | undefined;
1624
1631
  let subagentQuotaFailureModel = parsed.modelId;
1632
+ const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null;
1625
1633
 
1626
1634
  try {
1627
1635
  if (
@@ -1645,6 +1653,7 @@ async function handleResponsesInner(
1645
1653
  undefined,
1646
1654
  previewSelectionOptions,
1647
1655
  );
1656
+ subagentFallbackPreviewAccountId = previewAccountId;
1648
1657
  subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null;
1649
1658
  const fallback = applySubagentModelFallback(
1650
1659
  parsed,
@@ -1683,6 +1692,103 @@ async function handleResponsesInner(
1683
1692
  previewSelectionAdmission?.release();
1684
1693
  }
1685
1694
 
1695
+ // Native fallback can consume ciphertext, so recover only after final route selection.
1696
+ if (
1697
+ inboundWire === "responses"
1698
+ &&
1699
+ threadSpawn
1700
+ && unreadableEncryptedAgentTask
1701
+ && agentTaskRecovery
1702
+ && !isCanonicalOpenAiForwardProvider(route.provider)
1703
+ && !options.comboAttempt
1704
+ ) {
1705
+ let recovered = false;
1706
+ try {
1707
+ recovered = await recoverEncryptedAgentTask(
1708
+ req,
1709
+ (body as { input?: unknown } | undefined)?.input,
1710
+ agentTaskRecovery,
1711
+ config,
1712
+ { parentThreadId, abortSignal: options.abortSignal },
1713
+ );
1714
+ } catch {
1715
+ recovered = false;
1716
+ }
1717
+ if (recovered) {
1718
+ unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
1719
+ (body as { input?: unknown } | undefined)?.input,
1720
+ );
1721
+ if (!unreadableEncryptedAgentTask) {
1722
+ try {
1723
+ const reparsed = parseRequest(body);
1724
+ const kept: Array<keyof OcxParsedRequest> = [
1725
+ "_previousResponseInputExpanded",
1726
+ "_providerContinuation",
1727
+ "_cursorConversationId",
1728
+ "_clientThreadId",
1729
+ "_reasoningReplayScope",
1730
+ "_cursorIsolateConversation",
1731
+ ];
1732
+ for (const key of kept) {
1733
+ if (parsed[key] !== undefined) {
1734
+ (reparsed as unknown as Record<string, unknown>)[key] = parsed[key];
1735
+ }
1736
+ }
1737
+ parsed = reparsed;
1738
+ // The recovery mutated `body.input` in place, so `_rawBody` now carries decrypted task
1739
+ // text. Bar it from the continuation cache before any recording path can reach it —
1740
+ // that cache is persisted to disk, which would defeat the recovery cache's TTL.
1741
+ markBodyNonPersistable(parsed._rawBody);
1742
+
1743
+ // The ciphertext-only pass intentionally excludes routed candidates. Once recovery
1744
+ // makes the assignment readable, run selection again with the full configured chain
1745
+ // and keep the route in sync with any newly selected fallback.
1746
+ const fallback = applySubagentModelFallback(
1747
+ parsed,
1748
+ req.headers,
1749
+ config,
1750
+ subagentFallbackPreviewAccountId,
1751
+ Date.now(),
1752
+ false,
1753
+ previewSelectionOptions,
1754
+ );
1755
+ if (fallback) {
1756
+ (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
1757
+ (logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
1758
+ if (isInjectionDebugEnabled()) {
1759
+ injectionDebugLog(`[opencodex] subagent model fallback ${fallback.from} -> ${fallback.to}`);
1760
+ }
1761
+ }
1762
+ subagentQuotaFailureModel = fallback?.to ?? parsed.modelId;
1763
+
1764
+ if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
1765
+ try {
1766
+ route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody));
1767
+ logCtx.routeDecision = route.routeDecision;
1768
+ } catch (err) {
1769
+ if (err instanceof NoAvailableComboTargetsError) {
1770
+ return comboUnavailableResponse(err.message);
1771
+ }
1772
+ if (err instanceof NoEligiblePolicyCandidateError) {
1773
+ logCtx.routeDecision = err.trace;
1774
+ }
1775
+ return formatErrorResponse(
1776
+ 404,
1777
+ "invalid_request_error",
1778
+ err instanceof Error ? err.message : String(err),
1779
+ );
1780
+ }
1781
+ }
1782
+ toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget);
1783
+ } catch {
1784
+ unreadableEncryptedAgentTask = true;
1785
+ }
1786
+ }
1787
+ }
1788
+ }
1789
+
1790
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
1791
+
1686
1792
  // Encrypted child tasks may only reach the canonical native backend. This check
1687
1793
  // runs against the FINAL route so native-only fallback can rescue a routed primary.
1688
1794
  if (!isCanonicalOpenAiForwardProvider(route.provider) && unreadableEncryptedAgentTask) {
@@ -2356,15 +2462,29 @@ async function handleResponsesInner(
2356
2462
  }
2357
2463
  if (!upstreamResponse.ok) {
2358
2464
  if (options.comboAttempt) {
2465
+ // No pre-read guard here: `consumeComboFailure` -> `readBoundedResponseBody` reads
2466
+ // `response.body` itself and already threads the abort signal through its own read,
2467
+ // and the combo contract is that this body's getter is touched exactly once (pinned by
2468
+ // "captures passthrough failed usage from its original bounded body exactly once").
2469
+ // Attaching a guard would be a second `.body` access and break that contract for no
2470
+ // gain, since the bounded reader owns settlement on this path.
2359
2471
  const failure = await consumeComboFailure(upstreamResponse, options.abortSignal);
2360
2472
  options.onConsumedComboFailure?.(failure);
2361
2473
  return failure.response;
2362
2474
  }
2363
- const errorText = await upstreamResponse.text().catch(() => "");
2364
- return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
2365
- statusText: upstreamResponse.statusText,
2366
- headers,
2367
- });
2475
+ // The plain passthrough error path has no bounded reader of its own: `.text()` attaches
2476
+ // the reader only when it runs, so an abort landing between fetch resolution and that
2477
+ // call orphans Bun's internal read rejection (src/lib/abort.ts).
2478
+ const detachPassthroughErrorGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
2479
+ try {
2480
+ const errorText = await upstreamResponse.text().catch(() => "");
2481
+ return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
2482
+ statusText: upstreamResponse.statusText,
2483
+ headers,
2484
+ });
2485
+ } finally {
2486
+ detachPassthroughErrorGuard();
2487
+ }
2368
2488
  }
2369
2489
 
2370
2490
  // Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
@@ -2891,6 +3011,7 @@ async function handleResponsesInner(
2891
3011
  connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
2892
3012
  routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
2893
3013
  stallTimeoutSec: wsPlan.stallTimeoutSec,
3014
+ streamRoutedModelOutput: wsPlan.streamRoutedModelOutput,
2894
3015
  on429: retryAfter => {
2895
3016
  const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
2896
3017
  retryAfter,
@@ -3386,12 +3507,22 @@ async function handleResponsesInner(
3386
3507
  }
3387
3508
  if (!upstreamResponse.ok) {
3388
3509
  if (options.comboAttempt) {
3510
+ // No pre-read guard: `consumeComboFailure` -> `readBoundedResponseBody` reads
3511
+ // `response.body` itself with the abort signal threaded through, and the combo
3512
+ // contract is that this body's getter is touched exactly once. A guard here would be
3513
+ // a second `.body` access for no gain, since the bounded reader owns settlement.
3389
3514
  const failure = await consumeComboFailure(upstreamResponse, options.abortSignal)
3390
3515
  .finally(cleanupUpstreamAbort);
3391
3516
  options.onConsumedComboFailure?.(failure);
3392
3517
  return failure.response;
3393
3518
  }
3519
+ // The plain error path has no bounded reader of its own: `.text()` attaches the reader
3520
+ // only when it runs, so an abort landing between fetch resolution and that call orphans
3521
+ // Bun's internal read rejection — the uncatchable teardown `cancelBodyOnAbort` absorbs
3522
+ // (src/lib/abort.ts). Found while investigating #1419; not a fix for the native trap.
3523
+ const detachErrorBodyGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
3394
3524
  const errorText = await upstreamResponse.text().catch(() => "unknown error");
3525
+ detachErrorBodyGuard();
3395
3526
  cleanupUpstreamAbort();
3396
3527
  if (!isFixedCodexAccount(authCtx)) {
3397
3528
  recordSubagentQuotaFailureForThreadSpawn(
@@ -3655,7 +3786,12 @@ async function handleResponsesInner(
3655
3786
  }
3656
3787
 
3657
3788
  if (!response.ok) {
3789
+ // Same pre-read guard as the initial response's error branch: a non-2xx continuation body
3790
+ // is still a Bun fetch body, and an abort landing before `.text()` attaches its reader
3791
+ // orphans the internal rejection.
3792
+ const detachContinuationErrorGuard = cancelBodyOnAbort(response.body, upstream.signal);
3658
3793
  const errorText = await response.text().catch(() => "unknown error");
3794
+ detachContinuationErrorGuard();
3659
3795
  yield {
3660
3796
  type: "error",
3661
3797
  status: response.status,
@@ -145,6 +145,10 @@ function isStructurallyValidFernetToken(token: string): boolean {
145
145
  return ciphertextLength >= 16 && ciphertextLength % 16 === 0;
146
146
  }
147
147
 
148
+ export function structurallyValidFernetTokens(payload: string): string[] {
149
+ return fernetTokenRuns(payload).map(run => run.token);
150
+ }
151
+
148
152
  /** Maximal, boundary-delimited and structurally valid Fernet runs embedded in a slot. */
149
153
  function fernetTokenRuns(payload: string): FernetTokenRun[] {
150
154
  const runs: FernetTokenRun[] = [];
@@ -305,4 +309,3 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
305
309
  visit(input);
306
310
  return rewritten;
307
311
  }
308
-
@@ -152,6 +152,10 @@ export async function handleSearch(
152
152
  headers,
153
153
  body: JSON.stringify(relayBody),
154
154
  signal: linkedSignal.signal,
155
+ // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization`
156
+ // across origins but forwards nonstandard headers such as `chatgpt-account-id`,
157
+ // `session_id`, and `x-codex-turn-metadata` to the redirect target.
158
+ redirect: "manual",
155
159
  });
156
160
  const observed = await readBoundedResponseBytes(upstreamResponse, {
157
161
  maxBytes: SEARCH_RESPONSE_MAX_BYTES,
package/src/types.ts CHANGED
@@ -777,6 +777,16 @@ export interface OcxConfig {
777
777
  * - "v2": force ALL models to v2 surface (override upstream pins)
778
778
  */
779
779
  multiAgentMode?: "v1" | "default" | "v2";
780
+ /** Experimental, default-off ChatGPT recovery for encrypted V2 routed tasks. */
781
+ agentTaskRecovery?: {
782
+ enabled?: boolean;
783
+ /** ChatGPT model used by the recovery request. Default: gpt-5.6-sol. */
784
+ model?: string;
785
+ /** Recovery request timeout in milliseconds. Default: 45000. */
786
+ timeoutMs?: number;
787
+ /** Maximum in-memory ciphertext-to-assignment entries. Default: 200. */
788
+ cacheEntries?: number;
789
+ };
780
790
  /** Provider-level Codex-visible context caps. Values only lower known model context windows. */
781
791
  providerContextCaps?: Record<string, number>;
782
792
  /** Global Codex-visible context cap value (tokens). Falls back to DEFAULT_PROVIDER_CONTEXT_CAP. */
@@ -1151,6 +1161,14 @@ export interface OcxWebSearchSidecarConfig {
1151
1161
  * during a web-search turn. Default 200000. Must be an integer from 1 through 2147483647.
1152
1162
  */
1153
1163
  routedModelStallTimeoutMs?: number;
1164
+ /**
1165
+ * Stream the routed model's leading output (text/thinking deltas) live instead of buffering the
1166
+ * whole iteration. Live delivery stops at the first tool-call boundary so web_search interception
1167
+ * stays atomic. Tradeoff: text the model emits BEFORE deciding to search — which buffered mode
1168
+ * silently drops — becomes visible to the client and may partially repeat in the post-search
1169
+ * answer. Default: false (buffered, previous behavior).
1170
+ */
1171
+ streamRoutedModelOutput?: boolean;
1154
1172
  }
1155
1173
 
1156
1174
  export interface OpenRouterProviderRouting {
@@ -1438,6 +1456,15 @@ export interface OcxProviderConfig {
1438
1456
  * fields. Default off; only enable for providers that document this parameter.
1439
1457
  */
1440
1458
  promptCacheKey?: boolean;
1459
+ /**
1460
+ * Opt-in: forward `service_tier` to the upstream `/chat/completions` body.
1461
+ * OpenAI-specific extension with the same hazard as `promptCacheKey` — strict backends
1462
+ * reject unknown fields, and 66 registry providers share the `openai-chat` adapter, so a
1463
+ * caller-supplied `service_tier` would otherwise turn working requests into upstream 400s.
1464
+ * `supportsServiceTier` is the Responses-wire flag and does not apply here.
1465
+ * Default off; only enable for providers that document this parameter on the chat wire.
1466
+ */
1467
+ chatServiceTier?: boolean;
1441
1468
  /**
1442
1469
  * Provider-local passthrough SSE repair for broken openai-responses gateways that reuse exact
1443
1470
  * placeholder message/reasoning ids or omit the terminal id after a stable added event.
@@ -277,6 +277,17 @@ export const CONTEXT_TIERS: readonly ContextTier[] = [
277
277
  source: "https://docs.x.ai/developers/pricing",
278
278
  verifiedAt: "2026-08-03",
279
279
  },
280
+ {
281
+ // 260813: grok-4.6 long-context tier mirrored from grok-4.5; the official pricing row
282
+ // was not yet published when the model page went up, so treat as provisional.
283
+ provider: "xai",
284
+ modelId: "grok-4.6",
285
+ thresholdInputTokens: 200_000,
286
+ inclusive: true,
287
+ multiplier: UNIFORM_DOUBLE,
288
+ source: "https://docs.x.ai/developers/pricing",
289
+ verifiedAt: "2026-08-13",
290
+ },
280
291
  {
281
292
  // daybreak-blue-latest aliases gpt-5.6-sol, which publishes the full long-context row
282
293
  // ($10 / $1 / $12.50 / $45). Scoped to openai-apikey ON PURPOSE: Daybreak is not
@@ -95,6 +95,10 @@ export async function describeImage(
95
95
  headers,
96
96
  body: JSON.stringify(body),
97
97
  signal: linkedSignal.signal,
98
+ // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization`
99
+ // across origins but forwards nonstandard headers such as `chatgpt-account-id`,
100
+ // `session_id`, and `x-codex-turn-metadata` to the redirect target.
101
+ redirect: "manual",
98
102
  }),
99
103
  { abortSignal: linkedSignal.signal, label: "vision-sidecar" },
100
104
  );
@@ -166,13 +166,17 @@ export async function runAnthropicWebSearch(
166
166
  () => fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal: linkedSignal.signal }),
167
167
  { abortSignal: linkedSignal.signal, label: "web-search-sidecar-anthropic" },
168
168
  );
169
+ // Guard before any branch reads the body: the failure branch's `res.text()` ran ahead of
170
+ // the success-path guard, reopening the fetch-resolution-to-reader-attach race
171
+ // (found investigating #1419).
172
+ const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
169
173
  if (!res.ok) {
170
174
  const t = await res.text().catch(() => "");
175
+ detachBodyGuard();
171
176
  console.warn(`[web-search] anthropic sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`);
172
177
  // Redact before surfacing: the body can echo auth headers/tokens (#398 review).
173
178
  return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` };
174
179
  }
175
- const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
176
180
  try {
177
181
  return await parseAnthropicSidecarSSE(res);
178
182
  } finally {
@@ -78,16 +78,24 @@ export async function runWebSearch(
78
78
  headers,
79
79
  body: JSON.stringify(body),
80
80
  signal: linkedSignal.signal,
81
+ // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization`
82
+ // across origins but forwards nonstandard headers such as `chatgpt-account-id`,
83
+ // `session_id`, and `x-codex-turn-metadata` to the redirect target.
84
+ redirect: "manual",
81
85
  }),
82
86
  { abortSignal: linkedSignal.signal, label: "web-search-sidecar" },
83
87
  );
84
88
  recordOutcome?.(res.status);
89
+ // Attach the body guard before ANY branch reads it. The success path guarded itself below,
90
+ // but the failure branch's `res.text()` runs first, so a cancel landing between fetch
91
+ // resolution and reader attach orphaned the internal rejection (found investigating #1419).
92
+ const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
85
93
  if (!res.ok) {
86
94
  const t = await res.text().catch(() => "");
95
+ detachBodyGuard();
87
96
  console.warn(`[web-search] sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`);
88
97
  return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` };
89
98
  }
90
- const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
91
99
  try {
92
100
  return await parseSidecarSSE(res);
93
101
  } finally {
@@ -120,6 +120,8 @@ export interface SidecarPlan {
120
120
  routedModelStallTimeoutMs: number;
121
121
  /** Effective bridge stall deadline for the sidecar turn (see webSearchStallTimeoutSec). */
122
122
  stallTimeoutSec: number;
123
+ /** Stream leading routed-model output live until the first tool-call boundary (opt-in). */
124
+ streamRoutedModelOutput: boolean;
123
125
  }
124
126
 
125
127
  export function shouldResolveOpenAiWebSearchSidecar(
@@ -166,6 +168,7 @@ export function planWebSearch(
166
168
  // The routed model being text-only means the search model must verbalize image results (either backend).
167
169
  const describeImages = modelInList(provider.noVisionModels, modelId);
168
170
  const reasoning = cfg.reasoning ?? DEFAULT_SIDECAR_REASONING;
171
+ const streamRoutedModelOutput = cfg.streamRoutedModelOutput === true;
169
172
 
170
173
  // Anthropic backend authenticates with the STORED credential — no forward provider or ChatGPT login gate.
171
174
  // resolveSidecarBackend only returns "anthropic" when it was explicitly configured OR a usable credential
@@ -181,6 +184,7 @@ export function planWebSearch(
181
184
  maxSearches,
182
185
  routedModelStallTimeoutMs,
183
186
  stallTimeoutSec,
187
+ streamRoutedModelOutput,
184
188
  };
185
189
  }
186
190
 
@@ -194,5 +198,6 @@ export function planWebSearch(
194
198
  maxSearches,
195
199
  routedModelStallTimeoutMs,
196
200
  stallTimeoutSec,
201
+ streamRoutedModelOutput,
197
202
  };
198
203
  }
@@ -267,6 +267,12 @@ export interface WebSearchLoopDeps {
267
267
  * sidecar search, so a legitimately slow-but-progressing unit never trips the bridge watchdog.
268
268
  */
269
269
  stallTimeoutSec?: number;
270
+ /**
271
+ * Opt-in: stream the routed model's leading text/thinking deltas live instead of holding the whole
272
+ * iteration back. The live window closes at the first buffer-only event (tool calls above all) so
273
+ * the web_search interception decision stays atomic; everything after replays in order at the end.
274
+ */
275
+ streamRoutedModelOutput?: boolean;
270
276
  /** One-shot TTFT callback: first non-empty model output observed (WP4). */
271
277
  onFirstOutput?: () => void;
272
278
  /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
@@ -336,7 +342,14 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
336
342
  response: Response;
337
343
  responseAdapter: ProviderAdapter;
338
344
  }
339
- type IterationSplit = ReturnType<typeof scanEventsForWebSearch>;
345
+ type IterationSplit = ReturnType<typeof scanEventsForWebSearch> & {
346
+ /**
347
+ * How many leading passthrough events were already delivered live this iteration. They are
348
+ * exactly the first N passthrough entries (live delivery stops before the first event that
349
+ * scanEventsForWebSearch could group or reorder), so the terminal replay skips them by count.
350
+ */
351
+ streamedPassthroughCount: number;
352
+ };
340
353
 
341
354
  // Same-target 429 budget is per REQUEST, not per model iteration: later search rounds inherit
342
355
  // what earlier rounds left of `attempts`, so a bounded multi-round turn can never exceed the
@@ -531,10 +544,23 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
531
544
  return r.value;
532
545
  };
533
546
 
547
+ // Event types that may leave the live window before the first tool-call boundary: pure
548
+ // text/thinking output the native (sidecar-less) path would deliver identically. Everything
549
+ // else — tool calls above all, and any type scanEventsForWebSearch could group or a future
550
+ // adapter could add — closes the window so live delivery can never reorder against the replay.
551
+ const LIVE_STREAMABLE = new Set<AdapterEvent["type"]>([
552
+ "text_delta", "thinking_delta", "reasoning_raw_delta",
553
+ "thinking_signature", "redacted_thinking", "kiro_redacted_reasoning",
554
+ ]);
555
+
534
556
  // Consume and validate one successful response body under a resettable raw-byte inactivity guard.
535
- // Only invisible heartbeat events escape while semantic output remains buffered for safe scanning.
557
+ // By default only invisible heartbeat events escape while semantic output remains buffered for
558
+ // safe scanning; with `streamRoutedModelOutput` the leading text/thinking deltas stream live and
559
+ // the live window closes permanently at the first buffer-only event (see LIVE_STREAMABLE).
536
560
  const consumeIterationEvents = async function* (prepared: IterationResponse): AsyncGenerator<AdapterEvent, IterationSplit> {
537
561
  const events: AdapterEvent[] = [];
562
+ let liveWindowOpen = deps.streamRoutedModelOutput === true;
563
+ let streamedPassthroughCount = 0;
538
564
  try {
539
565
  const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter);
540
566
  for await (const event of parseStreamWithProgress(prepared.response, parse, {
@@ -550,7 +576,16 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
550
576
  // Tool events remain buffered below, so the decision to invoke the hosted sidecar is still
551
577
  // atomic and no search call can escape before its stream has validated successfully.
552
578
  else if (event.type === "text_delta" && event.phase === "commentary") yield event;
553
- else events.push(event);
579
+ else if (liveWindowOpen && LIVE_STREAMABLE.has(event.type)) {
580
+ // Live events are ALSO buffered: the scanner still needs them for thinking extraction
581
+ // and the forced-answer output check; only the terminal replay skips them (by count).
582
+ yield event;
583
+ streamedPassthroughCount++;
584
+ events.push(event);
585
+ } else {
586
+ liveWindowOpen = false;
587
+ events.push(event);
588
+ }
554
589
  }
555
590
  } catch (error) {
556
591
  if (isTranslatorBudgetExceededError(error)) throw error;
@@ -572,7 +607,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
572
607
  }
573
608
  throw new LoopError(502, terminal.message);
574
609
  }
575
- return scanEventsForWebSearch(events);
610
+ return { ...scanEventsForWebSearch(events), streamedPassthroughCount };
576
611
  };
577
612
 
578
613
  // Execute one model-requested web_search call. The call may batch several queries (native
@@ -743,7 +778,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
743
778
  + `, ${i + 1} iteration${i > 0 ? "s" : ""}, ${Date.now() - loopT0}ms`,
744
779
  );
745
780
  }
746
- yield* replay(split.passthrough);
781
+ // Live-streamed leading events are exactly the first N passthrough entries — replay
782
+ // only the buffered tail so nothing reaches the client twice.
783
+ yield* replay(split.passthrough.slice(split.streamedPassthroughCount));
747
784
  return;
748
785
  }
749
786
  // The thinking that led to the search belongs to the FIRST call's assistant replay turn.