File size: 5,878 Bytes
c6ec6e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
/**
 * src/managers/KeyManager.ts
 *
 * Manages a pool of API keys for a single LLM provider with:
 *   - Round-robin selection across active keys
 *   - Automatic per-key cooldown on HTTP 429 (configurable duration)
 *   - Async waiting if ALL keys are in cooldown
 *   - Dynamic concurrency limit recommendation: min(activeKeys Γ— 2, 20)
 *
 * Usage:
 *   const mgr = new KeyManager(['key1', 'key2', 'key3'], 'groq');
 *   const key = await mgr.getKey();   // non-blocking if any key available
 *   // ... if 429 error ...
 *   mgr.blacklist(key);
 */

import { logger } from '../utils/logger';

// ─── Types ────────────────────────────────────────────────────────────────────

export type LLMProvider = 'groq' | 'gemini' | 'sambanova';

// ─── KeyManager ───────────────────────────────────────────────────────────────

export class KeyManager {
  private readonly provider: LLMProvider;
  private readonly keys: readonly string[];
  private readonly cooldownMs: number;

  /**
   * Maps an API key β†’ the timestamp (ms since epoch) when it was blacklisted.
   * Entries are pruned lazily on each getKey() call.
   */
  private readonly blacklisted = new Map<string, number>();

  /**
   * Round-robin cursor within the *active* (non-blacklisted) key list.
   * Resets automatically as keys come and go from the blacklist.
   */
  private rrIndex = 0;

  constructor(
    keys: string[],
    provider: LLMProvider,
    cooldownMs = 65_000 // 65 s β€” slightly above the standard 60 s Groq cooldown
  ) {
    if (keys.length === 0) {
      throw new Error(
        `KeyManager[${provider}]: received 0 API keys. ` +
        `Check your environment variable.`
      );
    }
    this.keys = Object.freeze([...keys]);
    this.provider = provider;
    this.cooldownMs = cooldownMs;

    logger.info(
      `KeyManager[${provider}]: initialized with ${keys.length} key(s), ` +
      `cooldown=${cooldownMs}ms`
    );
  }

  // ─── Public API ─────────────────────────────────────────────────────────

  /**
   * Returns the number of keys NOT currently in cooldown.
   */
  get activeCount(): number {
    this.pruneBlacklist();
    return this.keys.filter(k => !this.blacklisted.has(k)).length;
  }

  /**
   * Recommended p-limit concurrency value for this provider.
   * Formula: min(activeKeys Γ— 2, 20), minimum of 1.
   */
  get concurrencyLimit(): number {
    return Math.min(Math.max(this.activeCount * 2, 1), 20);
  }

  /**
   * Returns the next available API key using round-robin rotation.
   *
   * If all keys are in cooldown, this method WAITS asynchronously for
   * the earliest key to recover rather than throwing β€” ensuring the
   * pipeline self-heals without crashing.
   */
  async getKey(): Promise<string> {
    this.pruneBlacklist();

    const available = this.keys.filter(k => !this.blacklisted.has(k));

    if (available.length > 0) {
      // Advance round-robin and pick
      const key = available[this.rrIndex % available.length];
      this.rrIndex = (this.rrIndex + 1) % available.length;
      return key;
    }

    // ── All keys are in cooldown β€” compute the shortest wait ────────────
    const cooldownEntries = Array.from(this.blacklisted.values());
    const earliestBlacklist = Math.min(...cooldownEntries);
    const waitMs = Math.max(0, earliestBlacklist + this.cooldownMs - Date.now());

    logger.warn(
      `KeyManager[${this.provider}]: ALL ${this.keys.length} key(s) in cooldown. ` +
      `Waiting ${Math.ceil(waitMs / 1000)}s for recovery...`
    );

    await new Promise<void>(resolve => setTimeout(resolve, waitMs + 100 /* tiny buffer */));

    // Recursive call β€” at least one key should now be active
    return this.getKey();
  }

  /**
   * Marks a key as rate-limited. It will be skipped until the cooldown expires.
   *
   * @param key - The exact key string returned by `getKey()`
   */
  blacklist(key: string): void {
    if (!this.keys.includes(key)) {
      logger.warn(`KeyManager[${this.provider}]: attempted to blacklist unknown key β€” ignoring`);
      return;
    }

    const alreadyBlacklisted = this.blacklisted.has(key);
    if (!alreadyBlacklisted) {
      this.blacklisted.set(key, Date.now());
      logger.warn(
        `KeyManager[${this.provider}]: key ***${key.slice(-4)} blacklisted. ` +
        `${this.activeCount} active key(s) remaining.`
      );
    }
  }

  /**
   * Returns a snapshot of the current key pool status (for health endpoints).
   */
  getStatus(): { provider: LLMProvider; total: number; active: number; coolingDown: number } {
    this.pruneBlacklist();
    const coolingDown = this.blacklisted.size;
    return {
      provider: this.provider,
      total: this.keys.length,
      active: this.keys.length - coolingDown,
      coolingDown,
    };
  }

  // ─── Private Helpers ────────────────────────────────────────────────────

  /**
   * Removes keys from the blacklist whose cooldown duration has elapsed.
   * Called lazily before every key selection.
   */
  private pruneBlacklist(): void {
    const now = Date.now();
    for (const [key, blacklistedAt] of this.blacklisted.entries()) {
      if (now - blacklistedAt >= this.cooldownMs) {
        this.blacklisted.delete(key);
        logger.info(
          `KeyManager[${this.provider}]: key ***${key.slice(-4)} restored from cooldown`
        );
      }
    }
  }
}