feat(cooldowns): expose provider/model cooldowns via CLI and HTTP

Add `routstrd cooldowns` (with --json) and `GET /cooldowns` so you can see
which providers/models the router is currently skipping. Cooldown state is
owned by the SDK ProviderManager; shared logic in src/utils/cooldowns.ts
filters expired entries, tags provider- vs model-scoped cooldowns, and
computes when each lifts (210s window). Includes bun tests and docs.
This commit is contained in:
redshift
2026-09-24 19:55:00 +05:30
parent 6970438672
commit ee6fcc479a
6 changed files with 403 additions and 0 deletions
+16
View File
@@ -163,6 +163,12 @@ Stop the daemon:
routstrd stop
```
See which providers/models the router is currently skipping (cooldowns):
```sh
routstrd cooldowns
routstrd cooldowns --json
```
### NPC (Lightning Address)
The in-process wallet registers the NPC (npubx.cash) plugin, which gives the
@@ -194,6 +200,16 @@ The daemon exposes an HTTP server (default port 8008) with the following endpoin
GET /health
```
#### Cooldowns
```
GET /cooldowns
```
Providers and models the router is currently skipping. Each entry reports its
scope (`provider` blocks every model on that provider, `model` blocks one),
when the cooldown started, and when it lifts. Expired entries are filtered out,
and the cooldown window comes from the SDK (`cooldownDurationMs`).
#### Automatic Refresh Settings
```
POST /settings/auto-refresh
+24
View File
@@ -165,6 +165,30 @@ routstrd providers enable 0 2 5
Show all known providers with their stored review events and event IDs.
### `routstrd cooldowns`
List every provider and model the router is currently skipping because of a
cooldown. Cooldowns are scoped: a provider-wide entry blocks every model on
that provider, a model-scoped entry blocks only that model. Entries lift
automatically once the cooldown window (210s) elapses — nothing needs to be
cleared by hand.
| Option | Default | Description |
|--------|---------|-------------|
| `--json` | false | Print the raw daemon response (timestamps and remaining ms per entry) |
```
Cooldowns (210s window)
2 active across 2 providers:
PROVIDER https://api.nonkycai.com/ expires in 3m 20s
MODEL https://ai.redsh1ft.com/ deepseek-v4.1-flash expires in 2m 30s
```
Reads `GET /cooldowns` from the daemon; a daemon built before this command
existed has no such endpoint and must be restarted on a newer build.
### `routstrd clients`
List and manage API clients (subcommand required).
+51
View File
@@ -10,7 +10,12 @@ import {
loadConfig,
getDaemonBaseUrl,
getUserNpub,
type CommandResponse,
} from "./utils/daemon-client";
import {
formatCooldowns,
type CooldownsOutput,
} from "./utils/cooldowns";
import { waitForDaemonToExit } from "./utils/daemon-stop";
import {
listClientsAction,
@@ -1401,6 +1406,52 @@ providersCmd
}
});
// Cooldowns - providers/models the router is currently skipping
program
.command("cooldowns")
.description(
"List providers and models currently on cooldown (temporarily skipped by the router)",
)
.option("--json", "Print the raw daemon response as JSON", false)
.action(async (options: { json: boolean }) => {
await ensureDaemonRunning();
let result: CommandResponse;
try {
result = await callDaemon("/cooldowns");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A daemon built before this command existed has no /cooldowns route:
// it either 404s or falls through to the "POST only" catch-all.
if (message.includes("404") || message === "Only POST is supported.") {
console.error(
"The running daemon has no /cooldowns endpoint, so it cannot report cooldowns. " +
"Restart it on a newer build (routstrd service restart) and try again.",
);
process.exit(1);
}
throw error;
}
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as CooldownsOutput | undefined;
if (options.json) {
console.log(JSON.stringify(output ?? {}, null, 2));
return;
}
if (!output) {
console.log("No cooldown data returned by the daemon.");
return;
}
console.log(formatCooldowns(output));
});
// Clients - list and manage clients
const clientsCmd = program
.command("clients")
+21
View File
@@ -22,6 +22,10 @@ import { receiveCashuToken } from "../wallet";
import { getClientsFromStore } from "../../utils/clients";
import { getUsageSummary } from "./usage-summary";
import { applyDefaultOutputTokenLimit } from "./request-body";
import {
buildCooldownsOutput,
type StoredCooldownEntry,
} from "../../utils/cooldowns";
// Hop-by-hop headers describe the *upstream* connection, not this one, and must
// never be copied onto our response. In particular, copying the upstream's
@@ -1655,6 +1659,23 @@ export function createDaemonRequestHandler(deps: {
return;
}
// Providers/models the router is currently skipping. Cooldown state lives
// in the SdkStore (written by the SDK's ProviderManager); entries that have
// already expired are filtered out by buildCooldownsOutput.
if (req.method === "GET" && url.pathname === "/cooldowns") {
await respond(res, async () => {
const state = deps.store.getState();
const stored: StoredCooldownEntry[] = state.providersOnCooldown || [];
return {
output: buildCooldownsOutput(
stored,
deps.providerManager.getCooldownDurationMs(),
),
};
});
return;
}
if (req.method === "GET" && url.pathname === "/providers/reviews") {
try {
const state = deps.store.getState();
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, test } from "bun:test";
import {
buildCooldownsOutput,
formatCooldownRemaining,
formatCooldowns,
type StoredCooldownEntry,
} from "./cooldowns";
const WINDOW_MS = 210 * 1000;
const NOW = 1_700_000_000_000;
function entry(
baseUrl: string,
ageMs: number,
modelId?: string,
): StoredCooldownEntry {
return modelId === undefined
? { baseUrl, timestamp: NOW - ageMs }
: { baseUrl, modelId, timestamp: NOW - ageMs };
}
describe("buildCooldownsOutput", () => {
test("is empty when nothing is stored", () => {
const output = buildCooldownsOutput([], WINDOW_MS, NOW);
expect(output.count).toBe(0);
expect(output.providerCount).toBe(0);
expect(output.cooldowns).toEqual([]);
expect(output.cooldownDurationMs).toBe(WINDOW_MS);
});
test("drops entries that already expired", () => {
const output = buildCooldownsOutput(
[
entry("https://expired.example/", WINDOW_MS),
entry("https://expired-long-ago.example/", 86_400_000),
entry("https://live.example/", 1_000),
],
WINDOW_MS,
NOW,
);
expect(output.cooldowns.map((c) => c.baseUrl)).toEqual([
"https://live.example/",
]);
});
test("tags provider-wide and model-scoped entries and computes expiry", () => {
const output = buildCooldownsOutput(
[
entry("https://provider.example/", 30_000),
entry("https://model.example/", 60_000, "kimi-k3"),
],
WINDOW_MS,
NOW,
);
expect(output.count).toBe(2);
expect(output.providerCount).toBe(2);
const [first, second] = output.cooldowns;
// Longest remaining first: the provider-wide entry aged 30s of 210s.
expect(first!).toEqual({
baseUrl: "https://provider.example/",
modelId: null,
scope: "provider",
startedAt: NOW - 30_000,
expiresAt: NOW - 30_000 + WINDOW_MS,
remainingMs: WINDOW_MS - 30_000,
});
expect(second!.scope).toBe("model");
expect(second!.modelId).toBe("kimi-k3");
expect(second!.remainingMs).toBe(WINDOW_MS - 60_000);
});
test("counts distinct providers once, even with several cooled models", () => {
const output = buildCooldownsOutput(
[
entry("https://a.example/", 1_000, "m1"),
entry("https://a.example/", 2_000, "m2"),
entry("https://a.example/", 3_000),
],
WINDOW_MS,
NOW,
);
expect(output.count).toBe(3);
expect(output.providerCount).toBe(1);
});
test("treats an empty-string modelId as model-scoped", () => {
// The SDK keys `modelId: ""` as a model-scoped entry, so it must not be
// reported as a provider-wide cooldown.
const output = buildCooldownsOutput(
[entry("https://empty.example/", 1_000, "")],
WINDOW_MS,
NOW,
);
expect(output.cooldowns[0]!.scope).toBe("model");
expect(output.cooldowns[0]!.modelId).toBe("");
});
});
describe("formatCooldownRemaining", () => {
test("formats sub-minute, minute, and clamped negative values", () => {
expect(formatCooldownRemaining(0)).toBe("0s");
expect(formatCooldownRemaining(42_000)).toBe("42s");
expect(formatCooldownRemaining(65_000)).toBe("1m 05s");
expect(formatCooldownRemaining(WINDOW_MS)).toBe("3m 30s");
expect(formatCooldownRemaining(-5_000)).toBe("0s");
});
});
describe("formatCooldowns", () => {
test("reports an empty cooldown list", () => {
expect(
formatCooldowns(buildCooldownsOutput([], WINDOW_MS, NOW)),
).toBe("Cooldowns (210s window)\n\n Nothing is on cooldown right now.");
});
test("lists provider-wide and model-scoped cooldowns with remaining time", () => {
const text = formatCooldowns(
buildCooldownsOutput(
[
{
baseUrl: "https://provider.example/",
timestamp: NOW - (WINDOW_MS - 120_000),
},
{
baseUrl: "https://model.example/",
modelId: "kimi-k3",
timestamp: NOW - (WINDOW_MS - 60_000),
},
],
WINDOW_MS,
NOW,
),
);
expect(text.split("\n")).toEqual([
"Cooldowns (210s window)",
"",
" 2 active across 2 providers:",
"",
" PROVIDER https://provider.example/ expires in 2m 00s",
" MODEL https://model.example/ kimi-k3 expires in 1m 00s",
]);
});
test("uses the singular provider label for a single provider", () => {
const text = formatCooldowns(
buildCooldownsOutput(
[{ baseUrl: "https://one.example/", timestamp: NOW - 1_000 }],
WINDOW_MS,
NOW,
),
);
expect(text).toContain("1 active across 1 provider:");
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Cooldown reporting, shared by the daemon (`GET /cooldowns`) and the
* `routstrd cooldowns` CLI command.
*
* Cooldown state is owned by the SDK: its `ProviderManager` writes
* `providersOnCooldown` into the SdkStore, and every entry blocks routing for
* the length of the cooldown window (`getCooldownDurationMs()`, 210s today).
* An entry with a `modelId` cools down only that model on the provider; an
* entry without one cools down every model on the provider.
*
* The SDK prunes expired entries lazily (on the next routing decision), so a
* read of the persisted list can contain entries that already timed out.
* Reporting therefore filters by age and never mutates the store.
*/
/** One persisted cooldown entry, as stored by the SDK. */
export interface StoredCooldownEntry {
baseUrl: string;
/** Present for model-scoped entries; absent for provider-wide ones. */
modelId?: string;
/** When the cooldown started (ms since epoch). */
timestamp: number;
}
export interface CooldownSummary {
baseUrl: string;
/** Model id for model-scoped cooldowns, `null` for provider-wide ones. */
modelId: string | null;
scope: "provider" | "model";
startedAt: number;
expiresAt: number;
remainingMs: number;
}
export interface CooldownsOutput {
/** Server time the payload was built at (ms since epoch). */
now: number;
cooldownDurationMs: number;
/** Number of active cooldown entries (provider- and model-scoped). */
count: number;
/** Number of distinct providers with at least one active entry. */
providerCount: number;
cooldowns: CooldownSummary[];
}
/**
* Build the `/cooldowns` payload: drop expired entries, tag each entry's
* scope, and compute when it lifts. Longest remaining cooldown comes first.
*/
export function buildCooldownsOutput(
entries: StoredCooldownEntry[],
cooldownDurationMs: number,
now: number = Date.now(),
): CooldownsOutput {
const cooldowns = entries
.filter((entry) => now - entry.timestamp < cooldownDurationMs)
.map((entry): CooldownSummary => {
const modelId = entry.modelId ?? null;
const expiresAt = entry.timestamp + cooldownDurationMs;
return {
baseUrl: entry.baseUrl,
modelId,
scope: modelId === null ? "provider" : "model",
startedAt: entry.timestamp,
expiresAt,
remainingMs: Math.max(0, expiresAt - now),
};
})
.sort(
(a, b) =>
b.remainingMs - a.remainingMs ||
a.baseUrl.localeCompare(b.baseUrl) ||
(a.modelId ?? "").localeCompare(b.modelId ?? ""),
);
return {
now,
cooldownDurationMs,
count: cooldowns.length,
providerCount: new Set(cooldowns.map((entry) => entry.baseUrl)).size,
cooldowns,
};
}
/** Format a remaining-time value for the terminal, e.g. `2m 05s` or `42s`. */
export function formatCooldownRemaining(ms: number): string {
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0
? `${minutes}m ${String(seconds).padStart(2, "0")}s`
: `${seconds}s`;
}
/** Render a `/cooldowns` payload for `routstrd cooldowns`. */
export function formatCooldowns(output: CooldownsOutput): string {
const windowSeconds = Math.round(output.cooldownDurationMs / 1000);
const heading = `Cooldowns (${windowSeconds}s window)`;
if (output.cooldowns.length === 0) {
return `${heading}\n\n Nothing is on cooldown right now.`;
}
const scopeCell = (entry: CooldownSummary) =>
entry.scope === "provider" ? "PROVIDER" : "MODEL";
const urlWidth = Math.max(
...output.cooldowns.map((entry) => entry.baseUrl.length),
);
const modelWidth = Math.max(
0,
...output.cooldowns.map((entry) => (entry.modelId ?? "").length),
);
const lines = [
heading,
"",
` ${output.count} active across ${output.providerCount} ${
output.providerCount === 1 ? "provider" : "providers"
}:`,
"",
];
for (const entry of output.cooldowns) {
const cells = [scopeCell(entry).padEnd("PROVIDER".length), entry.baseUrl.padEnd(urlWidth)];
if (entry.scope === "model") {
cells.push((entry.modelId ?? "").padEnd(modelWidth));
}
lines.push(
` ${cells.join(" ")} expires in ${formatCooldownRemaining(entry.remainingMs)}`,
);
}
return lines.join("\n");
}