opencode-morph-rotation 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rnbsov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # opencode-morph-rotation
2
+
3
+ OpenCode plugin for automatic Morph API key rotation with rate-limit handling.
4
+
5
+ ## Features
6
+
7
+ - 🔄 Automatic key rotation when rate limited
8
+ - 📊 Key health monitoring and status reporting
9
+ - ➕ Add/remove keys at runtime via tools
10
+ - 🛡️ Auto-disable keys after repeated failures
11
+ - 📋 `/morph-quota` slash command for quick status
12
+
13
+ ## Installation
14
+
15
+ Add to your `opencode.jsonc`:
16
+
17
+ ```jsonc
18
+ {
19
+ "plugin": [
20
+ "opencode-morph-rotation@latest"
21
+ ]
22
+ }
23
+ ```
24
+
25
+ Then restart OpenCode.
26
+
27
+ ## Setup
28
+
29
+ ### 1. Add your keys
30
+
31
+ Use the `morph_add_key` tool in chat:
32
+
33
+ > Add my Morph key sk-ABC123... with label "account1"
34
+
35
+ Or manually create `~/.config/opencode/morph-keys.json`:
36
+
37
+ ```json
38
+ {
39
+ "keys": [
40
+ {
41
+ "key": "sk-YOUR_KEY_1",
42
+ "label": "account1",
43
+ "enabled": true,
44
+ "rateLimitResetTime": 0,
45
+ "consecutiveFailures": 0,
46
+ "lastUsed": 0,
47
+ "addedAt": 1711152000000
48
+ }
49
+ ],
50
+ "strategy": "least-recently-used",
51
+ "defaultCooldownMs": 60000,
52
+ "maxConsecutiveFailures": 5
53
+ }
54
+ ```
55
+
56
+ ### 2. Remove MORPH_API_KEY from MCP config
57
+
58
+ Since the plugin injects `MORPH_API_KEY` via `shell.env`, remove any hardcoded key from your MCP server config to avoid conflicts.
59
+
60
+ ## Available Tools
61
+
62
+ | Tool | Description |
63
+ |------|-------------|
64
+ | `morph_quota` | Show status of all keys |
65
+ | `morph_add_key` | Add a new key for rotation |
66
+ | `morph_remove_key` | Remove a key by label |
67
+ | `morph_mark_rate_limited` | Manually mark a key as rate limited |
68
+
69
+ ## Rotation Strategies
70
+
71
+ - **least-recently-used** (default): Picks the key used longest ago
72
+ - **round-robin**: Cycles through keys in order
73
+ - **sticky**: Stays on current key until it fails
74
+
75
+ ## How It Works
76
+
77
+ 1. Plugin loads on OpenCode start
78
+ 2. Before each shell/tool execution, `shell.env` hook picks the best available key
79
+ 3. When a key hits rate limit (429), use `morph_mark_rate_limited` to rotate
80
+ 4. Disabled keys re-enable after cooldown period
81
+
82
+ ## License
83
+
84
+ MIT
package/bun.lock ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "opencode-morph-rotation",
7
+ "dependencies": {
8
+ "@opencode-ai/plugin": "^1.3.0",
9
+ "zod": "^3.24.0",
10
+ },
11
+ "peerDependencies": {
12
+ "@opencode-ai/plugin": ">=0.15.30",
13
+ },
14
+ },
15
+ },
16
+ "packages": {
17
+ "@opencode-ai/plugin": ["@opencode-ai/plugin@1.3.0", "", { "dependencies": { "@opencode-ai/sdk": "1.3.0", "zod": "4.1.8" } }, "sha512-mR1Kdcpr3Iv+KS7cL2DRFB6QAcSoR6/DojmwuxYF/pMCahMtaCLiqZGQjoSNl12+gQ6RsIJJyUh/jX3JVlOx8A=="],
18
+
19
+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.0", "", {}, "sha512-5WyYEpcV6Zk9otXOMIrvZRbJm1yxt/c8EXSBn1p6Sw1yagz8HRljkoUTJFxzD0x2+/6vAZItr3OrXDZfE+oA2g=="],
20
+
21
+ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
22
+
23
+ "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
24
+ }
25
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "opencode-morph-rotation",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin for Morph API key rotation with rate limit handling",
5
+ "main": "src/index.ts",
6
+ "type": "module",
7
+ "keywords": ["opencode", "opencode-plugin", "morph", "api-key-rotation", "rate-limit"],
8
+ "author": "rnbsov",
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/rnbsov/opencode-morph-rotation"
13
+ },
14
+ "dependencies": {
15
+ "@opencode-ai/plugin": "^1.3.0",
16
+ "zod": "^3.24.0"
17
+ },
18
+ "peerDependencies": {
19
+ "@opencode-ai/plugin": ">=0.15.30"
20
+ }
21
+ }
package/src/config.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { join } from 'path';
2
+ import { homedir } from 'os';
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
4
+ import type { MorphKeysConfig } from './types';
5
+ import { DEFAULT_CONFIG } from './types';
6
+
7
+ const CONFIG_DIR = join(homedir(), '.config', 'opencode');
8
+ const KEYS_FILE = join(CONFIG_DIR, 'morph-keys.json');
9
+ const COMMAND_DIR = join(CONFIG_DIR, 'command');
10
+ const COMMAND_FILE = join(COMMAND_DIR, 'morph-quota.md');
11
+
12
+ export { CONFIG_DIR, KEYS_FILE, COMMAND_DIR, COMMAND_FILE };
13
+
14
+ export function loadKeysConfig(): MorphKeysConfig {
15
+ if (!existsSync(KEYS_FILE)) {
16
+ return { ...DEFAULT_CONFIG };
17
+ }
18
+ try {
19
+ const raw = readFileSync(KEYS_FILE, 'utf-8');
20
+ const parsed = JSON.parse(raw);
21
+ return { ...DEFAULT_CONFIG, ...parsed };
22
+ } catch {
23
+ return { ...DEFAULT_CONFIG };
24
+ }
25
+ }
26
+
27
+ export function saveKeysConfig(config: MorphKeysConfig): void {
28
+ mkdirSync(CONFIG_DIR, { recursive: true });
29
+ writeFileSync(KEYS_FILE, JSON.stringify(config, null, 2), 'utf-8');
30
+ }
31
+
32
+ export function ensureCommandFile(): void {
33
+ mkdirSync(COMMAND_DIR, { recursive: true });
34
+ if (!existsSync(COMMAND_FILE)) {
35
+ const content = `---
36
+ name: morph-quota
37
+ description: Check Morph API key rotation status and quota
38
+ ---
39
+ Check the status of all configured Morph API keys, including which are active, rate-limited, or disabled. Shows rotation strategy and key health.
40
+ `;
41
+ writeFileSync(COMMAND_FILE, content, 'utf-8');
42
+ }
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,169 @@
1
+ import type { Plugin } from '@opencode-ai/plugin';
2
+ import { tool } from '@opencode-ai/plugin';
3
+ import { z } from 'zod';
4
+ import { KeyManager } from './key-manager';
5
+ import { loadKeysConfig, saveKeysConfig, ensureCommandFile } from './config';
6
+ import { maskKey, formatDuration, keyStatusLine } from './utils';
7
+
8
+ export const plugin: Plugin = async (ctx) => {
9
+ const config = loadKeysConfig();
10
+ const manager = new KeyManager(config);
11
+
12
+ // Create /morph-quota command file
13
+ ensureCommandFile();
14
+
15
+ // Save config periodically (on key state changes)
16
+ const persistConfig = () => {
17
+ try {
18
+ saveKeysConfig(manager.getConfig());
19
+ } catch {
20
+ // Silent fail on config save
21
+ }
22
+ };
23
+
24
+ return {
25
+ 'shell.env': async () => {
26
+ const bestKey = manager.getBestKey();
27
+ if (bestKey) {
28
+ return { MORPH_API_KEY: bestKey.key };
29
+ }
30
+ // If no keys available, don't override existing env
31
+ return {};
32
+ },
33
+
34
+ tool: {
35
+ morph_quota: tool({
36
+ description:
37
+ 'Show status of all configured Morph API keys including rotation state, rate limits, and health',
38
+ args: {},
39
+ execute: async () => {
40
+ const statuses = manager.getAllStatus();
41
+ if (statuses.length === 0) {
42
+ return 'No Morph API keys configured. Use morph_add_key to add keys.';
43
+ }
44
+
45
+ const now = Date.now();
46
+ const lines: string[] = ['## Morph Key Rotation Status\n'];
47
+ const cfg = manager.getConfig();
48
+ lines.push(`Strategy: **${cfg.strategy}**`);
49
+ lines.push(`Keys: ${statuses.length} total\n`);
50
+
51
+ for (const k of statuses) {
52
+ let status: 'active' | 'rate-limited' | 'disabled' | 'cooldown';
53
+ let extra = '';
54
+
55
+ if (!k.enabled) {
56
+ status = 'disabled';
57
+ extra = `(${k.consecutiveFailures} failures)`;
58
+ } else if (k.rateLimitResetTime > now) {
59
+ status = 'cooldown';
60
+ extra = `resets in ${formatDuration(k.rateLimitResetTime - now)}`;
61
+ } else {
62
+ status = 'active';
63
+ if (k.lastUsed > 0) {
64
+ extra = `last used ${formatDuration(now - k.lastUsed)} ago`;
65
+ } else {
66
+ extra = 'never used';
67
+ }
68
+ }
69
+
70
+ lines.push(keyStatusLine(k.label, maskKey(k.key), status, extra));
71
+ }
72
+
73
+ const bestKey = manager.getBestKey();
74
+ if (bestKey) {
75
+ lines.push(`\nCurrent key: **${bestKey.label}** (${maskKey(bestKey.key)})`);
76
+ } else {
77
+ lines.push('\n⚠️ No keys currently available! All rate-limited or disabled.');
78
+ }
79
+
80
+ return lines.join('\n');
81
+ },
82
+ }),
83
+
84
+ morph_add_key: tool({
85
+ description: 'Add a new Morph API key for rotation',
86
+ args: {
87
+ key: z.string().describe('The Morph API key (sk-...)'),
88
+ label: z.string().describe('Human-readable label for this key (e.g., account name)'),
89
+ },
90
+ execute: async (args) => {
91
+ if (!args.key.startsWith('sk-') && !args.key.startsWith('morph-')) {
92
+ return 'Invalid key format. Morph keys start with sk- or morph-';
93
+ }
94
+ const added = manager.addKey(args.key, args.label);
95
+ if (!added) {
96
+ return `Key already exists: ${maskKey(args.key)}`;
97
+ }
98
+ persistConfig();
99
+ return `Added key "${args.label}" (${maskKey(args.key)}). Total keys: ${manager.getAllStatus().length}`;
100
+ },
101
+ }),
102
+
103
+ morph_remove_key: tool({
104
+ description: 'Remove a Morph API key from rotation',
105
+ args: {
106
+ label: z.string().describe('Label of the key to remove'),
107
+ },
108
+ execute: async (args) => {
109
+ const statuses = manager.getAllStatus();
110
+ const keyToRemove = statuses.find((k) => k.label === args.label);
111
+ if (!keyToRemove) {
112
+ const available = statuses.map((k) => k.label).join(', ');
113
+ return `Key "${args.label}" not found. Available: ${available}`;
114
+ }
115
+ manager.removeKey(keyToRemove.key);
116
+ persistConfig();
117
+ return `Removed key "${args.label}". Remaining: ${manager.getAllStatus().length}`;
118
+ },
119
+ }),
120
+
121
+ morph_mark_rate_limited: tool({
122
+ description:
123
+ 'Mark a Morph API key as rate limited. Use when you receive a 429 error from Morph API.',
124
+ args: {
125
+ label: z
126
+ .string()
127
+ .optional()
128
+ .describe('Label of the key to mark. If omitted, marks the current key.'),
129
+ cooldown_seconds: z
130
+ .number()
131
+ .optional()
132
+ .describe('Cooldown in seconds. Default: 60'),
133
+ },
134
+ execute: async (args) => {
135
+ let keyToMark: string | undefined;
136
+
137
+ if (args.label) {
138
+ const found = manager.getAllStatus().find((k) => k.label === args.label);
139
+ keyToMark = found?.key;
140
+ } else {
141
+ // Mark the most recently used key (the "current" one)
142
+ const bestKey = manager.getBestKey();
143
+ keyToMark = bestKey?.key;
144
+ }
145
+
146
+ if (!keyToMark) {
147
+ return 'No key found to mark as rate limited.';
148
+ }
149
+
150
+ const cooldownMs = (args.cooldown_seconds ?? 60) * 1000;
151
+ manager.markRateLimited(keyToMark, cooldownMs);
152
+ persistConfig();
153
+
154
+ const status = manager.getKeyStatus(keyToMark);
155
+ const nextKey = manager.getBestKey();
156
+ let msg = `Marked ${maskKey(keyToMark)} as rate limited for ${args.cooldown_seconds ?? 60}s.`;
157
+ if (nextKey) {
158
+ msg += ` Rotated to: ${nextKey.label} (${maskKey(nextKey.key)})`;
159
+ } else {
160
+ msg += ' ⚠️ No more keys available!';
161
+ }
162
+ return msg;
163
+ },
164
+ }),
165
+ },
166
+ };
167
+ };
168
+
169
+ export default plugin;
@@ -0,0 +1,107 @@
1
+ import type { MorphKey, MorphKeysConfig } from './types';
2
+
3
+ export class KeyManager {
4
+ private config: MorphKeysConfig;
5
+
6
+ constructor(config: MorphKeysConfig) {
7
+ this.config = config;
8
+ }
9
+
10
+ /** Get the best available key based on strategy */
11
+ getBestKey(): MorphKey | null {
12
+ const now = Date.now();
13
+ const available = this.config.keys.filter(
14
+ (k) => k.enabled && k.rateLimitResetTime < now
15
+ );
16
+
17
+ if (available.length === 0) return null;
18
+
19
+ switch (this.config.strategy) {
20
+ case 'round-robin':
21
+ return available.sort((a, b) => a.lastUsed - b.lastUsed)[0];
22
+
23
+ case 'least-recently-used':
24
+ return available.sort((a, b) => a.lastUsed - b.lastUsed)[0];
25
+
26
+ case 'sticky':
27
+ return available.sort((a, b) => b.lastUsed - a.lastUsed)[0];
28
+
29
+ default:
30
+ return available[0];
31
+ }
32
+ }
33
+
34
+ /** Mark a key as rate limited */
35
+ markRateLimited(apiKey: string, cooldownMs?: number): void {
36
+ const key = this.findKey(apiKey);
37
+ if (!key) return;
38
+ const cooldown = cooldownMs ?? this.config.defaultCooldownMs;
39
+ key.rateLimitResetTime = Date.now() + cooldown;
40
+ key.consecutiveFailures++;
41
+ this.checkDisable(key);
42
+ }
43
+
44
+ /** Mark a key as failed (non-rate-limit failure) */
45
+ markFailed(apiKey: string): void {
46
+ const key = this.findKey(apiKey);
47
+ if (!key) return;
48
+ key.consecutiveFailures++;
49
+ this.checkDisable(key);
50
+ }
51
+
52
+ /** Mark a key as successfully used */
53
+ markSuccess(apiKey: string): void {
54
+ const key = this.findKey(apiKey);
55
+ if (!key) return;
56
+ key.consecutiveFailures = 0;
57
+ key.lastUsed = Date.now();
58
+ }
59
+
60
+ /** Get status of a specific key */
61
+ getKeyStatus(apiKey: string): MorphKey | undefined {
62
+ return this.findKey(apiKey);
63
+ }
64
+
65
+ /** Get all key statuses */
66
+ getAllStatus(): MorphKey[] {
67
+ return [...this.config.keys];
68
+ }
69
+
70
+ /** Add a new key */
71
+ addKey(apiKey: string, label: string): boolean {
72
+ if (this.findKey(apiKey)) return false;
73
+ this.config.keys.push({
74
+ key: apiKey,
75
+ label,
76
+ enabled: true,
77
+ rateLimitResetTime: 0,
78
+ consecutiveFailures: 0,
79
+ lastUsed: 0,
80
+ addedAt: Date.now(),
81
+ });
82
+ return true;
83
+ }
84
+
85
+ /** Remove a key */
86
+ removeKey(apiKey: string): boolean {
87
+ const idx = this.config.keys.findIndex((k) => k.key === apiKey);
88
+ if (idx === -1) return false;
89
+ this.config.keys.splice(idx, 1);
90
+ return true;
91
+ }
92
+
93
+ /** Get the underlying config for saving */
94
+ getConfig(): MorphKeysConfig {
95
+ return this.config;
96
+ }
97
+
98
+ private findKey(apiKey: string): MorphKey | undefined {
99
+ return this.config.keys.find((k) => k.key === apiKey);
100
+ }
101
+
102
+ private checkDisable(key: MorphKey): void {
103
+ if (key.consecutiveFailures >= this.config.maxConsecutiveFailures) {
104
+ key.enabled = false;
105
+ }
106
+ }
107
+ }
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ export interface MorphKey {
2
+ /** The API key string (sk-...) */
3
+ key: string;
4
+ /** Human-readable label for this key */
5
+ label: string;
6
+ /** Whether this key is enabled for rotation */
7
+ enabled: boolean;
8
+ /** Timestamp when rate limit resets (0 = not rate limited) */
9
+ rateLimitResetTime: number;
10
+ /** Number of consecutive failures */
11
+ consecutiveFailures: number;
12
+ /** Timestamp of last successful use */
13
+ lastUsed: number;
14
+ /** Timestamp when key was added */
15
+ addedAt: number;
16
+ }
17
+
18
+ export interface MorphKeysConfig {
19
+ keys: MorphKey[];
20
+ /** Rotation strategy: 'round-robin' | 'least-recently-used' | 'sticky' */
21
+ strategy: 'round-robin' | 'least-recently-used' | 'sticky';
22
+ /** Default cooldown in ms when rate limited without Retry-After header */
23
+ defaultCooldownMs: number;
24
+ /** Max consecutive failures before disabling a key */
25
+ maxConsecutiveFailures: number;
26
+ }
27
+
28
+ export const DEFAULT_CONFIG: MorphKeysConfig = {
29
+ keys: [],
30
+ strategy: 'least-recently-used',
31
+ defaultCooldownMs: 60_000,
32
+ maxConsecutiveFailures: 5,
33
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,37 @@
1
+ export function maskKey(key: string): string {
2
+ if (key.length <= 8) return '****';
3
+ return key.slice(0, 6) + '...' + key.slice(-4);
4
+ }
5
+
6
+ export function formatDuration(ms: number): string {
7
+ if (ms <= 0) return 'now';
8
+ const seconds = Math.floor(ms / 1000);
9
+ if (seconds < 60) return `${seconds}s`;
10
+ const minutes = Math.floor(seconds / 60);
11
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
12
+ const hours = Math.floor(minutes / 60);
13
+ return `${hours}h ${minutes % 60}m`;
14
+ }
15
+
16
+ export function progressBar(current: number, max: number, width = 20): string {
17
+ const ratio = Math.min(current / max, 1);
18
+ const filled = Math.round(ratio * width);
19
+ const empty = width - filled;
20
+ return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${Math.round(ratio * 100)}%`;
21
+ }
22
+
23
+ export function keyStatusLine(
24
+ label: string,
25
+ maskedKey: string,
26
+ status: 'active' | 'rate-limited' | 'disabled' | 'cooldown',
27
+ extra?: string
28
+ ): string {
29
+ const icons: Record<string, string> = {
30
+ active: '🟢',
31
+ 'rate-limited': '🟡',
32
+ disabled: '🔴',
33
+ cooldown: '⏳',
34
+ };
35
+ const icon = icons[status] || '⚪';
36
+ return `${icon} ${label} (${maskedKey}) — ${status}${extra ? ` ${extra}` : ''}`;
37
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, beforeEach } from 'bun:test';
2
+ import { KeyManager } from '../src/key-manager';
3
+ import type { MorphKeysConfig, MorphKey } from '../src/types';
4
+ import { DEFAULT_CONFIG } from '../src/types';
5
+
6
+ function makeKey(label: string, key = `sk-${label}`): MorphKey {
7
+ return {
8
+ key,
9
+ label,
10
+ enabled: true,
11
+ rateLimitResetTime: 0,
12
+ consecutiveFailures: 0,
13
+ lastUsed: 0,
14
+ addedAt: Date.now(),
15
+ };
16
+ }
17
+
18
+ describe('KeyManager', () => {
19
+ let manager: KeyManager;
20
+
21
+ beforeEach(() => {
22
+ const config: MorphKeysConfig = {
23
+ ...DEFAULT_CONFIG,
24
+ keys: [makeKey('key1'), makeKey('key2'), makeKey('key3')],
25
+ };
26
+ manager = new KeyManager(config);
27
+ });
28
+
29
+ it('should return a key when keys are available', () => {
30
+ const key = manager.getBestKey();
31
+ expect(key).not.toBeNull();
32
+ expect(key!.key).toStartWith('sk-');
33
+ });
34
+
35
+ it('should skip rate-limited keys', () => {
36
+ manager.markRateLimited('sk-key1', 60_000);
37
+ const key = manager.getBestKey();
38
+ expect(key).not.toBeNull();
39
+ expect(key!.key).not.toBe('sk-key1');
40
+ });
41
+
42
+ it('should return null when all keys are rate-limited', () => {
43
+ manager.markRateLimited('sk-key1', 60_000);
44
+ manager.markRateLimited('sk-key2', 60_000);
45
+ manager.markRateLimited('sk-key3', 60_000);
46
+ const key = manager.getBestKey();
47
+ expect(key).toBeNull();
48
+ });
49
+
50
+ it('should disable key after max consecutive failures', () => {
51
+ for (let i = 0; i < 5; i++) {
52
+ manager.markFailed('sk-key1');
53
+ }
54
+ const status = manager.getKeyStatus('sk-key1');
55
+ expect(status?.enabled).toBe(false);
56
+ });
57
+
58
+ it('should reset failure count on success', () => {
59
+ manager.markFailed('sk-key1');
60
+ manager.markFailed('sk-key1');
61
+ manager.markSuccess('sk-key1');
62
+ const status = manager.getKeyStatus('sk-key1');
63
+ expect(status?.consecutiveFailures).toBe(0);
64
+ });
65
+
66
+ it('should use least-recently-used strategy', () => {
67
+ // Use key1, then key2
68
+ manager.markSuccess('sk-key1');
69
+ manager.markSuccess('sk-key2');
70
+ // Next key should be key3 (never used)
71
+ const key = manager.getBestKey();
72
+ expect(key!.key).toBe('sk-key3');
73
+ });
74
+
75
+ it('should return all keys status', () => {
76
+ const statuses = manager.getAllStatus();
77
+ expect(statuses).toHaveLength(3);
78
+ });
79
+
80
+ it('should add a new key', () => {
81
+ manager.addKey('sk-new', 'new-key');
82
+ const statuses = manager.getAllStatus();
83
+ expect(statuses).toHaveLength(4);
84
+ });
85
+
86
+ it('should not add duplicate key', () => {
87
+ const added = manager.addKey('sk-key1', 'duplicate');
88
+ expect(added).toBe(false);
89
+ });
90
+
91
+ it('should remove a key', () => {
92
+ const removed = manager.removeKey('sk-key1');
93
+ expect(removed).toBe(true);
94
+ expect(manager.getAllStatus()).toHaveLength(2);
95
+ });
96
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "declaration": true,
11
+ "sourceMap": true,
12
+ "types": ["bun-types"]
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist", "tests"]
16
+ }