feat(clients): add --manual-refresh and scheduled-refresh toggle (#97)

`routstrd clients` only exposed list/delete/add, so operators had no way to
refresh client integrations on demand or to stop the daemon from rewriting
their client configs every 21 minutes.

- clients --manual-refresh: refresh routstr21 models from Nostr and re-run
  every registered client integration now. The old `routstrd refresh` body
  moves into a shared refreshModelsAndClientsAction() so both commands stay
  in sync.
- clients --disable-automatic-refresh / --enable-automatic-refresh: toggle
  the daemon's scheduled refresh job via a new POST /settings/auto-refresh
  endpoint, so the toggle also works against a remote daemon where the config
  lives on the host. Persisted as autoRefresh.enabled in config.json.
- The daemon refresh job now re-reads autoRefresh on every tick (like the NWC
  auto-refill getter), so toggling takes effect without a restart. While
  disabled it polls once a minute so re-enabling applies promptly.
- Startup still fetches models (the proxy needs them) but skips the client
  integration pass when the job is disabled, so a restart cannot overwrite
  hand-edited client configs.

Adds tests/daemon/auto-refresh.* covering the endpoint contract and the
persisted flag.

Co-authored-by: redshift <213178690+1ftredsh@users.noreply.github.com>
This commit is contained in:
redshift
2026-09-08 11:34:13 +00:00
committed by GitHub
co-authored by redshift
parent cfa76f597b
commit be374601d8
9 changed files with 438 additions and 53 deletions
+34 -1
View File
@@ -103,6 +103,17 @@ Test connection:
routstrd ping
```
Refresh models and client integrations on demand:
```sh
routstrd clients --manual-refresh # same as `routstrd refresh`
```
Turn the daemon's scheduled refresh on or off (no restart needed):
```sh
routstrd clients --disable-automatic-refresh
routstrd clients --enable-automatic-refresh
```
Stop the daemon:
```sh
routstrd stop
@@ -139,6 +150,20 @@ The daemon exposes an HTTP server (default port 8008) with the following endpoin
GET /health
```
#### Automatic Refresh Settings
```
POST /settings/auto-refresh
```
Request body:
```json
{ "enabled": false }
```
Enables or disables the scheduled refresh job. Persisted to the daemon's
`config.json` as `autoRefresh.enabled` and picked up on the next tick, so no
daemon restart is required.
#### Route Request
```
POST /
@@ -186,10 +211,18 @@ Configuration is stored in `~/.routstrd/config.json`:
"port": 8008,
"host": "127.0.0.1",
"provider": null,
"cocodPath": null
"cocodPath": null,
"autoRefresh": { "enabled": true }
}
```
`autoRefresh.enabled` (default `true`) controls the daemon's scheduled refresh
job, which re-fetches Nostr events, routstr21 models, and client integrations
every 21 minutes. Set it to `false` (or run
`routstrd clients --disable-automatic-refresh`) to turn the schedule off and
refresh manually with `routstrd clients --manual-refresh`. `autoRefresh.intervalMs`
overrides the 21-minute interval.
### Environment Variables
- `ROUTSTRD_DIR` - Config directory (default: `~/.routstrd`)
+17 -1
View File
@@ -107,6 +107,20 @@ routstrd providers enable 0 2 5
List and manage API clients (subcommand required).
| Option | Description |
|--------|-------------|
| `--manual-refresh` | Refresh routstr21 models and all client integrations now |
| `--disable-automatic-refresh` | Disable the daemon's scheduled refresh job |
| `--enable-automatic-refresh` | Re-enable the daemon's scheduled refresh job |
The daemon refreshes models and client integrations on a schedule (every 21 minutes by default). Use `--manual-refresh` to do it on demand, and `--disable-automatic-refresh` to stop the scheduled job — the setting is stored in the daemon's `config.json` (`autoRefresh.enabled`) and takes effect without a restart.
```sh
routstrd clients --manual-refresh # refresh models + integrations now
routstrd clients --disable-automatic-refresh # no scheduled refresh
routstrd clients --enable-automatic-refresh # scheduled refresh back on
```
#### `routstrd clients list`
List all registered clients with their ID, name, API key, and creation date.
@@ -157,7 +171,7 @@ routstrd remote https://your-remote-daemon.com
### `routstrd refresh`
Refresh routstr21 models from Nostr and re-run integrations for all registered clients.
Refresh routstr21 models from Nostr and re-run integrations for all registered clients. Equivalent to `routstrd clients --manual-refresh`.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
@@ -167,6 +181,7 @@ Refresh routstr21 models from Nostr and re-run integrations for all registered c
| `nsec` | string\|null | null | Nostr secret key for NIP-98 auth |
| `cocodPath` | string\|null | null | Custom path to cocod executable |
| `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) |
| `autoRefresh` | object | `{ enabled: true }` | Scheduled refresh job settings (`enabled`, `intervalMs`) |
| Variable | Default | Description |
|----------|---------|-------------|
@@ -293,6 +308,7 @@ Config file: `~/.routstrd/config.json`
| `provider` | string\|null | null | Default provider URL |
| `cocodPath` | string\|null | null | Custom path to cocod executable |
| `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) |
| `autoRefresh` | object | `{ enabled: true }` | Scheduled refresh job settings (`enabled`, `intervalMs`) |
### Environment Variables
+52 -23
View File
@@ -15,6 +15,8 @@ import {
listClientsAction,
deleteClientAction,
addClientAction,
refreshModelsAndClientsAction,
setAutomaticRefreshAction,
} from "./utils/clients";
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
import { execSync } from "child_process";
@@ -28,7 +30,7 @@ import {
type RoutstrdConfig,
} from "./utils/config";
import { COCO_LOGS_DIR, logger } from "./utils/logger";
import { setupIntegration, runIntegrationsForClients, type IntegrationKey } from "./integrations";
import { setupIntegration, type IntegrationKey } from "./integrations";
import {
assertLegacyCocodNotRunning,
claimLegacyCocodPidFile,
@@ -48,7 +50,6 @@ import {
walletDir as defaultWalletDir,
walletPidPath,
} from "./daemon/wallet/paths";
import { getClientsList } from "./utils/clients";
import * as QRCode from "qrcode";
import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98";
import { generateSecretKey, nip19 } from "nostr-tools";
@@ -858,26 +859,7 @@ program
.description("Refresh routstr21 models and client integrations")
.action(async () => {
await ensureDaemonRunning();
const config = await loadConfig();
// Refresh models via daemon API
console.log("Refreshing routstr21 models...");
const result = await callDaemon("/v1/models?refresh=true");
if (result.error) {
console.log(`Model refresh failed: ${result.error}`);
process.exit(1);
}
console.log("Models refreshed.");
// Refresh integrations for all clients
const clients = await getClientsList();
if (clients.length > 0) {
console.log(`Refreshing ${clients.length} client integration(s)...`);
await runIntegrationsForClients(clients, config);
console.log("Client integrations refreshed.");
} else {
console.log("No clients to refresh.");
}
await refreshModelsAndClientsAction();
});
// Models - list routstr21 models
@@ -1256,7 +1238,54 @@ providersCmd
// Clients - list and manage clients
const clientsCmd = program
.command("clients")
.description("List and manage clients");
.description("List and manage clients")
.option(
"--manual-refresh",
"Refresh routstr21 models and all client integrations now",
false,
)
.option(
"--disable-automatic-refresh",
"Disable the daemon's scheduled refresh job",
false,
)
.option(
"--enable-automatic-refresh",
"Re-enable the daemon's scheduled refresh job",
false,
)
.action(
async (options: {
manualRefresh: boolean;
disableAutomaticRefresh: boolean;
enableAutomaticRefresh: boolean;
}) => {
if (options.disableAutomaticRefresh && options.enableAutomaticRefresh) {
console.error(
"error: --disable-automatic-refresh and --enable-automatic-refresh are mutually exclusive.",
);
process.exit(1);
}
if (
!options.manualRefresh &&
!options.disableAutomaticRefresh &&
!options.enableAutomaticRefresh
) {
clientsCmd.help({ error: true });
return;
}
if (options.manualRefresh) {
await ensureDaemonRunning();
await refreshModelsAndClientsAction();
}
if (options.disableAutomaticRefresh || options.enableAutomaticRefresh) {
await setAutomaticRefreshAction(options.enableAutomaticRefresh);
}
},
);
clientsCmd
.command("list")
+21
View File
@@ -785,6 +785,27 @@ export function createDaemonRequestHandler(deps: {
return;
}
// Scheduled refresh job (Nostr events, models, client integrations).
// The job re-reads this on every tick, so no daemon restart is needed.
if (req.method === "POST" && url.pathname === "/settings/auto-refresh") {
await respond(res, async () => {
const body = await readJsonBody(req);
const enabled = body.enabled === true || body.enabled === "true";
const config = await loadDaemonConfig();
config.autoRefresh = { ...config.autoRefresh, enabled };
saveDaemonConfig(config);
return {
output: {
message: `Automatic refresh ${enabled ? "enabled" : "disabled"}.`,
autoRefresh: config.autoRefresh,
},
};
});
return;
}
if (req.method === "GET" && url.pathname === "/models") {
try {
const forceRefresh =
+89 -27
View File
@@ -235,39 +235,94 @@ async function main(): Promise<void> {
// Ignore
}
const REFRESH_INTERVAL_MS = 21 * 60 * 1000; // 21 mins
const DEFAULT_REFRESH_INTERVAL_MS = 21 * 60 * 1000; // 21 mins
// While the job is disabled we keep a light poll running so `clients
// --enable-automatic-refresh` takes effect without a daemon restart.
const DISABLED_REFRESH_POLL_MS = 60 * 1000;
/**
* Read autoRefresh from disk on every tick (like the NWC auto-refill
* getter) so CLI/config changes apply immediately without a restart.
*/
const readAutoRefreshSettings = (): {
enabled: boolean;
intervalMs: number;
} => {
const autoRefresh = loadDaemonConfigSync().autoRefresh;
const intervalMs =
typeof autoRefresh?.intervalMs === "number" && autoRefresh.intervalMs > 0
? autoRefresh.intervalMs
: DEFAULT_REFRESH_INTERVAL_MS;
return { enabled: autoRefresh?.enabled !== false, intervalMs };
};
// Recurring job to refresh routstr21 models
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
let refreshJobActive = false;
let disabledNoticeLogged = false;
const runScheduledRefresh = async (): Promise<void> => {
logger.log("Running scheduled Nostr event refresh...");
try {
await modelManager.refreshNostrEvents();
} catch (error) {
logger.error("Scheduled Nostr event refresh failed:", error);
}
logger.log("Running scheduled model refresh...");
try {
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled");
} catch (error) {
logger.error("Scheduled model refresh failed:", error);
}
};
const scheduleNextRefresh = (): void => {
if (!refreshJobActive) return;
const { enabled, intervalMs } = readAutoRefreshSettings();
if (!enabled) {
if (!disabledNoticeLogged) {
logger.log(
"Scheduled refresh job is disabled (autoRefresh.enabled=false). Polling for re-enable every 60s.",
);
disabledNoticeLogged = true;
}
refreshTimer = setTimeout(
scheduleNextRefresh,
Math.min(intervalMs, DISABLED_REFRESH_POLL_MS),
);
return;
}
if (disabledNoticeLogged) {
logger.log("Scheduled refresh job re-enabled.");
disabledNoticeLogged = false;
}
refreshTimer = setTimeout(() => {
void runScheduledRefresh()
.catch((error) => logger.error("Model refresh interval escaped:", error))
.finally(() => scheduleNextRefresh());
}, intervalMs);
};
const startModelRefreshJob = () => {
refreshJobActive = true;
const { enabled, intervalMs } = readAutoRefreshSettings();
logger.log(
`Starting recurring model refresh job (every ${REFRESH_INTERVAL_MS / 1000 / 60 / 60} hours)`,
enabled
? `Starting recurring model refresh job (every ${Math.round(intervalMs / 60_000)} minutes)`
: "Recurring model refresh job is disabled (autoRefresh.enabled=false).",
);
refreshInterval = setInterval(() => {
(async () => {
logger.log("Running scheduled Nostr event refresh...");
try {
await modelManager.refreshNostrEvents();
} catch (error) {
logger.error("Scheduled Nostr event refresh failed:", error);
}
logger.log("Running scheduled model refresh...");
try {
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled");
} catch (error) {
logger.error("Scheduled model refresh failed:", error);
}
})().catch((error) => logger.error("Model refresh interval escaped:", error));
}, REFRESH_INTERVAL_MS);
scheduleNextRefresh();
};
const stopModelRefreshJob = () => {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
refreshJobActive = false;
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
logger.log("Stopped recurring model refresh job.");
}
};
@@ -381,9 +436,16 @@ async function main(): Promise<void> {
startModelRefreshJob();
startRefundJob();
// Run an immediate refresh to populate models right away
logger.log("Running initial model refresh...");
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Initial");
// Run an immediate refresh to populate models right away. Client
// integrations are skipped when the scheduled job is disabled, so a
// restart does not overwrite hand-edited client configs.
if (readAutoRefreshSettings().enabled) {
logger.log("Running initial model refresh...");
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Initial");
} else {
logger.log("Running initial model refresh (client integrations skipped)...");
await getRoutstr21Models(true);
}
})
.catch((error) => {
logger.error("Initial model refresh failed:", error);
+74 -1
View File
@@ -6,7 +6,11 @@ import {
ensureDaemonRunning,
} from "./daemon-client";
import { logger } from "./logger";
import { CLIENT_INTEGRATIONS, CLIENT_CONFIGS } from "../integrations/registry";
import {
CLIENT_INTEGRATIONS,
CLIENT_CONFIGS,
runIntegrationsForClients,
} from "../integrations/registry";
export interface ClientEntry {
clientId: string;
@@ -125,6 +129,75 @@ export async function addDaemonClient(
return { message: output.message, client: output.client, created: true };
}
/**
* Refresh routstr21 models from Nostr, then re-run every registered client
* integration so local client configs pick up new models and API keys.
*
* Shared by `routstrd refresh` and `routstrd clients --manual-refresh` — the
* same work the daemon performs on its scheduled refresh.
*/
export async function refreshModelsAndClientsAction(): Promise<void> {
const config = await loadConfig();
console.log("Refreshing routstr21 models...");
const result = await callDaemon("/v1/models?refresh=true");
if (result.error) {
console.log(`Model refresh failed: ${result.error}`);
process.exit(1);
}
console.log("Models refreshed.");
const clients = await getClientsList();
if (clients.length === 0) {
console.log("No clients to refresh.");
return;
}
console.log(`Refreshing ${clients.length} client integration(s)...`);
await runIntegrationsForClients(clients, config);
console.log("Client integrations refreshed.");
}
/**
* Toggle the daemon's scheduled refresh job. Routed through the daemon so it
* also works against a remote daemon, where the config lives on the host.
*/
export async function setAutomaticRefreshAction(
enabled: boolean,
): Promise<void> {
await ensureDaemonRunning();
const result = await callDaemon("/settings/auto-refresh", {
method: "POST",
body: { enabled },
});
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as { message?: string } | undefined;
console.log(
output?.message ?? `Automatic refresh ${enabled ? "enabled" : "disabled"}.`,
);
if (enabled) {
console.log(
"The daemon will keep refreshing models and client integrations on a schedule.",
);
return;
}
console.log(
"The daemon will stop the scheduled refresh of models and client integrations.",
);
console.log("Run 'routstrd clients --manual-refresh' to refresh on demand.");
console.log(
"Run 'routstrd clients --enable-automatic-refresh' to turn it back on.",
);
}
export async function listClientsAction(): Promise<void> {
await ensureDaemonRunning();
+14
View File
@@ -20,6 +20,18 @@ export interface NwcAutoRefillConfig {
cooldownMs: number;
}
/**
* Scheduled background refresh job (Nostr events, routstr21 models, and client
* integrations). Missing values mean "enabled" with the default interval, so
* older config files keep working unchanged.
*/
export interface AutoRefreshConfig {
/** Run the recurring refresh job. Defaults to true. */
enabled?: boolean;
/** Milliseconds between refreshes. Defaults to 21 minutes. */
intervalMs?: number;
}
/** NWC configuration section */
export interface NwcConfig {
/** NWC mode: "funding_source" = NWC funds the cocod Cashu wallet */
@@ -57,6 +69,8 @@ export interface RoutstrdConfig {
relays?: string[];
/** NWC integration configuration */
nwc?: NwcConfig;
/** Scheduled refresh job settings (see AutoRefreshConfig). */
autoRefresh?: AutoRefreshConfig;
/**
* Default max_tokens (chat/completions) and max_output_tokens (responses)
* injected into proxied requests when the client does not supply one.
+102
View File
@@ -0,0 +1,102 @@
// Scenario runner for auto-refresh.test.ts — executed as a standalone bun
// process with ROUTSTRD_DIR set before module evaluation, because CONFIG_FILE
// is resolved at import time.
import { createServer } from "http";
import type { AddressInfo } from "net";
const { createDaemonRequestHandler } = await import("../../src/daemon/http/index");
const { loadDaemonConfigSync } = await import("../../src/daemon/config-store");
function assert(cond: unknown, msg: string): void {
if (!cond) {
console.error(`ASSERT-FAIL: ${msg}`);
process.exit(1);
}
}
/**
* Only the settings endpoints are exercised, so every daemon dependency is a
* stub that would throw loudly if the request reached the proxy path.
*/
const stubDeps = {
provider: null,
server: { close() {} },
store: {},
walletClient: {},
walletAdapter: {},
storageAdapter: {},
discoveryAdapter: {},
modelManager: {},
ensureProvidersBootstrapped: async () => {},
getRoutstr21Models: async () => [],
getModelProviders: async () => [],
refreshProvidersAndModels: async () => {},
mode: "apikeys" as const,
maxTokens: 64000,
usageTrackingDriver: {},
providerManager: {},
refundClient: {},
};
async function withServer(
run: (port: number) => Promise<void>,
): Promise<void> {
const server = createServer(
createDaemonRequestHandler(stubDeps as never),
);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
await run((server.address() as AddressInfo).port);
} finally {
server.close();
}
}
const scenario = process.argv[2];
switch (scenario) {
case "toggle-endpoint": {
await withServer(async (port) => {
const post = (body: unknown) =>
fetch(`http://127.0.0.1:${port}/settings/auto-refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// Missing config means "enabled" so existing installs are unaffected.
assert(
loadDaemonConfigSync().autoRefresh?.enabled === undefined,
"expected no autoRefresh in a fresh config",
);
const disabled = await post({ enabled: false });
assert(disabled.status === 200, `disable status ${disabled.status}`);
const disabledBody = (await disabled.json()) as {
output?: { autoRefresh?: { enabled?: boolean } };
};
assert(
disabledBody.output?.autoRefresh?.enabled === false,
"disable response did not report enabled=false",
);
assert(
loadDaemonConfigSync().autoRefresh?.enabled === false,
"disable was not persisted to config.json",
);
const enabled = await post({ enabled: true });
assert(enabled.status === 200, `enable status ${enabled.status}`);
assert(
loadDaemonConfigSync().autoRefresh?.enabled === true,
"enable was not persisted to config.json",
);
});
break;
}
default:
console.error(`unknown scenario: ${scenario}`);
process.exit(2);
}
console.log("SCENARIO-OK");
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { spawnSync } from "child_process";
import { mkdtempSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
// The scenario mutates process.env.ROUTSTRD_DIR before the config module is
// loaded, so it runs in an isolated bun subprocess (same approach as
// config-store.perms.test.ts).
const SCENARIO = join(import.meta.dir, "auto-refresh.scenario.ts");
function runScenario(name: string): { code: number; out: string } {
const dir = mkdtempSync(join(tmpdir(), `routstrd-auto-refresh-${name}-`));
const res = spawnSync("bun", [SCENARIO, name], {
env: {
...process.env,
NODE_ENV: "test",
ROUTSTRD_DIR: join(dir, "daemon"),
},
stdout: "pipe",
stderr: "pipe",
});
const out = `${res.stdout}${res.stderr}`;
rmSync(dir, { recursive: true, force: true });
return { code: res.status ?? res.exitCode ?? -1, out };
}
describe("auto-refresh settings endpoint", () => {
test("persists the scheduled-refresh toggle to the daemon config", () => {
const { code, out } = runScenario("toggle-endpoint");
expect(out).toContain("SCENARIO-OK");
expect(code).toBe(0);
});
});