takomi 2.1.31 → 2.1.33

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 (47) hide show
  1. package/.pi/extensions/oauth-router/README.md +2 -1
  2. package/.pi/extensions/oauth-router/config.ts +14 -5
  3. package/.pi/extensions/oauth-router/index.ts +105 -16
  4. package/.pi/extensions/oauth-router/provider.ts +144 -3
  5. package/.pi/extensions/oauth-router/state.ts +372 -372
  6. package/.pi/extensions/oauth-router/types.ts +19 -1
  7. package/.pi/extensions/takomi-runtime/model-routing-defaults.ts +296 -296
  8. package/assets/.agent/skills/photo-book-builder/SKILL.md +96 -0
  9. package/assets/.agent/skills/photo-book-builder/references/layout_templates.md +72 -0
  10. package/assets/.agent/skills/photo-book-builder/scripts/create_full_bleed_layouts.py +212 -0
  11. package/assets/.agent/skills/photo-book-builder/scripts/organize_photos.py +99 -0
  12. package/assets/.agent/skills/photo-book-builder/scripts/revert_organization.py +61 -0
  13. package/assets/.agent/skills/photo-book-builder/scripts/upscale_covers.py +47 -0
  14. package/assets/.agent/skills/youtube-pipeline/SKILL.md +73 -62
  15. package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Application.md +1 -0
  16. package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 1.md +86 -0
  17. package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 2.md +106 -0
  18. package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 3.md +112 -0
  19. package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 4.md +90 -0
  20. package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phased Outline.md +58 -0
  21. package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Repurposing.md +1 -0
  22. package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Research.md +438 -0
  23. package/assets/.agent/skills/youtube-pipeline/resources/prompts/Shorts Bridge Protocol.md +159 -0
  24. package/assets/.agent/skills/youtube-pipeline/resources/prompts/Title Thumbnail Picker Prompt.md +144 -0
  25. package/assets/.agent/skills/youtube-pipeline/resources/prompts/Transcription Extraction Prompt v2.md +190 -0
  26. package/assets/.agent/skills/youtube-pipeline/resources/prompts/Transcription Extraction Prompt.md +156 -0
  27. package/assets/.agent/skills/youtube-pipeline/resources/prompts/Video QA Prompt.md +133 -0
  28. package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase1-strategy.md +28 -18
  29. package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase2-packaging.md +2 -2
  30. package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase3-scripting.md +2 -2
  31. package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase3.5-shorts.md +2 -2
  32. package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase4-production.md +2 -2
  33. package/assets/.agent/skills/youtube-pipeline/resources/youtube-pipeline.md +15 -15
  34. package/assets/.agent/skills/youtube-pipeline/scripts/google-trends/package.json +17 -0
  35. package/assets/.agent/skills/youtube-pipeline/scripts/google-trends/pnpm-lock.yaml +31 -0
  36. package/assets/.agent/skills/youtube-pipeline/scripts/google-trends/search.js +168 -0
  37. package/package.json +1 -1
  38. package/src/cli.js +5 -0
  39. package/src/pi-harness.js +34 -8
  40. package/src/pi-optional-features.js +190 -195
  41. package/src/postinstall.js +27 -27
  42. package/src/skills-catalog.js +245 -245
  43. package/src/skills-installer.js +244 -244
  44. package/src/skills-selection-tui.js +200 -200
  45. package/src/store.js +418 -418
  46. package/src/takomi-stats.d.ts +3 -3
  47. package/src/takomi-stats.js +442 -35
@@ -28,6 +28,7 @@ Pi extension that auto-loads and registers an `oauth-router` provider with multi
28
28
  - 429 cooldowns
29
29
  - transient failure penalties
30
30
  - client/network transport failure tracking without default account cooldown
31
+ - live Pi UI footer/notification updates when an upstream attempt, retry delay, failover, or final router error happens
31
32
  - 5 router-level client/network retries by default before pre-output failover, starting at 5s and doubling
32
33
  - auth failure quarantine
33
34
  - safe pre-output failover
@@ -47,7 +48,7 @@ The extension ships with two default upstream profiles:
47
48
  - api: `openai-responses`
48
49
  - default models: `gpt-4o`, `gpt-4.1`, `o4-mini`
49
50
 
50
- Edit `~/.pi/agent/oauth-router/config.json` to add more upstreams, swap endpoints, change model catalogs, or tune retry behavior. By default client-side transport failures such as `Codex SSE response headers timed out after 10000ms` retry the same account 5 times with exponential backoff (`5s`, `10s`, `20s`, `40s`, then capped at `60s`) before router failover. These failures are recorded but do not cool down an account unless `clientNetworkPenaltyMs` is set above `0`.
51
+ Edit `~/.pi/agent/oauth-router/config.json` to add more upstreams, swap endpoints, change model catalogs, or tune retry behavior. By default client-side transport failures such as `Codex SSE response headers timed out after 10000ms` retry the same account 5 times with exponential backoff (`5s`, `10s`, `20s`, `40s`, then capped at `60s`) before router failover. While this is happening, Pi's footer shows the active retry/failover/error state so the UI no longer looks frozen. These failures are recorded but do not cool down an account unless `clientNetworkPenaltyMs` is set above `0`.
51
52
 
52
53
  ## Setup
53
54
 
@@ -9,6 +9,10 @@ export const CONFIG_PATH = join(DATA_ROOT, "config.json");
9
9
  export const CREDENTIALS_PATH = join(DATA_ROOT, "credentials.json");
10
10
  export const STATE_PATH = join(DATA_ROOT, "state.json");
11
11
 
12
+ const LEGACY_CODEX_CONTEXT_WINDOW = 272000;
13
+ const SAFE_CODEX_CONTEXT_WINDOW = 240000;
14
+ const CODEX_MODEL_IDS = new Set(["gpt-5.1", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5"]);
15
+
12
16
  const DEFAULT_MODELS: RouterModelConfig[] = [
13
17
  {
14
18
  id: "gpt-4o",
@@ -43,7 +47,7 @@ const DEFAULT_MODELS: RouterModelConfig[] = [
43
47
  reasoning: true,
44
48
  input: ["text", "image"],
45
49
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
46
- contextWindow: 272000,
50
+ contextWindow: SAFE_CODEX_CONTEXT_WINDOW,
47
51
  maxTokens: 128000,
48
52
  },
49
53
  {
@@ -52,7 +56,7 @@ const DEFAULT_MODELS: RouterModelConfig[] = [
52
56
  reasoning: true,
53
57
  input: ["text", "image"],
54
58
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
55
- contextWindow: 272000,
59
+ contextWindow: SAFE_CODEX_CONTEXT_WINDOW,
56
60
  maxTokens: 128000,
57
61
  },
58
62
  {
@@ -61,7 +65,7 @@ const DEFAULT_MODELS: RouterModelConfig[] = [
61
65
  reasoning: true,
62
66
  input: ["text", "image"],
63
67
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
64
- contextWindow: 272000,
68
+ contextWindow: SAFE_CODEX_CONTEXT_WINDOW,
65
69
  maxTokens: 128000,
66
70
  },
67
71
  {
@@ -70,7 +74,7 @@ const DEFAULT_MODELS: RouterModelConfig[] = [
70
74
  reasoning: true,
71
75
  input: ["text", "image"],
72
76
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
73
- contextWindow: 272000,
77
+ contextWindow: SAFE_CODEX_CONTEXT_WINDOW,
74
78
  maxTokens: 128000,
75
79
  },
76
80
  ];
@@ -210,7 +214,12 @@ function mergeModelConfigs(candidateModels: RouterModelConfig[] | undefined): Ro
210
214
  const previous = merged.get(model.id) ?? ({} as RouterModelConfig);
211
215
  merged.set(model.id, { ...previous, ...deepClone(model), id: model.id });
212
216
  }
213
- return Array.from(merged.values());
217
+ return Array.from(merged.values()).map((model) => {
218
+ if (CODEX_MODEL_IDS.has(model.id) && model.contextWindow === LEGACY_CODEX_CONTEXT_WINDOW) {
219
+ return { ...model, contextWindow: SAFE_CODEX_CONTEXT_WINDOW };
220
+ }
221
+ return model;
222
+ });
214
223
  }
215
224
 
216
225
  function mergeUpstreamConfigs(candidateUpstreams: RouterUpstreamConfig[] | undefined): RouterUpstreamConfig[] {
@@ -1,23 +1,112 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { registerRouterCommands, formatStatusReport } from "./commands.ts";
3
3
  import { registerRouterProvider, RouterRuntime } from "./provider.ts";
4
+ import type { RouterUiEvent } from "./types.ts";
4
5
 
5
- function updateFooter(pi: ExtensionAPI, runtime: RouterRuntime, notify = false) {
6
- pi.on("session_start", async (_event, ctx) => {
7
- const rows = runtime.getStatusRows();
8
- const healthy = rows.filter((row) => {
9
- if (!row.enabled) return false;
10
- if (row.authHealth !== "ok") return false;
11
- if (row.cooldownUntil && row.cooldownUntil > Date.now()) return false;
12
- if (row.penaltyUntil && row.penaltyUntil > Date.now()) return false;
13
- return true;
14
- }).length;
15
-
16
- ctx.ui.setStatus("oauth-router", `oauth-router ${healthy}/${rows.length || 0} healthy | ${runtime.getPolicy()}`);
17
- if (notify) {
18
- ctx.ui.notify("oauth-router loaded", "info");
6
+ function getHealthSummary(runtime: RouterRuntime) {
7
+ const rows = runtime.getStatusRows();
8
+ const healthy = rows.filter((row) => {
9
+ if (!row.enabled) return false;
10
+ if (row.authHealth !== "ok") return false;
11
+ if (row.cooldownUntil && row.cooldownUntil > Date.now()) return false;
12
+ if (row.penaltyUntil && row.penaltyUntil > Date.now()) return false;
13
+ return true;
14
+ }).length;
15
+
16
+ return `oauth-router ${healthy}/${rows.length || 0} healthy | ${runtime.getPolicy()}`;
17
+ }
18
+
19
+ function formatDuration(ms?: number): string {
20
+ if (!ms || ms <= 0) return "now";
21
+ if (ms < 1_000) return `${Math.round(ms)}ms`;
22
+ return `${Math.round(ms / 1_000)}s`;
23
+ }
24
+
25
+ function truncate(text: string | undefined, max = 110): string {
26
+ if (!text) return "";
27
+ const clean = text.replace(/\s+/g, " ").trim();
28
+ return clean.length > max ? `${clean.slice(0, Math.max(0, max - 1))}…` : clean;
29
+ }
30
+
31
+ function formatRoute(event: RouterUiEvent): string {
32
+ const parts = [event.accountLabel || event.accountId, event.upstreamId].filter(Boolean);
33
+ return parts.length ? parts.join(" @ ") : "router";
34
+ }
35
+
36
+ function formatActivityStatus(event: RouterUiEvent): string {
37
+ const route = formatRoute(event);
38
+ const model = event.modelId ? `${event.modelId} ` : "";
39
+ const reason = truncate(event.message, 70);
40
+
41
+ switch (event.phase) {
42
+ case "attempt": {
43
+ const retry = event.retryAttempt && event.maxRetries
44
+ ? ` retry ${event.retryAttempt}/${event.maxRetries}`
45
+ : "";
46
+ return `oauth-router contacting ${model}via ${route}${retry}`;
47
+ }
48
+ case "retry":
49
+ return `oauth-router retry ${event.retryAttempt}/${event.maxRetries} in ${formatDuration(event.delayMs)} | ${reason || event.failureKind || "network error"}`;
50
+ case "failover":
51
+ return `oauth-router failover from ${route} | ${reason || event.failureKind || "upstream error"}`;
52
+ case "success":
53
+ return `oauth-router ok: ${model}via ${route}`;
54
+ case "error":
55
+ return `oauth-router error | ${reason || event.failureKind || "request failed"}`;
56
+ }
57
+ }
58
+
59
+ function notifyActivity(ctx: ExtensionContext, event: RouterUiEvent) {
60
+ if (event.phase === "attempt" || event.phase === "success") return;
61
+
62
+ const message = formatActivityStatus(event);
63
+ const level = event.phase === "error" ? "error" : "info";
64
+ ctx.ui.notify(message, level);
65
+ }
66
+
67
+ function installRouterUiBridge(pi: ExtensionAPI, runtime: RouterRuntime, notifyOnLoad = false) {
68
+ let activeCtx: ExtensionContext | undefined;
69
+
70
+ const setBaseStatus = (ctx: ExtensionContext) => {
71
+ activeCtx = ctx;
72
+ if (ctx.hasUI) ctx.ui.setStatus("oauth-router", getHealthSummary(runtime));
73
+ };
74
+
75
+ runtime.setUiReporter((event) => {
76
+ const ctx = activeCtx;
77
+ if (!ctx?.hasUI) return;
78
+ try {
79
+ ctx.ui.setStatus("oauth-router", formatActivityStatus(event));
80
+ notifyActivity(ctx, event);
81
+ } catch {
82
+ // Best-effort UI only; never disturb provider streaming.
19
83
  }
20
84
  });
85
+
86
+ pi.on("session_start", async (_event, ctx) => {
87
+ setBaseStatus(ctx);
88
+ if (notifyOnLoad) ctx.ui.notify("oauth-router loaded", "info");
89
+ });
90
+
91
+ pi.on("agent_start", async (_event, ctx) => {
92
+ setBaseStatus(ctx);
93
+ });
94
+
95
+ pi.on("turn_start", async (_event, ctx) => {
96
+ setBaseStatus(ctx);
97
+ });
98
+
99
+ pi.on("turn_end", async (_event, ctx) => {
100
+ setBaseStatus(ctx);
101
+ });
102
+
103
+ pi.on("agent_end", async (_event, ctx) => {
104
+ setBaseStatus(ctx);
105
+ });
106
+
107
+ pi.on("session_shutdown", async () => {
108
+ activeCtx = undefined;
109
+ });
21
110
  }
22
111
 
23
112
  export default function (pi: ExtensionAPI) {
@@ -25,7 +114,7 @@ export default function (pi: ExtensionAPI) {
25
114
 
26
115
  registerRouterProvider(pi, runtime);
27
116
  registerRouterCommands(pi, runtime);
28
- updateFooter(pi, runtime, false);
117
+ installRouterUiBridge(pi, runtime, false);
29
118
 
30
119
  pi.registerCommand("router-debug-report", {
31
120
  description: "Emit a detailed oauth-router report",
@@ -25,6 +25,8 @@ import type {
25
25
  RouterErrorMessageInput,
26
26
  RouterModelConfig,
27
27
  RouterStatusRow,
28
+ RouterUiEvent,
29
+ RouterUiReporter,
28
30
  RouterUpstreamConfig,
29
31
  RoutingPolicyName,
30
32
  StoredRouterAccount,
@@ -122,6 +124,20 @@ function isClientNetworkFailure(lower: string): boolean {
122
124
  ].some((token) => lower.includes(token));
123
125
  }
124
126
 
127
+ function isContextOverflowFailure(lower: string): boolean {
128
+ return [
129
+ "context_length_exceeded",
130
+ "context length exceeded",
131
+ "context window exceeded",
132
+ "exceeds the context window",
133
+ "exceeds the model's maximum context length",
134
+ "exceeds model's maximum context length",
135
+ "maximum context length",
136
+ "too many tokens",
137
+ "token limit exceeded",
138
+ ].some((token) => lower.includes(token));
139
+ }
140
+
125
141
  function classifyFailure(
126
142
  status: number | undefined,
127
143
  headers: Record<string, string>,
@@ -135,6 +151,10 @@ function classifyFailure(
135
151
  return { kind: "rate-limit", status: status ?? 429, retryAfterMs, message };
136
152
  }
137
153
 
154
+ if (isContextOverflowFailure(lower)) {
155
+ return { kind: "context-overflow", status, message };
156
+ }
157
+
138
158
  if (status === 401 || status === 403 || lower.includes("unauthorized") || lower.includes("forbidden")) {
139
159
  return { kind: "auth", status: status ?? 401, message };
140
160
  }
@@ -176,6 +196,7 @@ export class RouterRuntime {
176
196
  private config: RouterConfig;
177
197
  private accounts: RouterAccountStore;
178
198
  private state: RouterStateStore;
199
+ private uiReporter?: RouterUiReporter;
179
200
 
180
201
  constructor() {
181
202
  this.config = loadRouterConfig();
@@ -190,6 +211,18 @@ export class RouterRuntime {
190
211
  this.state.pruneAccountIds(this.accounts.list().map((account) => account.id));
191
212
  }
192
213
 
214
+ setUiReporter(reporter?: RouterUiReporter) {
215
+ this.uiReporter = reporter;
216
+ }
217
+
218
+ private emitUi(event: RouterUiEvent) {
219
+ try {
220
+ this.uiReporter?.(event);
221
+ } catch {
222
+ // UI updates must never affect model streaming or retry behavior.
223
+ }
224
+ }
225
+
193
226
  getConfig(): RouterConfig {
194
227
  this.reloadConfig();
195
228
  return this.config;
@@ -446,6 +479,11 @@ export class RouterRuntime {
446
479
  this.state.recordClientNetworkFailure(accountId, failure.status, failure.message);
447
480
  }
448
481
  break;
482
+ case "context-overflow":
483
+ // Context-size failures are request-level, not account-health failures.
484
+ // Do not cool down or quarantine accounts; Pi needs the overflow error surfaced
485
+ // directly so its built-in compaction recovery can compact and retry.
486
+ break;
449
487
  case "fatal":
450
488
  this.state.markTransientFailure(accountId, this.config.transientPenaltyMs, failure.status ?? 500, failure.message);
451
489
  break;
@@ -468,17 +506,40 @@ export class RouterRuntime {
468
506
  ): Promise<{ completed: boolean; emittedMeaningfulOutput: boolean; failure?: FailureClassification }> {
469
507
  for (let retryIndex = 0; ; retryIndex += 1) {
470
508
  const result = await this.trySingleAccount(selection, outerStream, context, options);
509
+ const failure = result.failure;
471
510
  const canRetry =
472
511
  !result.completed &&
473
512
  !result.emittedMeaningfulOutput &&
474
- result.failure?.kind === "client-network" &&
513
+ failure?.kind === "client-network" &&
475
514
  retryIndex < this.config.clientNetworkMaxRetries;
476
515
 
477
- if (!canRetry) return result;
516
+ if (!canRetry || !failure) return result;
478
517
 
479
518
  const delayMs = this.getClientNetworkRetryDelayMs(retryIndex);
519
+ this.emitUi({
520
+ phase: "retry",
521
+ modelId,
522
+ accountId: selection.account.id,
523
+ accountLabel: selection.account.label,
524
+ upstreamId: selection.account.upstreamId,
525
+ failureKind: failure.kind,
526
+ status: failure.status,
527
+ message: failure.message,
528
+ retryAttempt: retryIndex + 1,
529
+ maxRetries: this.config.clientNetworkMaxRetries,
530
+ delayMs,
531
+ });
480
532
  await sleep(delayMs, options?.signal);
481
533
  this.state.markAttempt(selection.account.id, modelId);
534
+ this.emitUi({
535
+ phase: "attempt",
536
+ modelId,
537
+ accountId: selection.account.id,
538
+ accountLabel: selection.account.label,
539
+ upstreamId: selection.account.upstreamId,
540
+ retryAttempt: retryIndex + 2,
541
+ maxRetries: this.config.clientNetworkMaxRetries,
542
+ });
482
543
  }
483
544
  }
484
545
 
@@ -521,10 +582,20 @@ export class RouterRuntime {
521
582
  reason: "aborted",
522
583
  error: { ...event.error, stopReason: "aborted", errorMessage: message },
523
584
  });
524
- return { completed: true, emittedMeaningfulOutput };
585
+ return {
586
+ completed: true,
587
+ emittedMeaningfulOutput,
588
+ failure: { kind: "fatal", status: responseStatus, message },
589
+ };
525
590
  }
526
591
 
527
592
  const failure = classifyFailure(responseStatus, responseHeaders, message, this.config);
593
+
594
+ if (failure.kind === "context-overflow") {
595
+ outerStream.push(event);
596
+ return { completed: true, emittedMeaningfulOutput, failure };
597
+ }
598
+
528
599
  this.markFailure(selection.account.id, failure);
529
600
 
530
601
  if (!emittedMeaningfulOutput) {
@@ -596,6 +667,13 @@ export class RouterRuntime {
596
667
  const message = lastFailure
597
668
  ? `No healthy oauth-router accounts remaining after failover. Last error: ${lastFailure.message}`
598
669
  : `No healthy oauth-router accounts are configured for model ${model.id}. Add accounts with /router-login add.`;
670
+ this.emitUi({
671
+ phase: "error",
672
+ modelId: model.id,
673
+ failureKind: lastFailure?.kind,
674
+ status: lastFailure?.status,
675
+ message,
676
+ });
599
677
  outer.push({ type: "error", reason: "error", error: createErrorMessage({ model, message }) });
600
678
  outer.end();
601
679
  return;
@@ -604,6 +682,13 @@ export class RouterRuntime {
604
682
  this.state.advanceCursor(policy, picked.nextCursor);
605
683
  tried.add(picked.selected.account.id);
606
684
  this.state.markAttempt(picked.selected.account.id, model.id);
685
+ this.emitUi({
686
+ phase: "attempt",
687
+ modelId: model.id,
688
+ accountId: picked.selected.account.id,
689
+ accountLabel: picked.selected.account.label,
690
+ upstreamId: picked.selected.account.upstreamId,
691
+ });
607
692
 
608
693
  let selection: DelegateSelection;
609
694
  try {
@@ -617,11 +702,39 @@ export class RouterRuntime {
617
702
  }
618
703
  lastFailure = { kind: "auth", status: 401, message };
619
704
  this.markFailure(picked.selected.account.id, lastFailure);
705
+ this.emitUi({
706
+ phase: "failover",
707
+ modelId: model.id,
708
+ accountId: picked.selected.account.id,
709
+ accountLabel: picked.selected.account.label,
710
+ upstreamId: picked.selected.account.upstreamId,
711
+ failureKind: lastFailure.kind,
712
+ status: lastFailure.status,
713
+ message: lastFailure.message,
714
+ });
620
715
  continue;
621
716
  }
622
717
 
623
718
  const result = await this.tryAccountWithClientNetworkRetries(selection, outer, context, model.id, options);
624
719
  if (result.completed) {
720
+ this.emitUi(result.failure
721
+ ? {
722
+ phase: "error",
723
+ modelId: model.id,
724
+ accountId: selection.account.id,
725
+ accountLabel: selection.account.label,
726
+ upstreamId: selection.account.upstreamId,
727
+ failureKind: result.failure.kind,
728
+ status: result.failure.status,
729
+ message: result.failure.message,
730
+ }
731
+ : {
732
+ phase: "success",
733
+ modelId: model.id,
734
+ accountId: selection.account.id,
735
+ accountLabel: selection.account.label,
736
+ upstreamId: selection.account.upstreamId,
737
+ });
625
738
  outer.end();
626
739
  return;
627
740
  }
@@ -630,13 +743,41 @@ export class RouterRuntime {
630
743
  lastFailure = result.failure;
631
744
 
632
745
  if (emittedMeaningfulOutput) {
746
+ this.emitUi({
747
+ phase: "error",
748
+ modelId: model.id,
749
+ accountId: selection.account.id,
750
+ accountLabel: selection.account.label,
751
+ upstreamId: selection.account.upstreamId,
752
+ failureKind: lastFailure?.kind,
753
+ status: lastFailure?.status,
754
+ message: lastFailure?.message ?? "Upstream failed after partial output; failover is unsafe.",
755
+ });
633
756
  outer.end();
634
757
  return;
635
758
  }
759
+
760
+ if (lastFailure) {
761
+ this.emitUi({
762
+ phase: "failover",
763
+ modelId: model.id,
764
+ accountId: selection.account.id,
765
+ accountLabel: selection.account.label,
766
+ upstreamId: selection.account.upstreamId,
767
+ failureKind: lastFailure.kind,
768
+ status: lastFailure.status,
769
+ message: lastFailure.message,
770
+ });
771
+ }
636
772
  }
637
773
  } catch (error) {
638
774
  const message = error instanceof Error ? error.message : String(error);
639
775
  const aborted = isAbortLike(message, options?.signal);
776
+ this.emitUi({
777
+ phase: "error",
778
+ modelId: model.id,
779
+ message: aborted ? "Request was aborted" : message,
780
+ });
640
781
  outer.push({ type: "error", reason: aborted ? "aborted" : "error", error: createErrorMessage({ model, message, stopReason: aborted ? "aborted" : "error" }) });
641
782
  outer.end();
642
783
  }