From ab1c2ad2197c020fb1099affc9d138e7c87bee7d Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:27:51 +0100 Subject: [PATCH] fix(clients): update Hermes provider in place & improve client integration error feedback (#72) * feat: cap proxied completion budget via configurable maxTokens Inject a default max_tokens (chat/completions) or max_output_tokens (responses) when a client omits one, so the SDK prices against completion x maxTokens instead of the provider's worst-case max_completion_cost. Default 64000; set 0 to disable. * Silence file logger during test runs bun test sets NODE_ENV=test, but tests import modules that pull in the logger singleton, causing test output to be written into the real ~/.routstrd log files alongside production daemon output. Early-return in writeLog when running under test. * Add SECURITY.md with vulnerability reporting policy * fix(clients): update Hermes provider in place and improve client integration error feedback - hermes: re-running 'clients add --hermes' now updates base_url/api_key/model in place instead of keeping a stale entry, and repoints model.provider when the provider name changes - clients: print a clear error on integration setup failure with a NIP-98 auth hint for rejected npubs, and exit non-zero if any integration failed - deps: bump @routstr/sdk to 0.3.21 --------- Co-authored-by: redshift <213178690+1ftredsh@users.noreply.github.com> --- bun.lock | 4 ++-- src/integrations/hermes.ts | 49 ++++++++++++++++++++++++++++++++++---- src/utils/clients.ts | 21 ++++++++++++++++ 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index a6f4afe..0330bd4 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@cashu/cashu-ts": "^4.3.0", "@cashu/coco-core": "^1.0.1", "@cashu/coco-sqlite-bun": "^1.0.1", - "@routstr/sdk": "^0.3.20", + "@routstr/sdk": "^0.3.21", "@scure/bip39": "^2.2.0", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", @@ -98,7 +98,7 @@ "@panva/hpke-noble": ["@panva/hpke-noble@1.1.3", "", { "dependencies": { "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@noble/post-quantum": "^0.6.1" }, "peerDependencies": { "hpke": "^1.0.0" } }, "sha512-zPG7MR9x7QE7+KdYsKBO9H0vp3AdYt9/4AT3ab7T7W6SL0fdRqhgNRu8q4OGTJNLeKpdbkkRb6LhBDaA9+9xWQ=="], - "@routstr/sdk": ["@routstr/sdk@0.3.20", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "ehbp": "^0.2.3", "rxjs": "^7.8.1", "tinfoil": "^1.1.6", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-R7BjOT4LNZYk0ypebWKKm9CZD9PP66OvuhAZ7ABiCFcc8F0iynVFMTcz+mZMZldKO5RY+h+OqWa5L1u//B2aqQ=="], + "@routstr/sdk": ["@routstr/sdk@0.3.21", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "ehbp": "^0.2.3", "rxjs": "^7.8.1", "tinfoil": "^1.1.6", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-9QvwXvBM/crvOo1RZSkJFEY95P8FFaHY0XJI5SGYCv6EQtXjZAymF5wSbK5WzJSB6dgcaofYVUrg7xXRUiXr6Q=="], "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="], diff --git a/src/integrations/hermes.ts b/src/integrations/hermes.ts index 63706fd..51c7b8d 100644 --- a/src/integrations/hermes.ts +++ b/src/integrations/hermes.ts @@ -17,6 +17,13 @@ interface HermesCustomProvider { [key: string]: unknown; } +/** Hermes references a custom provider as `custom:`, where the slug is + * the provider name lowercased with runs of whitespace collapsed to hyphens + * (e.g. `Routstr (routstr.ft.hn)` -> `custom:routstr-(routstr.ft.hn)`). */ +function hermesProviderRef(name: string): string { + return `custom:${name.toLowerCase().replace(/\s+/g, "-")}`; +} + export function mergeHermesConfig( content: string, routstr: HermesRoutstrConfig, @@ -29,8 +36,9 @@ export function mergeHermesConfig( const urlDisplay = routstr.baseUrl .replace(/\/v1$/, "") .replace(/^https?:\/\//, ""); + const providerName = `Routstr (${urlDisplay})`; const provider = { - name: `Routstr (${urlDisplay})`, + name: providerName, base_url: routstr.baseUrl, api_key: routstr.apiKey, model: routstr.defaultModel, @@ -46,17 +54,48 @@ export function mergeHermesConfig( }); } + // Replace an existing Routstr custom provider in place so that re-running + // `clients add --hermes` after changing the daemon URL updates base_url / + // api_key / model instead of silently keeping the stale entry. We only touch + // the first matching entry; any other providers are left as-is. const existingConfig = document.toJS() as { custom_providers?: HermesCustomProvider[]; }; const existingProviders = existingConfig.custom_providers; - const providers = Array.isArray(existingProviders) ? existingProviders : []; - if (providers.some((item) => item.name?.startsWith("Routstr ("))) { - return content; + const providers = Array.isArray(existingProviders) + ? existingProviders.slice() + : []; + const routstrIndex = providers.findIndex( + (item) => typeof item?.name === "string" && item.name.startsWith("Routstr ("), + ); + let previousName: string | undefined; + if (routstrIndex >= 0) { + previousName = providers[routstrIndex]!.name; + providers[routstrIndex] = { ...providers[routstrIndex], ...provider }; + } else { + providers.push(provider); } - providers.push(provider); document.set("custom_providers", providers); + // If we renamed the Routstr provider, keep `model.provider` pointing at it so + // Hermes doesn't end up referencing a provider that no longer exists. We only + // adjust configs whose default model already routes through a Routstr custom + // provider, leaving any other selection untouched. + if ( + !isNewConfig && + previousName && + previousName !== providerName && + document.hasIn(["model", "provider"]) + ) { + const currentRef = document.getIn(["model", "provider"]); + if ( + typeof currentRef === "string" && + currentRef === hermesProviderRef(previousName) + ) { + document.setIn(["model", "provider"], hermesProviderRef(providerName)); + } + } + return document.toString(); } diff --git a/src/utils/clients.ts b/src/utils/clients.ts index 4a8d78a..9149367 100644 --- a/src/utils/clients.ts +++ b/src/utils/clients.ts @@ -2,6 +2,7 @@ import { callDaemon, loadConfig, getDaemonBaseUrl, + getUserNpub, ensureDaemonRunning, } from "./daemon-client"; import { logger } from "./logger"; @@ -202,6 +203,7 @@ export async function addClientAction(options: AddClientOptions): Promise if (options.hermes) integrationKeys.push("hermes"); if (integrationKeys.length > 0) { + let hadFailure = false; for (const key of integrationKeys) { const integrationFn = CLIENT_INTEGRATIONS[key]; const integrationConfig = CLIENT_CONFIGS[key]; @@ -222,15 +224,34 @@ export async function addClientAction(options: AddClientOptions): Promise console.log(` Client ID: ${client.id}`); console.log(` API Key: ${client.apiKey}`); } catch (error) { + const message = (error as Error)?.message ?? String(error); logger.error( `Failed to set up ${integrationConfig.name} integration:`, error, ); + console.error( + `\n Error: failed to set up ${integrationConfig.name} integration.`, + ); + console.error(` ${message}`); + if (/NIP-98|registered npub\/pubkey|registered/i.test(message)) { + const npub = getUserNpub(config); + console.error( + ` The daemon at ${getDaemonBaseUrl(config)} rejected this account.`, + ); + if (npub) { + console.error( + ` Register/authorize this npub on the remote daemon first:`, + ); + console.error(` ${npub}`); + } + } + hadFailure = true; continue; } } console.log(`\n Access Routstr at: ${getDaemonBaseUrl(config)}/v1`); + if (hadFailure) process.exit(1); return; }