@kylebrodeur/pi-model-router 0.1.3 → 0.1.4

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/CHANGELOG.md CHANGED
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - Transparent wait and retry interception for string-based rate limit errors (e.g., "quota will reset after X seconds")
11
12
  - Ollama auto-sync feature
12
13
  - Rate-limit fallback with transparent HTTP error handling (402, 429, 503, 529)
13
14
  - Feature toggles in config (`features` object)
package/LEARNINGS.md CHANGED
@@ -73,10 +73,11 @@ The fallback mechanism uses a user-configurable sequence of models: `fallbackSeq
73
73
  * **Key benefit**: Prevents catastrophic failures when a primary model is unavailable.
74
74
 
75
75
  ### 3. Graceful Error Handling
76
- The extension transparently handles errors. For "out of credits" (`402`) or "rate limit" (`429`), it automatically switches to a fallback model and emits a custom session entry (`router-fallback`) for headless tooling to detect.
76
+ The extension transparently handles errors. For "out of credits" (`402`) or "rate limit" (`429`), it automatically switches to a fallback model and emits a custom session entry (`router-fallback`) for headless tooling to detect.
77
+ Additionally, for string-based 429 errors specifying a cooldown (e.g., "quota will reset after 58s"), the router can intercept the stream, pause for the required duration (if under `shortDelayThreshold`), and automatically retry the original request without failing the turn.
77
78
 
78
79
  * **When to use**: For any extension exposed to external API services.
79
- * **Key insight**: Never mask API errors; provide enough detail (status codes) in UI notifications for users to diagnose.
80
+ * **Key insight**: Never mask API errors; provide enough detail (status codes) in UI notifications for users to diagnose, but handle transient issues (like short rate limits) invisibly where possible.
80
81
 
81
82
  ## 🔌 Pi Integration Patterns
82
83
 
package/README.md CHANGED
@@ -121,6 +121,30 @@ Copy the example config to one of:
121
121
 
122
122
  **Priority:** Project config `.pi/model-router.json` overrides user config `~/.pi/agent/model-router.json`. Both override defaults.
123
123
 
124
+ ### Rate Limit Interception & Fallback
125
+
126
+ The router can gracefully handle 429 Rate Limit and Quota errors. If the error specifies a wait time (e.g., "reset after 58s"), the router will pause and automatically retry the prompt if the wait time is under your threshold. If it exceeds the threshold or is unparseable, it fails over to the next available model in your fallback sequence.
127
+
128
+ ```json
129
+ {
130
+ "rateLimitFallback": {
131
+ "enabled": true,
132
+ "shortDelayThreshold": 60,
133
+ "autoFallback": true,
134
+ "autoRestore": true,
135
+ "restoreCheckInterval": 300,
136
+ "fallbackSequence": ["anthropic/claude-3-haiku-20240307", "ollama/*"]
137
+ }
138
+ }
139
+ ```
140
+
141
+ | Field | Description |
142
+ |-------|-------------|
143
+ | `shortDelayThreshold` | Maximum time (in seconds) the router will pause and wait to retry when encountering a rate limit. If the cooldown is longer than this, it triggers a fallback. |
144
+ | `fallbackSequence` | Array of model IDs (or wildcards like `ollama/*`) to try if the primary model fails or the wait time is too long. |
145
+ | `autoFallback` | (Optional) Automatically switch session to the fallback model globally after a hard failure. |
146
+ | `autoRestore` | (Optional) If fallback was triggered, automatically try to restore the original cloud model after `restoreCheckInterval` seconds. |
147
+
124
148
  ### Progressive Enhancement Configs
125
149
 
126
150
  After installing optional extensions, copy one of these to `.pi/model-router.json`:
@@ -30,6 +30,20 @@ import {
30
30
  hasImageAttachment,
31
31
  } from './routing';
32
32
 
33
+ const rateLimitRegex = /(?:429|rate limit|quota).*?(?:reset after|try again in|wait)\s*(\d+)\s*([smh])/i;
34
+
35
+ function extractWaitTimeMs(errorText: string): number | null {
36
+ const match = errorText.match(rateLimitRegex);
37
+ if (!match) return null;
38
+ const value = parseInt(match[1], 10);
39
+ const unit = match[2].toLowerCase();
40
+
41
+ if (unit === 's') return value * 1000;
42
+ if (unit === 'm') return value * 60000;
43
+ if (unit === 'h') return value * 3600000;
44
+ return null;
45
+ }
46
+
33
47
  export const createErrorMessage = (
34
48
  model: Model<Api>,
35
49
  message: string,
@@ -457,74 +471,109 @@ export const registerRouterProvider = (
457
471
  const apiKey = auth.apiKey;
458
472
  const headers = auth.headers;
459
473
 
460
- try {
461
- // HONESTY CHECK & AUTO-TRUNCATION
462
- // If the picked model has a smaller context than what we reported, truncate now.
463
- let effectiveContext = context;
464
- const targetLimit = targetModel.contextWindow || 128_000;
465
- if (targetLimit < model.contextWindow!) {
466
- effectiveContext = truncateContext(context, targetLimit);
467
- }
474
+ let retryCount = 0;
475
+ let modelSuccess = false;
468
476
 
469
- const thinkingOverride = actions.getThinkingOverride(
470
- model.id,
471
- decision.tier,
472
- );
473
- const delegatedReasoning =
474
- targetModel.reasoning &&
475
- (thinkingOverride ?? decision.thinking) !== 'off'
476
- ? (thinkingOverride ?? decision.thinking)
477
- : undefined;
478
-
479
- if (state.lastExtensionContext) {
480
- if (delegatedReasoning) {
481
- state.lastExtensionContext.ui.setHiddenThinkingLabel?.(
482
- `Thinking (${targetProvider}/${targetModelId})...`,
483
- );
484
- } else {
485
- state.lastExtensionContext.ui.setHiddenThinkingLabel?.();
477
+ while (retryCount < 2) {
478
+ let contentReceived = false;
479
+ try {
480
+ // HONESTY CHECK & AUTO-TRUNCATION
481
+ // If the picked model has a smaller context than what we reported, truncate now.
482
+ let effectiveContext = context;
483
+ const targetLimit = targetModel.contextWindow || 128_000;
484
+ if (targetLimit < model.contextWindow!) {
485
+ effectiveContext = truncateContext(context, targetLimit);
486
486
  }
487
- }
488
487
 
489
- const delegatedStream = streamSimple(
490
- targetModel,
491
- effectiveContext,
492
- {
493
- ...options,
494
- apiKey,
495
- headers,
496
- ...(delegatedReasoning
497
- ? { reasoning: delegatedReasoning }
498
- : {}),
499
- },
500
- );
488
+ const thinkingOverride = actions.getThinkingOverride(
489
+ model.id,
490
+ decision.tier,
491
+ );
492
+ const delegatedReasoning =
493
+ targetModel.reasoning &&
494
+ (thinkingOverride ?? decision.thinking) !== 'off'
495
+ ? (thinkingOverride ?? decision.thinking)
496
+ : undefined;
497
+
498
+ if (state.lastExtensionContext) {
499
+ if (delegatedReasoning) {
500
+ state.lastExtensionContext.ui.setHiddenThinkingLabel?.(
501
+ `Thinking (${targetProvider}/${targetModelId})...`,
502
+ );
503
+ } else {
504
+ state.lastExtensionContext.ui.setHiddenThinkingLabel?.();
505
+ }
506
+ }
501
507
 
502
- let contentReceived = false;
503
- for await (const event of delegatedStream) {
504
- if (event.type === 'done') {
505
- const cost = event.message.usage?.cost?.total ?? 0;
506
- state.accumulatedCost += cost;
508
+ const delegatedStream = streamSimple(
509
+ targetModel,
510
+ effectiveContext,
511
+ {
512
+ ...options,
513
+ apiKey,
514
+ headers,
515
+ ...(delegatedReasoning
516
+ ? { reasoning: delegatedReasoning }
517
+ : {}),
518
+ },
519
+ );
520
+
521
+ for await (const event of delegatedStream) {
522
+ if (event.type === 'done') {
523
+ const cost = event.message.usage?.cost?.total ?? 0;
524
+ state.accumulatedCost += cost;
525
+ }
526
+ if (event.type === 'error' && !contentReceived) {
527
+ throw new Error(
528
+ (event as any).error?.errorMessage ||
529
+ 'Model failed before sending content.',
530
+ );
531
+ }
532
+ const isContent =
533
+ event.type === 'text_delta' ||
534
+ event.type === 'thinking_delta' ||
535
+ event.type === 'toolcall_delta' ||
536
+ event.type === 'toolcall_end';
537
+ if (isContent) contentReceived = true;
538
+ stream.push(event);
507
539
  }
508
- if (event.type === 'error' && !contentReceived) {
509
- throw new Error(
510
- (event as any).error?.errorMessage ||
511
- 'Model failed before sending content.',
512
- );
540
+ modelSuccess = true;
541
+ success = true;
542
+ if (i > 0) decision.isFallback = true;
543
+ break; // break the retry loop
544
+ } catch (err) {
545
+ const errMsg = err instanceof Error ? err.message : String(err);
546
+ const waitMs = extractWaitTimeMs(errMsg);
547
+ const maxWaitMs = (state.currentConfig.rateLimitFallback?.shortDelayThreshold ?? 60) * 1000;
548
+
549
+ if (waitMs && waitMs <= maxWaitMs && retryCount === 0 && !contentReceived) {
550
+ const partialMsg = {
551
+ role: 'assistant',
552
+ content: [],
553
+ api: model.api,
554
+ provider: targetProvider,
555
+ model: targetModelId,
556
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
557
+ timestamp: Date.now(),
558
+ } as unknown as AssistantMessage;
559
+
560
+ stream.push({
561
+ type: 'text_delta',
562
+ contentIndex: 0,
563
+ delta: `\n_⏳ [Router] Rate limit reached on ${targetProvider}/${targetModelId}. Waiting ${Math.ceil(waitMs/1000)}s before retrying..._\n`,
564
+ partial: partialMsg
565
+ });
566
+ await new Promise(resolve => setTimeout(resolve, waitMs + 1000)); // buffer 1s
567
+ retryCount++;
568
+ continue; // try the same model again
513
569
  }
514
- const isContent =
515
- event.type === 'text_delta' ||
516
- event.type === 'thinking_delta' ||
517
- event.type === 'toolcall_delta' ||
518
- event.type === 'toolcall_end';
519
- if (isContent) contentReceived = true;
520
- stream.push(event);
570
+
571
+ lastError = err;
572
+ break; // model failed completely, break retry loop to go to next fallback model
521
573
  }
522
- success = true;
523
- if (i > 0) decision.isFallback = true;
524
- break;
525
- } catch (err) {
526
- lastError = err;
527
574
  }
575
+
576
+ if (modelSuccess) break; // break fallback loop
528
577
  }
529
578
 
530
579
  if (!success) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylebrodeur/pi-model-router",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Intelligent per-turn model router extension for the pi coding agent (Enhanced Fork)",
6
6
  "keywords": [