@tangle-network/agent-app 0.43.68 → 0.43.70

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.
package/README.md CHANGED
@@ -16,7 +16,7 @@ The substrate packages — `@tangle-network/agent-runtime`, `agent-eval`, `agent
16
16
  - **Bounded tool loop** — `runAppToolLoop` / `streamAppToolLoop`: stream a turn → collect tool calls → dispatch → fold results back → re-run, capped. These are 1:1 aliases of `@tangle-network/agent-runtime`'s `runToolLoop` / `streamToolLoop` (the engine owns the loop; this package adds no logic on that path). Substrate-free behind a `streamTurn` seam, so it drives a sandboxed agent, a Worker, or an in-browser copilot unchanged.
17
17
  - **Assembled chat vertical** — `createChatTurnRoutes` wires auth → thread/message store → streaming turn with buffered replay → uploads → sidecar question answering into one route factory, over `authorize` / `produce` / `store` / `interactions` seams. No hand-rolled orchestration. See [`examples/chat-app.md`](./examples/chat-app.md).
18
18
  - **Sandbox-optional** — the same tools, billing, eval, and loop work without a container. A `fetch`-only adapter maps any OpenAI-compatible stream (Tangle Router, tcloud) into the loop. See [`examples/browser-copilot.md`](./examples/browser-copilot.md).
19
- - **Resumable turns (sandbox-free path)** — for a browser/edge copilot streaming the Router directly, buffer a turn so a dropped tab loses nothing and a reconnecting client replays the tail. **Sandbox products don't need this** — the sandbox SDK already buffers + replays sessions (server-side reconnect via `box.streamPrompt`'s `lastEventId`; browser-direct via `box.mintScopedToken()` + `SessionGatewayClient`). See [`examples/resumable-turns.md`](./examples/resumable-turns.md).
19
+ - **Resumable turns** — buffer a turn so a dropped tab loses nothing and a reconnecting client replays the tail. Two niches: a browser/edge copilot streaming the Router directly (no sandbox session to attach to), and any DETACHED sandbox run a browser must tail (`dispatchPrompt({ detach: true })` / `driveTurn` execute on a lane the session gateway never sees). **An INTERACTIVE sandbox turn doesn't need it** — drive it on the message lane (`box.session(id).sendMessage()`) and attach the tab with `box.mintScopedToken()` + `SessionGatewayClient`, or let the worker resume with `box.streamPrompt('', { executionId, lastEventId })`. See [`examples/resumable-turns.md`](./examples/resumable-turns.md).
20
20
  - **Composes the engine, never forks it** — `/eval` re-exports `@tangle-network/agent-eval`'s verifier; `/integrations` wraps the hub; `/tangle` and `/billing` take the tcloud client as a structural contract. Engines are **peer dependencies** — you pin the version, nothing is bundled.
21
21
  - **ESM, typed, zero runtime deps** in the substrate-free modules (`/runtime`, `/web`, `/crypto`, `/redact`, `/stream`). Ships with `.d.ts` and npm [provenance](https://www.npmjs.com/package/@tangle-network/agent-app#provenance).
22
22
 
@@ -5,6 +5,7 @@ import { ChatTurnIdentity, ChatTurnProducer } from '@tangle-network/agent-runtim
5
5
  import { InteractionAnswerRoute, InteractionAnswerRouteOptions } from '../interactions/index.js';
6
6
  import { PersistedChatMessageForTurn } from '../stream/index.js';
7
7
  import { d as TurnEventStore } from '../turn-buffer-DGnAPKwa.js';
8
+ import { M as ModelFailoverAttempt } from '../failover-H0x12kY3.js';
8
9
  export { D as DEFAULT_STALE_TURN_LOCK_GRACE_MS, a as DEFAULT_TERMINAL_TURN_LOCK_GRACE_MS, R as ReconcileStaleTurnLockOptions, b as ReconcileStaleTurnLockResult, S as StaleTurnLockSandboxProbeResult, c as StaleTurnLockSessionProbeResult, r as reconcileStaleTurnLock } from '../stale-turn-lock-DucQzvXu.js';
9
10
  import { J as JsonRecord } from '../stream-normalizer-QYUl4vnl.js';
10
11
  import { SandboxExecChannel, PromptInputPart } from '../sandbox/index.js';
@@ -298,7 +299,22 @@ interface ChatTurnRouteProducer extends ChatTurnProducer {
298
299
  * parts until the turn completes. */
299
300
  draftParts?(): Array<Record<string, unknown>>;
300
301
  usage?(): ChatTurnUsage;
302
+ /** The model that SERVED the turn. With failover wired this is the model that
303
+ * actually answered, which is not necessarily the one the caller preferred —
304
+ * read it after the stream drains, never before. */
301
305
  model?: string;
306
+ /** Model-failover attribution, when the producer supports it. Reported onto
307
+ * the usage/billing receipt so a downgrade is never silent. */
308
+ modelFailover?(): ChatTurnModelFailover;
309
+ }
310
+ /** Which model served, and what it took to get there. */
311
+ interface ChatTurnModelFailover {
312
+ /** The model that served the turn. */
313
+ model?: string;
314
+ /** Every model tried, in order, with the reason each was abandoned. */
315
+ attempts: ModelFailoverAttempt[];
316
+ /** True when the preferred model did not serve. */
317
+ usedFallback: boolean;
302
318
  }
303
319
  /** Resolve authorization status and context for a chat turn including tenant and user identification */
304
320
  type ChatTurnAuthorization<TContext> = {
@@ -410,6 +426,13 @@ interface ChatTurnLifecycleComplete<TContext> extends ChatTurnLifecycleBase<TCon
410
426
  finalText: string;
411
427
  usage: ChatTurnUsage;
412
428
  durationMs: number;
429
+ /** The model that SERVED the turn — the fallback's id when failover moved it.
430
+ * `usage` is that model's, so telemetry that splits cost or quality by model
431
+ * must key on this and not on the requested one. */
432
+ model?: string;
433
+ /** Attribution for a downgrade: which models were tried and why each failed.
434
+ * `undefined` when the producer reports no failover support. */
435
+ modelFailover?: ChatTurnModelFailover;
413
436
  }
414
437
  /** Represent an error occurring during a chat turn lifecycle with context and duration information */
415
438
  interface ChatTurnLifecycleError<TContext> extends ChatTurnLifecycleBase<TContext> {
@@ -516,6 +539,13 @@ interface CreateChatTurnRoutesOptions<TContext = void> {
516
539
  context: TContext;
517
540
  failed: boolean;
518
541
  failureReason?: string;
542
+ /** The model that SERVED this turn. With failover wired it may differ from
543
+ * the requested one, so a product that bills or scores per model MUST read
544
+ * it here rather than assuming the model it asked for. */
545
+ model?: string;
546
+ /** Present when the producer supports failover: the full attempt trail, and
547
+ * `usedFallback` — the flag that makes a silent downgrade impossible. */
548
+ modelFailover?: ChatTurnModelFailover;
519
549
  }): Promise<void>;
520
550
  /** Per-event side channel (product broadcast). The turn-buffer tap is
521
551
  * already wired; this runs in addition. */
@@ -566,6 +596,115 @@ interface ChatTurnRoutes {
566
596
  /** Build chat turn routes to handle and validate incoming chat requests with optional logging */
567
597
  declare function createChatTurnRoutes<TContext = void>(options: CreateChatTurnRoutesOptions<TContext>): ChatTurnRoutes;
568
598
 
599
+ /**
600
+ * Model failover for a STREAMING turn.
601
+ *
602
+ * `/model-resolution`'s `runWithModelFailover` already owns the policy: walk a
603
+ * chain, classify a resolved-or-thrown signal, re-throw a non-outage failure
604
+ * immediately, and carry the attempt trail. This module does NOT re-implement
605
+ * any of that — it composes it. What it adds is the one thing a whole-call
606
+ * primitive cannot express, because a stream fails PARTWAY:
607
+ *
608
+ * **A turn may only fail over before its first client-visible byte.**
609
+ *
610
+ * Once a text delta, tool call, or ask has reached the browser (and the
611
+ * persisted transcript), restarting on another model would duplicate the
612
+ * answer. So each attempt is probed: open the stream, pull events into a small
613
+ * buffer, and decide at the first meaningful event whether this model is
614
+ * serving. Committing replays the buffer and hands the live iterator through;
615
+ * abandoning discards the buffer (those events describe the dead model's
616
+ * session — including its `step-finish` usage, which must never be billed) and
617
+ * lets `runWithModelFailover` walk to the next model.
618
+ *
619
+ * The classification itself is `isUpstreamUnavailable` verbatim, so this path
620
+ * inherits the measured facts from the 2026-07-25 outage — above all that an
621
+ * outage is NOT always a thrown error: the sandbox RESOLVES a terminal `error`
622
+ * event carrying `{ errorCode: 'provider_inference_unavailable' }`, which a
623
+ * classifier inspecting only `catch` misses entirely. That resolved shape is
624
+ * the whole reason the breakage went unnoticed, so it is classified here first.
625
+ *
626
+ * Conservative by construction:
627
+ * - A terminal failure that is NOT an outage (400, bad schema, content filter)
628
+ * COMMITS rather than failing over — it surfaces to the user exactly as it
629
+ * does today. Those fail identically on every model; walking the chain would
630
+ * only multiply latency and spend to reach the same error.
631
+ * - A clean stream that simply produced nothing is NOT retried. An empty answer
632
+ * is not evidence of a dead upstream, and a silent re-roll on another model is
633
+ * precisely the unattributable downgrade this work exists to prevent.
634
+ * - A chain of length 1 costs nothing: one attempt, no extra call, no added
635
+ * latency, byte-identical to no failover at all.
636
+ */
637
+
638
+ /**
639
+ * True when `event` puts content in front of the user (or in the persisted
640
+ * transcript), making a restart on another model unsafe.
641
+ *
642
+ * Deliberately an allow-list of KNOWN-INERT types rather than a deny-list: an
643
+ * unrecognized event commits. Getting this wrong in the safe direction costs a
644
+ * missed failover; getting it wrong the other way duplicates a user's answer.
645
+ */
646
+ declare function isCommittingSandboxEvent(event: unknown): boolean;
647
+ /** A terminal failure event, classified. `outage` decides failover vs surface. */
648
+ interface TerminalFailure {
649
+ outage: boolean;
650
+ reason: string;
651
+ code?: string;
652
+ }
653
+ /**
654
+ * Classify a terminal failure event. Returns `null` for any non-terminal event.
655
+ *
656
+ * The RESOLVED shape is checked first and deliberately: the sandbox reports an
657
+ * upstream outage by resolving `{ success: false, errorCode:
658
+ * 'provider_inference_unavailable' }` inside a terminal `error` event's `data`,
659
+ * never by throwing. Both `data` and the whole record are offered to
660
+ * `isUpstreamUnavailable` so a payload nested either way is caught.
661
+ */
662
+ declare function classifyTerminalFailure(event: unknown): TerminalFailure | null;
663
+ /** Open the raw turn stream for one specific model. */
664
+ type OpenModelStream = (args: {
665
+ model: string;
666
+ /** 1 for the preferred model, 2 for the first fallback, and so on. */
667
+ attempt: number;
668
+ }) => AsyncIterable<unknown> | Promise<AsyncIterable<unknown>>;
669
+ /** Fired when a model is abandoned and the next one is about to be tried. */
670
+ interface ModelFallbackInfo {
671
+ from: string;
672
+ to: string;
673
+ reason: string;
674
+ }
675
+ /** Define inputs for streaming a turn across a model failover chain */
676
+ interface ModelFailoverStreamOptions {
677
+ /** Preferred model first, then fallbacks in descending preference. */
678
+ models: readonly string[];
679
+ open: OpenModelStream;
680
+ /** Override the commit-point rule. Default {@link isCommittingSandboxEvent}. */
681
+ isCommitting?: (event: unknown) => boolean;
682
+ onFallback?: (info: ModelFallbackInfo) => void;
683
+ log?: (message: string, meta?: Record<string, unknown>) => void;
684
+ }
685
+ /** The failover-wrapped stream plus the attribution every consumer needs. */
686
+ interface ModelFailoverStreamHandle {
687
+ events: AsyncGenerator<unknown, void, unknown>;
688
+ /** The model that actually served. `undefined` until the first pull resolves it. */
689
+ servingModel(): string | undefined;
690
+ /** Every model tried, in order, with the reason each was abandoned. */
691
+ attempts(): ModelFailoverAttempt[];
692
+ /** True when the preferred model did not serve — the attributability signal. */
693
+ usedFallback(): boolean;
694
+ }
695
+ /**
696
+ * Wrap `open` in reactive model failover, streaming from the first model in
697
+ * `models` that reaches its commit point.
698
+ *
699
+ * Zero added latency on the happy path: the preferred model is opened first and,
700
+ * the moment it emits anything meaningful, its events flow straight through.
701
+ *
702
+ * @throws ModelFailoverExhaustedError when every model's upstream is down, and
703
+ * re-throws a non-outage error from the FIRST model without walking the
704
+ * chain (both behaviors inherited from `runWithModelFailover`).
705
+ */
706
+ declare function streamWithModelFailover(options: ModelFailoverStreamOptions): ModelFailoverStreamHandle;
707
+
569
708
  /**
570
709
  * Sandbox lane: bridge a raw sandbox event stream (`streamSandboxPrompt`) into
571
710
  * the `ChatTurnProducer` shape agent-runtime's `handleChatTurn` consumes AND
@@ -608,10 +747,46 @@ type FilePartPromotionOutcome = {
608
747
  };
609
748
  /** Define options for producing sandbox chat events with rendering and interaction controls */
610
749
  interface SandboxChatProducerOptions {
611
- /** The raw sandbox event stream (e.g. `streamSandboxPrompt(...)`). */
612
- events: AsyncIterable<unknown>;
613
- /** Recorded on the persisted assistant message. */
750
+ /** The raw sandbox event stream (e.g. `streamSandboxPrompt(...)`).
751
+ *
752
+ * An ALREADY-OPEN stream is bound to one model and cannot be reopened, so
753
+ * this form can never fail over. Prefer {@link openEvents}; exactly one of
754
+ * the two is required. */
755
+ events?: AsyncIterable<unknown>;
756
+ /** Open the raw sandbox stream FOR A GIVEN MODEL — the failover-capable form
757
+ * of {@link events}. The callback receives the model to run and returns the
758
+ * same `AsyncIterable` `events` would have been (typically
759
+ * `streamSandboxPrompt(shell, box, prompt, { ...opts, model })`).
760
+ *
761
+ * Wiring this turns failover ON with no further flag: whenever the resolved
762
+ * chain (`model` + {@link fallbackModels}) holds more than one entry, a model
763
+ * whose upstream is dead is abandoned BEFORE its first client-visible byte
764
+ * and the next one is tried. A one-entry chain opens exactly once — no extra
765
+ * call, no added latency, byte-identical to today.
766
+ *
767
+ * Requires {@link model}: failover has to know which model it is running. */
768
+ openEvents?: OpenModelStream;
769
+ /** Recorded on the persisted assistant message. When failover moves the turn
770
+ * to another model, the producer's `model` reports the model that ACTUALLY
771
+ * served, not this preferred one — see {@link modelFailover}. */
614
772
  model?: string;
773
+ /** Models to try, in order, when `model`'s upstream is dead. Product config,
774
+ * never a shell default: agent-app cannot know which ids are live, and a
775
+ * baked list would rot into exactly the stale-liveness bug this guards
776
+ * against. Empty/omitted → one attempt, today's behavior.
777
+ *
778
+ * Pick these deliberately. A same-family fallback is NOT automatically safe:
779
+ * `gemini-2.5-flash` produced zero persisted deliverables in 2 of 3 measured
780
+ * runs on a med-legal filing flow where `gemini-2.5-pro` succeeded. That is
781
+ * why every fallback is surfaced (persisted `model`, a transcript notice, and
782
+ * `modelFailover()`) instead of being applied silently. */
783
+ fallbackModels?: readonly string[];
784
+ /** Opt out of failover while still using {@link openEvents}. `false` collapses
785
+ * the chain to `model` alone. */
786
+ modelFailover?: false;
787
+ /** Fired when a model is abandoned mid-chain (telemetry/alerting). The user
788
+ * already sees a transcript notice; this is for the operator. */
789
+ onModelFallback?: (info: ModelFallbackInfo) => void;
615
790
  /** Which ask kinds the product renders a card for. Anything else is
616
791
  * auto-declined (see `declineInteraction`) so the run never hangs in the
617
792
  * broker waiting on a card no client will show. Default: question/plan.
@@ -705,9 +880,30 @@ interface DetachedTurnOptions {
705
880
  scopeId: string;
706
881
  /** The raw sandbox event stream for this turn (e.g. `streamSandboxPrompt`).
707
882
  * Ownership of the box, prompt, tooling, and attachments stays with the
708
- * caller — this only projects the stream. */
709
- events: AsyncIterable<unknown>;
710
- /** Recorded on the persisted assistant message + usage receipt. */
883
+ * caller — this only projects the stream.
884
+ *
885
+ * An already-open stream is bound to one model and cannot fail over. Prefer
886
+ * {@link openEvents}; exactly one of the two is required. */
887
+ events?: AsyncIterable<unknown>;
888
+ /** Open the raw sandbox stream FOR A GIVEN MODEL — the failover-capable form
889
+ * of {@link events}. Wiring it turns failover on with no further flag
890
+ * whenever {@link fallbackModels} is non-empty. Requires {@link model}.
891
+ *
892
+ * An autonomous run is the case that most needs this: nobody is watching to
893
+ * notice a dead upstream and retry by hand, so without failover the mission
894
+ * step or queue job simply fails. Forwarded to the producer. */
895
+ openEvents?: SandboxChatProducerOptions['openEvents'];
896
+ /** Models to try, in order, when `model`'s upstream is dead. Product config —
897
+ * see the producer's note on why a same-family fallback is not automatically
898
+ * safe and why every fallback is surfaced. */
899
+ fallbackModels?: SandboxChatProducerOptions['fallbackModels'];
900
+ /** Opt out of failover while still using {@link openEvents}. */
901
+ modelFailover?: false;
902
+ /** Fired when a model is abandoned mid-chain (telemetry/alerting). */
903
+ onModelFallback?: SandboxChatProducerOptions['onModelFallback'];
904
+ /** The PREFERRED model. Recorded on the persisted assistant message + usage
905
+ * receipt — unless failover moved the turn, in which case the model that
906
+ * actually served is recorded instead and surfaced on the result. */
711
907
  model?: string;
712
908
  /** Per-flush buffer coalescer. Default `coalesceDeltas`. */
713
909
  coalesce?: (events: unknown[]) => unknown[];
@@ -786,6 +982,15 @@ interface DetachedTurnResult {
786
982
  * `null` when the turn produced nothing and the row was retracted. Absent
787
983
  * when the caller owns persistence (today's behavior). */
788
984
  messageId?: string | null;
985
+ /** The model that SERVED the turn — the fallback's id when failover moved it.
986
+ * A caller that bills or scores per model must read this, not the model it
987
+ * requested. */
988
+ model?: string;
989
+ /** True when the preferred model did not serve. Makes an autonomous
990
+ * downgrade — which no human watched happen — attributable after the fact. */
991
+ usedModelFallback?: boolean;
992
+ /** Every model tried, in order, with the reason each was abandoned. */
993
+ modelAttempts?: ModelFailoverAttempt[];
789
994
  }
790
995
  /**
791
996
  * Stream a detached turn into the live turn-event buffer, durably.
@@ -1311,4 +1516,4 @@ interface PromoteAgentFilePartOptions {
1311
1516
  /** Promote a part of an agent file with optional byte limits and MIME type detection */
1312
1517
  declare function promoteAgentFilePart(options: PromoteAgentFilePartOptions): Promise<PromoteFilePartResult>;
1313
1518
 
1314
- export { type AssistantDraftSnapshot, type AssistantDraftStore, type AssistantDraftWriter, type AssistantDraftWriterOptions, type AssistantRowValues, type AttachmentPathArgs, type AttachmentPathCheck, type AttachmentReadResult, type AttachmentUploadAuthorization, type AttachmentWriteResult, type BuildDispatchPartsInput, ChatAttachmentKind, type ChatRouteDurableProjection, type ChatRouteDurableProjectionLogger, type ChatTurnAuthorization, type ChatTurnAuthorizeArgs, ChatTurnFilePartInput, type ChatTurnGateResult, type ChatTurnHeartbeat, type ChatTurnInputPatch, type ChatTurnLifecycle, type ChatTurnLifecycleComplete, type ChatTurnLifecycleError, type ChatTurnLifecycleStart, type ChatTurnLock, type ChatTurnLockResult, type ChatTurnMessageStore, ChatTurnPartInput, type ChatTurnProduceArgs, ChatTurnRequestPayload, type ChatTurnRouteProducer, type ChatTurnRoutes, type ChatTurnUsage, type CreateAttachmentUploadRouteOptions, type CreateChatTurnRoutesOptions, type CreateUploadRouteOptions, type DetachedTurnFinal, type DetachedTurnOptions, type DetachedTurnParts, type DetachedTurnResult, type DispatchPartsOutcome, type DraftPersistenceTuning, type DraftStoredMessage, type FilePartPromotionOutcome, PROMOTE_MAX_FILE_BYTES, type PromoteAgentFilePartOptions, type PromoteFilePartResult, PromptInputPart, type RawAgentFilePart, type ReadAttachmentFn, type ReadSandboxMentionFn, type ResolveChatAttachmentsOptions, type ResolveChatAttachmentsResult, type SandboxChatProducerOptions, type SandboxUploadSink, UPLOAD_INLINE_MAX_BYTES, UPLOAD_MAX_FILE_BYTES, type UploadAuthorization, type UploadedChatFile, type WriteAttachmentFn, assistantRowIdForTurn, buildDispatchParts, bytesToBase64, createAssistantDraftWriter, createAttachmentUploadRoute, createChatTurnRoutes, createSandboxChatProducer, createUploadRoute, defaultValidateAttachmentPath, isDraftContentEvent, promoteAgentFilePart, resolveChatAttachments, runDetachedTurn, sanitizeUploadFilename, sniffMimeFromName, storeSupportsDraftPersistence, withDurableChatProjection };
1519
+ export { type AssistantDraftSnapshot, type AssistantDraftStore, type AssistantDraftWriter, type AssistantDraftWriterOptions, type AssistantRowValues, type AttachmentPathArgs, type AttachmentPathCheck, type AttachmentReadResult, type AttachmentUploadAuthorization, type AttachmentWriteResult, type BuildDispatchPartsInput, ChatAttachmentKind, type ChatRouteDurableProjection, type ChatRouteDurableProjectionLogger, type ChatTurnAuthorization, type ChatTurnAuthorizeArgs, ChatTurnFilePartInput, type ChatTurnGateResult, type ChatTurnHeartbeat, type ChatTurnInputPatch, type ChatTurnLifecycle, type ChatTurnLifecycleComplete, type ChatTurnLifecycleError, type ChatTurnLifecycleStart, type ChatTurnLock, type ChatTurnLockResult, type ChatTurnMessageStore, type ChatTurnModelFailover, ChatTurnPartInput, type ChatTurnProduceArgs, ChatTurnRequestPayload, type ChatTurnRouteProducer, type ChatTurnRoutes, type ChatTurnUsage, type CreateAttachmentUploadRouteOptions, type CreateChatTurnRoutesOptions, type CreateUploadRouteOptions, type DetachedTurnFinal, type DetachedTurnOptions, type DetachedTurnParts, type DetachedTurnResult, type DispatchPartsOutcome, type DraftPersistenceTuning, type DraftStoredMessage, type FilePartPromotionOutcome, type ModelFailoverStreamHandle, type ModelFailoverStreamOptions, type ModelFallbackInfo, type OpenModelStream, PROMOTE_MAX_FILE_BYTES, type PromoteAgentFilePartOptions, type PromoteFilePartResult, PromptInputPart, type RawAgentFilePart, type ReadAttachmentFn, type ReadSandboxMentionFn, type ResolveChatAttachmentsOptions, type ResolveChatAttachmentsResult, type SandboxChatProducerOptions, type SandboxUploadSink, UPLOAD_INLINE_MAX_BYTES, UPLOAD_MAX_FILE_BYTES, type UploadAuthorization, type UploadedChatFile, type WriteAttachmentFn, assistantRowIdForTurn, buildDispatchParts, bytesToBase64, classifyTerminalFailure, createAssistantDraftWriter, createAttachmentUploadRoute, createChatTurnRoutes, createSandboxChatProducer, createUploadRoute, defaultValidateAttachmentPath, isCommittingSandboxEvent, isDraftContentEvent, promoteAgentFilePart, resolveChatAttachments, runDetachedTurn, sanitizeUploadFilename, sniffMimeFromName, storeSupportsDraftPersistence, streamWithModelFailover, withDurableChatProjection };
@@ -17,6 +17,11 @@ import {
17
17
  DEFAULT_TERMINAL_TURN_LOCK_GRACE_MS,
18
18
  reconcileStaleTurnLock
19
19
  } from "../chunk-CCVWR33L.js";
20
+ import {
21
+ buildModelChain,
22
+ isUpstreamUnavailable,
23
+ runWithModelFailover
24
+ } from "../chunk-KFTRTOT2.js";
20
25
  import {
21
26
  parseJsonObjectBody
22
27
  } from "../chunk-PTUMBMVH.js";
@@ -480,6 +485,7 @@ function createChatTurnRoutes(options) {
480
485
  error: terminalError ?? lastFailureData ?? new Error("chat turn failed")
481
486
  });
482
487
  } else {
488
+ const failoverInfo = producer?.modelFailover?.();
483
489
  await lifecycle.onTurnComplete?.({
484
490
  identity,
485
491
  executionId,
@@ -487,7 +493,9 @@ function createChatTurnRoutes(options) {
487
493
  context,
488
494
  durationMs,
489
495
  finalText: producer?.finalText() ?? "",
490
- usage: producer?.usage?.() ?? {}
496
+ usage: producer?.usage?.() ?? {},
497
+ ...producer?.model ? { model: producer.model } : {},
498
+ ...failoverInfo ? { modelFailover: failoverInfo } : {}
491
499
  });
492
500
  }
493
501
  } catch (err) {
@@ -634,13 +642,18 @@ function createChatTurnRoutes(options) {
634
642
  // (not a throw) still lands here — so surface `runFailed` so the
635
643
  // product skips billing an errored turn instead of marking it
636
644
  // complete with empty text.
637
- onTurnComplete: ({ identity: turnIdentity, finalText }) => options.onTurnComplete({
638
- identity: turnIdentity,
639
- finalText,
640
- context,
641
- failed: runFailed,
642
- ...runFailed ? { failureReason: failureReasonOf(lastFailureData) } : {}
643
- })
645
+ onTurnComplete: ({ identity: turnIdentity, finalText }) => {
646
+ const failoverInfo = producer?.modelFailover?.();
647
+ return options.onTurnComplete({
648
+ identity: turnIdentity,
649
+ finalText,
650
+ context,
651
+ failed: runFailed,
652
+ ...runFailed ? { failureReason: failureReasonOf(lastFailureData) } : {},
653
+ ...producer?.model ? { model: producer.model } : {},
654
+ ...failoverInfo ? { modelFailover: failoverInfo } : {}
655
+ });
656
+ }
644
657
  } : {},
645
658
  ...options.traceFlush ? { traceFlush: () => options.traceFlush(context) } : {}
646
659
  }
@@ -779,6 +792,130 @@ function concatStreams(streams) {
779
792
  });
780
793
  }
781
794
 
795
+ // src/chat-routes/model-failover-stream.ts
796
+ var TERMINAL_FAILURE_TYPES = /* @__PURE__ */ new Set(["error", "session.run.failed"]);
797
+ var NON_COMMITTING_PART_TYPES = /* @__PURE__ */ new Set(["step-start", "step-finish"]);
798
+ function isCommittingSandboxEvent(event) {
799
+ const record = asRecord(event);
800
+ if (!record) return false;
801
+ const type = asString(record.type) ?? "";
802
+ if (!type) return false;
803
+ if (type === "message.part.updated") {
804
+ const part = asRecord(asRecord(record.data)?.part);
805
+ const partType = asString(part?.type) ?? "";
806
+ if (NON_COMMITTING_PART_TYPES.has(partType)) return false;
807
+ if (partType === "text" || partType === "reasoning") {
808
+ const text = asString(part?.text) ?? asString(part?.content) ?? "";
809
+ return text.length > 0;
810
+ }
811
+ return true;
812
+ }
813
+ if (type === "start" || type === "execution.started" || type === "status" || type === "model-processing" || type === "session.created" || type === "session.updated" || type === "session.idle" || type === "step-start" || type === "step-finish" || type === "turn" || type === "warning") {
814
+ return false;
815
+ }
816
+ return true;
817
+ }
818
+ function classifyTerminalFailure(event) {
819
+ const record = asRecord(event);
820
+ if (!record) return null;
821
+ const type = asString(record.type) ?? "";
822
+ if (!TERMINAL_FAILURE_TYPES.has(type)) return null;
823
+ const data = asRecord(record.data);
824
+ const outage = isUpstreamUnavailable(data) || isUpstreamUnavailable(record);
825
+ const reason = asString(data?.message) ?? asString(data?.error) ?? asString(data?.reason) ?? asString(record.message) ?? `sandbox stream reported ${type}`;
826
+ const code = asString(data?.errorCode) ?? asString(data?.code);
827
+ return { outage, reason, ...code ? { code } : {} };
828
+ }
829
+ async function closeIterator(iterator, log) {
830
+ try {
831
+ await iterator.return?.();
832
+ } catch (err) {
833
+ log?.("[chat-routes] abandoning a failed model stream threw", {
834
+ error: err instanceof Error ? err.message : String(err)
835
+ });
836
+ }
837
+ }
838
+ function streamWithModelFailover(options) {
839
+ const committing = options.isCommitting ?? isCommittingSandboxEvent;
840
+ let serving;
841
+ let trail = [];
842
+ let fellBack = false;
843
+ let attemptIndex = 0;
844
+ const probe = async (model) => {
845
+ attemptIndex += 1;
846
+ const source = await options.open({ model, attempt: attemptIndex });
847
+ const iterator = source[Symbol.asyncIterator]();
848
+ const buffered = [];
849
+ for (; ; ) {
850
+ const next = await iterator.next();
851
+ if (next.done) {
852
+ return { committed: true, buffered, iterator: null };
853
+ }
854
+ const event = next.value;
855
+ const failure = classifyTerminalFailure(event);
856
+ if (failure?.outage) {
857
+ await closeIterator(iterator, options.log);
858
+ return {
859
+ committed: false,
860
+ error: failure.reason,
861
+ ...failure.code ? { errorCode: failure.code } : {}
862
+ };
863
+ }
864
+ buffered.push(event);
865
+ if (failure) return { committed: true, buffered, iterator };
866
+ if (committing(event)) return { committed: true, buffered, iterator };
867
+ }
868
+ };
869
+ const events = (async function* () {
870
+ let handle;
871
+ try {
872
+ const outcome = await runWithModelFailover({
873
+ models: options.models,
874
+ run: probe,
875
+ // The probe has already classified the raw payload with
876
+ // `isUpstreamUnavailable`; this reads its verdict rather than
877
+ // re-classifying a wrapper object, so the two can never disagree.
878
+ isUnavailableResult: (result) => result.committed === false,
879
+ onFallback: (attempt, nextModel) => {
880
+ const info = {
881
+ from: attempt.model,
882
+ to: nextModel,
883
+ reason: attempt.reason ?? "upstream unavailable"
884
+ };
885
+ options.log?.("[chat-routes] model upstream unavailable; falling over", { ...info });
886
+ options.onFallback?.(info);
887
+ }
888
+ });
889
+ serving = outcome.model;
890
+ trail = outcome.attempts;
891
+ fellBack = outcome.usedFallback;
892
+ handle = outcome.value;
893
+ } catch (err) {
894
+ const attempts = err?.attempts;
895
+ if (Array.isArray(attempts)) trail = attempts;
896
+ throw err;
897
+ }
898
+ for (const event of handle.buffered) yield event;
899
+ const live = handle.iterator;
900
+ if (!live) return;
901
+ try {
902
+ for (; ; ) {
903
+ const next = await live.next();
904
+ if (next.done) return;
905
+ yield next.value;
906
+ }
907
+ } finally {
908
+ await closeIterator(live, options.log);
909
+ }
910
+ })();
911
+ return {
912
+ events,
913
+ servingModel: () => serving,
914
+ attempts: () => trail,
915
+ usedFallback: () => fellBack
916
+ };
917
+ }
918
+
782
919
  // src/chat-routes/sandbox-producer.ts
783
920
  function textDelta(tracker, key, part, rawDelta) {
784
921
  const explicit = typeof rawDelta === "string" ? rawDelta : void 0;
@@ -885,6 +1022,40 @@ function sandboxStreamFailureDiagnostic(error) {
885
1022
  function createSandboxChatProducer(options) {
886
1023
  const log = options.log ?? ((message, meta) => console.error(message, meta ?? ""));
887
1024
  const renderable = options.isRenderableInteraction ?? isRenderableInteractionKind;
1025
+ if (options.openEvents && !options.model) {
1026
+ throw new Error(
1027
+ "createSandboxChatProducer: `openEvents` requires `model` \u2014 failover must know which model it is running"
1028
+ );
1029
+ }
1030
+ if (!options.openEvents && !options.events) {
1031
+ throw new Error("createSandboxChatProducer: pass `openEvents` (failover-capable) or `events`");
1032
+ }
1033
+ const chain = options.model ? buildModelChain(options.model, options.modelFailover === false ? [] : options.fallbackModels ?? []) : [];
1034
+ const pendingModelNotices = [];
1035
+ let modelNoticeCount = 0;
1036
+ let failover;
1037
+ let source;
1038
+ if (options.openEvents) {
1039
+ failover = streamWithModelFailover({
1040
+ models: chain,
1041
+ open: options.openEvents,
1042
+ log,
1043
+ onFallback: (info) => {
1044
+ modelNoticeCount += 1;
1045
+ pendingModelNotices.push({
1046
+ id: `model-fallback-${modelNoticeCount}`,
1047
+ // Named models on both sides: a quality regression after a downgrade
1048
+ // must be attributable to the model that actually answered.
1049
+ text: `${info.from} was unavailable (${info.reason}) \u2014 answered with ${info.to} instead.`
1050
+ });
1051
+ options.onModelFallback?.(info);
1052
+ }
1053
+ });
1054
+ source = failover.events;
1055
+ } else {
1056
+ source = options.events;
1057
+ }
1058
+ const servingModel = () => failover?.servingModel() ?? options.model;
888
1059
  let fullText = "";
889
1060
  const partOrder = [];
890
1061
  const partMap = /* @__PURE__ */ new Map();
@@ -931,9 +1102,18 @@ function createSandboxChatProducer(options) {
931
1102
  yield { type: "usage", usage: { promptTokens, completionTokens } };
932
1103
  }
933
1104
  }
1105
+ function* drainModelNotices() {
1106
+ while (pendingModelNotices.length > 0) {
1107
+ const queued = pendingModelNotices.shift();
1108
+ const notice = noticePart("warning", queued.id, queued.text);
1109
+ recordPersistedPart(notice, void 0, noticePartKey(notice.id));
1110
+ yield { type: "notice", id: notice.id, noticeKind: "warning", text: queued.text };
1111
+ }
1112
+ }
934
1113
  async function* stream() {
935
1114
  try {
936
- for await (const raw of options.events) {
1115
+ for await (const raw of source) {
1116
+ yield* drainModelNotices();
937
1117
  const record = asRecord(raw);
938
1118
  if (!record || typeof record.type !== "string") continue;
939
1119
  const normalized = normalizeToolEvent({ type: record.type, data: asRecord(record.data) });
@@ -1155,6 +1335,7 @@ ${errorContent}` : errorContent;
1155
1335
  }
1156
1336
  yield toProducerWireEvent(record);
1157
1337
  }
1338
+ yield* drainModelNotices();
1158
1339
  } catch (streamErr) {
1159
1340
  const diagnostic = sandboxStreamFailureDiagnostic(streamErr);
1160
1341
  log("[chat-routes] sandbox stream failed", {
@@ -1193,7 +1374,19 @@ ${diagnostic.userMessage}` : diagnostic.userMessage;
1193
1374
  // phantom failures the final write then reverses.
1194
1375
  draftParts: () => draftAssistantParts(partOrder, partMap, fullText),
1195
1376
  usage: () => usage,
1196
- ...options.model ? { model: options.model } : {}
1377
+ // A GETTER, not a captured value: `turn-routes` reads `producer.model` at
1378
+ // draft-snapshot and persist time, both of which happen after the stream
1379
+ // resolved which model serves. A plain property would freeze the PREFERRED
1380
+ // model into the row and make a downgrade unattributable — the exact
1381
+ // failure mode this work exists to prevent.
1382
+ get model() {
1383
+ return servingModel();
1384
+ },
1385
+ modelFailover: () => ({
1386
+ model: servingModel(),
1387
+ attempts: failover?.attempts() ?? [],
1388
+ usedFallback: failover?.usedFallback() ?? false
1389
+ })
1197
1390
  };
1198
1391
  }
1199
1392
 
@@ -1219,6 +1412,7 @@ function cachedResultFrom(final) {
1219
1412
  async function runDetachedTurn(opts) {
1220
1413
  const { store, turnId, scopeId } = opts;
1221
1414
  let producer;
1415
+ const servingModel = () => producer?.model ?? opts.model;
1222
1416
  let draft;
1223
1417
  if (opts.persist) {
1224
1418
  const { store: persistStore, threadId, messageId, transformText, ...tuning } = opts.persist;
@@ -1236,13 +1430,19 @@ async function runDetachedTurn(opts) {
1236
1430
  content: producer.finalText?.() ?? "",
1237
1431
  ...producer.draftParts ? { parts: producer.draftParts() } : {},
1238
1432
  ...producer.usage ? { usage: producer.usage() } : {},
1239
- ...opts.model ? { model: opts.model } : {}
1433
+ ...servingModel() ? { model: servingModel() } : {}
1240
1434
  } : null,
1241
1435
  ...transformText ? { transformText } : {},
1242
1436
  ...opts.log ? { log: opts.log } : {}
1243
1437
  });
1244
1438
  }
1245
- const settleRow = async (result) => {
1439
+ const settleRow = async (base) => {
1440
+ const info = producer?.modelFailover?.();
1441
+ const result = {
1442
+ ...base,
1443
+ ...servingModel() ? { model: servingModel() } : {},
1444
+ ...info ? { usedModelFallback: info.usedFallback, modelAttempts: info.attempts } : {}
1445
+ };
1246
1446
  if (!draft) return result;
1247
1447
  const transform = opts.persist?.transformText;
1248
1448
  const content = transform ? await transform(result.text) : result.text;
@@ -1259,7 +1459,7 @@ async function runDetachedTurn(opts) {
1259
1459
  const values = {
1260
1460
  content,
1261
1461
  ...parts2.length > 0 ? { parts: parts2 } : {},
1262
- ...opts.model ? { model: opts.model } : {},
1462
+ ...result.model ? { model: result.model } : {},
1263
1463
  ...result.usage.inputTokens !== void 0 ? { inputTokens: result.usage.inputTokens } : {},
1264
1464
  ...result.usage.outputTokens !== void 0 ? { outputTokens: result.usage.outputTokens } : {},
1265
1465
  ...result.usage.reasoningTokens !== void 0 ? { reasoningTokens: result.usage.reasoningTokens } : {},
@@ -1310,8 +1510,11 @@ async function runDetachedTurn(opts) {
1310
1510
  });
1311
1511
  await tap.onEvent({ type: "turn", turnId });
1312
1512
  producer = createSandboxChatProducer({
1313
- events: opts.events,
1513
+ ...opts.openEvents ? { openEvents: opts.openEvents } : { events: opts.events },
1314
1514
  model: opts.model,
1515
+ ...opts.fallbackModels ? { fallbackModels: opts.fallbackModels } : {},
1516
+ ...opts.modelFailover === false ? { modelFailover: false } : {},
1517
+ ...opts.onModelFallback ? { onModelFallback: opts.onModelFallback } : {},
1315
1518
  isRenderableInteraction: opts.isRenderableInteraction,
1316
1519
  declineInteraction: opts.declineInteraction,
1317
1520
  promoteFilePart: opts.promoteFilePart,
@@ -2064,6 +2267,7 @@ export {
2064
2267
  bytesToBase64,
2065
2268
  chatTurnRequestInit,
2066
2269
  checkAttachmentType,
2270
+ classifyTerminalFailure,
2067
2271
  createAssistantDraftWriter,
2068
2272
  createAttachmentUploadRoute,
2069
2273
  createChatTurnRoutes,
@@ -2073,6 +2277,7 @@ export {
2073
2277
  defaultValidateAttachmentPath,
2074
2278
  fileMentionsToParts,
2075
2279
  formatBytes,
2280
+ isCommittingSandboxEvent,
2076
2281
  isDraftContentEvent,
2077
2282
  mediaTypeForMentionPath,
2078
2283
  mentionKindForPath,
@@ -2088,6 +2293,7 @@ export {
2088
2293
  sniffBinary,
2089
2294
  sniffMimeFromName,
2090
2295
  storeSupportsDraftPersistence,
2296
+ streamWithModelFailover,
2091
2297
  validateSandboxMentionPath,
2092
2298
  withDurableChatProjection
2093
2299
  };