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>
This commit is contained in:
redshift
2026-08-17 07:27:51 +00:00
committed by GitHub
co-authored by redshift
parent 35b9f697cd
commit ab1c2ad219
3 changed files with 67 additions and 7 deletions
+2 -2
View File
@@ -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=="],
+44 -5
View File
@@ -17,6 +17,13 @@ interface HermesCustomProvider {
[key: string]: unknown;
}
/** Hermes references a custom provider as `custom:<slug>`, 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();
}
+21
View File
@@ -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<void>
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<void>
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;
}