merge: resolve conflict in start-daemon.ts with origin/main

Keep PR's cross-platform Bun.spawn (no sh -c) while integrating main's
debug log capture (openSync + debugLogFd for stdout/stderr redirection).
This commit is contained in:
redshift
2026-07-30 17:59:36 +01:00
20 changed files with 1895 additions and 519 deletions
+4
View File
@@ -6,6 +6,10 @@ Routstr daemon - A CLI tool for managing routstr processes, similar to `cocod` (
routstrd is a Bun-based CLI tool that provides a background daemon for the Routstr protocol. It integrates with `cocod` for wallet management and uses the Routstr SDK to handle provider routing and model discovery.
## Routstr for Teams
For team-based routing, see [routstrd-auth](https://github.com/Routstr/routstrd-auth).
## Features
- **Daemon Mode**: Run routstrd as a background HTTP server
+47 -2
View File
@@ -5,7 +5,7 @@
"name": "routstrd",
"dependencies": {
"@cashu/cashu-ts": "^4.3.0",
"@routstr/sdk": "^0.3.9",
"@routstr/sdk": "^0.3.17",
"applesauce-core": "^5.1.0",
"applesauce-relay": "^5.1.0",
"applesauce-wallet-connect": "^6.0.0",
@@ -13,6 +13,7 @@
"nostr-tools": "^2.12.0",
"qrcode": "^1.5.4",
"rxjs": "^7.8.1",
"yaml": "^2.9.0",
"zustand": "^5.0.5",
},
"devDependencies": {
@@ -25,6 +26,12 @@
},
},
"packages": {
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.30" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A6qfyaVs4lxmRxRux6U3ViOa8mMbsSd0OaHghpei2MpiBT6791J4zFH5MN7kaW1tLfQ246rSC2DVTMOavsycjQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VO7I+vPffqI5sMnPoUq5DCSqKIgQIk/naJWRdQVpz2ma2zoprC/lqiJiUEl2s6DfvTD76TbhD3q39ROjlA6rGw=="],
"@cashu/cashu-ts": ["@cashu/cashu-ts@4.5.1", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/bip32": "^2.2.0" } }, "sha512-AGB7wh1SC0iANRSsdwM091eoS1uVhSf/Qf2VM3plw8EzlYjiFkwSa9X0Nh6NpsE1VVeAuPpNInvpznvElp2Q0A=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
@@ -33,6 +40,12 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@freedomofpress/crypto-browser": ["@freedomofpress/crypto-browser@0.1.7", "", { "dependencies": { "@noble/curves": "^1.6.0" } }, "sha512-zjWmZDKdAu8g0Zq1IjBQ+sKQ/NpfzStBDFjy/qHUSMVEL4wNlNGtA7lhtw8v8asXa0yqF2QTYQ3rq6xCTQeADw=="],
"@freedomofpress/sigstore-browser": ["@freedomofpress/sigstore-browser@0.1.13", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/tuf-browser": "^0.1.11", "@noble/curves": "^2.0.1" } }, "sha512-3YfmP9JQ5h8CAO/vKJEGYrFdzss6xWxYi5ZkbR4KoHlxIaLGrnu+5TQDoZVWhuhyfDBdLQqj6ypdN0W6PsH3Jw=="],
"@freedomofpress/tuf-browser": ["@freedomofpress/tuf-browser@0.1.11", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7" } }, "sha512-d76ohB/AS5+zI+lnbiFMX/BIK3nT18hZ8q3Na6X4vyYaSYjXkeknzhAnbx1da+8ObWLwsaZLue46haEch28qtQ=="],
"@libsql/client": ["@libsql/client@0.15.15", "", { "dependencies": { "@libsql/core": "^0.15.14", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.5.22", "promise-limit": "^2.7.0" } }, "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w=="],
"@libsql/core": ["@libsql/core@0.15.15", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA=="],
@@ -71,7 +84,11 @@
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@routstr/sdk": ["@routstr/sdk@0.3.9", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "rxjs": "^7.8.1", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-bvn52jEYCcxOEVci1bGlbfH10LUmv2KErbFiXObp+hdnTg8ARthnY4DD6aCC5uJS71gKjRYWAFP16blMzs1aUA=="],
"@noble/post-quantum": ["@noble/post-quantum@0.6.1", "", { "dependencies": { "@noble/ciphers": "~2.2.0", "@noble/curves": "~2.2.0", "@noble/hashes": "~2.2.0" } }, "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A=="],
"@panva/hpke-noble": ["@panva/hpke-noble@1.1.2", "", { "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-Gr/jKr/ErpRow8aHK+SAp0sAaWy7iJGHlFsXsdTjD8ULcN4vvsVVWR3fiD6Ogf+VoSB28XY/xWRDUEIGXw+9aA=="],
"@routstr/sdk": ["@routstr/sdk@0.3.17", "", { "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-3rLsVo8bo3pQG+KzACNfRo3dz6XqfsCoFLpvuqkVNgXUyxSQEQPFRiDtUeACf+a6Xp3f9LSLknVoTeL78s28Tg=="],
"@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="],
@@ -79,6 +96,10 @@
"@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tinfoilsh/verifier": ["@tinfoilsh/verifier@1.1.6", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/sigstore-browser": "^0.1.13", "@freedomofpress/tuf-browser": "^0.1.11" } }, "sha512-egZlLFU98kh48GKV5B8Gh1abGHCUGEcSijWmTv3hn5lqAMXdLsqyehKDaUen8/98CrNxiOP+mzeqVbKSyAs5ZQ=="],
"@tursodatabase/database": ["@tursodatabase/database@0.2.2", "", { "dependencies": { "@tursodatabase/database-common": "^0.2.2" }, "optionalDependencies": { "@tursodatabase/database-darwin-arm64": "0.2.2", "@tursodatabase/database-linux-arm64-gnu": "0.2.2", "@tursodatabase/database-linux-x64-gnu": "0.2.2", "@tursodatabase/database-win32-x64-msvc": "0.2.2" } }, "sha512-BlvoyiwIWIIQA65KEYlCqLDpRKIOM1AN99tBMjz1B+ydO9h0cDb+lCbfBf6f7+Lk696KB1ODpePiF5EfGm9qpw=="],
"@tursodatabase/database-common": ["@tursodatabase/database-common@0.2.2", "", {}, "sha512-cSNpms6MIaRj29B37XzIu9yGbea0HSGDupZs8QrMxR3rKqgHJIfY04ysqDD/4lS8TNIImPlPZrZgqQz+b/FoKQ=="],
@@ -157,10 +178,14 @@
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"ehbp": ["ehbp@0.2.3", "", { "dependencies": { "@panva/hpke-noble": "^1.0.3", "hpke": "^1.0.1" } }, "sha512-f7iLlhGpeE5xNOYASR3d529PzmBD7Pq5Yb/z0R7vQcxalwNuVTHhX6i4wDJRIVai18MznwoOQ3OWyT/EWaDJ3w=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -181,6 +206,8 @@
"hash-sum": ["hash-sum@2.0.0", "", {}, "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg=="],
"hpke": ["hpke@1.1.2", "", {}, "sha512-6Z8JoG/zYXly+MfsgQ5aNibBx/HDq2F74254Tv8VK4DR3/Mmf6zVrGakqsodfy9pF0XGKSYh5MA9x6D30gbhbg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@@ -191,6 +218,8 @@
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="],
"light-bolt11-decoder": ["light-bolt11-decoder@3.2.0", "", { "dependencies": { "@scure/base": "1.1.1" } }, "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ=="],
@@ -221,6 +250,8 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"openai": ["openai@6.44.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
@@ -271,6 +302,8 @@
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"tinfoil": ["tinfoil@1.1.6", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.41", "@freedomofpress/sigstore-browser": "^0.1.13", "@tinfoilsh/verifier": "1.1.6", "@types/ws": "^8.18.1", "ehbp": "^0.2.0", "openai": "^6.34.0", "ws": "^8.21.0" }, "peerDependencies": { "ai": "^6.0.168" }, "optionalPeers": ["ai"] }, "sha512-kAgVPuvdydLSF72GH8I5Awnc0835MQCNHb9cS/J2TxGgMF2LFgqaJhe9l1iPf7Si64h3s+Gebz0XfTJ80QZ8wQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
@@ -293,12 +326,22 @@
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="],
"@freedomofpress/crypto-browser/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
"@noble/post-quantum/@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
"@panva/hpke-noble/@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
"@routstr/sdk/@cashu/cashu-ts": ["@cashu/cashu-ts@3.7.0", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/bip32": "^2.2.0" } }, "sha512-Jy4Hek+Wouh94xS4InmTJRLpBPXA6fddVOAHR5lfNyTsCk9r3desY0vnwUGvOZBZWsSKZXedqdD/YpjWb3zngQ=="],
"@scure/bip39/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
@@ -333,6 +376,8 @@
"nostr-tools/@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="],
"@freedomofpress/crypto-browser/@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"applesauce-common/applesauce-core/nostr-tools": ["nostr-tools@2.19.4", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg=="],
"applesauce-core/nostr-tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="],
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "routstrd",
"version": "0.3.1",
"version": "0.3.11",
"module": "src/index.ts",
"type": "module",
"private": false,
@@ -24,7 +24,7 @@
},
"dependencies": {
"@cashu/cashu-ts": "^4.3.0",
"@routstr/sdk": "^0.3.9",
"@routstr/sdk": "^0.3.18",
"applesauce-core": "^5.1.0",
"applesauce-relay": "^5.1.0",
"applesauce-wallet-connect": "^6.0.0",
@@ -32,6 +32,7 @@
"nostr-tools": "^2.12.0",
"qrcode": "^1.5.4",
"rxjs": "^7.8.1",
"yaml": "^2.9.0",
"zustand": "^5.0.5"
}
}
+185 -2
View File
@@ -36,6 +36,11 @@ import {
resolveCocodExecutable,
} from "./daemon/wallet/cocod-client";
import packageJson from "../package.json" with { type: "json" };
import {
compareVersions,
getGlobalPackageVersion,
getLatestNpmVersion,
} from "./utils/update-checker.ts";
type RoutstrModel = {
id: string;
@@ -103,6 +108,130 @@ async function installCocodOrExit(): Promise<void> {
console.log("cocod installed successfully.");
}
/**
* Restart the routstrd and cocod daemons after an update so the new
* binaries take effect immediately. Failures are collected and reported
* but never roll back the update itself.
*/
async function restartDaemonsAfterUpdate(): Promise<void> {
const config = await loadConfig();
const isRemote = !!config.daemonUrl;
const failures: string[] = [];
// --- routstrd daemon ---
if (isRemote) {
console.log("\nUsing remote daemon — skipping routstrd daemon restart.");
} else {
try {
const wasRunning = await isDaemonRunning();
if (!wasRunning) {
console.log("\nroutstrd daemon was not running — skipping restart.");
} else {
console.log("\nRestarting routstrd daemon...");
// Graceful stop — the /stop endpoint closes the HTTP server
// (draining active connections) then exits.
await callDaemon("/stop", { method: "POST" });
for (let i = 0; i < 50; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (!(await isDaemonRunning())) break;
}
if (await isDaemonRunning()) {
throw new Error("routstrd did not stop within 5 seconds");
}
console.log("routstrd daemon stopped.");
console.log("Starting routstrd daemon...");
await startDaemon({
port: String(config.port || 8008),
provider: config.provider || undefined,
});
console.log("routstrd daemon restarted.");
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
failures.push(`routstrd daemon: ${msg}`);
}
}
// --- cocod daemon ---
try {
const cocodExecutable = resolveCocodExecutable(config.cocodPath);
// Check whether cocod is currently running.
const pingProc = Bun.spawn([cocodExecutable, "ping"], {
stdout: "ignore",
stderr: "ignore",
});
const cocodWasRunning = (await pingProc.exited) === 0;
if (!cocodWasRunning) {
console.log("\ncocod daemon was not running — skipping restart.");
} else {
console.log("\nRestarting cocod daemon...");
const stopProc = Bun.spawn([cocodExecutable, "stop"], {
stdout: "inherit",
stderr: "inherit",
});
const stopCode = await stopProc.exited;
if (stopCode !== 0) {
throw new Error(`cocod stop exited with code ${stopCode}`);
}
console.log("cocod daemon stopped.");
// Start cocod in the background (detached, like the wallet client does).
const env = { ...process.env };
const startProc = Bun.spawn([cocodExecutable, "daemon"], {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
detached: true,
env,
});
startProc.unref?.();
// Poll until cocod responds to ping (max ~10 s).
let started = false;
for (let i = 0; i < 100; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
const poll = Bun.spawn([cocodExecutable, "ping"], {
stdout: "ignore",
stderr: "ignore",
});
if ((await poll.exited) === 0) {
started = true;
break;
}
}
if (!started) {
throw new Error("cocod did not come back up within 10 seconds");
}
console.log("cocod daemon restarted.");
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
failures.push(`cocod daemon: ${msg}`);
}
// --- report ---
if (failures.length > 0) {
console.error("\n⚠ Some daemons failed to restart:");
for (const f of failures) {
console.error(` - ${f}`);
}
console.error(
"The update was applied but may not take effect until daemons are manually restarted.",
);
process.exit(1);
}
console.log("\n✓ All daemons restarted successfully.");
}
async function requireLocalDaemon(): Promise<void> {
const config = await loadConfig();
if (config.daemonUrl) {
@@ -235,6 +364,58 @@ program
.description("Routstr daemon - Manage routstr processes")
.version(packageJson.version, "--version", "output the version number");
program
.command("update")
.description("Update routstrd and cocod to the latest versions")
.action(async () => {
const packages = [
{ name: "routstrd", label: "routstrd" },
{ name: "@routstr/cocod", label: "cocod" },
];
let updatedAny = false;
for (const { name, label } of packages) {
const installed = await getGlobalPackageVersion(name);
const latest = await getLatestNpmVersion(name);
// Only skip when we're confident the installed version is current.
// If we can't determine either version we fall through to installing.
if (
installed &&
latest &&
(compareVersions(installed, latest) ?? -1) >= 0
) {
console.log(`${label} is already up to date (v${installed}).`);
continue;
}
const fromPart = installed ? ` from v${installed}` : "";
const toPart = latest ? ` to v${latest}` : "";
console.log(`Updating ${label}${fromPart}${toPart}...`);
const proc = Bun.spawn(["bun", "install", "-g", name], {
stdout: "inherit",
stderr: "inherit",
});
const code = await proc.exited;
if (code !== 0) {
console.error(`Failed to update ${label}.`);
process.exit(1);
}
console.log(`${label} updated successfully.\n`);
updatedAny = true;
}
if (updatedAny) {
console.log("All requested updates have been applied!");
// Restart daemons so the new binaries take effect immediately.
await restartDaemonsAfterUpdate();
} else {
console.log("\nAll packages are already up to date — nothing to do.");
}
});
program
.command("refund")
.description("Refund pending tokens and API keys to a specified mint")
@@ -743,10 +924,12 @@ const providersCmd = program
providersCmd
.command("list")
.description("List all providers with their enabled/disabled status")
.action(async () => {
.option("--refresh", "Force re-fetch all Nostr events and refresh models from all enabled providers", false)
.action(async (options: { refresh: boolean }) => {
await ensureDaemonRunning();
const result = await callDaemon("/providers");
const query = options.refresh ? "/providers?refresh=true" : "/providers";
const result = await callDaemon(query);
if (result.error) {
console.log(result.error);
process.exit(1);
+45 -3
View File
@@ -7,6 +7,7 @@ import {
ProviderManager,
} from "@routstr/sdk";
import type { UsageTrackingDriver, SdkLogger } from "@routstr/sdk";
import type { RequestResponseLogSink } from "../request-response-log-sink";
import { logger } from "../../utils/logger";
import { loadDaemonConfig, saveDaemonConfig } from "../config-store";
import {
@@ -16,6 +17,7 @@ import {
} from "../wallet/cocod-client";
import { decodeCashuTokenAmount } from "../wallet";
import { getClientsFromStore } from "../../utils/clients";
import { getUsageSummary } from "./usage-summary";
type ClientMode = "xcashu" | "lazyrefund" | "apikeys";
@@ -35,17 +37,18 @@ type DaemonDeps = {
walletClient: CocodClient;
walletAdapter: any;
storageAdapter: any;
providerRegistry: any;
discoveryAdapter: any;
modelManager: any;
ensureProvidersBootstrapped: () => Promise<void>;
getRoutstr21Models: (forceRefresh?: boolean) => Promise<any[]>;
getModelProviders: (modelId: string) => Promise<any>;
refreshProvidersAndModels: () => Promise<void>;
mode?: ClientMode;
/** Nostr hex pubkey for routstr review/model events (kind 38425/38423). */
routstrPubkey?: string;
providerManager: ProviderManager;
refundClient: any;
requestResponseLogSink?: RequestResponseLogSink;
};
/**
@@ -288,18 +291,19 @@ export function createDaemonRequestHandler(deps: {
walletClient: CocodClient;
walletAdapter: any;
storageAdapter: any;
providerRegistry: any;
discoveryAdapter: any;
modelManager: any;
ensureProvidersBootstrapped: () => Promise<void>;
getRoutstr21Models: (forceRefresh?: boolean) => Promise<any[]>;
getModelProviders: (modelId: string) => Promise<any>;
refreshProvidersAndModels: () => Promise<void>;
mode?: "xcashu" | "apikeys";
/** Nostr hex pubkey for routstr review/model events (kind 38425/38423). */
routstrPubkey?: string;
usageTrackingDriver: UsageTrackingDriver;
providerManager: ProviderManager;
refundClient: any;
requestResponseLogSink?: RequestResponseLogSink;
}) {
return async function handler(req: IncomingMessage, res: ServerResponse) {
const host = req.headers.host || "localhost";
@@ -815,6 +819,7 @@ export function createDaemonRequestHandler(deps: {
}
deps.store.getState().setDisabledProviders(disabledProviders);
deps.discoveryAdapter.setDisabledProviders(disabledProviders);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
@@ -871,6 +876,7 @@ export function createDaemonRequestHandler(deps: {
}
deps.store.getState().setDisabledProviders(disabledProviders);
deps.discoveryAdapter.setDisabledProviders(disabledProviders);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
@@ -1079,6 +1085,14 @@ export function createDaemonRequestHandler(deps: {
if (req.method === "GET" && url.pathname === "/providers") {
try {
const forceRefresh =
url.searchParams.get("refresh")?.toLowerCase() === "true";
if (forceRefresh) {
logger.log("Force-refreshing providers from Nostr and fetching models...");
await deps.refreshProvidersAndModels();
}
const state = deps.store.getState();
const baseUrlsList: string[] = state.baseUrlsList || [];
const disabledProviders: string[] = state.disabledProviders || [];
@@ -1112,8 +1126,16 @@ export function createDaemonRequestHandler(deps: {
if (req.method === "GET" && url.pathname === "/usage") {
try {
const npubFilter = url.searchParams.get("npub")?.trim();
const clients = npubFilter ? getClientsFromStore(deps.store) : undefined;
const clientFilter = npubFilter
? clients!
.filter((c) => c.ownerNpub === npubFilter)
.map((c) => c.clientId)
: undefined;
const output = await deps.usageTrackingDriver.list({
limit: parseLimit(url.searchParams.get("limit")),
...(clientFilter ? { clients: clientFilter } : {}),
});
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ output }));
@@ -1123,6 +1145,24 @@ export function createDaemonRequestHandler(deps: {
return;
}
if (req.method === "GET" && url.pathname === "/usage/summary") {
try {
const tz = Number.parseInt(url.searchParams.get("tz") || "0", 10) || 0;
const npubFilter = url.searchParams.get("npub")?.trim();
const clients = getClientsFromStore(deps.store);
const clientFilter = npubFilter
? clients
.filter((c) => c.ownerNpub === npubFilter)
.map((c) => c.clientId)
: undefined;
const summary = await getUsageSummary(deps.usageTrackingDriver, clients, tz, clientFilter);
sendJson(res, 200, { output: summary });
} catch (error) {
sendJson(res, 500, { error: toErrorMessage(error) });
}
return;
}
if (req.method === "GET" && url.pathname === "/usagePi") {
try {
const timestamp = (url.searchParams.get("timestamp") || "").trim();
@@ -1226,7 +1266,6 @@ export function createDaemonRequestHandler(deps: {
headers: incomingHeaders,
walletAdapter: deps.walletAdapter,
storageAdapter: deps.storageAdapter,
providerRegistry: deps.providerRegistry,
discoveryAdapter: deps.discoveryAdapter,
modelManager: deps.modelManager,
debugLevel: "DEBUG",
@@ -1235,6 +1274,9 @@ export function createDaemonRequestHandler(deps: {
sdkStore: deps.store,
providerManager: deps.providerManager,
logger: reqLogger,
...(deps.requestResponseLogSink
? { requestResponseLogSink: deps.requestResponseLogSink }
: {}),
...(deps.routstrPubkey ? { routstrPubkey: deps.routstrPubkey } : {}),
});
+272
View File
@@ -0,0 +1,272 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { createMemoryUsageTrackingDriver } from "@routstr/sdk/storage";
import type { UsageTrackingEntry } from "@routstr/sdk/storage";
import type { ClientEntry } from "../../utils/clients";
import { getUsageSummary, __resetUsageSummaryCacheForTest } from "./usage-summary";
// ─── Test fixtures ────────────────────────────────────────────────────────────
//
// All timestamps are computed relative to "now" so the fixtures never age
// out of getUsageSummary's 30-day rolling window. We anchor to a recent UTC
// midnight ~10 days ago and keep the same hour-of-day structure the
// tz-bucketing assertions depend on.
//
// tz = 300 (UTC-5 / EST). After shifting by -300min, UTC dates become:
// d1: <anchor> 10:00Z → local <anchor> 05:00 → local day "<anchor>"
// d2: <anchor+1d> 03:00Z → local <anchor> 22:00 → local day "<anchor>"
// d3: <anchor+1d> 06:00Z → local <anchor+1d> 01:00 → local day "<anchor+1d>"
const _BASE_UTC_MIDNIGHT = Math.floor(Date.now() / 86_400_000) * 86_400_000;
const _ANCHOR_MS = _BASE_UTC_MIDNIGHT - 10 * 86_400_000; // 10 days ago UTC
const D1 = _ANCHOR_MS + 10 * 3_600_000; // 10:00Z → local day <anchor>
const D2 = _ANCHOR_MS + 1 * 86_400_000 + 3 * 3_600_000; // 03:00Z next day → local day <anchor>
const D3 = _ANCHOR_MS + 1 * 86_400_000 + 6 * 3_600_000; // 06:00Z next day → local day <anchor+1d>
/** Compute the local-day date string the same way the SDK's day grouping does. */
function localDayDateStr(ts: number, tzOffsetMinutes: number): string {
const d = new Date(ts - tzOffsetMinutes * 60_000);
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
}
const BASE_ENTRY: Omit<UsageTrackingEntry, "id" | "timestamp" | "modelId" | "client" | "totalTokens"> = {
baseUrl: "https://api.openai.com/",
requestId: "req-1",
cost: 0.01,
satsCost: 10,
promptTokens: 100,
completionTokens: 50,
};
function makeEntry(
id: string,
overrides: Partial<UsageTrackingEntry> & Pick<UsageTrackingEntry, "id" | "timestamp" | "modelId" | "totalTokens">,
): UsageTrackingEntry {
return {
...BASE_ENTRY,
...overrides,
id,
};
}
// Seed entries:
// - 2 entries for model-a, client-1 (owned by npub1)
// - 1 entry for model-b, client-2 (no npub)
// - 1 entry for model-a, no client (null client)
// - totalTokens spread across tiny (< 1000) and small (1000-10000) buckets
const ENTRIES: UsageTrackingEntry[] = [
// model-a, client-1, day 2026-05-20 (D1), totalTokens=500 (tiny)
makeEntry("e1", {
id: "e1",
timestamp: D1,
modelId: "model-a",
client: "client-1",
totalTokens: 500,
promptTokens: 350,
completionTokens: 150,
satsCost: 10,
cost: 0.01,
}),
// model-a, client-1, day 2026-05-20 (D2), totalTokens=2000 (small)
makeEntry("e2", {
id: "e2",
timestamp: D2,
modelId: "model-a",
client: "client-1",
totalTokens: 2000,
promptTokens: 1400,
completionTokens: 600,
satsCost: 20,
cost: 0.02,
}),
// model-b, client-2, day 2026-05-21 (D3), totalTokens=800 (tiny)
makeEntry("e3", {
id: "e3",
timestamp: D3,
modelId: "model-b",
client: "client-2",
totalTokens: 800,
promptTokens: 600,
completionTokens: 200,
satsCost: 8,
cost: 0.008,
}),
// model-a, no client, day 2026-05-21 (D3), totalTokens=5000 (small)
makeEntry("e4", {
id: "e4",
timestamp: D3 + 1000,
modelId: "model-a",
client: undefined,
totalTokens: 5000,
promptTokens: 3500,
completionTokens: 1500,
satsCost: 50,
cost: 0.05,
}),
];
const CLIENTS: ClientEntry[] = [
{
clientId: "client-1",
name: "Client One",
apiKey: "sk-client1",
createdAt: D1 - 86400000,
ownerNpub: "npub1abc",
},
{
clientId: "client-2",
name: "Client Two",
apiKey: "sk-client2",
createdAt: D1 - 86400000,
// no ownerNpub
},
];
const TZ = 300; // UTC-5 / EST
describe("getUsageSummary", () => {
let driver: ReturnType<typeof createMemoryUsageTrackingDriver>;
beforeEach(async () => {
// Reset the module-level memo cache explicitly so each test starts cold.
// Without this, tests 2-10 would share the cached object from test 1
// (all use the same fixtures → identical cache key → same TTL window).
__resetUsageSummaryCacheForTest();
driver = createMemoryUsageTrackingDriver(ENTRIES);
});
it("returns correct totals", async () => {
const summary = await getUsageSummary(driver, CLIENTS, TZ);
expect(summary.totals.requests).toBe(4);
expect(summary.totals.totalTokens).toBe(500 + 2000 + 800 + 5000);
expect(summary.totals.promptTokens).toBe(350 + 1400 + 600 + 3500);
expect(summary.totals.completionTokens).toBe(150 + 600 + 200 + 1500);
expect(summary.totals.satsCost).toBe(10 + 20 + 8 + 50);
expect(summary.totals.cost).toBeCloseTo(0.01 + 0.02 + 0.008 + 0.05, 5);
});
it("returns models sorted desc by satsCost", async () => {
const summary = await getUsageSummary(driver, CLIENTS, TZ);
const { models } = summary;
// model-a: 3 entries (e1, e2, e4) total satsCost = 10+20+50 = 80
// model-b: 1 entry (e3) satsCost = 8
expect(models).toHaveLength(2);
expect(models[0]!.modelId).toBe("model-a");
expect(models[0]!.satsCost).toBe(80);
expect(models[0]!.requests).toBe(3);
expect(models[1]!.modelId).toBe("model-b");
expect(models[1]!.satsCost).toBe(8);
});
it("returns days most-recent-first with correct tz-bucketing", async () => {
const summary = await getUsageSummary(driver, CLIENTS, TZ);
const { days } = summary;
// D1 (10:00Z) and D2 (next day 03:00Z) both bucket to local day <anchor>;
// D3 (next day 06:00Z) buckets to local day <anchor+1d>.
// So: day <anchor> has e1+e2, day <anchor+1d> has e3+e4
expect(days.length).toBeGreaterThanOrEqual(2);
// Most-recent-first: <anchor+1d> first
expect(days[0]!.date).toBe(localDayDateStr(D3, TZ));
expect(days[0]!.requests).toBe(2); // e3, e4
expect(days[0]!.satsCost).toBe(8 + 50);
expect(days[1]!.date).toBe(localDayDateStr(D1, TZ));
expect(days[1]!.requests).toBe(2); // e1, e2
expect(days[1]!.satsCost).toBe(10 + 20);
});
it("returns empty hoursToday (test entries are not today)", async () => {
const summary = await getUsageSummary(driver, CLIENTS, TZ);
// Test entries are ~10 days ago, not today
expect(summary.hoursToday).toHaveLength(0);
});
it("returns correct sizeBuckets", async () => {
const summary = await getUsageSummary(driver, CLIENTS, TZ);
const { sizeBuckets } = summary;
// tiny [0, 1000): e1 (500) + e3 (800) = 2 entries
expect(sizeBuckets.tiny.count).toBe(2);
expect(sizeBuckets.tiny.cost).toBe(10 + 8);
// small [1000, 10000): e2 (2000) + e4 (5000) = 2 entries
expect(sizeBuckets.small.count).toBe(2);
expect(sizeBuckets.small.cost).toBe(20 + 50);
// medium, large, huge should be 0
expect(sizeBuckets.medium.count).toBe(0);
expect(sizeBuckets.large.count).toBe(0);
expect(sizeBuckets.huge.count).toBe(0);
});
it("returns per-client topModels for top non-null clients", async () => {
const summary = await getUsageSummary(driver, CLIENTS, TZ);
const { clients } = summary;
// client-1: e1 + e2 (model-a), satsCost = 30
// client-2: e3 (model-b), satsCost = 8
// unknown (null client): e4 (model-a), satsCost = 50
// Sorted desc by satsCost: unknown (50), client-1 (30), client-2 (8)
const c1 = clients.find((c) => c.client === "client-1");
expect(c1).toBeDefined();
expect(c1!.requests).toBe(2);
expect(c1!.satsCost).toBe(30);
// client-1 is in top 3 non-null clients, should have topModels
expect(c1!.topModels.length).toBeGreaterThan(0);
expect(c1!.topModels[0]!.modelId).toBe("model-a");
const c2 = clients.find((c) => c.client === "client-2");
expect(c2).toBeDefined();
expect(c2!.requests).toBe(1);
expect(c2!.satsCost).toBe(8);
// unknown client (null group) should have empty topModels
const unknown = clients.find((c) => c.client === "unknown");
expect(unknown).toBeDefined();
expect(unknown!.topModels).toHaveLength(0);
});
it("folds client rows into npubs correctly", async () => {
const summary = await getUsageSummary(driver, CLIENTS, TZ);
const { npubs } = summary;
// Only client-1 has ownerNpub = "npub1abc"
// npub1abc: e1 + e2 = requests 2, satsCost 30
expect(npubs).toHaveLength(1);
expect(npubs[0]!.npub).toBe("npub1abc");
expect(npubs[0]!.requests).toBe(2);
expect(npubs[0]!.satsCost).toBe(30);
// Top models for npub1abc (clients: ["client-1"]): only model-a
expect(npubs[0]!.topModels.length).toBeGreaterThan(0);
expect(npubs[0]!.topModels[0]!.modelId).toBe("model-a");
});
it("returns recent entries (up to 50)", async () => {
const summary = await getUsageSummary(driver, CLIENTS, TZ);
expect(summary.recent).toHaveLength(4); // only 4 entries in driver
// Should be sorted desc by timestamp
expect(summary.recent[0]!.timestamp).toBeGreaterThanOrEqual(summary.recent[1]!.timestamp);
});
it("returns providers correctly", async () => {
const summary = await getUsageSummary(driver, CLIENTS, TZ);
const { providers } = summary;
// All entries use the same baseUrl
expect(providers).toHaveLength(1);
expect(providers[0]!.baseUrl).toBe("https://api.openai.com/");
expect(providers[0]!.requests).toBe(4);
});
it("caches results for same key within TTL", async () => {
const summary1 = await getUsageSummary(driver, CLIENTS, TZ);
const summary2 = await getUsageSummary(driver, CLIENTS, TZ);
// Same object reference means it was served from cache
expect(summary1).toBe(summary2);
});
});
+361
View File
@@ -0,0 +1,361 @@
import type {
UsageAggregateRow,
UsageTrackingDriver,
UsageTrackingEntry,
} from "@routstr/sdk/storage";
import type { ClientEntry } from "../../utils/clients";
// ─── Public shape ────────────────────────────────────────────────────────────
export interface StatRow {
requests: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
cost: number;
satsCost: number;
}
export interface ModelSummary extends StatRow {
modelId: string;
}
export interface ProviderSummary extends StatRow {
baseUrl: string;
}
export interface TopModel {
modelId: string;
requests: number;
satsCost: number;
totalTokens: number;
}
export interface ClientSummary extends StatRow {
client: string;
topModels: TopModel[];
}
export interface NpubSummary extends StatRow {
npub: string;
topModels: TopModel[];
}
export interface DaySummary extends StatRow {
date: string; // "YYYY-MM-DD"
}
export interface HourSummary extends StatRow {
hour: number; // 0..23
}
export interface SizeBucket {
count: number;
cost: number; // summed satsCost
}
export interface UsageSummary {
generatedAt: number;
totals: StatRow;
models: ModelSummary[];
providers: ProviderSummary[];
clients: ClientSummary[];
npubs: NpubSummary[];
days: DaySummary[];
hoursToday: HourSummary[];
sizeBuckets: {
tiny: SizeBucket;
small: SizeBucket;
medium: SizeBucket;
large: SizeBucket;
huge: SizeBucket;
};
recent: UsageTrackingEntry[];
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function rowToStat(r: UsageAggregateRow): StatRow {
return {
requests: r.requests,
promptTokens: r.promptTokens,
completionTokens: r.completionTokens,
totalTokens: r.totalTokens,
cost: r.cost,
satsCost: r.satsCost,
};
}
function rowToTopModel(r: UsageAggregateRow): TopModel {
return {
modelId: r.group ?? "unknown",
requests: r.requests,
satsCost: r.satsCost,
totalTokens: r.totalTokens,
};
}
function emptyBucket(): SizeBucket {
return { count: 0, cost: 0 };
}
/** [minInclusive, maxExclusive) token bounds for each size bucket. */
const SIZE_BUCKET_BOUNDS = {
tiny: [0, 1000],
small: [1000, 10000],
medium: [10000, 50000],
large: [50000, 100000],
huge: [100000, Infinity],
} as const;
function computeSizeBuckets(
entries: UsageTrackingEntry[],
): UsageSummary["sizeBuckets"] {
const buckets = {
tiny: emptyBucket(),
small: emptyBucket(),
medium: emptyBucket(),
large: emptyBucket(),
huge: emptyBucket(),
};
for (const entry of entries) {
for (const [name, [min, max]] of Object.entries(SIZE_BUCKET_BOUNDS)) {
if (entry.totalTokens >= min && entry.totalTokens < max) {
const bucket = buckets[name as keyof typeof buckets];
bucket.count++;
bucket.cost += entry.satsCost;
break;
}
}
}
return buckets;
}
/** Returns the UTC ms for the start of the local day containing `now`. */
function startOfLocalDayUtc(now: number, tzOffsetMinutes: number): number {
return (
Math.floor((now - tzOffsetMinutes * 60000) / 86400000) * 86400000 +
tzOffsetMinutes * 60000
);
}
// ─── Module-level memo cache ─────────────────────────────────────────────────
interface CacheEntry {
key: string;
summary: UsageSummary;
}
let _cache: CacheEntry | null = null;
const CACHE_TTL_MS = 60_000;
/** Clears the module-level memo cache. Intended for use in unit tests only. */
export function __resetUsageSummaryCacheForTest(): void {
_cache = null;
}
// ─── Main builder ─────────────────────────────────────────────────────────────
export async function getUsageSummary(
driver: UsageTrackingDriver,
clients: ClientEntry[],
tzOffsetMinutes: number,
/** If set, only include usage for these client IDs (e.g. from `?npub=` filtering). */
clientFilter?: string[],
): Promise<UsageSummary> {
// Cache key: total row count + per-client identity + filter + tz.
const count = await driver.count(clientFilter ? { clients: clientFilter } : {});
const clientIdentity = clients.map((c) => `${c.clientId}:${c.ownerNpub ?? ""}`).join(",");
const filterKey = clientFilter ? `:f:${clientFilter.sort().join(",")}` : "";
const cacheKey = `${count}:${clientIdentity}:${tzOffsetMinutes}${filterKey}`;
const now = Date.now();
if (
_cache !== null &&
_cache.key === cacheKey &&
now - _cache.summary.generatedAt <= CACHE_TTL_MS
) {
return _cache.summary;
}
// Base filter applied to every aggregate/list/count call
const baseFilter = clientFilter ? { clients: clientFilter } as const : {};
// Short-circuit: if the filter yields no rows, return zeroed summary
if (count === 0) {
const zeroStat: StatRow = {
requests: 0, promptTokens: 0, completionTokens: 0,
totalTokens: 0, cost: 0, satsCost: 0,
};
const zeroSummary: UsageSummary = {
generatedAt: now,
totals: zeroStat,
models: [],
providers: [],
clients: [],
npubs: [],
days: [],
hoursToday: [],
sizeBuckets: {
tiny: emptyBucket(), small: emptyBucket(), medium: emptyBucket(),
large: emptyBucket(), huge: emptyBucket(),
},
recent: [],
};
_cache = { key: cacheKey, summary: zeroSummary };
return zeroSummary;
}
// ── Totals ─────────────────────────────────────────────────────────────────
const [totalsRow] = await driver.aggregate({ ...baseFilter });
const totals: StatRow = totalsRow ? rowToStat(totalsRow) : {
requests: 0, promptTokens: 0, completionTokens: 0,
totalTokens: 0, cost: 0, satsCost: 0,
};
// ── Models ─────────────────────────────────────────────────────────────────
const modelRows = await driver.aggregate({ ...baseFilter, groupBy: "modelId" });
const models: ModelSummary[] = modelRows.map((r) => ({
modelId: r.group ?? "unknown",
...rowToStat(r),
}));
// ── Providers ──────────────────────────────────────────────────────────────
const providerRows = await driver.aggregate({ ...baseFilter, groupBy: "baseUrl" });
const providers: ProviderSummary[] = providerRows.map((r) => ({
baseUrl: r.group ?? "unknown",
...rowToStat(r),
}));
// ── Clients ────────────────────────────────────────────────────────────────
const clientRows = await driver.aggregate({ ...baseFilter, groupBy: "client" });
const clientSummaries: ClientSummary[] = clientRows.map((r) => ({
client: r.group ?? "unknown",
...rowToStat(r),
topModels: [],
}));
// Fill topModels for the top 3 non-null client rows
const topClientRows = clientRows
.filter((r) => r.group !== null)
.slice(0, 3);
for (let i = 0; i < topClientRows.length; i++) {
const clientId = topClientRows[i]!.group!;
const topModelRows = await driver.aggregate({
...baseFilter,
groupBy: "modelId",
client: clientId,
});
// Find matching ClientSummary and set topModels
const summary = clientSummaries.find((c) => c.client === clientId);
if (summary) {
summary.topModels = topModelRows.slice(0, 5).map(rowToTopModel);
}
}
// ── Npubs ──────────────────────────────────────────────────────────────────
// Build clientId → ownerNpub lookup (only clients with ownerNpub)
const clientToNpub = new Map<string, string>();
for (const c of clients) {
if (c.ownerNpub) {
clientToNpub.set(c.clientId, c.ownerNpub);
}
}
// Fold client rows into per-npub sums
const npubStats = new Map<string, StatRow>();
const npubClientIds = new Map<string, string[]>();
for (const r of clientRows) {
if (r.group === null) continue;
const npub = clientToNpub.get(r.group);
if (!npub) continue;
const existing = npubStats.get(npub);
if (existing) {
existing.requests += r.requests;
existing.promptTokens += r.promptTokens;
existing.completionTokens += r.completionTokens;
existing.totalTokens += r.totalTokens;
existing.cost += r.cost;
existing.satsCost += r.satsCost;
} else {
npubStats.set(npub, { ...rowToStat(r) });
}
const ids = npubClientIds.get(npub) ?? [];
ids.push(r.group);
npubClientIds.set(npub, ids);
}
// Sort npubs desc by satsCost
const sortedNpubs = [...npubStats.entries()].sort(
(a, b) => b[1].satsCost - a[1].satsCost,
);
const npubs: NpubSummary[] = sortedNpubs.map(([npub, stat]) => ({
npub,
...stat,
topModels: [],
}));
// Fill topModels for top 5 npubs
for (let i = 0; i < Math.min(5, npubs.length); i++) {
const npubSummary = npubs[i]!;
const ids = npubClientIds.get(npubSummary.npub) ?? [];
if (ids.length > 0) {
const topModelRows = await driver.aggregate({
groupBy: "modelId",
clients: ids,
});
npubSummary.topModels = topModelRows.slice(0, 5).map(rowToTopModel);
}
}
// ── Days (last 30, most-recent-first) ─────────────────────────────────────
const dayRows = await driver.aggregate({
...baseFilter,
groupBy: "day",
tzOffsetMinutes,
after: now - 30 * 86400000,
});
const days: DaySummary[] = dayRows
.map((r) => ({ date: r.group!, ...rowToStat(r) }))
.reverse(); // aggregate returns ascending; we want most-recent-first
// ── Hours today ────────────────────────────────────────────────────────────
const todayStartUtc = startOfLocalDayUtc(now, tzOffsetMinutes);
const hourRows = await driver.aggregate({
...baseFilter,
groupBy: "hour",
tzOffsetMinutes,
after: todayStartUtc - 1,
});
const hoursToday: HourSummary[] = hourRows.map((r) => ({
hour: Number(r.group),
...rowToStat(r),
}));
// ── Size buckets ───────────────────────────────────────────────────────────
// The SDK's aggregate() no longer supports token-range filters
// (minTotalTokens/maxTotalTokens were removed in SDK pr-8), so bucket in
// JS from a single list() call instead of five aggregate() queries.
const allEntries = await driver.list(baseFilter);
const sizeBuckets = computeSizeBuckets(allEntries);
// ── Recent entries ─────────────────────────────────────────────────────────
const recent = await driver.list({ ...baseFilter, limit: 50 });
const summary: UsageSummary = {
generatedAt: now,
totals,
models,
providers,
clients: clientSummaries,
npubs,
days,
hoursToday,
sizeBuckets,
recent,
};
_cache = { key: cacheKey, summary };
return summary;
}
+107 -57
View File
@@ -1,13 +1,23 @@
import { createServer } from "http";
import { existsSync, unlinkSync } from "fs";
import {
ModelManager,
ProviderManager,
createStorageAdapterFromStore,
createSdkStore,
} from "@routstr/sdk";
// ModelManager must come from the bun entrypoint so persistent Nostr event
// storage (eventStoreDbPath) gets its SQLite-backed factory. The default
// "@routstr/sdk" export is browser-safe and throws without that factory
// (SDK 0.3.7+ browser-safe entrypoint split).
import { ModelManager } from "@routstr/sdk/bun";
import type { SdkLogger } from "@routstr/sdk";
import { CONFIG_DIR, DB_PATH, SOCKET_PATH, PID_FILE } from "../utils/config";
import {
CONFIG_DIR,
DB_PATH,
SOCKET_PATH,
PID_FILE,
REQUEST_RESPONSE_LOGS_DIR,
} from "../utils/config";
import { logger } from "../utils/logger";
@@ -29,22 +39,44 @@ import {
createBunSqliteDriver,
createBunSqliteUsageTrackingDriver,
createShardedDiscoveryAdapter,
createProviderRegistryFromDiscoveryAdapter,
} from "@routstr/sdk/storage";
} from "@routstr/sdk/storage/bun";
import { createWalletAdapter } from "./wallet";
import type { AutoRefillConfig } from "./wallet/auto-refill";
import { createCocodClient } from "./wallet/cocod-client";
import { createModelService } from "./models";
import { createDaemonRequestHandler } from "./http";
import { FileRequestResponseLogSink } from "./request-response-log-sink";
import { refreshModelsAndIntegrations } from "../integrations";
import { RoutstrClient } from "@routstr/sdk";
// Global error handlers — the daemon is spawned detached with stdout/stderr
// redirected to a file, so without these, uncaught async errors would kill
// the process silently. Log to the file logger before exiting.
process.on("uncaughtException", (error) => {
logger.error("UNCAUGHT EXCEPTION:", error);
});
process.on("unhandledRejection", (reason) => {
logger.error("UNHANDLED REJECTION:", reason);
});
async function main(): Promise<void> {
const args = parseArgs(process.argv);
const config = await loadDaemonConfig();
const port = args.port;
const provider = args.provider || config.provider;
const requestResponseLogDir =
process.env.ROUTSTRD_REQUEST_RESPONSE_LOG_DIR ||
(config.requestResponseLogging?.enabled
? config.requestResponseLogging.dir || REQUEST_RESPONSE_LOGS_DIR
: undefined);
const requestResponseLogSink = requestResponseLogDir
? new FileRequestResponseLogSink({
dir: requestResponseLogDir,
logger: daemonSdkLogger.child("request-response-log"),
})
: undefined;
await ensureDirs();
@@ -54,23 +86,22 @@ async function main(): Promise<void> {
const sqliteDriver = await createBunSqliteDriver(DB_PATH, { logger: daemonSdkLogger });
const { store, hydrate } = createSdkStore({ driver: sqliteDriver });
await hydrate;
const { Database } = await import("bun:sqlite");
const usageTrackingDriver = createBunSqliteUsageTrackingDriver({
const usageTrackingDriver = await createBunSqliteUsageTrackingDriver({
dbPath: DB_PATH,
sqlite: { Database },
legacyStorageDriver: sqliteDriver,
});
const discoveryAdapter = await createShardedDiscoveryAdapter({ driver: sqliteDriver });
const providerRegistry = createProviderRegistryFromDiscoveryAdapter(discoveryAdapter, daemonSdkLogger);
const storageAdapter = createStorageAdapterFromStore(store);
const modelManager = new ModelManager(discoveryAdapter, {
logger: daemonSdkLogger,
eventStoreDbPath: `${CONFIG_DIR}/events.db`,
routstrPubkey: config.routstrPubkey,
nostrRelays: config.relays,
});
// Create shared ProviderManager for consistent failure tracking across all requests
const providerManager = new ProviderManager(providerRegistry, store, daemonSdkLogger);
const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders } =
const providerManager = new ProviderManager(discoveryAdapter, store, daemonSdkLogger);
const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } =
createModelService(modelManager, store);
const walletClient = createCocodClient({ cocodPath: config.cocodPath });
@@ -101,7 +132,7 @@ async function main(): Promise<void> {
const refundClient = new RoutstrClient(
walletAdapter,
storageAdapter,
providerRegistry,
discoveryAdapter,
"min",
"apikeys",
{ logger: daemonSdkLogger },
@@ -117,17 +148,18 @@ async function main(): Promise<void> {
walletClient,
walletAdapter,
storageAdapter,
providerRegistry,
discoveryAdapter,
modelManager,
ensureProvidersBootstrapped,
getRoutstr21Models,
getModelProviders,
refreshProvidersAndModels,
mode: config.mode || "apikeys",
routstrPubkey: config.routstrPubkey,
usageTrackingDriver,
providerManager,
refundClient,
requestResponseLogSink,
}),
);
@@ -151,13 +183,22 @@ async function main(): Promise<void> {
`Starting recurring model refresh job (every ${REFRESH_INTERVAL_MS / 1000 / 60 / 60} hours)`,
);
refreshInterval = setInterval(async () => {
logger.log("Running scheduled model refresh...");
try {
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled");
} catch (error) {
logger.error("Scheduled model refresh failed:", error);
}
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);
};
@@ -178,46 +219,48 @@ async function main(): Promise<void> {
`Starting recurring refund job (every ${REFUND_INTERVAL_MS / 1000 / 60} minutes)`,
);
refundInterval = setInterval(async () => {
logger.log("Running scheduled refund...");
try {
const state = store.getState() as any;
const pendingDistribution = (state.cachedTokens || []).map(
(t: { baseUrl: string; balance?: number }) => ({
baseUrl: t.baseUrl,
amount: t.balance || 0,
}),
);
const apiKeysStored = (state.apiKeys || []).map(
(k: { baseUrl: string; balance?: number }) => ({
baseUrl: k.baseUrl,
amount: k.balance || 0,
}),
);
refundInterval = setInterval(() => {
(async () => {
logger.log("Running scheduled refund...");
try {
const state = store.getState() as any;
const pendingDistribution = (state.cachedTokens || []).map(
(t: { baseUrl: string; balance?: number }) => ({
baseUrl: t.baseUrl,
amount: t.balance || 0,
}),
);
const apiKeysStored = (state.apiKeys || []).map(
(k: { baseUrl: string; balance?: number }) => ({
baseUrl: k.baseUrl,
amount: k.balance || 0,
}),
);
if (pendingDistribution.length === 0 && apiKeysStored.length === 0) {
logger.log("No pending tokens to refund.");
return;
if (pendingDistribution.length === 0 && apiKeysStored.length === 0) {
logger.log("No pending tokens to refund.");
return;
}
const mintUrl = walletAdapter.getActiveMintUrl();
if (!mintUrl) {
logger.log("No active mint URL for refund.");
return;
}
const spender = refundClient.getCashuSpender();
const results = await spender.refundProviders(mintUrl);
const successCount = results.filter(
(r: { success: boolean }) => r.success,
).length;
logger.log(
`Scheduled refund completed: ${successCount}/${results.length} providers refunded.`,
);
} catch (error) {
logger.error("Scheduled refund failed:", error);
}
const mintUrl = walletAdapter.getActiveMintUrl();
if (!mintUrl) {
logger.log("No active mint URL for refund.");
return;
}
const spender = refundClient.getCashuSpender();
const results = await spender.refundProviders(mintUrl);
const successCount = results.filter(
(r: { success: boolean }) => r.success,
).length;
logger.log(
`Scheduled refund completed: ${successCount}/${results.length} providers refunded.`,
);
} catch (error) {
logger.error("Scheduled refund failed:", error);
}
})().catch((error) => logger.error("Refund interval escaped:", error));
}, REFUND_INTERVAL_MS);
};
@@ -237,10 +280,17 @@ async function main(): Promise<void> {
server.listen(port, async () => {
logger.log(`Routstr daemon listening on http://localhost:${port}/v1`);
if (requestResponseLogDir) {
logger.log(`Raw request/response logs: ${requestResponseLogDir}`);
}
// Start the recurring model refresh job after initial bootstrap
void ensureProvidersBootstrapped()
.then(async () => {
// Catch up on any Nostr events published since last run
logger.log("Running initial Nostr event refresh...");
await modelManager.refreshNostrEvents();
startModelRefreshJob();
startRefundJob();
// Run an immediate refresh to populate models right away
+55
View File
@@ -120,9 +120,64 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
};
};
/**
* Force-refresh everything: re-fetch Nostr provider discovery events,
* routstr21 model list, Nostr review events, and models from all enabled
* providers. Syncs the discovered provider list into the store.
*/
const refreshProvidersAndModels = async (): Promise<void> => {
// Reset the bootstrap promise so we don't reuse cached results
providerBootstrapPromise = null;
console.log("Force-refreshing providers from Nostr...");
// Force-refresh provider discovery from Nostr (kind 38421)
const providers = await modelManager.bootstrapProviders(false, true);
console.log(`Discovered ${providers.length} providers from Nostr`);
// Force-refresh routstr21 models from Nostr (kind 38423)
const routstr21ModelIds = await modelManager.fetchRoutstr21Models(true);
console.log(`Fetched ${routstr21ModelIds.length} routstr21 model IDs from Nostr`);
// Force-refresh models from all providers
const models = await modelManager.fetchModels(providers, true);
console.log(`Fetched ${models.length} models from ${providers.length} providers`);
// Sync review events from Nostr (kind 38425) and apply disabled status
const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr(
providers,
undefined,
true,
);
if (reviewedDisabled.length > 0) {
console.log(
`Review sync disabled ${reviewedDisabled.length} provider(s): ${reviewedDisabled.join(", ")}`,
);
}
// Sync discovered providers into the store
const { baseUrlsList, setBaseUrlsList, disabledProviders, setDisabledProviders } =
store.getState() as any;
// Replace baseUrlsList with the fresh provider list
setBaseUrlsList(providers);
// Merge review-disabled providers into the store's disabled list
const existingDisabled = new Set(disabledProviders || []);
for (const url of reviewedDisabled) {
existingDisabled.add(url);
}
setDisabledProviders([...existingDisabled]);
console.log(
`Provider refresh complete: ${providers.length} total, ${existingDisabled.size} disabled`,
);
};
return {
ensureProvidersBootstrapped,
getRoutstr21Models,
getModelProviders,
refreshProvidersAndModels,
};
}
+269
View File
@@ -0,0 +1,269 @@
import { createWriteStream, mkdirSync, type WriteStream } from "fs";
import { writeFile } from "fs/promises";
import { join } from "path";
import type { SdkLogger } from "@routstr/sdk";
export interface RequestResponseLogRequestInput {
method: string;
url: string;
path: string;
baseUrl: string;
headers: Record<string, string>;
body?: unknown;
rawBody?: string;
}
export interface RequestResponseLogSink {
logRequest?(input: RequestResponseLogRequestInput): string | undefined | Promise<string | undefined>;
logResponseStart?(id: string | undefined, response: Response): void | Promise<void>;
logResponseChunk?(id: string | undefined, sequence: number, text: string): void | Promise<void>;
logResponseEnd?(id: string | undefined): void | Promise<void>;
logResponseError?(id: string | undefined, error: unknown): void | Promise<void>;
logResponseBody?(id: string | undefined, response: Response): void | Promise<void>;
}
interface ActiveResponseLog {
stream: WriteStream;
pending: Promise<void>;
}
export interface FileRequestResponseLogSinkOptions {
dir: string;
logger?: SdkLogger;
}
const SENSITIVE_HEADER_NAMES = new Set([
"authorization",
"x-cashu",
"cookie",
"set-cookie",
"proxy-authorization",
]);
const SENSITIVE_BODY_FIELD_NAMES = new Set([
"authorization",
"api_key",
"apikey",
"apiKey",
"access_token",
"accessToken",
"bearer",
"cashu",
"cookie",
"key",
"password",
"secret",
"token",
"x-cashu",
]);
const REDACTED = "[REDACTED]";
const sanitizeForFilename = (value: string): string =>
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
const makeId = (): string => {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const random = crypto.randomUUID().slice(0, 8);
return `${timestamp}-${random}`;
};
const headersToObject = (headers: Headers): Record<string, string> => {
const out: Record<string, string> = {};
headers.forEach((value, key) => {
out[key] = value;
});
return out;
};
const redactHeaders = (headers: Record<string, string>): Record<string, string> =>
Object.fromEntries(
Object.entries(headers).map(([key, value]) => [
key,
SENSITIVE_HEADER_NAMES.has(key.toLowerCase()) ? REDACTED : value,
]),
);
const redactBody = (value: unknown): unknown => {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(redactBody);
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
key,
SENSITIVE_BODY_FIELD_NAMES.has(key) || SENSITIVE_BODY_FIELD_NAMES.has(key.toLowerCase())
? REDACTED
: redactBody(entry),
]),
);
};
const redactRawBody = (rawBody: string | undefined): string | undefined => {
if (!rawBody) return undefined;
try {
return JSON.stringify(redactBody(JSON.parse(rawBody)));
} catch {
return rawBody;
}
};
export class FileRequestResponseLogSink implements RequestResponseLogSink {
private requestsDir: string;
private responsesDir: string;
private activeResponses = new Map<string, ActiveResponseLog>();
constructor(private options: FileRequestResponseLogSinkOptions) {
this.requestsDir = join(options.dir, "requests");
this.responsesDir = join(options.dir, "responses");
mkdirSync(this.requestsDir, { recursive: true });
mkdirSync(this.responsesDir, { recursive: true });
}
async logRequest(input: RequestResponseLogRequestInput): Promise<string | undefined> {
try {
const id = makeId();
const filePath = join(this.requestsDir, `${sanitizeForFilename(id)}.json`);
await writeFile(
filePath,
JSON.stringify(
{
id,
timestamp: new Date().toISOString(),
method: input.method,
url: input.url,
path: input.path,
baseUrl: input.baseUrl,
headers: redactHeaders(input.headers),
body: redactBody(input.body),
rawBody: redactRawBody(input.rawBody),
},
null,
2,
),
);
return id;
} catch (error) {
this.options.logger?.error?.("[request-response-log] failed to log request:", error);
return undefined;
}
}
async logResponseStart(id: string | undefined, response: Response): Promise<void> {
if (!id) return;
await this.append(id, {
type: "response_start",
status: response.status,
statusText: response.statusText,
headers: redactHeaders(headersToObject(response.headers)),
});
}
logResponseChunk(id: string | undefined, sequence: number, text: string): void {
if (!id) return;
void this.append(id, {
type: "chunk",
sequence,
text,
});
}
async logResponseEnd(id: string | undefined): Promise<void> {
if (!id) return;
await this.append(id, { type: "end" });
await this.close(id);
}
async logResponseError(id: string | undefined, error: unknown): Promise<void> {
if (!id) return;
await this.append(id, {
type: "error",
error: error instanceof Error ? { message: error.message, stack: error.stack } : String(error),
});
await this.close(id);
}
async logResponseBody(id: string | undefined, response: Response): Promise<void> {
if (!id) return;
try {
if (!response.body) {
await this.logResponseEnd(id);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let sequence = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value && value.byteLength > 0) {
await this.append(id, {
type: "chunk",
sequence: sequence++,
text: decoder.decode(value, { stream: true }),
});
}
}
const tail = decoder.decode();
if (tail) {
await this.append(id, {
type: "chunk",
sequence: sequence++,
text: tail,
});
}
await this.logResponseEnd(id);
} catch (error) {
await this.logResponseError(id, error);
}
}
private getOrCreate(id: string): ActiveResponseLog {
const existing = this.activeResponses.get(id);
if (existing) return existing;
const filePath = join(this.responsesDir, `${sanitizeForFilename(id)}.jsonl`);
const stream = createWriteStream(filePath, { flags: "a" });
const active: ActiveResponseLog = {
stream,
pending: Promise.resolve(),
};
stream.on("error", (error) => {
this.options.logger?.error?.("[request-response-log] response log stream error:", error);
});
this.activeResponses.set(id, active);
return active;
}
private async append(id: string, event: Record<string, unknown>): Promise<void> {
try {
const active = this.getOrCreate(id);
const line = JSON.stringify({ requestLogId: id, timestamp: new Date().toISOString(), ...event }) + "\n";
active.pending = active.pending.then(
() =>
new Promise<void>((resolve, reject) => {
active.stream.write(line, (error) => {
if (error) reject(error);
else resolve();
});
}),
);
await active.pending;
} catch (error) {
this.options.logger?.error?.("[request-response-log] failed to append response event:", error);
}
}
private async close(id: string): Promise<void> {
const active = this.activeResponses.get(id);
if (!active) return;
this.activeResponses.delete(id);
await active.pending;
await new Promise<void>((resolve) => active.stream.end(resolve));
}
}
+6
View File
@@ -11,6 +11,12 @@ export type UsageData = {
totalTokens: number;
cost: number;
satsCost: number;
provider?: string;
inputMsats?: number;
outputMsats?: number;
totalMsats?: number;
cacheReadInputTokens?: number;
cacheCreationInputTokens?: number;
};
export type UsageTrackingEntry = UsageData & {
+72 -29
View File
@@ -1,10 +1,65 @@
import { existsSync, mkdirSync } from "fs";
import { readFile, writeFile } from "fs/promises";
import { dirname } from "path";
import { parseDocument } from "yaml";
import type { RoutstrdConfig } from "../utils/config";
import type { IntegrationConfig, RoutstrModel } from "./registry";
import { callDaemon, getDaemonBaseUrl } from "../utils/daemon-client";
interface HermesRoutstrConfig {
baseUrl: string;
apiKey: string;
defaultModel: string;
}
interface HermesCustomProvider {
name?: string;
[key: string]: unknown;
}
export function mergeHermesConfig(
content: string,
routstr: HermesRoutstrConfig,
): string {
const document = parseDocument(content || "{}", { prettyErrors: true });
if (document.errors.length > 0) {
throw document.errors[0];
}
const urlDisplay = routstr.baseUrl
.replace(/\/v1$/, "")
.replace(/^https?:\/\//, "");
const provider = {
name: `Routstr (${urlDisplay})`,
base_url: routstr.baseUrl,
api_key: routstr.apiKey,
model: routstr.defaultModel,
};
const isNewConfig = content.trim() === "";
if (isNewConfig) {
document.set("model", {
default: routstr.defaultModel,
provider: "custom",
base_url: routstr.baseUrl,
api_key: routstr.apiKey,
});
}
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;
}
providers.push(provider);
document.set("custom_providers", providers);
return document.toString();
}
export async function installHermesIntegration(
config: RoutstrdConfig,
apiKey: string,
@@ -26,10 +81,10 @@ export async function installHermesIntegration(
if (models.length >= 3) {
defaultModel = models[2]!.id;
console.log(`Set default model to 3rd available model: ${defaultModel}`);
console.log(`Using 3rd available model for new Hermes configurations: ${defaultModel}`);
} else if (models.length > 0) {
defaultModel = models[0]!.id;
console.log(`Only ${models.length} models available, using ${defaultModel} as default.`);
console.log(`Only ${models.length} models available, using ${defaultModel} for new Hermes configurations.`);
} else {
console.log("No models available from routstr daemon, using fallback default.");
}
@@ -47,36 +102,24 @@ export async function installHermesIntegration(
console.error(`Error reading ${configPath}, creating new one.`);
}
// Remove existing model block
content = content.replace(/^model:\n(?: .*\n)*/gm, "");
// Remove existing custom_providers block
content = content.replace(/^custom_providers:\n(?:- .*\n(?: .*\n)*)*/gm, "");
// Clean up extra blank lines
content = content.replace(/\n{3,}/g, "\n\n").trim();
const urlDisplay = baseUrl.replace(/^https?:\/\//, "");
const modelBlock = `model:
default: ${defaultModel}
provider: custom
base_url: ${baseUrlV1}
api_key: ${apiKey}`;
const providerBlock = `custom_providers:
- name: Routstr (${urlDisplay})
base_url: ${baseUrlV1}
api_key: ${apiKey}
model: ${defaultModel}`;
const parts: string[] = [modelBlock];
if (content) {
parts.push(content);
let newContent: string;
try {
newContent = mergeHermesConfig(content, {
baseUrl: baseUrlV1,
apiKey,
defaultModel,
});
} catch (error) {
console.error(`Failed to parse ${configPath} as YAML; leaving it unchanged:`, error);
return;
}
parts.push(providerBlock);
const newContent = parts.join("\n\n") + "\n";
try {
if (newContent === content) {
console.log(`${configPath} already contains current routstr settings.`);
return;
}
mkdirSync(dirname(configPath), { recursive: true });
await writeFile(configPath, newContent);
console.log(`Successfully updated ${configPath} with routstr settings.`);
+7 -2
View File
@@ -1,3 +1,4 @@
import { openSync } from "fs";
import { logger } from "./utils/logger";
import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
import { withCrossProcessLock } from "./utils/process-lock";
@@ -5,6 +6,7 @@ import { fileURLToPath } from "url";
import { existsSync } from "fs";
const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`;
async function isDaemonHealthy(port: string): Promise<boolean> {
const controller = new AbortController();
@@ -45,9 +47,12 @@ async function startDaemonUnlocked(
if (!existsSync(daemonScript)) {
daemonScript = fileURLToPath(new URL("./daemon/index.ts", import.meta.url));
}
const debugLogFd = openSync(DEBUG_LOG_PATH, "a");
const proc = Bun.spawn(["bun", daemonScript, ...args], {
stdout: "ignore",
stderr: "ignore",
stdout: debugLogFd,
stderr: debugLogFd,
stdin: "ignore",
detached: true,
});
+126 -71
View File
@@ -1,6 +1,6 @@
import { getVisibleTabs } from "./constants.ts";
import type { Tab } from "./types.ts";
import { fetchBalance, fetchClients, fetchStatus, fetchUsage, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
import { fetchBalance, fetchClients, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
import {
applyScrollToContent,
exitSearchMode,
@@ -28,7 +28,8 @@ import {
} from "./terminal.ts";
import { COLORS } from "./constants.ts";
import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts";
import type { TabId, UsageStats } from "./types.ts";
import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
import { checkForUpdates } from "../../utils/update-checker.ts";
export async function runUsageTui(): Promise<void> {
const running = await isDaemonRunning();
@@ -49,10 +50,42 @@ export async function runUsageTui(): Promise<void> {
let clients: ClientInfo[] = [];
let visibleTabs: Tab[] = getVisibleTabs(false);
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let shouldUpdate = true;
let autoRefresh = true;
let cleanedUp = false;
let rendering = false;
let fetching = false;
// Update-check state — re-checked at most every 210 minutes to avoid
// spamming the npm registry. The first check happens shortly after
// startup so the TUI doesn't block on a network call.
const UPDATE_CHECK_INTERVAL_MS = 210 * 60 * 1000;
const UPDATE_CHECK_DELAY_MS = 3000;
let updateInfo: UpdateInfo | null = null;
let lastUpdateCheck = 0;
let updateCheckInProgress = false;
async function maybeCheckForUpdates(): Promise<void> {
if (updateCheckInProgress) return;
const now = Date.now();
if (updateInfo && now - lastUpdateCheck < UPDATE_CHECK_INTERVAL_MS) return;
updateCheckInProgress = true;
lastUpdateCheck = now;
try {
const result = await checkForUpdates();
const outdated = result.packages.filter((p) => p.hasUpdate);
updateInfo = {
hasUpdate: result.hasUpdate,
text: outdated.length > 0
? `Update available: ${outdated.map((p) => p.label).join(", ")} ${outdated.map((p) => p.latest).join(", ")}`
: "",
};
render();
} catch {
// Silently ignore — update checks are best-effort
} finally {
updateCheckInProgress = false;
}
}
if (isInteractive) {
stdout.write(enterAlternateScreen() + hideCursor());
@@ -80,123 +113,141 @@ export async function runUsageTui(): Promise<void> {
process.on("SIGINT", () => cleanup(0));
process.on("SIGTERM", () => cleanup(0));
async function render(forceFetch = false) {
if (rendering) return;
rendering = true;
/**
* Background fetch — async, never blocks rendering. Updates the state
* variables only on success, then triggers a repaint. Overlapping calls
* are skipped via the `fetching` guard so we don't race stale results.
*/
async function fetchData(): Promise<void> {
if (fetching) return;
fetching = true;
try {
const width = getWidth();
const height = getHeight();
const running = await isDaemonRunning();
if (!running) {
stats = null;
} else {
// Fire all 4 fetches concurrently — cuts the blocked window significantly.
const [newStats, newBalance, newStatus, newClients] = await Promise.all([
fetchUsageSummary(),
fetchBalance(),
fetchStatus(),
fetchClients(),
]);
if (newStats) stats = newStats;
if (newBalance) balance = newBalance;
if (newStatus) status = newStatus;
if (newClients && newClients.length > 0) clients = newClients;
if (forceFetch || shouldUpdate) {
stats = await fetchUsage(10000);
balance = await fetchBalance();
status = await fetchStatus();
clients = await fetchClients();
const npubsVisible = hasAnyNpubs(clients);
visibleTabs = getVisibleTabs(npubsVisible);
// If current tab is npubs but it's no longer visible, fall back to clients
if (currentTab === "npubs" && !npubsVisible) {
currentTab = "clients";
vimState.scrollPos = 0;
}
shouldUpdate = false;
}
if (!stats) {
stdout.write(
moveCursor(1, 1) +
eraseDown() +
`${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
`Make sure routstrd is running.\n` +
`\nPress Q to quit.`
);
return;
}
const content = renderTabContent(currentTab, stats, balance, status, width, clients);
const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
const chromeLines = chrome.split("\n").length - 1;
const footerSeparator = renderSeparator(width);
const footerLines = footerSeparator.split("\n").length - 1;
const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
const visibleContent = applyScrollToContent(content, contentViewportHeight);
const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
render();
} finally {
rendering = false;
fetching = false;
}
}
/**
* Synchronous paint — never awaits I/O. Reads the current state variables
* and writes to stdout. Safe to call from key handlers without blocking.
*/
function render(): void {
const width = getWidth();
const height = getHeight();
if (!stats) {
stdout.write(
moveCursor(1, 1) +
eraseDown() +
`${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
`Make sure routstrd is running.\n` +
`\nPress Q to quit.`
);
return;
}
const content = renderTabContent(currentTab, stats, balance, status, width, clients);
const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
const chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
const chromeLines = chrome.split("\n").length - 1;
const footerSeparator = renderSeparator(width);
const footerLines = footerSeparator.split("\n").length - 1;
const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
const visibleContent = applyScrollToContent(content, contentViewportHeight);
const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
}
const handleKey = (key: string) => {
if (vimState.isSearching) {
if (key === "\x1b" || key === "\x1b[3~") {
exitSearchMode();
void render(false);
render();
return;
}
if (key === "\r" || key === "\n") {
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
exitSearchMode();
void render(false);
render();
return;
}
if (key === "\x7f" || key === "\x08") {
vimState.searchQuery = vimState.searchQuery.slice(0, -1);
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
void render(false);
render();
return;
}
if (key === "\x03") {
exitSearchMode();
void render(false);
render();
return;
}
if (key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
vimState.searchQuery += key;
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
void render(false);
render();
}
return;
}
if (key === "q" || key === "Q" || key === "\u0003") return cleanup(0);
if (key === "r" || key === "R") {
shouldUpdate = true;
void render(true);
void fetchData();
return;
}
if (key === "a" || key === "A") {
autoRefresh = !autoRefresh;
shouldUpdate = true;
void render(false);
render();
return;
}
if (key === "j" || key === "\x1b[B" || key === "\x1bOB") {
scrollDown();
void render(false);
render();
return;
}
if (key === "k" || key === "\x1b[A" || key === "\x1bOA") {
scrollUp();
void render(false);
render();
return;
}
if (key === "l" || key === "\x1b[C" || key === "\x1bOC") {
const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length]!.id;
vimState.scrollPos = 0;
void render(false);
render();
return;
}
if (key === "h" || key === "\x1b[D" || key === "\x1bOD") {
const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length]!.id;
vimState.scrollPos = 0;
void render(false);
render();
return;
}
@@ -204,54 +255,58 @@ export async function runUsageTui(): Promise<void> {
if (vimState.lastKey === "g" && Date.now() - vimState.lastKeyTime < 300) {
scrollToBottom();
vimState.lastKey = "";
void render(false);
render();
return;
}
vimState.lastKey = "g";
vimState.lastKeyTime = Date.now();
scrollToTop();
void render(false);
render();
return;
}
if (key === "\x02") { pageUp(); void render(false); return; }
if (key === "\x06") { pageDown(); void render(false); return; }
if (key === "\x15") { scrollUp(10); void render(false); return; }
if (key === "\x04") { scrollDown(10); void render(false); return; }
if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); void render(false); return; }
if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); void render(false); return; }
if (key === "/") { startSearch(false); void render(false); return; }
if (key === "?") { startSearch(true); void render(false); return; }
if (key === "\x02") { pageUp(); render(); return; }
if (key === "\x06") { pageDown(); render(); return; }
if (key === "\x15") { scrollUp(10); render(); return; }
if (key === "\x04") { scrollDown(10); render(); return; }
if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); render(); return; }
if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); render(); return; }
if (key === "/") { startSearch(false); render(); return; }
if (key === "?") { startSearch(true); render(); return; }
if (key === "n") {
if (vimState.searchReverse) prevSearchResult(stats?.entries.length || 0);
else nextSearchResult(stats?.entries.length || 0);
void render(false);
render();
return;
}
if (key === "N") {
if (vimState.searchReverse) nextSearchResult(stats?.entries.length || 0);
else prevSearchResult(stats?.entries.length || 0);
void render(false);
render();
return;
}
if (key === "\x1b") { scrollToTop(); void render(false); return; }
if (key === "\x1b") { scrollToTop(); render(); return; }
const tab = visibleTabs.find((t) => t.key === key);
if (tab) {
currentTab = tab.id;
vimState.scrollPos = 0;
void render(false);
render();
}
};
if (isInteractive) stdin.on("data", handleKey);
await render(true);
await fetchData();
// Kick off the first update check after a short delay so the initial
// data fetch isn't blocked by a network round-trip to npm.
setTimeout(() => void maybeCheckForUpdates(), UPDATE_CHECK_DELAY_MS);
refreshInterval = setInterval(() => {
if (autoRefresh) {
shouldUpdate = true;
void render(true);
void fetchData();
}
void maybeCheckForUpdates();
}, 2000);
}
+12 -193
View File
@@ -1,6 +1,6 @@
import type { UsageTrackingEntry } from "../../daemon/types.ts";
import { callDaemon, isDaemonRunning } from "../../utils/daemon-client.ts";
import type { ClientStats, DayStats, ModelStats, NpubStats, ProviderStats, UsageStats } from "./types.ts";
import type { UsageStats, UsageSummary } from "./types.ts";
export { isDaemonRunning };
@@ -26,9 +26,6 @@ export interface StatusInfo {
export async function fetchStatus(): Promise<StatusInfo | null> {
try {
const running = await isDaemonRunning();
if (!running) return null;
const result = await callDaemon("/status");
if (result.error) return null;
@@ -52,9 +49,6 @@ export async function fetchStatus(): Promise<StatusInfo | null> {
export async function fetchBalance(): Promise<BalanceInfo | null> {
try {
const running = await isDaemonRunning();
if (!running) return null;
const result = await callDaemon("/keys/balance");
if (result.error) return null;
@@ -76,53 +70,28 @@ export async function fetchBalance(): Promise<BalanceInfo | null> {
}
}
export async function fetchUsage(limit = 10000): Promise<UsageStats | null> {
export async function fetchUsageSummary(): Promise<UsageStats | null> {
try {
const running = await isDaemonRunning();
if (!running) return null;
const result = await callDaemon(`/usage?limit=${limit}`);
const tz = new Date().getTimezoneOffset();
const result = await callDaemon(`/usage/summary?tz=${tz}`);
if (result.error) return null;
// The auth proxy filters usage to the authenticated npub's clients and
// returns client IDs without the owner suffix.
const entries = result.output as UsageTrackingEntry[] | undefined;
const visibleEntries = Array.isArray(entries) ? entries : [];
// Calculate totals from visible entries
const totals = visibleEntries.reduce(
(acc, entry) => ({
promptTokens: acc.promptTokens + entry.promptTokens,
completionTokens: acc.completionTokens + entry.completionTokens,
totalTokens: acc.totalTokens + entry.totalTokens,
satsCost: acc.satsCost + entry.satsCost,
}),
{ promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 },
);
const summary = result.output as UsageSummary | undefined;
if (!summary || typeof summary.totals !== "object") return null;
return {
entries: visibleEntries,
totalEntries: visibleEntries.length,
totalSatsCost: totals.satsCost,
recentSatsCost: totals.satsCost, // For now, recent = total since we don't have time window
limit,
entries: summary.recent,
totalEntries: summary.totals.requests,
totalSatsCost: summary.totals.satsCost,
recentSatsCost: summary.totals.satsCost,
limit: 50,
summary,
};
} catch {
return null;
}
}
export function getTodayStart(): number {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
export function formatDate(timestamp: number): string {
const d = new Date(timestamp);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
export function formatTime(timestamp: number): string {
const d = new Date(timestamp);
const p = (n: number) => String(n).padStart(2, "0");
@@ -135,100 +104,6 @@ export function formatNumber(n: number): string {
return n.toString();
}
export function getDayStats(entries: UsageTrackingEntry[]): Map<string, DayStats> {
const days = new Map<string, DayStats>();
for (const entry of entries) {
const date = formatDate(entry.timestamp);
const existing = days.get(date) || { date, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
days.set(date, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return days;
}
export function getHourlyToday(entries: UsageTrackingEntry[]): Map<number, DayStats> {
const todayStart = getTodayStart();
const hours = new Map<number, DayStats>();
for (const entry of entries) {
if (entry.timestamp < todayStart) continue;
const hour = new Date(entry.timestamp).getHours();
const existing = hours.get(hour) || {
date: formatDate(entry.timestamp), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
hours.set(hour, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return hours;
}
export function getModelStats(entries: UsageTrackingEntry[]): ModelStats[] {
const models = new Map<string, ModelStats>();
for (const entry of entries) {
const existing = models.get(entry.modelId) || {
modelId: entry.modelId, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
models.set(entry.modelId, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return Array.from(models.values()).sort((a, b) => b.satsCost - a.satsCost);
}
export function getProviderStats(entries: UsageTrackingEntry[]): ProviderStats[] {
const providers = new Map<string, ProviderStats>();
for (const entry of entries) {
const url = entry.baseUrl || "unknown";
const existing = providers.get(url) || {
baseUrl: url, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
providers.set(url, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return Array.from(providers.values()).sort((a, b) => b.satsCost - a.satsCost);
}
export function getClientStats(entries: UsageTrackingEntry[]): ClientStats[] {
const clients = new Map<string, ClientStats>();
for (const entry of entries) {
const client = entry.client || "unknown";
const existing = clients.get(client) || {
client, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
clients.set(client, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost);
}
// ─── Client / Npub helpers ────────────────────────────────────────────
export interface ClientInfo {
@@ -239,9 +114,6 @@ export interface ClientInfo {
export async function fetchClients(): Promise<ClientInfo[]> {
try {
const running = await isDaemonRunning();
if (!running) return [];
const result = await callDaemon("/clients");
if (result.error) return [];
@@ -262,56 +134,3 @@ export async function fetchClients(): Promise<ClientInfo[]> {
export function hasAnyNpubs(clients: ClientInfo[]): boolean {
return clients.some((c) => !!c.ownerNpub);
}
export function getNpubStats(entries: UsageTrackingEntry[], clients: ClientInfo[]): NpubStats[] {
// Build client-id → ownerNpub lookup
const clientNpubMap = new Map<string, string>();
for (const c of clients) {
if (c.ownerNpub) {
clientNpubMap.set(c.clientId, c.ownerNpub);
}
}
// Aggregate usage per npub
const npubs = new Map<string, NpubStats>();
for (const entry of entries) {
const npub = clientNpubMap.get(entry.client || "");
if (!npub) continue; // skip entries whose client has no ownerNpub
const existing = npubs.get(npub) || {
npub,
requests: 0,
satsCost: 0,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
};
npubs.set(npub, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return Array.from(npubs.values()).sort((a, b) => b.satsCost - a.satsCost);
}
// ─── Totals ───────────────────────────────────────────────────────────
export function getTotals(entries: UsageTrackingEntry[] | undefined) {
if (!entries || !Array.isArray(entries)) {
return { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 };
}
return entries.reduce(
(acc, entry) => ({
promptTokens: acc.promptTokens + entry.promptTokens,
completionTokens: acc.completionTokens + entry.completionTokens,
totalTokens: acc.totalTokens + entry.totalTokens,
satsCost: acc.satsCost + entry.satsCost,
}),
{ promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 }
);
}
+92 -113
View File
@@ -1,23 +1,14 @@
import { CLIENT_COLORS, COLORS, MODEL_COLORS } from "./constants.ts";
import type { Tab } from "./types.ts";
import {
formatDate,
formatNumber,
formatTime,
getClientStats,
getDayStats,
getHourlyToday,
getModelStats,
getNpubStats,
getProviderStats,
getTodayStart,
getTotals,
type ClientInfo,
} from "./data.ts";
import { vimState } from "./state.ts";
import { stripAnsi } from "./terminal.ts";
import type { BalanceInfo, StatusInfo } from "./data.ts";
import type { TabId, UsageStats } from "./types.ts";
import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
/** Format a cost value: 0.12, 1.23, 12.34, 123.45, 1.23k, 1.23m */
function formatCost(value: number): string {
@@ -33,13 +24,20 @@ function formatReqs(value: number): string {
return value.toString();
}
export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[]): string {
export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[], updateInfo?: UpdateInfo): string {
const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
const maxKey = visibleTabs.length;
const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
const fill = width - title.length - help.length - vimIndicator.length - 6;
return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}\n`;
const headerLine = `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}`;
if (updateInfo?.hasUpdate) {
const banner = `${COLORS.bold}${COLORS.yellow} ↻ UPDATE AVAILABLE — run ${COLORS.green}routstrd update${COLORS.yellow} to update${COLORS.reset}`;
return `${headerLine}\n${banner}\n`;
}
return `${headerLine}\n`;
}
export function renderSearchBar(): string {
@@ -120,8 +118,7 @@ export function renderBarChart(
export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number): string {
// Use the server-calculated totals (all entries) instead of summing limited entries
const totals = getTotals(stats.entries);
const totals = stats.summary.totals;
const totalRequests = stats.totalEntries; // Use server's total count, not entries.length
const totalVisibleCost = stats.totalSatsCost; // <-- Use server's total, not client-side sum of limited entries
const avgCost = totalRequests > 0 ? totalVisibleCost / totalRequests : 0;
@@ -240,7 +237,7 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s
output = renderBox(statusLines, width, "System Status") + "\n" + output;
}
const modelStats = getModelStats(stats.entries);
const modelStats = stats.summary.models;
if (modelStats.length > 0) {
const maxCost = modelStats[0]!.satsCost;
const totalCost = Math.max(totalVisibleCost, 1);
@@ -259,7 +256,7 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s
output += "\n" + renderBox(modelLines, width, "Top Models by Cost");
}
const clientStats = getClientStats(stats.entries);
const clientStats = stats.summary.clients;
if (clientStats.length > 0) {
const maxCost = clientStats[0]!.satsCost;
const totalCost = Math.max(totalVisibleCost, 1);
@@ -282,19 +279,35 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s
}
export function renderToday(stats: UsageStats, width: number): string {
const hourly = getHourlyToday(stats.entries);
const todayStart = getTodayStart();
const currentHour = new Date().getHours();
const todayStats = { date: formatDate(Date.now()), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
// Build today's date string from LOCAL date components so it matches the
// server's tz-bucketed day keys (which use the client's tzOffsetMinutes).
const _now = new Date();
const todayDateStr = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "0")}`;
for (const entry of stats.entries) {
if (entry.timestamp >= todayStart) {
todayStats.requests++;
todayStats.satsCost += entry.satsCost;
todayStats.promptTokens += entry.promptTokens;
todayStats.completionTokens += entry.completionTokens;
todayStats.totalTokens += entry.totalTokens;
let todayStats: { date: string; requests: number; satsCost: number; promptTokens: number; completionTokens: number; totalTokens: number } = {
date: todayDateStr,
requests: 0,
satsCost: 0,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
};
let recentDays: Array<{ date: string; requests: number; satsCost: number; totalTokens: number; promptTokens: number; completionTokens: number }> = [];
let hourlyMap: Map<number, { requests: number; satsCost: number }> = new Map();
if (stats.summary) {
// Use server-aggregated data
const { days, hoursToday } = stats.summary;
// days[0] is most-recent-first; check if it's today
const todayDayStat = days[0]?.date === todayDateStr ? days[0] : undefined;
if (todayDayStat) {
todayStats = { date: todayDayStat.date, requests: todayDayStat.requests, satsCost: todayDayStat.satsCost, promptTokens: todayDayStat.promptTokens, completionTokens: todayDayStat.completionTokens, totalTokens: todayDayStat.totalTokens };
}
// Exclude today by date (it has its own box) rather than by position —
// days[0] is only today when there was activity today.
recentDays = days.filter((d) => d.date !== todayDateStr).slice(0, 6);
hourlyMap = new Map(hoursToday.map((h) => [h.hour, { requests: h.requests, satsCost: h.satsCost }]));
}
const summaryLines = [
@@ -306,18 +319,17 @@ export function renderToday(stats: UsageStats, width: number): string {
let output = renderBox(summaryLines, width, "Today");
const days = Array.from(getDayStats(stats.entries).values()).slice(0, 7);
if (days.length > 1) {
const dayLines = days.slice(1).map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`);
if (recentDays.length > 0) {
const dayLines = recentDays.map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`);
output += "\n" + renderBox(dayLines, width, "Recent Days");
}
const hourLines: string[] = [];
const maxHourCost = Math.max(...Array.from(hourly.values()).map((h) => h.satsCost), 1);
const maxHourCost = Math.max(...Array.from(hourlyMap.values()).map((h) => h.satsCost), 1);
const totalTodayCost = Math.max(todayStats.satsCost, 1);
const hourLabels: string[] = [];
for (let h = currentHour; h >= 0; h--) {
const hStat = hourly.get(h);
const hStat = hourlyMap.get(h);
const reqs = hStat?.requests || 0;
const cost = hStat?.satsCost || 0;
hourLabels.push(`${h.toString().padStart(2, "0")}:00 (${formatReqs(reqs)} req, ${formatCost(cost)} sats) `);
@@ -325,7 +337,7 @@ export function renderToday(stats: UsageStats, width: number): string {
const maxHourLabel = Math.max(...hourLabels.map((l) => l.length));
startBarSection("hourly", maxHourLabel);
for (let i = currentHour; i >= 0; i--) {
const hStat = hourly.get(i);
const hStat = hourlyMap.get(i);
const reqs = hStat?.requests || 0;
const cost = hStat?.satsCost || 0;
hourLines.push(renderBarChart(
@@ -346,7 +358,7 @@ export function renderToday(stats: UsageStats, width: number): string {
}
export function renderModels(stats: UsageStats, width: number): string {
const modelStats = getModelStats(stats.entries);
const modelStats = stats.summary.models;
if (modelStats.length === 0) return renderBox(["No model data available"], width, "Models");
// Use totalSatsCost (all-time) for percentage calculations to match header
@@ -373,7 +385,7 @@ export function renderModels(stats: UsageStats, width: number): string {
}
export function renderProviders(stats: UsageStats, width: number): string {
const providerStats = getProviderStats(stats.entries);
const providerStats = stats.summary.providers;
if (providerStats.length === 0) return renderBox(["No provider data available"], width, "Providers");
const lines: string[] = [];
@@ -389,8 +401,8 @@ export function renderProviders(stats: UsageStats, width: number): string {
}
export function renderTokens(stats: UsageStats, width: number): string {
const totals = getTotals(stats.entries);
const modelStats = getModelStats(stats.entries);
const totals = stats.summary.totals;
const modelStats = stats.summary.models;
const summaryLines = [
`${COLORS.bold}Total Prompt Tokens:${COLORS.reset} ${formatNumber(totals.promptTokens)}`,
`${COLORS.bold}Total Completion Tokens:${COLORS.reset} ${formatNumber(totals.completionTokens)}`,
@@ -410,23 +422,7 @@ export function renderTokens(stats: UsageStats, width: number): string {
output += "\n" + renderBox(tokenLines, width, "Tokens by Model");
}
const sizeBuckets = {
tiny: { min: 0, max: 1000, count: 0, cost: 0 },
small: { min: 1000, max: 10000, count: 0, cost: 0 },
medium: { min: 10000, max: 50000, count: 0, cost: 0 },
large: { min: 50000, max: 100000, count: 0, cost: 0 },
huge: { min: 100000, max: Infinity, count: 0, cost: 0 },
};
for (const entry of stats.entries) {
for (const bucket of Object.values(sizeBuckets)) {
if (entry.totalTokens >= bucket.min && entry.totalTokens < bucket.max) {
bucket.count++;
bucket.cost += entry.satsCost;
break;
}
}
}
const sizeBuckets = stats.summary.sizeBuckets;
const sizeLines = Object.entries(sizeBuckets).map(([name, bucket]) => `${name.padEnd(6)}: ${formatReqs(bucket.count).padStart(5)} reqs, ${formatCost(bucket.cost)} sats`);
output += "\n" + renderBox(sizeLines, width, "Request Size Distribution");
@@ -434,7 +430,7 @@ export function renderTokens(stats: UsageStats, width: number): string {
}
export function renderClients(stats: UsageStats, width: number): string {
const clientStats = getClientStats(stats.entries);
const clientStats = stats.summary.clients;
if (clientStats.length === 0) return renderBox(["No client data available (API key auth not used)"], width, "Client Breakdown");
// Use totalSatsCost (all-time) for percentage calculations to match header
@@ -478,31 +474,19 @@ export function renderClients(stats: UsageStats, width: number): string {
endBarSection("client-detail");
let output = renderBox(lines, width, "Client Breakdown");
const clientModelMap = new Map<string, Map<string, { requests: number; satsCost: number; tokens: number }>>();
for (const entry of stats.entries) {
const client = entry.client || "unknown";
const model = entry.modelId;
if (!clientModelMap.has(client)) clientModelMap.set(client, new Map());
const modelMap = clientModelMap.get(client)!;
const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 };
modelMap.set(model, {
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
tokens: existing.tokens + entry.totalTokens,
});
}
const clientModelLines: string[] = [];
for (const topClient of clientStats.slice(0, 3)) {
const modelMap = clientModelMap.get(topClient.client);
if (!modelMap) continue;
const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5);
clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`);
for (const [model, data] of models) {
clientModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`);
if (stats.summary) {
// Use pre-aggregated topModels from summary; exclude the "unknown" bucket
// (null client rows have no meaningful model attribution to display here).
for (const topClient of stats.summary.clients.filter((c) => c.client !== "unknown").slice(0, 3)) {
if (topClient.topModels.length === 0) continue;
clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`);
for (const m of topClient.topModels) {
clientModelLines.push(` ${(MODEL_COLORS[m.modelId] || MODEL_COLORS.default)}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`);
}
clientModelLines.push("");
}
clientModelLines.push("");
}
if (clientModelLines.length > 0) {
@@ -512,7 +496,7 @@ export function renderClients(stats: UsageStats, width: number): string {
}
export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string {
const npubStats = getNpubStats(stats.entries, clients);
const npubStats = stats.summary.npubs;
if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown");
const totalCost = stats.totalSatsCost;
@@ -557,38 +541,18 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
endBarSection("npub-detail");
let output = renderBox(lines, width, "Npub Breakdown");
// Top models per npub (same pattern as clients tab)
const npubModelMap = new Map<string, Map<string, { requests: number; satsCost: number; tokens: number }>>();
const clientNpubMap = new Map<string, string>();
for (const c of clients) {
if (c.ownerNpub) clientNpubMap.set(c.clientId, c.ownerNpub);
}
for (const entry of stats.entries) {
const npub = clientNpubMap.get(entry.client || "");
if (!npub) continue;
const model = entry.modelId;
if (!npubModelMap.has(npub)) npubModelMap.set(npub, new Map());
const modelMap = npubModelMap.get(npub)!;
const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 };
modelMap.set(model, {
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
tokens: existing.tokens + entry.totalTokens,
});
}
const npubModelLines: string[] = [];
for (const topNpub of npubStats.slice(0, 5)) {
const modelMap = npubModelMap.get(topNpub.npub);
if (!modelMap) continue;
const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5);
npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
for (const [model, data] of models) {
npubModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`);
if (stats.summary) {
// Use pre-aggregated topModels from summary
for (const topNpub of stats.summary.npubs.slice(0, 5)) {
if (topNpub.topModels.length === 0) continue;
npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
for (const m of topNpub.topModels) {
npubModelLines.push(` ${(MODEL_COLORS[m.modelId] || MODEL_COLORS.default)}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`);
}
npubModelLines.push("");
}
npubModelLines.push("");
}
if (npubModelLines.length > 0) {
@@ -609,20 +573,35 @@ export function renderRecent(stats: UsageStats, width: number): string {
if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
const clientCol = 14;
const tokensCol = 18;
const costCol = 18;
const providerCol = Math.max(16, width - 4 - 10 - 18 - tokensCol - costCol - clientCol - 5);
const msatsToSats = (msats?: number) => typeof msats === "number" ? msats / 1000 : 0;
const lines: string[] = [];
lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".padEnd(16)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`);
lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"I/CR/CW/O".padEnd(tokensCol)} ${"I/O/T in sats".padEnd(costCol)} ${"BASE:PROVIDER".padEnd(providerCol)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`);
lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
for (const entry of recentEntries) {
const time = formatTime(entry.timestamp).slice(0, 8);
const model = entry.modelId.slice(0, 18).padEnd(18);
const tokens = `${formatNumber(entry.totalTokens).padEnd(6)} (${formatNumber(entry.promptTokens)}+${formatNumber(entry.completionTokens)})`;
const cost = `${formatCost(entry.satsCost).padEnd(8)} sats`;
const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, 16).padEnd(16);
const tokens = [
entry.promptTokens,
entry.cacheReadInputTokens || 0,
entry.cacheCreationInputTokens || 0,
entry.completionTokens,
].map(formatNumber).join("/");
const totalSats = typeof entry.totalMsats === "number" ? entry.totalMsats / 1000 : entry.satsCost;
const cost = [
formatCost(msatsToSats(entry.inputMsats)),
formatCost(msatsToSats(entry.outputMsats)),
formatCost(totalSats),
].join("/");
const baseUrl = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "");
const provider = `${baseUrl}:${entry.provider || "unknown"}`.slice(0, providerCol).padEnd(providerCol);
const clientName = (entry.client || "unknown").slice(0, clientCol - 1);
const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset}`);
lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(tokensCol)} ${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset}`);
}
return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
+10 -45
View File
@@ -1,4 +1,13 @@
import type { UsageTrackingEntry } from "../../daemon/types.ts";
import type { UsageSummary } from "../../daemon/http/usage-summary.ts";
import type { UpdateCheckResult } from "../../utils/update-checker.ts";
export type { UsageSummary };
export interface UpdateInfo {
hasUpdate: boolean;
text: string;
}
export interface UsageStats {
entries: UsageTrackingEntry[];
@@ -6,42 +15,7 @@ export interface UsageStats {
totalSatsCost: number;
recentSatsCost: number;
limit: number;
}
export interface DayStats {
date: string;
requests: number;
satsCost: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface ModelStats {
modelId: string;
requests: number;
satsCost: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface ProviderStats {
baseUrl: string;
requests: number;
satsCost: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface ClientStats {
client: string;
requests: number;
satsCost: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
summary: UsageSummary;
}
export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "npubs" | "recent";
@@ -52,15 +26,6 @@ export interface Tab {
key: string;
}
export interface NpubStats {
npub: string;
requests: number;
satsCost: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface VimState {
scrollPos: number;
searchQuery: string;
+11
View File
@@ -6,6 +6,7 @@ export const PID_FILE = process.env.ROUTSTRD_PID || `${CONFIG_DIR}/routstrd.pid`
export const DB_PATH = `${CONFIG_DIR}/routstr.db`;
export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
export const LOGS_DIR = `${CONFIG_DIR}/logs`;
export const REQUEST_RESPONSE_LOGS_DIR = `${CONFIG_DIR}/request-response-logs`;
/** NWC auto-refill configuration */
export interface NwcAutoRefillConfig {
@@ -34,6 +35,13 @@ export interface RoutstrdConfig {
provider: string | null;
cocodPath: string | null;
mode?: "xcashu" | "apikeys";
/** Raw upstream request/response logging. Disabled by default because logs can contain sensitive prompts, outputs, and auth/payment headers. */
requestResponseLogging?: {
/** Enable raw request/response file logging. */
enabled?: boolean;
/** Root log directory. SDK writes requests/*.json and responses/*.jsonl below this directory. Defaults to ~/.routstrd/request-response-logs. */
dir?: string;
};
daemonUrl?: string;
/** URL of the auth proxy (routstrd-auth) for management endpoints (npubs, clients, usage).
* Defaults to daemonUrl or localhost:{port} if not set. */
@@ -41,6 +49,9 @@ export interface RoutstrdConfig {
nsec?: string;
/** Nostr hex pubkey for routstr review/model events (kind 38425/38423). */
routstrPubkey?: string;
/** Nostr relay URLs for provider/model discovery (kinds 38421/38423/38425).
* When unset, each method uses its own built-in defaults. */
relays?: string[];
/** NWC integration configuration */
nwc?: NwcConfig;
}
+114
View File
@@ -0,0 +1,114 @@
const NPM_REGISTRY = "https://registry.npmjs.org";
/** Packages that `routstrd update` manages. */
export const UPDATE_PACKAGES = [
{ name: "routstrd", label: "routstrd" },
{ name: "@routstr/cocod", label: "cocod" },
] as const;
/**
* Fetch the latest published version of a package from the npm registry.
* Returns null if the version cannot be determined (e.g. offline, not found).
*/
export async function getLatestNpmVersion(
packageName: string,
): Promise<string | null> {
try {
const response = await fetch(
`${NPM_REGISTRY}/${encodeURIComponent(packageName)}/latest`,
);
if (!response.ok) return null;
const data = (await response.json()) as { version?: string };
return data.version ?? null;
} catch {
return null;
}
}
/**
* Get the version of a globally-installed bun package.
* Returns null when the package is not installed globally or the version
* cannot be parsed as semver (e.g. installed from a git URL).
*/
export async function getGlobalPackageVersion(
packageName: string,
): Promise<string | null> {
try {
const proc = Bun.spawn(["bun", "pm", "ls", "-g"], {
stdout: "pipe",
stderr: "ignore",
});
const output = await new Response(proc.stdout).text();
await proc.exited;
// Lines look like: ├── routstrd@0.3.10 or └── @routstr/cocod@0.0.24
const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = output.match(new RegExp(`${escaped}@([^\\s]+)`));
if (!match) return null;
const version = match[1];
// Reject non-semver versions (e.g. github:routstr/cocod#3f6ac14)
if (!version || !/^\d+\.\d+\.\d+/.test(version)) return null;
return version;
} catch {
return null;
}
}
/**
* Compare two semver version strings.
* Returns a positive number if `a` is newer, negative if `b` is newer,
* 0 if equal, or null if either value is not parseable semver.
*/
export function compareVersions(a: string, b: string): number | null {
const parse = (v: string): [number, number, number] | null => {
const match = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match?.[1] || !match[2] || !match[3]) return null;
return [
parseInt(match[1], 10),
parseInt(match[2], 10),
parseInt(match[3], 10),
];
};
const va = parse(a);
const vb = parse(b);
if (!va || !vb) return null;
const [a1, a2, a3] = va;
const [b1, b2, b3] = vb;
if (a1 !== b1) return a1 - b1;
if (a2 !== b2) return a2 - b2;
if (a3 !== b3) return a3 - b3;
return 0;
}
export interface PackageUpdate {
name: string;
label: string;
current: string | null;
latest: string | null;
hasUpdate: boolean;
}
export interface UpdateCheckResult {
hasUpdate: boolean;
packages: PackageUpdate[];
}
/**
* Check both routstrd and cocod for available updates.
* Returns a result with per-package details and an overall `hasUpdate` flag.
*/
export async function checkForUpdates(): Promise<UpdateCheckResult> {
const packages = await Promise.all(
UPDATE_PACKAGES.map(async ({ name, label }) => {
const [current, latest] = await Promise.all([
getGlobalPackageVersion(name),
getLatestNpmVersion(name),
]);
const hasUpdate = !!(current && latest && (compareVersions(current, latest) ?? -1) < 0);
return { name, label, current, latest, hasUpdate } satisfies PackageUpdate;
}),
);
return {
hasUpdate: packages.some((p) => p.hasUpdate),
packages,
};
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from "bun:test";
import { parse } from "yaml";
import { mergeHermesConfig } from "../../src/integrations/hermes";
const ROUTSTR = {
baseUrl: "http://localhost:8008/v1",
apiKey: "routstr-key",
defaultModel: "deepseek-v4-flash",
};
describe("mergeHermesConfig", () => {
it("preserves the user's model and unrelated custom providers", () => {
const existing = `model:
default: user-model
provider: custom
base_url: https://user.example/v1
api_key: user-key
custom_providers:
- name: User Provider
base_url: https://user.example/v1
api_key: user-key
model: user-model
other_setting: true
`;
const merged = parse(mergeHermesConfig(existing, ROUTSTR));
expect(merged.model).toEqual({
default: "user-model",
provider: "custom",
base_url: "https://user.example/v1",
api_key: "user-key",
});
expect(merged.other_setting).toBe(true);
expect(merged.custom_providers).toContainEqual({
name: "User Provider",
base_url: "https://user.example/v1",
api_key: "user-key",
model: "user-model",
});
expect(merged.custom_providers).toContainEqual({
name: "Routstr (localhost:8008)",
base_url: ROUTSTR.baseUrl,
api_key: ROUTSTR.apiKey,
model: ROUTSTR.defaultModel,
});
});
it("does not add a model configuration to an existing Hermes file", () => {
const merged = parse(mergeHermesConfig("other_setting: true\n", ROUTSTR));
expect(merged.model).toBeUndefined();
expect(merged.other_setting).toBe(true);
});
it("creates a Routstr default when creating a new Hermes file", () => {
const merged = parse(mergeHermesConfig("", ROUTSTR));
expect(merged.model).toEqual({
default: ROUTSTR.defaultModel,
provider: "custom",
base_url: ROUTSTR.baseUrl,
api_key: ROUTSTR.apiKey,
});
});
it("keeps an existing Routstr provider unchanged without duplicating it", () => {
const existing = `custom_providers:
- name: Routstr (old-host:8008)
base_url: http://old-host:8008/v1
api_key: old-key
model: old-model
`;
const once = mergeHermesConfig(existing, ROUTSTR);
const twice = mergeHermesConfig(once, ROUTSTR);
const merged = parse(twice);
const routstrProviders = merged.custom_providers.filter(
(provider: { name?: string }) => provider.name?.startsWith("Routstr ("),
);
expect(routstrProviders).toHaveLength(1);
expect(routstrProviders[0]).toEqual({
name: "Routstr (old-host:8008)",
base_url: "http://old-host:8008/v1",
api_key: "old-key",
model: "old-model",
});
expect(twice).toBe(once);
});
it("rejects malformed YAML instead of replacing it", () => {
expect(() => mergeHermesConfig("model: [unterminated", ROUTSTR)).toThrow();
});
});