mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user