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 +21 -0
- package/README.md +84 -0
- package/bun.lock +25 -0
- package/package.json +21 -0
- package/src/config.ts +43 -0
- package/src/index.ts +169 -0
- package/src/key-manager.ts +107 -0
- package/src/types.ts +33 -0
- package/src/utils.ts +37 -0
- package/tests/key-manager.test.ts +96 -0
- package/tsconfig.json +16 -0
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
|
+
}
|