oira666_pi-limits-wait 0.3.0 → 0.4.1

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 (3) hide show
  1. package/README.md +54 -0
  2. package/package.json +1 -1
  3. package/src/index.ts +314 -10
package/README.md CHANGED
@@ -21,6 +21,60 @@ Restart Pi or run `/reload` after installing.
21
21
  - On `server_is_overloaded`, waits 5 minutes, then retries. If the provider is still overloaded after Pi's normal retries, it waits another 5 minutes and repeats.
22
22
  - Shows a countdown in the Pi status/working line.
23
23
  - Press Enter during the countdown to skip the wait and retry immediately.
24
+ - Optionally falls back to configured models when the current/default model is rate-limited.
25
+
26
+ ## Optional fallback models
27
+
28
+ By default, if you do not configure fallback models, the extension behaves exactly as before: it waits for the current model's limit to reset and retries.
29
+
30
+ To enable automatic model fallback, add `oira666_pi-limits-wait.try_models` to Pi's normal `settings.json`.
31
+
32
+ Pi settings can be global or project-local:
33
+
34
+ | Location | Scope |
35
+ |----------|-------|
36
+ | `~/.pi/agent/settings.json` | Global, all projects |
37
+ | `.pi/settings.json` | Project-local |
38
+
39
+ Project settings override global settings. The extension reads these through Pi's `SettingsManager`, so normal Pi settings paths and `PI_CODING_AGENT_DIR` are respected.
40
+
41
+ Example:
42
+
43
+ ```json
44
+ {
45
+ "oira666_pi-limits-wait": {
46
+ "try_models": [
47
+ {
48
+ "provider": "anthropic",
49
+ "modelname": "claude-sonnet-4-5",
50
+ "reasoning effort": "medium"
51
+ },
52
+ {
53
+ "provider": "openai",
54
+ "modelname": "gpt-5.1-codex"
55
+ }
56
+ ]
57
+ }
58
+ }
59
+ ```
60
+
61
+ Each `try_models` entry supports:
62
+
63
+ - `provider` — required. The Pi provider name, for example `anthropic`, `openai`, `google`, etc.
64
+ - `modelname` — required. The model id/name as Pi knows it.
65
+ - `reasoning effort` — optional. One of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`. If omitted, Pi's current/default reasoning level is used.
66
+
67
+ Fallback behavior:
68
+
69
+ 1. Pi starts with the normal default or user-selected model.
70
+ 2. If that model is rate-limited, the extension tries models in this order:
71
+ - the original default/user-selected model;
72
+ - then every model from `oira666_pi-limits-wait.try_models`, top to bottom.
73
+ 3. The first model that responds without a rate-limit becomes the active Pi model for the rest of the session/task.
74
+ 4. If that model later becomes rate-limited too, the extension starts again from the same ordered list.
75
+ 5. Rate-limit reset times are remembered only in memory, so known-limited models are skipped until their countdown expires. This memory is cleared when Pi restarts.
76
+
77
+ When the settings are loaded, the extension shows the full usable fallback model list. When models become rate-limited, it shows a live countdown for each limited model.
24
78
 
25
79
  ## License
26
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Extension for PI that allows to automatically wait for limits to reset and retry the request",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import { SettingsManager, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
2
3
  import {
3
4
  createAssistantMessageEventStream,
4
5
  getApiProviders,
@@ -27,9 +28,11 @@ const PI_IDENTITY_SENTENCE_PATTERN =
27
28
 
28
29
  const DEFAULT_RATE_LIMIT_WAIT_MS = 30 * 60 * 1_000; // 30 minutes
29
30
  const DEFAULT_OVERLOADED_WAIT_MS = 5 * 60 * 1_000; // 5 minutes
31
+ const SETTINGS_KEY = "oira666_pi-limits-wait";
30
32
 
31
33
  /** Key used with ctx.ui.setStatus() for the countdown line. */
32
34
  const STATUS_KEY = "limits-wait";
35
+ const MODELS_STATUS_KEY = "limits-wait-models";
33
36
 
34
37
  type RetryReason = "rate-limit" | "overloaded";
35
38
 
@@ -38,13 +41,39 @@ type RetryableError = {
38
41
  waitMs: number;
39
42
  };
40
43
 
44
+ type ConfiguredModel = {
45
+ provider: string;
46
+ modelname: string;
47
+ reasoningEffort?: ThinkingLevel;
48
+ };
49
+
50
+ type FallbackModel = {
51
+ model: Model<Api>;
52
+ reasoningEffort?: ThinkingLevel;
53
+ };
54
+
55
+ type RateLimitMemory = {
56
+ reason: RetryReason;
57
+ limitedAt: number;
58
+ deadline: number;
59
+ };
60
+
41
61
  // ─── Shared UI context ────────────────────────────────────────────────────────
42
62
 
43
63
  let sharedCtx: ExtensionContext | undefined;
64
+ let extensionApi: ExtensionAPI | undefined;
44
65
  let restoreFetch: (() => void) | undefined;
45
66
  let ambientStatusCleanup: (() => void) | undefined;
67
+ let modelStatusCleanup: (() => void) | undefined;
46
68
  let activeProviderRequests = 0;
47
69
  const wrappedApis = new Set<Api>();
70
+ const builtinStreamSimpleByApi = new Map<Api, StreamSimpleFn>();
71
+ let fallbackModels: FallbackModel[] = [];
72
+ let primaryModel: Model<Api> | undefined;
73
+ let primaryThinkingLevel: ThinkingLevel | undefined;
74
+ let suppressNextModelSelect = false;
75
+ let settingsSignature: string | undefined;
76
+ const rateLimitMemory = new Map<string, RateLimitMemory>();
48
77
 
49
78
  // ─── Anthropic subscription prompt sanitisation ──────────────────────────────
50
79
 
@@ -74,6 +103,229 @@ function isAnthropicOAuthSession(ctx: ExtensionContext): boolean {
74
103
  );
75
104
  }
76
105
 
106
+ // ─── Optional fallback model settings ────────────────────────────────────────
107
+
108
+ function modelKey(model: Pick<Model<Api>, "provider" | "id">): string {
109
+ return `${model.provider}/${model.id}`;
110
+ }
111
+
112
+ function formatModel(model: Pick<Model<Api>, "provider" | "id">): string {
113
+ return `${model.provider}/${model.id}`;
114
+ }
115
+
116
+ function isThinkingLevel(value: unknown): value is ThinkingLevel {
117
+ return ["off", "minimal", "low", "medium", "high", "xhigh"].includes(String(value));
118
+ }
119
+
120
+ function parseConfiguredModels(raw: unknown): ConfiguredModel[] {
121
+ const source = raw && typeof raw === "object" && Array.isArray((raw as { try_models?: unknown }).try_models)
122
+ ? (raw as { try_models: unknown[] }).try_models
123
+ : [];
124
+
125
+ const models: ConfiguredModel[] = [];
126
+ for (const item of source) {
127
+ if (!item || typeof item !== "object") continue;
128
+ const record = item as Record<string, unknown>;
129
+ const provider = typeof record.provider === "string" ? record.provider.trim() : "";
130
+ const modelname =
131
+ typeof record.modelname === "string" ? record.modelname.trim()
132
+ : typeof record.modelName === "string" ? record.modelName.trim()
133
+ : typeof record.model === "string" ? record.model.trim()
134
+ : "";
135
+ const reasoning = record["reasoning effort"] ?? record.reasoningEffort ?? record.reasoning_effort;
136
+ if (!provider || !modelname) continue;
137
+ models.push({
138
+ provider,
139
+ modelname,
140
+ reasoningEffort: isThinkingLevel(reasoning) ? reasoning : undefined,
141
+ });
142
+ }
143
+ return models;
144
+ }
145
+
146
+ function loadFallbackSettings(ctx: ExtensionContext): void {
147
+ fallbackModels = [];
148
+
149
+ try {
150
+ const settingsManager = SettingsManager.create(ctx.cwd);
151
+ const globalSettings = settingsManager.getGlobalSettings() as Record<string, unknown>;
152
+ const projectSettings = settingsManager.getProjectSettings() as Record<string, unknown>;
153
+ const globalConfig = globalSettings[SETTINGS_KEY];
154
+ const projectConfig = projectSettings[SETTINGS_KEY];
155
+ const config = {
156
+ ...(globalConfig && typeof globalConfig === "object" && !Array.isArray(globalConfig) ? globalConfig : {}),
157
+ ...(projectConfig && typeof projectConfig === "object" && !Array.isArray(projectConfig) ? projectConfig : {}),
158
+ };
159
+ const content = JSON.stringify(config);
160
+ const shouldNotify = settingsSignature !== content;
161
+ settingsSignature = content;
162
+
163
+ const configured = parseConfiguredModels(config);
164
+ if (configured.length === 0) return;
165
+
166
+ const warnings: string[] = [];
167
+ const seen = new Set<string>();
168
+
169
+ for (const entry of configured) {
170
+ const model = ctx.modelRegistry.find(entry.provider, entry.modelname);
171
+ if (!model) {
172
+ warnings.push(`missing ${entry.provider}/${entry.modelname}`);
173
+ continue;
174
+ }
175
+ if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
176
+ warnings.push(`no auth ${entry.provider}/${entry.modelname}`);
177
+ continue;
178
+ }
179
+ const key = modelKey(model);
180
+ if (seen.has(key)) continue;
181
+ seen.add(key);
182
+ fallbackModels.push({ model, reasoningEffort: entry.reasoningEffort });
183
+ }
184
+
185
+ const lines = fallbackModels.map((entry, index) =>
186
+ `${index + 1}. ${formatModel(entry.model)}${entry.reasoningEffort ? ` (${entry.reasoningEffort})` : ""}`,
187
+ );
188
+ const message = lines.length > 0
189
+ ? `Loaded ${SETTINGS_KEY}.try_models:\n${lines.join("\n")}`
190
+ : `Loaded ${SETTINGS_KEY}.try_models, but no usable fallback models were found.`;
191
+ if (shouldNotify) {
192
+ ctx.ui.notify(warnings.length > 0 ? `${message}\nSkipped: ${warnings.join(", ")}` : message, warnings.length > 0 ? "warning" : "info");
193
+ }
194
+ } catch (err) {
195
+ ctx.ui.notify(`Could not load ${SETTINGS_KEY}.try_models: ${err instanceof Error ? err.message : String(err)}`, "warning");
196
+ }
197
+ }
198
+
199
+ function fallbackEnabled(): boolean {
200
+ return fallbackModels.length > 0;
201
+ }
202
+
203
+ function getPrimaryModel(current: Model<Api>): Model<Api> {
204
+ return primaryModel ?? current;
205
+ }
206
+
207
+ function candidateOrder(current: Model<Api>): FallbackModel[] {
208
+ const primary = getPrimaryModel(current);
209
+ const order: FallbackModel[] = [{ model: primary, reasoningEffort: primaryThinkingLevel }];
210
+ const seen = new Set([modelKey(primary)]);
211
+ for (const entry of fallbackModels) {
212
+ const key = modelKey(entry.model);
213
+ if (seen.has(key)) continue;
214
+ seen.add(key);
215
+ order.push(entry);
216
+ }
217
+ return order;
218
+ }
219
+
220
+ function activeLimit(model: Model<Api>): RateLimitMemory | undefined {
221
+ const entry = rateLimitMemory.get(modelKey(model));
222
+ if (!entry) return undefined;
223
+ if (Date.now() >= entry.deadline) {
224
+ rateLimitMemory.delete(modelKey(model));
225
+ return undefined;
226
+ }
227
+ return entry;
228
+ }
229
+
230
+ function formatDuration(ms: number): string {
231
+ const totalSecs = Math.max(0, Math.ceil(ms / 1_000));
232
+ const hours = Math.floor(totalSecs / 3_600);
233
+ const mins = Math.floor((totalSecs % 3_600) / 60);
234
+ const secs = (totalSecs % 60).toString().padStart(2, "0");
235
+ return hours > 0 ? `${hours}h ${mins}m ${secs}s` : `${mins}m ${secs}s`;
236
+ }
237
+
238
+ function updateRateLimitedModelsStatus(): void {
239
+ const ctx = sharedCtx;
240
+ if (!ctx) return;
241
+ const now = Date.now();
242
+ const lines: string[] = [];
243
+ for (const [key, entry] of rateLimitMemory) {
244
+ if (now >= entry.deadline) {
245
+ rateLimitMemory.delete(key);
246
+ continue;
247
+ }
248
+ lines.push(`${key}: ${formatDuration(entry.deadline - now)}`);
249
+ }
250
+ if (lines.length === 0) {
251
+ modelStatusCleanup?.();
252
+ return;
253
+ }
254
+ ctx.ui.setStatus(MODELS_STATUS_KEY, `🚦 Limited: ${lines.join(" | ")}`);
255
+ }
256
+
257
+ function ensureRateLimitedModelsStatus(): void {
258
+ if (modelStatusCleanup) return;
259
+ updateRateLimitedModelsStatus();
260
+ const ticker = setInterval(updateRateLimitedModelsStatus, 1_000);
261
+ modelStatusCleanup = () => {
262
+ clearInterval(ticker);
263
+ modelStatusCleanup = undefined;
264
+ sharedCtx?.ui.setStatus(MODELS_STATUS_KEY, undefined);
265
+ };
266
+ }
267
+
268
+ function rememberRateLimit(model: Model<Api>, retryable: RetryableError): void {
269
+ const deadline = Date.now() + retryable.waitMs;
270
+ rateLimitMemory.set(modelKey(model), { reason: retryable.reason, limitedAt: Date.now(), deadline });
271
+ sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.`, "warning");
272
+ ensureRateLimitedModelsStatus();
273
+ }
274
+
275
+ function nextAvailableCandidate(current: Model<Api>): FallbackModel | undefined {
276
+ return candidateOrder(current).find((entry) => !activeLimit(entry.model));
277
+ }
278
+
279
+ function earliestCandidateDeadline(current: Model<Api>): number | undefined {
280
+ const deadlines = candidateOrder(current)
281
+ .map((entry) => activeLimit(entry.model)?.deadline)
282
+ .filter((deadline): deadline is number => typeof deadline === "number");
283
+ return deadlines.length > 0 ? Math.min(...deadlines) : undefined;
284
+ }
285
+
286
+ function initialAttempt(model: Model<Api>): FallbackModel {
287
+ const configured = fallbackModels.find((entry) => modelKey(entry.model) === modelKey(model));
288
+ const current = configured ?? { model, reasoningEffort: primaryThinkingLevel };
289
+ if (!fallbackEnabled() || !activeLimit(model)) return current;
290
+ return nextAvailableCandidate(model) ?? current;
291
+ }
292
+
293
+ async function optionsForModel(
294
+ originalModel: Model<Api>,
295
+ target: FallbackModel,
296
+ options?: SimpleStreamOptions,
297
+ ): Promise<SimpleStreamOptions | undefined> {
298
+ const level = target.reasoningEffort ?? primaryThinkingLevel;
299
+ const reasoning = level && level !== "off" ? level : undefined;
300
+ if (modelKey(originalModel) === modelKey(target.model)) {
301
+ return reasoning ? { ...options, reasoning } : options;
302
+ }
303
+ const auth = await sharedCtx?.modelRegistry.getApiKeyAndHeaders(target.model);
304
+ if (!auth?.ok) throw new Error(auth ? auth.error : "Model registry is unavailable.");
305
+ return {
306
+ ...options,
307
+ ...(reasoning ? { reasoning } : {}),
308
+ apiKey: auth.apiKey,
309
+ headers: auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined,
310
+ } as SimpleStreamOptions;
311
+ }
312
+
313
+ async function switchPiModel(entry: FallbackModel): Promise<void> {
314
+ const pi = extensionApi;
315
+ if (!pi) return;
316
+ const current = sharedCtx?.model;
317
+ if (current && modelKey(current) === modelKey(entry.model)) return;
318
+ suppressNextModelSelect = true;
319
+ const ok = await pi.setModel(entry.model);
320
+ if (!ok) {
321
+ suppressNextModelSelect = false;
322
+ return;
323
+ }
324
+ const level = entry.reasoningEffort ?? primaryThinkingLevel;
325
+ if (level) pi.setThinkingLevel(level);
326
+ sharedCtx?.ui.notify(`Switched to ${formatModel(entry.model)}${level ? ` (${level})` : ""}.`, "info");
327
+ }
328
+
77
329
  // ─── Retryable error detection ────────────────────────────────────────────────
78
330
 
79
331
  export function isRateLimitError(msg: string): boolean {
@@ -363,6 +615,7 @@ export function streamWithLimitsRetry(
363
615
 
364
616
  void (async () => {
365
617
  let committed = false;
618
+ let attempt: FallbackModel = initialAttempt(model);
366
619
 
367
620
  const flush = (buffer: AssistantMessageEvent[]) => {
368
621
  for (const event of buffer) output.push(event);
@@ -377,7 +630,15 @@ export function streamWithLimitsRetry(
377
630
 
378
631
  try {
379
632
  try {
380
- const inner = delegate(model, context, options);
633
+ const attemptOptions = fallbackEnabled()
634
+ ? await optionsForModel(model, attempt, options)
635
+ : options;
636
+ const attemptDelegate = attempt.model.api === model.api
637
+ ? delegate
638
+ : builtinStreamSimpleByApi.get(attempt.model.api)
639
+ ?? getApiProviders().find((provider) => provider.api === attempt.model.api)?.streamSimple;
640
+ if (!attemptDelegate) throw new Error(`No stream handler registered for API ${attempt.model.api}.`);
641
+ const inner = attemptDelegate(attempt.model, context, attemptOptions);
381
642
  for await (const event of inner) {
382
643
  if (!committed) {
383
644
  if (event.type === "error") {
@@ -389,7 +650,7 @@ export function streamWithLimitsRetry(
389
650
  if (buffer.length > 0) {
390
651
  flush(buffer);
391
652
  } else {
392
- output.push({ type: "start", partial: freshMessage(model) });
653
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
393
654
  committed = true;
394
655
  }
395
656
  output.push(event);
@@ -403,10 +664,11 @@ export function streamWithLimitsRetry(
403
664
  }
404
665
 
405
666
  // First non-start, non-error event means this attempt is real.
667
+ if (fallbackEnabled()) await switchPiModel(attempt);
406
668
  if (buffer.length > 0) {
407
669
  flush(buffer);
408
670
  } else {
409
- output.push({ type: "start", partial: freshMessage(model) });
671
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
410
672
  committed = true;
411
673
  }
412
674
  output.push(event);
@@ -430,10 +692,10 @@ export function streamWithLimitsRetry(
430
692
  if (!retryable) {
431
693
  if (!committed) {
432
694
  // Synthetic start+error so the stream protocol stays valid.
433
- output.push({ type: "start", partial: freshMessage(model) });
695
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
434
696
  committed = true;
435
697
  }
436
- const error = freshMessage(model);
698
+ const error = freshMessage(attempt.model);
437
699
  error.stopReason = options?.signal?.aborted ? "aborted" : "error";
438
700
  error.errorMessage = errMsg;
439
701
  output.push({
@@ -458,11 +720,11 @@ export function streamWithLimitsRetry(
458
720
  if (buffer.length > 0) {
459
721
  flush(buffer);
460
722
  } else {
461
- output.push({ type: "start", partial: freshMessage(model) });
723
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
462
724
  committed = true;
463
725
  }
464
726
  }
465
- const error = freshMessage(model);
727
+ const error = freshMessage(attempt.model);
466
728
  error.stopReason = "error";
467
729
  error.errorMessage = "Provider stream ended without a terminal event.";
468
730
  output.push({ type: "error", reason: "error", error });
@@ -470,13 +732,40 @@ export function streamWithLimitsRetry(
470
732
  return;
471
733
  }
472
734
 
735
+ if (fallbackEnabled() && retryable.reason === "rate-limit") {
736
+ rememberRateLimit(attempt.model, retryable);
737
+ const next = nextAvailableCandidate(attempt.model);
738
+ if (next) {
739
+ attempt = next;
740
+ continue;
741
+ }
742
+
743
+ const deadline = earliestCandidateDeadline(attempt.model);
744
+ const waitMs = deadline ? Math.max(0, deadline - Date.now()) : retryable.waitMs;
745
+ const waitResult = await waitForRetry(retryable.reason, waitMs, options?.signal);
746
+ if (waitResult === "aborted") {
747
+ if (!committed) {
748
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
749
+ committed = true;
750
+ }
751
+ const error = freshMessage(attempt.model);
752
+ error.stopReason = "aborted";
753
+ error.errorMessage = `Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`;
754
+ output.push({ type: "error", reason: "aborted", error });
755
+ output.end();
756
+ return;
757
+ }
758
+ attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
759
+ continue;
760
+ }
761
+
473
762
  const waitResult = await waitForRetry(retryable.reason, retryable.waitMs, options?.signal);
474
763
  if (waitResult === "aborted") {
475
764
  if (!committed) {
476
- output.push({ type: "start", partial: freshMessage(model) });
765
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
477
766
  committed = true;
478
767
  }
479
- const error = freshMessage(model);
768
+ const error = freshMessage(attempt.model);
480
769
  error.stopReason = "aborted";
481
770
  error.errorMessage = `Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`;
482
771
  output.push({ type: "error", reason: "aborted", error });
@@ -499,6 +788,7 @@ function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
499
788
  const builtinStreamSimple = getApiProviders().find((provider) => provider.api === api)?.streamSimple;
500
789
  if (!builtinStreamSimple) return;
501
790
 
791
+ builtinStreamSimpleByApi.set(api, builtinStreamSimple);
502
792
  wrappedApis.add(api);
503
793
  pi.registerProvider(`limits-wait-${api}`, {
504
794
  api,
@@ -510,8 +800,22 @@ function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
510
800
  // ─── Extension entry point ────────────────────────────────────────────────────
511
801
 
512
802
  export default function (pi: ExtensionAPI) {
803
+ extensionApi = pi;
804
+
805
+ pi.on("model_select", (event) => {
806
+ if (suppressNextModelSelect) {
807
+ suppressNextModelSelect = false;
808
+ return;
809
+ }
810
+ primaryModel = event.model;
811
+ primaryThinkingLevel = pi.getThinkingLevel();
812
+ });
813
+
513
814
  pi.on("before_agent_start", (event, ctx) => {
514
815
  sharedCtx = ctx;
816
+ primaryModel ??= ctx.model;
817
+ primaryThinkingLevel ??= pi.getThinkingLevel();
818
+ loadFallbackSettings(ctx);
515
819
 
516
820
  // Some extensions may register providers after this extension loads. Wrap
517
821
  // any APIs that exist by the time an agent starts too.