mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 20:38:22 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user