diff --git a/README.md b/README.md index 747bc46..094f30a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock index 45795d7..b56af5b 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 32f3700..39d4eab 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/cli.ts b/src/cli.ts index 2642d4a..3d4084e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 { 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 { + 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 { 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); diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 1ed8a3e..96b4d59 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -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; getRoutstr21Models: (forceRefresh?: boolean) => Promise; getModelProviders: (modelId: string) => Promise; + refreshProvidersAndModels: () => Promise; 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; getRoutstr21Models: (forceRefresh?: boolean) => Promise; getModelProviders: (modelId: string) => Promise; + refreshProvidersAndModels: () => Promise; 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 } : {}), }); diff --git a/src/daemon/http/usage-summary.test.ts b/src/daemon/http/usage-summary.test.ts new file mode 100644 index 0000000..f97a201 --- /dev/null +++ b/src/daemon/http/usage-summary.test.ts @@ -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: 10:00Z → local 05:00 → local day "" +// d2: 03:00Z → local 22:00 → local day "" +// d3: 06:00Z → local 01:00 → local day "" + +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 +const D2 = _ANCHOR_MS + 1 * 86_400_000 + 3 * 3_600_000; // 03:00Z next day → local day +const D3 = _ANCHOR_MS + 1 * 86_400_000 + 6 * 3_600_000; // 06:00Z next day → local day + +/** 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 = { + baseUrl: "https://api.openai.com/", + requestId: "req-1", + cost: 0.01, + satsCost: 10, + promptTokens: 100, + completionTokens: 50, +}; + +function makeEntry( + id: string, + overrides: Partial & Pick, +): 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; + + 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 ; + // D3 (next day 06:00Z) buckets to local day . + // So: day has e1+e2, day has e3+e4 + expect(days.length).toBeGreaterThanOrEqual(2); + + // Most-recent-first: 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); + }); +}); diff --git a/src/daemon/http/usage-summary.ts b/src/daemon/http/usage-summary.ts new file mode 100644 index 0000000..e909fa2 --- /dev/null +++ b/src/daemon/http/usage-summary.ts @@ -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 { + // 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(); + 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(); + const npubClientIds = new Map(); + 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; +} diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 01b3e73..4ebee0a 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -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 { 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 { 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 { const refundClient = new RoutstrClient( walletAdapter, storageAdapter, - providerRegistry, + discoveryAdapter, "min", "apikeys", { logger: daemonSdkLogger }, @@ -117,17 +148,18 @@ async function main(): Promise { 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 { `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 { `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 { 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 diff --git a/src/daemon/models.ts b/src/daemon/models.ts index abb0999..702dd18 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -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 => { + // 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, }; } diff --git a/src/daemon/request-response-log-sink.ts b/src/daemon/request-response-log-sink.ts new file mode 100644 index 0000000..19350b3 --- /dev/null +++ b/src/daemon/request-response-log-sink.ts @@ -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; + body?: unknown; + rawBody?: string; +} + +export interface RequestResponseLogSink { + logRequest?(input: RequestResponseLogRequestInput): string | undefined | Promise; + logResponseStart?(id: string | undefined, response: Response): void | Promise; + logResponseChunk?(id: string | undefined, sequence: number, text: string): void | Promise; + logResponseEnd?(id: string | undefined): void | Promise; + logResponseError?(id: string | undefined, error: unknown): void | Promise; + logResponseBody?(id: string | undefined, response: Response): void | Promise; +} + +interface ActiveResponseLog { + stream: WriteStream; + pending: Promise; +} + +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 => { + const out: Record = {}; + headers.forEach((value, key) => { + out[key] = value; + }); + return out; +}; + +const redactHeaders = (headers: Record): Record => + 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).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(); + + 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 { + 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 { + 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 { + if (!id) return; + await this.append(id, { type: "end" }); + await this.close(id); + } + + async logResponseError(id: string | undefined, error: unknown): Promise { + 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 { + 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): Promise { + 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((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 { + const active = this.activeResponses.get(id); + if (!active) return; + this.activeResponses.delete(id); + await active.pending; + await new Promise((resolve) => active.stream.end(resolve)); + } +} diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 6744fb3..cf893b0 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -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 & { diff --git a/src/integrations/hermes.ts b/src/integrations/hermes.ts index 6fb7bd9..f5f3a28 100644 --- a/src/integrations/hermes.ts +++ b/src/integrations/hermes.ts @@ -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.`); diff --git a/src/start-daemon.ts b/src/start-daemon.ts index ac55929..68e767a 100644 --- a/src/start-daemon.ts +++ b/src/start-daemon.ts @@ -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 { 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, }); diff --git a/src/tui/usage/app.ts b/src/tui/usage/app.ts index e2d5f63..9f25b67 100644 --- a/src/tui/usage/app.ts +++ b/src/tui/usage/app.ts @@ -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 { const running = await isDaemonRunning(); @@ -49,10 +50,42 @@ export async function runUsageTui(): Promise { let clients: ClientInfo[] = []; let visibleTabs: Tab[] = getVisibleTabs(false); let refreshInterval: ReturnType | 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 { + 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 { 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 { + 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 { 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); } diff --git a/src/tui/usage/data.ts b/src/tui/usage/data.ts index b5392c0..e58412a 100644 --- a/src/tui/usage/data.ts +++ b/src/tui/usage/data.ts @@ -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 { 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 { export async function fetchBalance(): Promise { 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 { } } -export async function fetchUsage(limit = 10000): Promise { +export async function fetchUsageSummary(): Promise { 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 { - const days = new Map(); - 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 { - const todayStart = getTodayStart(); - const hours = new Map(); - 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(); - 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(); - 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(); - 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 { 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 { 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(); - for (const c of clients) { - if (c.ownerNpub) { - clientNpubMap.set(c.clientId, c.ownerNpub); - } - } - - // Aggregate usage per npub - const npubs = new Map(); - 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 } - ); -} diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index e22cfad..36a0c0a 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -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 = 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>(); - - 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>(); - const clientNpubMap = new Map(); - 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)`); diff --git a/src/tui/usage/types.ts b/src/tui/usage/types.ts index 7e204bd..fd19978 100644 --- a/src/tui/usage/types.ts +++ b/src/tui/usage/types.ts @@ -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; diff --git a/src/utils/config.ts b/src/utils/config.ts index a148ba5..202b2ca 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -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; } diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts new file mode 100644 index 0000000..465415d --- /dev/null +++ b/src/utils/update-checker.ts @@ -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 { + 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 { + 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 { + 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, + }; +} diff --git a/tests/integrations/hermes.test.ts b/tests/integrations/hermes.test.ts new file mode 100644 index 0000000..12ac581 --- /dev/null +++ b/tests/integrations/hermes.test.ts @@ -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(); + }); +});