@tangle-network/agent-app 0.43.69 → 0.43.71

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