@goodandready/dsh-key-rotation 0.7.26 → 0.7.28
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 -21
- package/README.md +157 -118
- package/cordis.patch.yml +6 -6
- package/lib/agent-budget.js +68 -68
- package/lib/bucket.js +52 -0
- package/lib/canary.js +56 -56
- package/lib/cascade.js +39 -39
- package/lib/client-helpers.js +21 -21
- package/lib/client.js +95 -5
- package/lib/concurrency.js +72 -72
- package/lib/heal.js +35 -35
- package/lib/histogram.js +66 -66
- package/lib/incident.js +76 -76
- package/lib/index.js +1658 -1402
- package/lib/keycheck.js +31 -0
- package/lib/maintenance.js +64 -0
- package/lib/pool.js +237 -227
- package/lib/quota-window.js +45 -45
- package/lib/quota.js +39 -39
- package/lib/region.js +50 -50
- package/lib/sandbox.js +117 -117
- package/lib/shadow.js +81 -81
- package/lib/usage-report.js +49 -0
- package/lib/webhook.js +133 -64
- package/package.json +58 -58
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 dsh-key-rotation contributors
|
|
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.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-key-rotation contributors
|
|
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
CHANGED
|
@@ -1,118 +1,157 @@
|
|
|
1
|
-
# dsh-key-rotation
|
|
2
|
-
|
|
3
|
-
**Per-provider API key rotation** for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh). Instead of failing on a quota/rate-limit error, the plugin transparently retries the request on the **next healthy key** in a per-provider pool.
|
|
4
|
-
|
|
5
|
-
> Hermes-style rotation: every configured provider has a key pool; when a key's limit is exhausted, the request is retried on the next key. Exhausted keys stay in cooldown and return to rotation after `cooldownMs`.
|
|
6
|
-
|
|
7
|
-
## What it does
|
|
8
|
-
|
|
9
|
-
- **Key pools per provider** — list the API keys (as credential/env names) that a provider may rotate through.
|
|
10
|
-
- **The provider you picked stays the provider** — rotation swaps the key, never the route, so a multi-call turn does not break. Legacy clone routes remain registered but are hidden from the model dropdown.
|
|
11
|
-
- **Transparent on-failure rotation** — on a switchable error (`QUOTA`, `RATE_LIMIT`, `AUTH`/`INVALID`…) the request is retried on the next key.
|
|
12
|
-
- **Cooldown** — an exhausted key is skipped for `cooldownMs`, then returns.
|
|
13
|
-
- **Dead/revoked key handling** — an auth/invalid key rotates to the next pool key instead of erroring out.
|
|
14
|
-
- **Settings GUI** — a **Settings → Key Rotation** section to manage everything without touching config files:
|
|
15
|
-
- **add a key in one place** — press *Add key*, paste the value, done. The credential name is generated for you (`<PROVIDER>_API_KEY`, then `_2`, `_3`, …) and shown only on hover; the card lists keys as *Key 1*, *Key 2*.
|
|
16
|
-
- **live key status** — per key: in use / ready / cooling down with a countdown / **no such credential**, which is what catches a mistyped name that would otherwise fail silently.
|
|
17
|
-
- **rotation counter** — how many times a provider switched key, on which failure, and how long ago.
|
|
18
|
-
- **key order** — ↑/↓ buttons; the order of keys is the order they are tried.
|
|
19
|
-
- **switch codes as checkboxes** instead of a comma-separated string.
|
|
20
|
-
- **Exponential backoff** — repeated failures on the same key double its cooldown (base → ×2 → ×4 → cap ×8), so a dead key is not retried every window.
|
|
21
|
-
- **Reset cooldown** — a *Reset cooldown* button in the card clears a provider's cooldown immediately (also via `POST /dsh-key-rotation/reset`).
|
|
22
|
-
- **Env bootstrap** — if a pool ref (e.g. `MYPROVIDER_API_KEY`) is already set in `process.env`, it is treated as a transient credential without needing a DSH credential first.
|
|
23
|
-
- **Per-provider cooldown** — override `cooldownMs` (and `maxCooldownMs`) per provider, fallback to the global values.
|
|
24
|
-
- **Exhaustion warning** — when every key is cooling, a red warning appears in the card and `lastExhaustionAt`/`exhaustionCount` are exposed via `GET /dsh-key-rotation/status`.
|
|
25
|
-
- **Failure log** — last 20 failures per provider (`at`, `ref`, `reason`, `cooldownMs`) via `/status` and a collapsible *Recent failures* list.
|
|
26
|
-
- **Non-stream safety net** — an `agent/request-error` hook retries sync calls (embeddings, batch) with the next key when the error is switchable.
|
|
27
|
-
- **Search/filter providers** — a search box above the list filters providers by id.
|
|
28
|
-
- **Bulk edit cooldown** — checkboxes per provider + a cooldown input + *Apply to selected*.
|
|
29
|
-
- **Undo delete** — after removing a key or provider, an *Undo* bar appears for 5 seconds.
|
|
30
|
-
- **Per-key last used** — `lastUsedAt` shown as "ago" next to each key.
|
|
31
|
-
- **Total requests badge** — sum of usage across a provider's keys, shown in its header.
|
|
32
|
-
- **Export single provider** — ⬇ button exports just that provider's entry.
|
|
33
|
-
- **Import from .env** — pick a `.env` file; `KEY=val` names are added to the first pool.
|
|
34
|
-
- **Copy key name** — click a *Key N* label to copy its ref name.
|
|
35
|
-
- **Sort by usage** — ⇅ sorts a provider's keys by usage (desc).
|
|
36
|
-
- **Probe history** — health-probe events appear greyed in *Recent failures*.
|
|
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
|
-
|
|
1
|
+
# dsh-key-rotation
|
|
2
|
+
|
|
3
|
+
**Per-provider API key rotation** for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh). Instead of failing on a quota/rate-limit error, the plugin transparently retries the request on the **next healthy key** in a per-provider pool.
|
|
4
|
+
|
|
5
|
+
> Hermes-style rotation: every configured provider has a key pool; when a key's limit is exhausted, the request is retried on the next key. Exhausted keys stay in cooldown and return to rotation after `cooldownMs`.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- **Key pools per provider** — list the API keys (as credential/env names) that a provider may rotate through.
|
|
10
|
+
- **The provider you picked stays the provider** — rotation swaps the key, never the route, so a multi-call turn does not break. Legacy clone routes remain registered but are hidden from the model dropdown.
|
|
11
|
+
- **Transparent on-failure rotation** — on a switchable error (`QUOTA`, `RATE_LIMIT`, `AUTH`/`INVALID`…) the request is retried on the next key.
|
|
12
|
+
- **Cooldown** — an exhausted key is skipped for `cooldownMs`, then returns.
|
|
13
|
+
- **Dead/revoked key handling** — an auth/invalid key rotates to the next pool key instead of erroring out.
|
|
14
|
+
- **Settings GUI** — a **Settings → Key Rotation** section to manage everything without touching config files:
|
|
15
|
+
- **add a key in one place** — press *Add key*, paste the value, done. The credential name is generated for you (`<PROVIDER>_API_KEY`, then `_2`, `_3`, …) and shown only on hover; the card lists keys as *Key 1*, *Key 2*.
|
|
16
|
+
- **live key status** — per key: in use / ready / cooling down with a countdown / **no such credential**, which is what catches a mistyped name that would otherwise fail silently.
|
|
17
|
+
- **rotation counter** — how many times a provider switched key, on which failure, and how long ago.
|
|
18
|
+
- **key order** — ↑/↓ buttons; the order of keys is the order they are tried.
|
|
19
|
+
- **switch codes as checkboxes** instead of a comma-separated string.
|
|
20
|
+
- **Exponential backoff** — repeated failures on the same key double its cooldown (base → ×2 → ×4 → cap ×8), so a dead key is not retried every window.
|
|
21
|
+
- **Reset cooldown** — a *Reset cooldown* button in the card clears a provider's cooldown immediately (also via `POST /dsh-key-rotation/reset`).
|
|
22
|
+
- **Env bootstrap** — if a pool ref (e.g. `MYPROVIDER_API_KEY`) is already set in `process.env`, it is treated as a transient credential without needing a DSH credential first.
|
|
23
|
+
- **Per-provider cooldown** — override `cooldownMs` (and `maxCooldownMs`) per provider, fallback to the global values.
|
|
24
|
+
- **Exhaustion warning** — when every key is cooling, a red warning appears in the card and `lastExhaustionAt`/`exhaustionCount` are exposed via `GET /dsh-key-rotation/status`.
|
|
25
|
+
- **Failure log** — last 20 failures per provider (`at`, `ref`, `reason`, `cooldownMs`) via `/status` and a collapsible *Recent failures* list.
|
|
26
|
+
- **Non-stream safety net** — an `agent/request-error` hook retries sync calls (embeddings, batch) with the next key when the error is switchable.
|
|
27
|
+
- **Search/filter providers** — a search box above the list filters providers by id.
|
|
28
|
+
- **Bulk edit cooldown** — checkboxes per provider + a cooldown input + *Apply to selected*.
|
|
29
|
+
- **Undo delete** — after removing a key or provider, an *Undo* bar appears for 5 seconds.
|
|
30
|
+
- **Per-key last used** — `lastUsedAt` shown as "ago" next to each key.
|
|
31
|
+
- **Total requests badge** — sum of usage across a provider's keys, shown in its header.
|
|
32
|
+
- **Export single provider** — ⬇ button exports just that provider's entry.
|
|
33
|
+
- **Import from .env** — pick a `.env` file; `KEY=val` names are added to the first pool.
|
|
34
|
+
- **Copy key name** — click a *Key N* label to copy its ref name.
|
|
35
|
+
- **Sort by usage** — ⇅ sorts a provider's keys by usage (desc).
|
|
36
|
+
- **Probe history** — health-probe events appear greyed in *Recent failures*.
|
|
37
|
+
- **RPM token bucket** (added in 0.7.27) — optional `rpmLimit` caps requests per minute **per key**: a key that hit the cap is skipped pre-emptively (before a real request is sent) and returns when its sliding 60s window frees up. `0` (default) disables it.
|
|
38
|
+
- **Tier/model-aware routing** (added in 0.7.27) — a pool defined for a model also serves model versions in its family: an exact model pool wins, then the longest name-prefix match (a pool for `gpt-4o` serves `gpt-4o-mini-2024`), then the provider base pool. Provider `tags` are free-form labels surfaced in `/status` for grouping.
|
|
39
|
+
- **Interactive webhooks** (added in 0.7.27) — set `webhookActionToken` and exhaustion notifications gain action buttons on Telegram (`inline_keyboard`), Discord (buttons) and Slack (actions block): *Reset cooldown* and *Pause 1h* per pool. Buttons call back to `POST /dsh-key-rotation/webhook-action` with a bearer token; actions `reset-<provider>`, `pause-<provider>` (1 h), `disable-rotation`, `enable-rotation`.
|
|
40
|
+
- **Token leak detector** (added in 0.7.27) — recognizes live key shapes (OpenAI `sk-`, Anthropic `sk-ant-`, Google `AIza…`, GitHub `ghp_…`, AWS `AKIA…`, Slack `xox…`, Telegram bot tokens, Stripe, PEM blocks). Saving the config section with a real key pasted into a wrong field is rejected (`400 secret-in-config`); the key-save response carries `looksLikeSecret`, and the card hints when the value does not look like an API key.
|
|
41
|
+
- **Header status chip** (added in 0.7.27) — a small chip in the session header shows one dot for all pools (green = all keys healthy, amber = some cooling, red = a pool fully exhausted) plus a `healthy/total` counter; polls every 4 s.
|
|
42
|
+
- **Key expiry pre-warning** (added in 0.7.28) — `expiryWarnDays` (default 7): keys expiring within that horizon send one webhook notification per key per day and show a yellow *expires in Nd* badge in the card.
|
|
43
|
+
- **Provider cost budget** (added in 0.7.28) — per-provider `costBudgetDaily` / `costBudgetWeekly` (0 = off): a warn webhook fires from 80% spend, and with `pauseOnBudget: true` the whole pool is paused for 24 h at 100%. The card shows a live budget line with warn/red coloring.
|
|
44
|
+
- **Usage report + CSV export** (added in 0.7.28) — `GET /dsh-key-rotation/usage?days=N[&provider=…][&format=csv]` returns per-key requests/cost over the window; an *Export CSV* button in the card downloads the same data for one provider.
|
|
45
|
+
- **RPM capacity indicator** (added in 0.7.28) — `/status` carries `rpm: {used, remaining, resetMs}` per key; the card shows a ⏱ counter next to the active key.
|
|
46
|
+
- **Real key test probe** (added in 0.7.28) — the per-key *Test* button now sends `probe=models` through the existing `/test` route, so it validates the key against the live API (models list + latency), not just credential presence.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# From npm after publishing:
|
|
52
|
+
dsh plugin --profile web add @goodandready/dsh-key-rotation
|
|
53
|
+
|
|
54
|
+
# From GitHub:
|
|
55
|
+
dsh plugin --profile web add github:GooDAnDReaDY/dsh-key-rotation
|
|
56
|
+
|
|
57
|
+
# Locally from a checkout:
|
|
58
|
+
dsh plugin --profile web add /path/to/dsh-key-rotation
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Restart the Web UI afterwards.
|
|
62
|
+
|
|
63
|
+
## Configure
|
|
64
|
+
|
|
65
|
+
### Web GUI (recommended)
|
|
66
|
+
|
|
67
|
+
Open **Settings → Key Rotation** and, for each provider, list the credential names of its keys. The plugin stores this in the `dsh-key-rotation` settings namespace (same place as `settings.yaml`).
|
|
68
|
+
|
|
69
|
+
### `settings.yaml`
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
dsh-key-rotation:
|
|
73
|
+
switchCodes: [QUOTA, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT, EMPTY_RESPONSE, UNKNOWN_MODEL]
|
|
74
|
+
cooldownMs: 60000
|
|
75
|
+
providers:
|
|
76
|
+
# `provider` is the id of a provider registered with dsh, as it appears
|
|
77
|
+
# in Settings -> Models. `keys` are CREDENTIAL NAMES, never key values.
|
|
78
|
+
- provider: my-provider
|
|
79
|
+
keys: [MY_PROVIDER_API_KEY, MY_PROVIDER_API_KEY_2, MY_PROVIDER_API_KEY_3]
|
|
80
|
+
- provider: another-provider
|
|
81
|
+
keys: [ANOTHER_PROVIDER_API_KEY, ANOTHER_PROVIDER_API_KEY_2]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| Field | Default | Description |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `switchCodes` | `[QUOTA, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT, EMPTY_RESPONSE, UNKNOWN_MODEL]` | Error codes that trigger a key switch. |
|
|
87
|
+
| `cooldownMs` | `60000` | How long an exhausted key stays out of rotation. |
|
|
88
|
+
| `providers` | — | `[{ provider, keys: [envName, ...] }]`. `keys` are credential/env **names**, not the key values themselves. |
|
|
89
|
+
| `rpmLimit` | `0` | Requests-per-minute cap **per key** (0 = off). A capped key is skipped pre-emptively until its 60 s window frees up. |
|
|
90
|
+
| `webhookActionToken` | `''` | Bearer token for the interactive webhook callback route. When set, exhaustion webhooks carry action buttons; empty disables them. |
|
|
91
|
+
| `expiryWarnDays` | `7` | Pre-warning horizon (days) for keys with `expiresAt`: webhook + card badge. |
|
|
92
|
+
| `providers[].costBudgetDaily` / `.costBudgetWeekly` | `0` | Daily / weekly spend budget per provider (0 = off). Warn webhook from 80%. |
|
|
93
|
+
| `providers[].pauseOnBudget` | `false` | Pause the whole pool for 24 h when a budget is exceeded. |
|
|
94
|
+
| `providers[].tags` | `[]` | Free-form labels for a provider pool, surfaced in `GET /status`. |
|
|
95
|
+
|
|
96
|
+
### Interactive webhook actions (added in 0.7.27)
|
|
97
|
+
|
|
98
|
+
When `webhookActionToken` is set, the exhaustion webhook payload is formatted per platform (Telegram / Discord / Slack, detected from the URL) with two buttons. A button POSTs to the plugin route:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
curl -X POST http://127.0.0.1:3080/dsh-key-rotation/webhook-action \
|
|
102
|
+
-H "Authorization: Bearer <webhookActionToken>" \
|
|
103
|
+
-H "content-type: application/json" \
|
|
104
|
+
-d '{"action": "reset-my-provider"}'
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Actions: `reset-<provider>` (clear cooldowns), `pause-<provider>` (pause the whole pool for 1 hour), `disable-rotation` / `enable-rotation` (global). Platform callback payloads (`callback_data`, Discord `custom_id`, Slack button `value`) are accepted too. Without the correct bearer token the route answers `401`; without a configured token — `503`.
|
|
108
|
+
|
|
109
|
+
### Usage report (added in 0.7.28)
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
curl "http://127.0.0.1:3080/dsh-key-rotation/usage?days=7" # JSON
|
|
113
|
+
curl "http://127.0.0.1:3080/dsh-key-rotation/usage?format=csv&days=30&provider=my-provider" > usage.csv
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Per-key rows: `requests`, `cost`, `active`, and per-day counts over the window (1–90 days, default 7).
|
|
117
|
+
|
|
118
|
+
### How keys are stored
|
|
119
|
+
|
|
120
|
+
The plugin config only ever references keys by **name** (e.g. `MY_PROVIDER_API_KEY`). The values live in the dsh **Credentials** service or `$DSH_HOME/.credentials.yaml` — never in the plugin config.
|
|
121
|
+
|
|
122
|
+
A key typed into the Key Rotation card is written to that same credentials store: the value travels to the host once and is never sent back to the browser. Only its **last 5 characters** are, so two keys can be told apart in the UI. A key supplied by the launching environment is shown as read-only, because overwriting it here would be shadowed anyway.
|
|
123
|
+
|
|
124
|
+
## How it works
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
request ──► {provider: rotation} clone route ──► pick next healthy key in pool
|
|
128
|
+
┌────────┐ on switchable failure retry with next key, stay in cooldown
|
|
129
|
+
└─────────┘
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- The plugin patches `ctx.credentials.resolve` so a pool reference resolves to the current healthy key (round-robin, skipping keys in cooldown).
|
|
133
|
+
- It intercepts `llm/stream` to retry the request on the next key after a switchable failure, instead of surfacing the error to the caller. The hook is deliberately **not** `async`: the loop iterates its result directly, and returning a promise breaks every turn.
|
|
134
|
+
- The provider identity never changes — only the resolved key does — which keeps the adapter's replay state consistent across a multi-call turn.
|
|
135
|
+
|
|
136
|
+
Two local-only routes back the card: `GET /dsh-key-rotation/status` (key state, rotation counters, last 5 characters of each key) and `PUT|DELETE /dsh-key-rotation/key` (store or drop one key value). Both refuse anything that is not a same-origin request from loopback.
|
|
137
|
+
|
|
138
|
+
## Structure
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
dsh-key-rotation/
|
|
142
|
+
├── package.json # dsh bundle/plugin metadata + peerDependencies
|
|
143
|
+
├── cordis.patch.yml # bundle layer: registers the virtual route "rotation"
|
|
144
|
+
├── lib/index.js # host: pools, credentials.resolve patch, stream retry
|
|
145
|
+
├── lib/client.js # browser: Settings → Key Rotation panel
|
|
146
|
+
└── README.md
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Security notes
|
|
150
|
+
|
|
151
|
+
- Key **values** never leave your Credentials store; the plugin config only holds env/credential **names**.
|
|
152
|
+
- `switchCodes` are error classification strings, not expressions — no secrets involved.
|
|
153
|
+
- The leak detector (0.7.27) rejects config saves that carry a live-looking key outside the credential fields; only the credential **type** and a short preview are ever reported, never the value.
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
package/cordis.patch.yml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
# dsh-key-rotation bundle layer.
|
|
2
|
-
# Registers the virtual provider route "rotation"; the adapter delegates each
|
|
3
|
-
# request to the configured clone routes of the matching backend and rotates
|
|
4
|
-
# them on switchable failures (QUOTA / RATE_LIMIT / ...).
|
|
5
|
-
- insert:
|
|
6
|
-
- id: dsh-key-rotation
|
|
1
|
+
# dsh-key-rotation bundle layer.
|
|
2
|
+
# Registers the virtual provider route "rotation"; the adapter delegates each
|
|
3
|
+
# request to the configured clone routes of the matching backend and rotates
|
|
4
|
+
# them on switchable failures (QUOTA / RATE_LIMIT / ...).
|
|
5
|
+
- insert:
|
|
6
|
+
- id: dsh-key-rotation
|
|
7
7
|
name: '@goodandready/dsh-key-rotation'
|
package/lib/agent-budget.js
CHANGED
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
// lib/agent-budget.js — per-agent rate cap.
|
|
2
|
-
// ponytail: in-memory counter per (agent, window), thread-safe-ish via timer map.
|
|
3
|
-
|
|
4
|
-
export const AGENT_BUDGET_DEFAULT_WINDOW_MS = 3600_000; // 1h
|
|
5
|
-
export const AGENT_BUDGET_DEFAULT_LIMIT = 0; // 0 = disabled
|
|
6
|
-
export const AGENT_BUDGET_MAX = 50000; // hard ceiling per agent
|
|
7
|
-
|
|
8
|
-
export class AgentBudget {
|
|
9
|
-
constructor({ windowMs = AGENT_BUDGET_DEFAULT_WINDOW_MS, limit = AGENT_BUDGET_DEFAULT_LIMIT } = {}) {
|
|
10
|
-
const w = Number.isFinite(windowMs) && windowMs > 0 ? Math.floor(windowMs) : AGENT_BUDGET_DEFAULT_WINDOW_MS;
|
|
11
|
-
const l = Number.isFinite(limit) && limit >= 0 ? Math.min(AGENT_BUDGET_MAX, Math.floor(limit)) : 0;
|
|
12
|
-
this._windowMs = w;
|
|
13
|
-
this._limit = l;
|
|
14
|
-
this._state = new Map(); // agent -> { hits: number[], windowStart: epochMs }
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
isEnabled() {
|
|
18
|
-
return this._limit > 0;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Decide if request from this agent is allowed. Returns { allowed, remaining, resetAt }.
|
|
22
|
-
// Records the hit only when allowed.
|
|
23
|
-
check(agentId, now = Date.now()) {
|
|
24
|
-
if (!this.isEnabled()) return { allowed: true, remaining: Infinity, resetAt: null };
|
|
25
|
-
if (!agentId || typeof agentId !== 'string') return { allowed: false, remaining: 0, resetAt: now };
|
|
26
|
-
let s = this._state.get(agentId);
|
|
27
|
-
if (!s) {
|
|
28
|
-
s = { hits: [], windowStart: now };
|
|
29
|
-
this._state.set(agentId, s);
|
|
30
|
-
}
|
|
31
|
-
// Window: prune hits older than windowStart + windowMs
|
|
32
|
-
const cutoff = now - this._windowMs;
|
|
33
|
-
while (s.hits.length > 0 && s.hits[0] < cutoff) s.hits.shift();
|
|
34
|
-
s.windowStart = s.hits.length ? s.hits[0] : now;
|
|
35
|
-
if (s.hits.length >= this._limit) {
|
|
36
|
-
const resetAt = s.hits[0] + this._windowMs;
|
|
37
|
-
return { allowed: false, remaining: 0, resetAt };
|
|
38
|
-
}
|
|
39
|
-
s.hits.push(now);
|
|
40
|
-
return { allowed: true, remaining: this._limit - s.hits.length, resetAt: now + this._windowMs };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Reset single agent or all
|
|
44
|
-
reset(agentId) {
|
|
45
|
-
if (agentId) this._state.delete(agentId);
|
|
46
|
-
else this._state.clear();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Inspect-only: return remaining without recording.
|
|
50
|
-
peek(agentId, now = Date.now()) {
|
|
51
|
-
if (!this.isEnabled()) return { remaining: Infinity, resetAt: null };
|
|
52
|
-
const s = this._state.get(agentId);
|
|
53
|
-
if (!s) return { remaining: this._limit, resetAt: null };
|
|
54
|
-
const cutoff = now - this._windowMs;
|
|
55
|
-
let count = 0;
|
|
56
|
-
for (let i = 0; i < s.hits.length; i++) {
|
|
57
|
-
if (s.hits[i] >= cutoff) count += 1;
|
|
58
|
-
}
|
|
59
|
-
const oldest = s.hits[0];
|
|
60
|
-
return { remaining: this._limit - count, resetAt: oldest ? oldest + this._windowMs : null };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
snapshot() {
|
|
64
|
-
const out = {};
|
|
65
|
-
for (const [k, v] of this._state) out[k] = { hits: v.hits.length };
|
|
66
|
-
return out;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
1
|
+
// lib/agent-budget.js — per-agent rate cap.
|
|
2
|
+
// ponytail: in-memory counter per (agent, window), thread-safe-ish via timer map.
|
|
3
|
+
|
|
4
|
+
export const AGENT_BUDGET_DEFAULT_WINDOW_MS = 3600_000; // 1h
|
|
5
|
+
export const AGENT_BUDGET_DEFAULT_LIMIT = 0; // 0 = disabled
|
|
6
|
+
export const AGENT_BUDGET_MAX = 50000; // hard ceiling per agent
|
|
7
|
+
|
|
8
|
+
export class AgentBudget {
|
|
9
|
+
constructor({ windowMs = AGENT_BUDGET_DEFAULT_WINDOW_MS, limit = AGENT_BUDGET_DEFAULT_LIMIT } = {}) {
|
|
10
|
+
const w = Number.isFinite(windowMs) && windowMs > 0 ? Math.floor(windowMs) : AGENT_BUDGET_DEFAULT_WINDOW_MS;
|
|
11
|
+
const l = Number.isFinite(limit) && limit >= 0 ? Math.min(AGENT_BUDGET_MAX, Math.floor(limit)) : 0;
|
|
12
|
+
this._windowMs = w;
|
|
13
|
+
this._limit = l;
|
|
14
|
+
this._state = new Map(); // agent -> { hits: number[], windowStart: epochMs }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
isEnabled() {
|
|
18
|
+
return this._limit > 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Decide if request from this agent is allowed. Returns { allowed, remaining, resetAt }.
|
|
22
|
+
// Records the hit only when allowed.
|
|
23
|
+
check(agentId, now = Date.now()) {
|
|
24
|
+
if (!this.isEnabled()) return { allowed: true, remaining: Infinity, resetAt: null };
|
|
25
|
+
if (!agentId || typeof agentId !== 'string') return { allowed: false, remaining: 0, resetAt: now };
|
|
26
|
+
let s = this._state.get(agentId);
|
|
27
|
+
if (!s) {
|
|
28
|
+
s = { hits: [], windowStart: now };
|
|
29
|
+
this._state.set(agentId, s);
|
|
30
|
+
}
|
|
31
|
+
// Window: prune hits older than windowStart + windowMs
|
|
32
|
+
const cutoff = now - this._windowMs;
|
|
33
|
+
while (s.hits.length > 0 && s.hits[0] < cutoff) s.hits.shift();
|
|
34
|
+
s.windowStart = s.hits.length ? s.hits[0] : now;
|
|
35
|
+
if (s.hits.length >= this._limit) {
|
|
36
|
+
const resetAt = s.hits[0] + this._windowMs;
|
|
37
|
+
return { allowed: false, remaining: 0, resetAt };
|
|
38
|
+
}
|
|
39
|
+
s.hits.push(now);
|
|
40
|
+
return { allowed: true, remaining: this._limit - s.hits.length, resetAt: now + this._windowMs };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Reset single agent or all
|
|
44
|
+
reset(agentId) {
|
|
45
|
+
if (agentId) this._state.delete(agentId);
|
|
46
|
+
else this._state.clear();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Inspect-only: return remaining without recording.
|
|
50
|
+
peek(agentId, now = Date.now()) {
|
|
51
|
+
if (!this.isEnabled()) return { remaining: Infinity, resetAt: null };
|
|
52
|
+
const s = this._state.get(agentId);
|
|
53
|
+
if (!s) return { remaining: this._limit, resetAt: null };
|
|
54
|
+
const cutoff = now - this._windowMs;
|
|
55
|
+
let count = 0;
|
|
56
|
+
for (let i = 0; i < s.hits.length; i++) {
|
|
57
|
+
if (s.hits[i] >= cutoff) count += 1;
|
|
58
|
+
}
|
|
59
|
+
const oldest = s.hits[0];
|
|
60
|
+
return { remaining: this._limit - count, resetAt: oldest ? oldest + this._windowMs : null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
snapshot() {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [k, v] of this._state) out[k] = { hits: v.hits.length };
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/bucket.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// lib/bucket.js - per-key RPM token bucket (#192).
|
|
2
|
+
// ponytail: RPM only - true token counts are not visible before the stream
|
|
3
|
+
// starts; wire TPM in when pi-ai surfaces usage on the resolve path.
|
|
4
|
+
const WINDOW_MS = 60000;
|
|
5
|
+
|
|
6
|
+
/** Sliding-window check: true if `ref` is under `limit` requests/min. */
|
|
7
|
+
export function bucketAllow(windows, ref, limit, now = Date.now()) {
|
|
8
|
+
if (!limit || limit <= 0) return true;
|
|
9
|
+
const cut = now - WINDOW_MS;
|
|
10
|
+
const hits = (windows.get(ref) ?? []).filter((t) => t > cut);
|
|
11
|
+
if (hits.length >= limit) {
|
|
12
|
+
windows.set(ref, hits);
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
hits.push(now);
|
|
16
|
+
windows.set(ref, hits);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Record a hit without checking (use after a successful resolve). */
|
|
21
|
+
export function bucketHit(windows, ref, now = Date.now()) {
|
|
22
|
+
const cut = now - WINDOW_MS;
|
|
23
|
+
const hits = (windows.get(ref) ?? []).filter((t) => t > cut);
|
|
24
|
+
hits.push(now);
|
|
25
|
+
windows.set(ref, hits);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** ms until `ref` may retry again (0 = now). */
|
|
29
|
+
export function bucketRetryMs(windows, ref, limit, now = Date.now()) {
|
|
30
|
+
if (!limit || limit <= 0) return 0;
|
|
31
|
+
const hits = (windows.get(ref) ?? []).filter((t) => t > now - WINDOW_MS);
|
|
32
|
+
if (hits.length < limit) return 0;
|
|
33
|
+
return Math.max(0, hits[0] + WINDOW_MS - now);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Drop state for refs that no longer exist. */
|
|
37
|
+
export function bucketSweep(windows, liveRefs) {
|
|
38
|
+
for (const ref of [...windows.keys()]) {
|
|
39
|
+
if (!liveRefs.has(ref)) windows.delete(ref);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** #210: snapshot for /status - used/remaining/resetMs for one ref. */
|
|
44
|
+
export function bucketInfo(windows, ref, limit, now = Date.now()) {
|
|
45
|
+
if (!limit || limit <= 0) return null;
|
|
46
|
+
const hits = (windows?.get(ref) ?? []).filter((t) => t > now - WINDOW_MS);
|
|
47
|
+
return {
|
|
48
|
+
used: hits.length,
|
|
49
|
+
remaining: Math.max(0, limit - hits.length),
|
|
50
|
+
resetMs: hits.length ? Math.max(0, hits[0] + WINDOW_MS - now) : 0,
|
|
51
|
+
};
|
|
52
|
+
}
|