diff --git a/README.md b/README.md index 1f6896b..59096b3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SKILL.md b/SKILL.md index a2d807a..0428855 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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). diff --git a/src/cli.ts b/src/cli.ts index 1d0d2a8..c25f35e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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") diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 9991e5d..b6fca34 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -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(); diff --git a/src/utils/cooldowns.test.ts b/src/utils/cooldowns.test.ts new file mode 100644 index 0000000..a3960e4 --- /dev/null +++ b/src/utils/cooldowns.test.ts @@ -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:"); + }); +}); diff --git a/src/utils/cooldowns.ts b/src/utils/cooldowns.ts new file mode 100644 index 0000000..5bb0212 --- /dev/null +++ b/src/utils/cooldowns.ts @@ -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"); +}