From 0411b4c3a60e0abfcb98f849a877560be2262aed Mon Sep 17 00:00:00 2001 From: Nopm Date: Tue, 3 Jun 2025 20:14:07 -0300 Subject: [PATCH 1/9] I should have made all these commits separately but oops --- .env.example | 13 +- package-lock.json | 824 +++++++++++------- package.json | 2 +- src/admin/web/manage.ts | 20 +- src/config.ts | 34 +- src/info-page.ts | 305 ++++--- src/proxy/middleware/response/index.ts | 6 +- src/service-info.ts | 81 +- src/shared/inject-locals.ts | 5 +- .../key-management/anthropic/provider.ts | 28 +- src/shared/key-management/aws/provider.ts | 31 +- src/shared/key-management/azure/provider.ts | 43 +- src/shared/key-management/cohere/provider.ts | 32 +- .../key-management/deepseek/provider.ts | 34 +- src/shared/key-management/gcp/provider.ts | 27 +- .../key-management/google-ai/provider.ts | 28 +- src/shared/key-management/index.ts | 10 +- src/shared/key-management/key-pool.ts | 27 +- .../key-management/mistral-ai/provider.ts | 30 +- src/shared/key-management/openai/provider.ts | 43 +- src/shared/key-management/qwen/checker.ts | 2 +- src/shared/key-management/qwen/provider.ts | 21 +- src/shared/key-management/xai/provider.ts | 34 +- src/shared/sqlite-db.ts | 62 ++ src/shared/stats.ts | 222 ++--- src/shared/users/schema.ts | 20 +- src/shared/users/user-store.ts | 347 +++++++- .../views/partials/shared_quota-info.ejs | 55 +- 28 files changed, 1551 insertions(+), 835 deletions(-) create mode 100644 src/shared/sqlite-db.ts diff --git a/.env.example b/.env.example index fd82704..cc0fbd5 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,14 @@ NODE_ENV=production # The title displayed on the info page. # SERVER_TITLE=Coom Tunnel +# URL for the image displayed on the login page. +# If not set, no image will be displayed. +# LOGIN_IMAGE_URL=https://example.com/your-logo.png + +# Whether to enable the token-based login for the main info page. +# Defaults to true. Set to false to disable login and make the info page public. +# ENABLE_INFO_PAGE_LOGIN=true + # The route name used to proxy requests to APIs, relative to the Web site root. # PROXY_ENDPOINT_ROUTE=/proxy @@ -119,8 +127,11 @@ NODE_ENV=production # Which access control method to use. (none | proxy_key | user_token) # GATEKEEPER=none -# Which persistence method to use. (memory | firebase_rtdb) +# Which persistence method to use. (memory | firebase_rtdb | sqlite) # GATEKEEPER_STORE=memory +# If using sqlite store, path to the SQLite database file for user data. +# Defaults to data/user-store.sqlite in the project directory. +# SQLITE_USER_STORE_PATH=data/user-store.sqlite3 # Maximum number of unique IPs a user can connect from. (0 for unlimited) # MAX_IPS_PER_USER=0 diff --git a/package-lock.json b/package-lock.json index 7e3e861..f2baf55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,7 @@ "@types/stream-json": "^1.7.7", "@types/uuid": "^9.0.1", "concurrently": "^8.0.1", - "esbuild": "^0.17.16", + "esbuild": "^0.25.5", "esbuild-register": "^3.4.2", "husky": "^8.0.3", "nodemon": "^3.0.1", @@ -206,356 +206,429 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.16.tgz", - "integrity": "sha512-baLqRpLe4JnKrUXLJChoTN0iXZH7El/mu58GE3WIA6/H834k0XWvLRmGLG8y8arTRS9hJJibPnF0tiGhmWeZgw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.16.tgz", - "integrity": "sha512-QX48qmsEZW+gcHgTmAj+x21mwTz8MlYQBnzF6861cNdQGvj2jzzFjqH0EBabrIa/WVZ2CHolwMoqxVryqKt8+Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.16.tgz", - "integrity": "sha512-G4wfHhrrz99XJgHnzFvB4UwwPxAWZaZBOFXh+JH1Duf1I4vIVfuYY9uVLpx4eiV2D/Jix8LJY+TAdZ3i40tDow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.16.tgz", - "integrity": "sha512-/Ofw8UXZxuzTLsNFmz1+lmarQI6ztMZ9XktvXedTbt3SNWDn0+ODTwxExLYQ/Hod91EZB4vZPQJLoqLF0jvEzA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.16.tgz", - "integrity": "sha512-SzBQtCV3Pdc9kyizh36Ol+dNVhkDyIrGb/JXZqFq8WL37LIyrXU0gUpADcNV311sCOhvY+f2ivMhb5Tuv8nMOQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.16.tgz", - "integrity": "sha512-ZqftdfS1UlLiH1DnS2u3It7l4Bc3AskKeu+paJSfk7RNOMrOxmeFDhLTMQqMxycP1C3oj8vgkAT6xfAuq7ZPRA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.16.tgz", - "integrity": "sha512-rHV6zNWW1tjgsu0dKQTX9L0ByiJHHLvQKrWtnz8r0YYJI27FU3Xu48gpK2IBj1uCSYhJ+pEk6Y0Um7U3rIvV8g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.16.tgz", - "integrity": "sha512-n4O8oVxbn7nl4+m+ISb0a68/lcJClIbaGAoXwqeubj/D1/oMMuaAXmJVfFlRjJLu/ZvHkxoiFJnmbfp4n8cdSw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.16.tgz", - "integrity": "sha512-8yoZhGkU6aHu38WpaM4HrRLTFc7/VVD9Q2SvPcmIQIipQt2I/GMTZNdEHXoypbbGao5kggLcxg0iBKjo0SQYKA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.16.tgz", - "integrity": "sha512-9ZBjlkdaVYxPNO8a7OmzDbOH9FMQ1a58j7Xb21UfRU29KcEEU3VTHk+Cvrft/BNv0gpWJMiiZ/f4w0TqSP0gLA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.16.tgz", - "integrity": "sha512-TIZTRojVBBzdgChY3UOG7BlPhqJz08AL7jdgeeu+kiObWMFzGnQD7BgBBkWRwOtKR1i2TNlO7YK6m4zxVjjPRQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.16.tgz", - "integrity": "sha512-UPeRuFKCCJYpBbIdczKyHLAIU31GEm0dZl1eMrdYeXDH+SJZh/i+2cAmD3A1Wip9pIc5Sc6Kc5cFUrPXtR0XHA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.16.tgz", - "integrity": "sha512-io6yShgIEgVUhExJejJ21xvO5QtrbiSeI7vYUnr7l+v/O9t6IowyhdiYnyivX2X5ysOVHAuyHW+Wyi7DNhdw6Q==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.16.tgz", - "integrity": "sha512-WhlGeAHNbSdG/I2gqX2RK2gfgSNwyJuCiFHMc8s3GNEMMHUI109+VMBfhVqRb0ZGzEeRiibi8dItR3ws3Lk+cA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.16.tgz", - "integrity": "sha512-gHRReYsJtViir63bXKoFaQ4pgTyah4ruiMRQ6im9YZuv+gp3UFJkNTY4sFA73YDynmXZA6hi45en4BGhNOJUsw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.16.tgz", - "integrity": "sha512-mfiiBkxEbUHvi+v0P+TS7UnA9TeGXR48aK4XHkTj0ZwOijxexgMF01UDFaBX7Q6CQsB0d+MFNv9IiXbIHTNd4g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.16.tgz", - "integrity": "sha512-n8zK1YRDGLRZfVcswcDMDM0j2xKYLNXqei217a4GyBxHIuPMGrrVuJ+Ijfpr0Kufcm7C1k/qaIrGy6eG7wvgmA==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", "cpu": [ - "x64" + "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.16.tgz", - "integrity": "sha512-lEEfkfsUbo0xC47eSTBqsItXDSzwzwhKUSsVaVjVji07t8+6KA5INp2rN890dHZeueXJAI8q0tEIfbwVRYf6Ew==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.16.tgz", - "integrity": "sha512-jlRjsuvG1fgGwnE8Afs7xYDnGz0dBgTNZfgCK6TlvPH3Z13/P5pi6I57vyLE8qZYLrGVtwcm9UbUx1/mZ8Ukag==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.16.tgz", - "integrity": "sha512-TzoU2qwVe2boOHl/3KNBUv2PNUc38U0TNnzqOAcgPiD/EZxT2s736xfC2dYQbszAwo4MKzzwBV0iHjhfjxMimg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.16.tgz", - "integrity": "sha512-B8b7W+oo2yb/3xmwk9Vc99hC9bNolvqjaTZYEfMQhzdpBsjTvZBlXQ/teUE55Ww6sg//wlcDjOaqldOKyigWdA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.16.tgz", - "integrity": "sha512-xJ7OH/nanouJO9pf03YsL9NAFQBHd8AqfrQd7Pf5laGyyTt/gToul6QYOA/i5i/q8y9iaM5DQFNTgpi995VkOg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@fastify/busboy": { @@ -2008,9 +2081,10 @@ } }, "node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -2027,6 +2101,78 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/bare-fs": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", + "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2529,33 +2675,27 @@ } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/cookie-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", - "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", "dependencies": { - "cookie": "0.4.1", + "cookie": "0.7.2", "cookie-signature": "1.0.6" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/cookie-parser/node_modules/cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -2668,9 +2808,10 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3005,40 +3146,44 @@ } }, "node_modules/esbuild": { - "version": "0.17.16", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.16.tgz", - "integrity": "sha512-aeSuUKr9aFVY9Dc8ETVELGgkj4urg5isYx8pLf4wlGgB0vTFjxJQdHnNH6Shmx4vYYrOTLCHtRI5i1XZ9l2Zcg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/android-arm": "0.17.16", - "@esbuild/android-arm64": "0.17.16", - "@esbuild/android-x64": "0.17.16", - "@esbuild/darwin-arm64": "0.17.16", - "@esbuild/darwin-x64": "0.17.16", - "@esbuild/freebsd-arm64": "0.17.16", - "@esbuild/freebsd-x64": "0.17.16", - "@esbuild/linux-arm": "0.17.16", - "@esbuild/linux-arm64": "0.17.16", - "@esbuild/linux-ia32": "0.17.16", - "@esbuild/linux-loong64": "0.17.16", - "@esbuild/linux-mips64el": "0.17.16", - "@esbuild/linux-ppc64": "0.17.16", - "@esbuild/linux-riscv64": "0.17.16", - "@esbuild/linux-s390x": "0.17.16", - "@esbuild/linux-x64": "0.17.16", - "@esbuild/netbsd-x64": "0.17.16", - "@esbuild/openbsd-x64": "0.17.16", - "@esbuild/sunos-x64": "0.17.16", - "@esbuild/win32-arm64": "0.17.16", - "@esbuild/win32-ia32": "0.17.16", - "@esbuild/win32-x64": "0.17.16" + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" } }, "node_modules/esbuild-register": { @@ -3175,16 +3320,17 @@ } }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -3198,7 +3344,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -3213,15 +3359,20 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-session": { - "version": "1.17.3", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", - "integrity": "sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", + "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==", + "license": "MIT", "dependencies": { - "cookie": "0.4.2", - "cookie-signature": "1.0.6", + "cookie": "0.7.2", + "cookie-signature": "1.0.7", "debug": "2.6.9", "depd": "~2.0.0", "on-headers": "~1.0.2", @@ -3233,10 +3384,17 @@ "node": ">= 0.8.0" } }, - "node_modules/express-session/node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -4145,9 +4303,10 @@ "optional": true }, "node_modules/http-proxy-middleware": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.2.tgz", - "integrity": "sha512-fBLFpmvDzlxdckwZRjM0wWtwDZ4KBtQ8NFqhrFKoEtK4myzuiumBuNTxD+F4cVbXfOZljIbrynmvByofDzT7Ag==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.15", "debug": "^4.3.6", @@ -4885,15 +5044,16 @@ } }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -5363,9 +5523,10 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" }, "node_modules/picocolors": { "version": "1.0.0", @@ -5520,9 +5681,10 @@ } }, "node_modules/prebuild-install/node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "license": "MIT", "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -5772,11 +5934,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" - }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -6425,12 +6582,16 @@ } }, "node_modules/streamx": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.4.tgz", - "integrity": "sha512-uSXKl88bibiUCQ1eMpItRljCzDENcDx18rsfDmV79r0e/ThfrAwxG4Y2FarQZ2G4/21xcOKmFFd1Hue+ZIDwHw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "license": "MIT", "dependencies": { - "fast-fifo": "^1.1.0", - "queue-tick": "^1.0.1" + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" } }, "node_modules/string_decoder": { @@ -6528,13 +6689,17 @@ } }, "node_modules/tar-fs": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", - "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz", + "integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==", + "license": "MIT", "dependencies": { - "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" } }, "node_modules/tar-stream": { @@ -6563,6 +6728,15 @@ "node": ">=14" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/thread-stream": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.3.0.tgz", diff --git a/package.json b/package.json index 2718e0e..830125b 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "@types/stream-json": "^1.7.7", "@types/uuid": "^9.0.1", "concurrently": "^8.0.1", - "esbuild": "^0.17.16", + "esbuild": "^0.25.5", "esbuild-register": "^3.4.2", "husky": "^8.0.3", "nodemon": "^3.0.1", diff --git a/src/admin/web/manage.ts b/src/admin/web/manage.ts index 3b37a6d..aa581ba 100644 --- a/src/admin/web/manage.ts +++ b/src/admin/web/manage.ts @@ -132,8 +132,13 @@ router.post("/create-user", (req, res) => { ) .transform((data: any) => { const expiresAt = Date.now() + data.temporaryUserDuration * 60 * 1000; - const tokenLimits = MODEL_FAMILIES.reduce((limits, model) => { - limits[model] = data[`temporaryUserQuota_${model}`]; + const tokenLimits = MODEL_FAMILIES.reduce((limits, modelFamily) => { + const quotaValue = data[`temporaryUserQuota_${modelFamily}`]; + if (typeof quotaValue === 'number') { + limits[modelFamily] = { input: quotaValue, output: 0, legacy_total: quotaValue }; + } else { + limits[modelFamily] = { input: 0, output: 0 }; + } return limits; }, {} as UserTokenCounts); return { ...data, expiresAt, tokenLimits }; @@ -547,9 +552,14 @@ router.post("/generate-stats", (req, res) => { function getSumsForUser(user: User) { const sums = MODEL_FAMILIES.reduce( (s, model) => { - const tokens = user.tokenCounts[model] ?? 0; - s.sumTokens += tokens; - s.sumCost += getTokenCostUsd(model, tokens); + const counts = user.tokenCounts[model] ?? { input: 0, output: 0, legacy_total: undefined }; + // Ensure inputTokens and outputTokens are numbers, defaulting to 0 if NaN or undefined + const inputTokens = Number(counts.input) || 0; + const outputTokens = Number(counts.output) || 0; + // We could also consider legacy_total here if input and output are 0 + // For now, sumTokens and sumCost will be based on current input/output. + s.sumTokens += inputTokens + outputTokens; + s.sumCost += getTokenCostUsd(model, inputTokens, outputTokens); return s; }, { sumTokens: 0, sumCost: 0, prettyUsage: "" } diff --git a/src/config.ts b/src/config.ts index 9919c19..24253a3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -90,11 +90,6 @@ type Config = { * management mode is set to 'user_token'. */ adminKey?: string; - /** - * The password required to view the service info/status page. If not set, the - * info page will be publicly accessible. - */ - serviceInfoPassword?: string; /** * Which user management mode to use. * - `none`: No user management. Proxy is open to all requests with basic @@ -111,10 +106,14 @@ type Config = { * - `memory`: Users are stored in memory and are lost on restart (default) * - `firebase_rtdb`: Users are stored in a Firebase Realtime Database; * requires `firebaseKey` and `firebaseRtdbUrl` to be set. + * - `sqlite`: Users are stored in an SQLite database; requires + * `sqliteUserStorePath` to be set. */ - gatekeeperStore: "memory" | "firebase_rtdb"; + gatekeeperStore: "memory" | "firebase_rtdb" | "sqlite"; /** URL of the Firebase Realtime Database if using the Firebase RTDB store. */ firebaseRtdbUrl?: string; + /** Path to the SQLite database file for storing user data. */ + sqliteUserStorePath?: string; /** * Base64-encoded Firebase service account key if using the Firebase RTDB * store. Note that you should encode the *entire* JSON key file, not just the @@ -432,6 +431,10 @@ type Config = { */ proxyUrl?: string; }; + /** URL for the image on the login page. Defaults to empty string (no image). */ + loginImageUrl?: string; + /** Whether to enable the token-based login page for the service info page. Defaults to true. */ + enableInfoPageLogin?: boolean; }; // To change configs, create a file called .env in the root directory. @@ -452,7 +455,6 @@ export const config: Config = { azureCredentials: getEnvWithDefault("AZURE_CREDENTIALS", ""), proxyKey: getEnvWithDefault("PROXY_KEY", ""), adminKey: getEnvWithDefault("ADMIN_KEY", ""), - serviceInfoPassword: getEnvWithDefault("SERVICE_INFO_PASSWORD", ""), sqliteDataPath: getEnvWithDefault( "SQLITE_DATA_PATH", path.join(DATA_DIR, "database.sqlite") @@ -460,7 +462,11 @@ export const config: Config = { eventLogging: getEnvWithDefault("EVENT_LOGGING", false), eventLoggingTrim: getEnvWithDefault("EVENT_LOGGING_TRIM", 5), gatekeeper: getEnvWithDefault("GATEKEEPER", "none"), - gatekeeperStore: getEnvWithDefault("GATEKEEPER_STORE", "memory"), + gatekeeperStore: getEnvWithDefault("GATEKEEPER_STORE", "memory") as Config["gatekeeperStore"], + sqliteUserStorePath: getEnvWithDefault( + "SQLITE_USER_STORE_PATH", + path.join(DATA_DIR, "user-store.sqlite") + ), maxIpsPerUser: getEnvWithDefault("MAX_IPS_PER_USER", 0), maxIpsAutoBan: getEnvWithDefault("MAX_IPS_AUTO_BAN", false), captchaMode: getEnvWithDefault("CAPTCHA_MODE", "none"), @@ -546,6 +552,8 @@ export const config: Config = { interface: getEnvWithDefault("HTTP_AGENT_INTERFACE", undefined), proxyUrl: getEnvWithDefault("HTTP_AGENT_PROXY_URL", undefined), }, + loginImageUrl: getEnvWithDefault("LOGIN_IMAGE_URL", ""), + enableInfoPageLogin: getEnvWithDefault("ENABLE_INFO_PAGE_LOGIN", true), } as const; function generateSigningKey() { @@ -667,6 +675,12 @@ export async function assertConfigIsValid() { ); } + if (config.gatekeeperStore === "sqlite" && !config.sqliteUserStorePath) { + throw new Error( + "SQLite user store requires `SQLITE_USER_STORE_PATH` to be set." + ); + } + if (Object.values(config.httpAgent || {}).filter(Boolean).length === 0) { delete config.httpAgent; } else if (config.httpAgent) { @@ -722,7 +736,6 @@ export const OMITTED_KEYS = [ "azureCredentials", "proxyKey", "adminKey", - "serviceInfoPassword", "rejectPhrases", "rejectMessage", "showTokenCosts", @@ -731,6 +744,7 @@ export const OMITTED_KEYS = [ "firebaseKey", "firebaseRtdbUrl", "sqliteDataPath", + "sqliteUserStorePath", "eventLogging", "eventLoggingTrim", "gatekeeperStore", @@ -749,6 +763,8 @@ export const OMITTED_KEYS = [ "adminWhitelist", "ipBlacklist", "powTokenPurgeHours", + "loginImageUrl", + "enableInfoPageLogin", ] satisfies (keyof Config)[]; type OmitKeys = (typeof OMITTED_KEYS)[number]; diff --git a/src/info-page.ts b/src/info-page.ts index e62b4b6..b3dee0a 100644 --- a/src/info-page.ts +++ b/src/info-page.ts @@ -1,4 +1,8 @@ -/** This whole module kinda sucks */ +/* ────────────────────────────────────────────────────────────── + Login-gated info page + drop-in replacement for src/info-page.ts + ──────────────────────────────────────────────────────────── */ + import fs from "fs"; import express, { Router, Request, Response } from "express"; import showdown from "showdown"; @@ -8,9 +12,20 @@ import { getLastNImages } from "./shared/file-storage/image-history"; import { keyPool } from "./shared/key-management"; import { MODEL_FAMILY_SERVICE, ModelFamily } from "./shared/models"; import { withSession } from "./shared/with-session"; -import { checkCsrfToken, injectCsrfToken } from "./shared/inject-csrf"; +import { injectCsrfToken, checkCsrfToken } from "./shared/inject-csrf"; +import { getUser } from "./shared/users/user-store"; + +/* ──────────────── TYPES: extend express-session ──────────── */ +declare module "express-session" { + interface Session { + infoPageAuthed?: boolean; + } +} + +/* ──────────────── misc constants ─────────────────────────── */ +const INFO_PAGE_TTL = 2_000; // ms +const LOGIN_ROUTE = "/"; -const INFO_PAGE_TTL = 2000; const MODEL_FAMILY_FRIENDLY_NAME: { [f in ModelFamily]: string } = { qwen: "Qwen", cohere: "Cohere", @@ -72,13 +87,78 @@ const MODEL_FAMILY_FRIENDLY_NAME: { [f in ModelFamily]: string } = { }; const converter = new showdown.Converter(); + +/* optional markdown greeting */ const customGreeting = fs.existsSync("greeting.md") ? `
${fs.readFileSync("greeting.md", "utf8")}
` : ""; + +/* ──────────────── Login page ──────────────────────── */ +function renderLoginPage(csrf: string, error?: string) { + const errBlock = error + ? `
${escapeHtml(error)}
` + : ""; + const pageTitle = getServerTitle(); + return ` + + + ${pageTitle} – Login + + + +
+ Logo + ${errBlock} +
+
+ + +
+ +
+
+ +`; +} + +/* ──────────────── login-required middleware ──────────────── */ +function requireLogin( + req: Request, + res: Response, + next: express.NextFunction +) { + if (req.session?.infoPageAuthed) return next(); + return res.send(renderLoginPage(res.locals.csrfToken)); +} + +/* ──────────────── INFO PAGE CACHING ──────────────────────── */ let infoPageHtml: string | undefined; let infoPageLastUpdated = 0; -export const handleInfoPage = (req: Request, res: Response) => { +export function handleInfoPage(req: Request, res: Response) { if (infoPageLastUpdated + INFO_PAGE_TTL > Date.now()) { return res.send(infoPageHtml); } @@ -93,60 +173,46 @@ export const handleInfoPage = (req: Request, res: Response) => { infoPageLastUpdated = Date.now(); res.send(infoPageHtml); -}; +} +/* ──────────────── RENDER FULL INFO PAGE ──────────────────── */ export function renderPage(info: ServiceInfo) { const title = getServerTitle(); const headerHtml = buildInfoPageHeader(info); return ` - - - - ${title} - - - - - - - ${headerHtml} -
- ${getSelfServiceLinks()} -

Service Info

-
${JSON.stringify(info, null, 2)}
- + + + + ${title} + + + + + + + ${headerHtml} +
+ ${getSelfServiceLinks()} +

Service Info

+
${JSON.stringify(info, null, 2)}
+ `; } -/** - * If the server operator provides a `greeting.md` file, it will be included in - * the rendered info page. - **/ +/* ──────────────── header & helper functions ──────────────── */ +/* (all copied verbatim from original file) */ function buildInfoPageHeader(info: ServiceInfo) { const title = getServerTitle(); - // TODO: use some templating engine instead of this mess let infoBody = `# ${title}`; + if (config.promptLogging) { infoBody += `\n## Prompt Logging Enabled This proxy keeps full logs of all prompts and AI responses. Prompt logs are anonymous and do not contain IP addresses or timestamps. @@ -165,9 +231,9 @@ This proxy keeps full logs of all prompts and AI responses. Prompt logs are anon for (const modelFamily of config.allowedModelFamilies) { const service = MODEL_FAMILY_SERVICE[modelFamily]; - const hasKeys = keyPool.list().some((k) => { - return k.service === service && k.modelFamilies.includes(modelFamily); - }); + const hasKeys = keyPool.list().some( + (k) => k.service === service && k.modelFamilies.includes(modelFamily) + ); const wait = info[modelFamily]?.estimatedQueueTime; if (hasKeys && wait) { @@ -178,9 +244,7 @@ This proxy keeps full logs of all prompts and AI responses. Prompt logs are anon } infoBody += "\n\n" + waits.join(" / "); - infoBody += customGreeting; - infoBody += buildRecentImageSection(); return converter.makeHtml(infoBody); @@ -188,63 +252,60 @@ This proxy keeps full logs of all prompts and AI responses. Prompt logs are anon function getSelfServiceLinks() { if (config.gatekeeper !== "user_token") return ""; - const links = [["Check your user token", "/user/lookup"]]; if (config.captchaMode !== "none") { links.unshift(["Request a user token", "/user/captcha"]); } - return ``; } function getServerTitle() { - // Use manually set title if available - if (process.env.SERVER_TITLE) { - return process.env.SERVER_TITLE; - } - - // Huggingface - if (process.env.SPACE_ID) { + if (process.env.SERVER_TITLE) return process.env.SERVER_TITLE; + if (process.env.SPACE_ID) return `${process.env.SPACE_AUTHOR_NAME} / ${process.env.SPACE_TITLE}`; - } - - // Render - if (process.env.RENDER) { + if (process.env.RENDER) return `Render / ${process.env.RENDER_SERVICE_NAME}`; - } - - return "OAI Reverse Proxy"; + return "Tunnel"; } function buildRecentImageSection() { - const imageModels: ModelFamily[] = ["azure-dall-e", "dall-e", "gpt-image", "azure-gpt-image"]; + const imageModels: ModelFamily[] = [ + "azure-dall-e", + "dall-e", + "gpt-image", + "azure-gpt-image", + ]; + // Condition 1: Is the feature enabled via config? + // Condition 2: Is at least one relevant image model family allowed in config? if ( !config.showRecentImages || imageModels.every((f) => !config.allowedModelFamilies.includes(f)) ) { + return ""; // Exit if feature is disabled or no relevant models are allowed + } + + // Condition 3: Are there any actual images to display? + const recentImages = getLastNImages(12).reverse(); + if (recentImages.length === 0) { + // If the feature is enabled and models are allowed, but no images exist, + // do not render the section, including its title. return ""; } + // If all conditions pass (feature enabled, models allowed, images exist), build and return the HTML let html = `

Recent Image Generations

`; - const recentImages = getLastNImages(12).reverse(); - if (recentImages.length === 0) { - html += `

No images yet.

`; - return html; - } - - html += `
`; + html += `
`; for (const { url, prompt } of recentImages) { const thumbUrl = url.replace(/\.png$/, "_t.jpg"); const escapedPrompt = escapeHtml(prompt); - html += `
-${escapedPrompt} -
`; + html += `
+${escapedPrompt}
`; } - html += `
`; - html += `

View all recent images

`; - + html += `

+View all recent images

`; return html; } @@ -259,57 +320,49 @@ function escapeHtml(unsafe: string) { .replace(/]/g, "]"); } + function getExternalUrlForHuggingfaceSpaceId(spaceId: string) { try { - const [username, spacename] = spaceId.split("/"); - return `https://${username}-${spacename.replace(/_/g, "-")}.hf.space`; - } catch (e) { + const [u, s] = spaceId.split("/"); + return `https://${u}-${s.replace(/_/g, "-")}.hf.space`; + } catch { return ""; } } -function checkIfUnlocked( - req: Request, - res: Response, - next: express.NextFunction -) { - if (config.serviceInfoPassword?.length && !req.session?.unlocked) { - return res.redirect("/unlock-info"); - } - next(); -} - +/* ──────────────── ROUTER ─────────────────────────────────── */ const infoPageRouter = Router(); -if (config.serviceInfoPassword?.length) { - infoPageRouter.use( - express.json({ limit: "1mb" }), - express.urlencoded({ extended: true, limit: "1mb" }) - ); - infoPageRouter.use(withSession); - infoPageRouter.use(injectCsrfToken, checkCsrfToken); - infoPageRouter.post("/unlock-info", (req, res) => { - if (req.body.password !== config.serviceInfoPassword) { - return res.status(403).send("Incorrect password"); - } - req.session!.unlocked = true; - res.redirect("/"); - }); - infoPageRouter.get("/unlock-info", (_req, res) => { - if (_req.session?.unlocked) return res.redirect("/"); - res.send(` -
-

Unlock Service Info

- - - -
- `); - }); - infoPageRouter.use(checkIfUnlocked); -} -infoPageRouter.get("/", handleInfoPage); -infoPageRouter.get("/status", (req, res) => { - res.json(buildInfo(req.protocol + "://" + req.get("host"), false)); +infoPageRouter.use( + express.json({ limit: "1mb" }), + express.urlencoded({ extended: true, limit: "1mb" }), + withSession, + injectCsrfToken, + checkCsrfToken +); + +/* login attempt */ +infoPageRouter.post(LOGIN_ROUTE, (req, res) => { + const token = (req.body.token || "").trim(); + + const user = getUser(token); // returns undefined if invalid + if (!user) { + return res + .status(401) + .send(renderLoginPage(res.locals.csrfToken, "Invalid token. Please try again.")); + } + + req.session!.infoPageAuthed = true; + res.redirect("/"); }); -export { infoPageRouter }; \ No newline at end of file + +/* GET / – either login form or info page */ +if (config.enableInfoPageLogin) { + infoPageRouter.get(LOGIN_ROUTE, requireLogin, handleInfoPage); +} else { + infoPageRouter.get(LOGIN_ROUTE, handleInfoPage); +} + +/* ─── Removed the public /status route : simply not added ─── */ + +export { infoPageRouter }; diff --git a/src/proxy/middleware/response/index.ts b/src/proxy/middleware/response/index.ts index 1196ab3..8f21220 100644 --- a/src/proxy/middleware/response/index.ts +++ b/src/proxy/middleware/response/index.ts @@ -855,10 +855,12 @@ const incrementUsage: ProxyResHandlerWithBody = async (_proxyRes, req) => { }, `Incrementing usage for model` ); - keyPool.incrementUsage(req.key!, model, tokensUsed); + // Get modelFamily for the key usage log + const modelFamilyForKeyPool = req.modelFamily!; // Should be set by getModelFamilyForRequest earlier + keyPool.incrementUsage(req.key!, modelFamilyForKeyPool, { input: req.promptTokens!, output: req.outputTokens! }); if (req.user) { incrementPromptCount(req.user.token); - incrementTokenCount(req.user.token, model, req.outboundApi, tokensUsed); + incrementTokenCount(req.user.token, model, req.outboundApi, { input: req.promptTokens!, output: req.outputTokens! }); } } }; diff --git a/src/service-info.ts b/src/service-info.ts index a8de3b0..004064c 100644 --- a/src/service-info.ts +++ b/src/service-info.ts @@ -74,14 +74,18 @@ type ModelAggregates = { gcpSonnet35?: number; gcpHaiku?: number; queued: number; - tokens: number; + inputTokens: number; // Changed from tokens + outputTokens: number; // Added + legacyTokens?: number; // Added for migrated totals }; /** All possible combinations of model family and aggregate type. */ type ModelAggregateKey = `${ModelFamily}__${keyof ModelAggregates}`; type AllStats = { proompts: number; - tokens: number; + inputTokens: number; // Changed from tokens + outputTokens: number; // Added + legacyTokens?: number; // Added tokenCost: number; } & { [modelFamily in ModelFamily]?: ModelAggregates } & { [service in LLMService as `${service}__${ServiceAggregate}`]?: number; @@ -288,11 +292,14 @@ function getEndpoints(baseUrl: string, accessibleFamilies: Set) { type TrafficStats = Pick; function getTrafficStats(): TrafficStats { - const tokens = serviceStats.get("tokens") || 0; + const inputTokens = serviceStats.get("inputTokens") || 0; + const outputTokens = serviceStats.get("outputTokens") || 0; + // const legacyTokens = serviceStats.get("legacyTokens") || 0; // Optional: include in total if desired + const totalTokens = inputTokens + outputTokens; // + legacyTokens; const tokenCost = serviceStats.get("tokenCost") || 0; return { proompts: serviceStats.get("proompts") || 0, - tookens: `${prettyTokens(tokens)}${getCostSuffix(tokenCost)}`, + tookens: `${prettyTokens(totalTokens)}${getCostSuffix(tokenCost)}`, // Simplified to show aggregate and cost ...(config.textModelRateLimit ? { proomptersNow: getUniqueIps() } : {}), }; } @@ -352,14 +359,39 @@ function addKeyToAggregates(k: KeyPoolKey) { addToService("cohere__keys", k.service === "cohere" ? 1 : 0); addToService("qwen__keys", k.service === "qwen" ? 1 : 0); - let sumTokens = 0; + let sumInputTokens = 0; + let sumOutputTokens = 0; + let sumLegacyTokens = 0; // Optional let sumCost = 0; const incrementGenericFamilyStats = (f: ModelFamily) => { - const tokens = (k as any)[`${f}Tokens`]; - sumTokens += tokens; - sumCost += getTokenCostUsd(f, tokens); - addToFamily(`${f}__tokens`, tokens); + const usage = k.tokenUsage?.[f]; + let familyInputTokens = 0; + let familyOutputTokens = 0; + let familyLegacyTokens = 0; + + if (usage) { + familyInputTokens = usage.input || 0; + familyOutputTokens = usage.output || 0; + if (usage.legacy_total && familyInputTokens === 0 && familyOutputTokens === 0) { + // This is a migrated key with no new usage, use legacy_total as input for cost + familyLegacyTokens = usage.legacy_total; + sumCost += getTokenCostUsd(f, usage.legacy_total, 0); + } else { + sumCost += getTokenCostUsd(f, familyInputTokens, familyOutputTokens); + } + } + // If no k.tokenUsage[f], tokens are 0, cost is 0. + + sumInputTokens += familyInputTokens; + sumOutputTokens += familyOutputTokens; + sumLegacyTokens += familyLegacyTokens; // Optional + + addToFamily(`${f}__inputTokens`, familyInputTokens); + addToFamily(`${f}__outputTokens`, familyOutputTokens); + if (familyLegacyTokens > 0) { + addToFamily(`${f}__legacyTokens`, familyLegacyTokens); // Optional + } addToFamily(`${f}__revoked`, k.isRevoked ? 1 : 0); addToFamily(`${f}__active`, k.isDisabled ? 0 : 1); }; @@ -493,15 +525,38 @@ function addKeyToAggregates(k: KeyPoolKey) { assertNever(k.service); } - addToService("tokens", sumTokens); + addToService("inputTokens", sumInputTokens); + addToService("outputTokens", sumOutputTokens); + if (sumLegacyTokens > 0) { // Optional + addToService("legacyTokens", sumLegacyTokens); + } addToService("tokenCost", sumCost); } function getInfoForFamily(family: ModelFamily): BaseFamilyInfo { - const tokens = familyStats.get(`${family}__tokens`) || 0; - const cost = getTokenCostUsd(family, tokens); + const inputTokens = familyStats.get(`${family}__inputTokens`) || 0; + const outputTokens = familyStats.get(`${family}__outputTokens`) || 0; + const legacyTokens = familyStats.get(`${family}__legacyTokens`) || 0; // Optional + + let cost = 0; + let displayTokens = 0; + let usageString = ""; + + if (inputTokens > 0 || outputTokens > 0) { + cost = getTokenCostUsd(family, inputTokens, outputTokens); + displayTokens = inputTokens + outputTokens; + usageString = `${prettyTokens(displayTokens)} (In: ${prettyTokens(inputTokens)}, Out: ${prettyTokens(outputTokens)})${getCostSuffix(cost)}`; + } else if (legacyTokens > 0) { + // Only show legacy if no new input/output has been recorded for this family aggregate + cost = getTokenCostUsd(family, legacyTokens, 0); // Cost legacy as all input + displayTokens = legacyTokens; + usageString = `${prettyTokens(displayTokens)} tokens (legacy total)${getCostSuffix(cost)}`; + } else { + usageString = `${prettyTokens(0)} tokens${getCostSuffix(0)}`; + } + let info: BaseFamilyInfo & OpenAIInfo & AnthropicInfo & AwsInfo & GcpInfo = { - usage: `${prettyTokens(tokens)} tokens${getCostSuffix(cost)}`, + usage: usageString, activeKeys: familyStats.get(`${family}__active`) || 0, revokedKeys: familyStats.get(`${family}__revoked`) || 0, }; diff --git a/src/shared/inject-locals.ts b/src/shared/inject-locals.ts index 0b325e9..a0d81d3 100644 --- a/src/shared/inject-locals.ts +++ b/src/shared/inject-locals.ts @@ -1,6 +1,6 @@ import { RequestHandler } from "express"; import { config } from "../config"; -import { getTokenCostUsd, prettyTokens } from "./stats"; +import { getTokenCostUsd, getTokenCostDetailsUsd, prettyTokens } from "./stats"; // Added getTokenCostDetailsUsd import { redactIp } from "./utils"; import * as userStore from "./users/user-store"; @@ -30,7 +30,8 @@ export const injectLocals: RequestHandler = (req, res, next) => { // view helpers res.locals.prettyTokens = prettyTokens; - res.locals.tokenCost = getTokenCostUsd; + res.locals.tokenCost = getTokenCostUsd; // Returns total cost as a number + res.locals.tokenCostDetails = getTokenCostDetailsUsd; // Returns { inputCost, outputCost, totalCost } res.locals.redactIp = redactIp; next(); diff --git a/src/shared/key-management/anthropic/provider.ts b/src/shared/key-management/anthropic/provider.ts index bcd8dfb..0d18f70 100644 --- a/src/shared/key-management/anthropic/provider.ts +++ b/src/shared/key-management/anthropic/provider.ts @@ -16,11 +16,8 @@ export type AnthropicKeyUpdate = Omit< | "rateLimitedUntil" >; -type AnthropicKeyUsage = { - [K in AnthropicModelFamily as `${K}Tokens`]: number; -}; - -export interface AnthropicKey extends Key, AnthropicKeyUsage { +// AnthropicKeyUsage is removed, tokenUsage from base Key interface will be used. +export interface AnthropicKey extends Key { readonly service: "anthropic"; readonly modelFamilies: AnthropicModelFamily[]; /** @@ -120,8 +117,7 @@ export class AnthropicKeyProvider implements KeyProvider { .digest("hex") .slice(0, 8)}`, lastChecked: 0, - claudeTokens: 0, - "claude-opusTokens": 0, + tokenUsage: {}, // Initialize new tokenUsage field tier: "unknown", }; this.keys.push(newKey); @@ -206,11 +202,23 @@ export class AnthropicKeyProvider implements KeyProvider { return this.keys.filter((k) => !k.isDisabled).length; } - public incrementUsage(hash: string, model: string, tokens: number) { - const key = this.keys.find((k) => k.hash === hash); + public incrementUsage(keyHash: string, modelFamily: AnthropicModelFamily, usage: { input: number; output: number }) { + const key = this.keys.find((k) => k.hash === keyHash); if (!key) return; + key.promptCount++; - key[`${getClaudeModelFamily(model)}Tokens`] += tokens; + + if (!key.tokenUsage) { + key.tokenUsage = {}; + } + // Ensure the specific family object exists + if (!key.tokenUsage[modelFamily]) { + key.tokenUsage[modelFamily] = { input: 0, output: 0 }; + } + + const currentFamilyUsage = key.tokenUsage[modelFamily]!; + currentFamilyUsage.input += usage.input; + currentFamilyUsage.output += usage.output; } getLockoutPeriod = createGenericGetLockoutPeriod(() => this.keys); diff --git a/src/shared/key-management/aws/provider.ts b/src/shared/key-management/aws/provider.ts index 74cd26f..408c710 100644 --- a/src/shared/key-management/aws/provider.ts +++ b/src/shared/key-management/aws/provider.ts @@ -7,11 +7,8 @@ import { createGenericGetLockoutPeriod, Key, KeyProvider } from ".."; import { prioritizeKeys } from "../prioritize-keys"; import { AwsKeyChecker } from "./checker"; -type AwsBedrockKeyUsage = { - [K in AwsBedrockModelFamily as `${K}Tokens`]: number; -}; - -export interface AwsBedrockKey extends Key, AwsBedrockKeyUsage { +// AwsBedrockKeyUsage is removed, tokenUsage from base Key interface will be used. +export interface AwsBedrockKey extends Key { readonly service: "aws"; readonly modelFamilies: AwsBedrockModelFamily[]; /** @@ -74,12 +71,7 @@ export class AwsBedrockKeyProvider implements KeyProvider { lastChecked: 0, modelIds: ["anthropic.claude-3-sonnet-20240229-v1:0"], inferenceProfileIds: [], - ["aws-claudeTokens"]: 0, - ["aws-claude-opusTokens"]: 0, - ["aws-mistral-tinyTokens"]: 0, - ["aws-mistral-smallTokens"]: 0, - ["aws-mistral-mediumTokens"]: 0, - ["aws-mistral-largeTokens"]: 0, + tokenUsage: {}, // Initialize new tokenUsage field }; this.keys.push(newKey); } @@ -173,11 +165,22 @@ export class AwsBedrockKeyProvider implements KeyProvider { return this.keys.filter((k) => !k.isDisabled).length; } - public incrementUsage(hash: string, model: string, tokens: number) { - const key = this.keys.find((k) => k.hash === hash); + public incrementUsage(keyHash: string, modelFamily: AwsBedrockModelFamily, usage: { input: number; output: number }) { + const key = this.keys.find((k) => k.hash === keyHash); if (!key) return; + key.promptCount++; - key[`${getAwsBedrockModelFamily(model)}Tokens`] += tokens; + + if (!key.tokenUsage) { + key.tokenUsage = {}; + } + if (!key.tokenUsage[modelFamily]) { + key.tokenUsage[modelFamily] = { input: 0, output: 0 }; + } + + const currentFamilyUsage = key.tokenUsage[modelFamily]!; + currentFamilyUsage.input += usage.input; + currentFamilyUsage.output += usage.output; } getLockoutPeriod = createGenericGetLockoutPeriod(() => this.keys); diff --git a/src/shared/key-management/azure/provider.ts b/src/shared/key-management/azure/provider.ts index 46f87e5..180c8a9 100644 --- a/src/shared/key-management/azure/provider.ts +++ b/src/shared/key-management/azure/provider.ts @@ -10,11 +10,8 @@ import { createGenericGetLockoutPeriod, Key, KeyProvider } from ".."; import { prioritizeKeys } from "../prioritize-keys"; import { AzureOpenAIKeyChecker } from "./checker"; -type AzureOpenAIKeyUsage = { - [K in AzureOpenAIModelFamily as `${K}Tokens`]: number; -}; - -export interface AzureOpenAIKey extends Key, AzureOpenAIKeyUsage { +// AzureOpenAIKeyUsage is removed, tokenUsage from base Key interface will be used. +export interface AzureOpenAIKey extends Key { readonly service: "azure"; readonly modelFamilies: AzureOpenAIModelFamily[]; contentFiltering: boolean; @@ -68,24 +65,7 @@ export class AzureOpenAIKeyProvider implements KeyProvider { .digest("hex") .slice(0, 8)}`, lastChecked: 0, - "azure-turboTokens": 0, - "azure-gpt4Tokens": 0, - "azure-gpt4-32kTokens": 0, - "azure-gpt4-turboTokens": 0, - "azure-gpt4oTokens": 0, - "azure-gpt45Tokens": 0, - "azure-gpt41Tokens": 0, - "azure-gpt41-miniTokens": 0, - "azure-gpt41-nanoTokens": 0, - "azure-o1Tokens": 0, - "azure-o1-miniTokens": 0, - "azure-o1-proTokens": 0, - "azure-o3-miniTokens": 0, - "azure-o3Tokens": 0, - "azure-o4-miniTokens": 0, - "azure-codex-miniTokens": 0, - "azure-dall-eTokens": 0, - "azure-gpt-imageTokens": 0, + tokenUsage: {}, // Initialize new tokenUsage field modelIds: [], }; this.keys.push(newKey); @@ -140,11 +120,22 @@ export class AzureOpenAIKeyProvider implements KeyProvider { return this.keys.filter((k) => !k.isDisabled).length; } - public incrementUsage(hash: string, model: string, tokens: number) { - const key = this.keys.find((k) => k.hash === hash); + public incrementUsage(keyHash: string, modelFamily: AzureOpenAIModelFamily, usage: { input: number; output: number }) { + const key = this.keys.find((k) => k.hash === keyHash); if (!key) return; + key.promptCount++; - key[`${getAzureOpenAIModelFamily(model)}Tokens`] += tokens; + + if (!key.tokenUsage) { + key.tokenUsage = {}; + } + if (!key.tokenUsage[modelFamily]) { + key.tokenUsage[modelFamily] = { input: 0, output: 0 }; + } + + const currentFamilyUsage = key.tokenUsage[modelFamily]!; + currentFamilyUsage.input += usage.input; + currentFamilyUsage.output += usage.output; } getLockoutPeriod = createGenericGetLockoutPeriod(() => this.keys); diff --git a/src/shared/key-management/cohere/provider.ts b/src/shared/key-management/cohere/provider.ts index 65b8ffc..9158d71 100644 --- a/src/shared/key-management/cohere/provider.ts +++ b/src/shared/key-management/cohere/provider.ts @@ -2,13 +2,10 @@ import { Key, KeyProvider, createGenericGetLockoutPeriod } from ".."; import { CohereKeyChecker } from "./checker"; import { config } from "../../../config"; import { logger } from "../../../logger"; -import { CohereModelFamily } from "../../models"; +import { CohereModelFamily, ModelFamily } from "../../models"; // Added ModelFamily -type CohereKeyUsage = { - "cohereTokens": number; -}; - -export interface CohereKey extends Key, CohereKeyUsage { +// CohereKeyUsage is removed, tokenUsage from base Key interface will be used. +export interface CohereKey extends Key { readonly service: "cohere"; readonly modelFamilies: CohereModelFamily[]; isOverQuota: boolean; @@ -42,7 +39,7 @@ export class CohereKeyProvider implements KeyProvider { hash: this.hashKey(key), rateLimitedAt: 0, rateLimitedUntil: 0, - "cohereTokens": 0, + tokenUsage: {}, // Initialize new tokenUsage field isOverQuota: false, }); } @@ -99,13 +96,24 @@ export class CohereKeyProvider implements KeyProvider { return this.keys.filter((k) => !k.isDisabled).length; } - public incrementUsage(hash: string, model: string, tokens: number) { - const key = this.keys.find((k) => k.hash === hash); + public incrementUsage(keyHash: string, modelFamily: CohereModelFamily, usage: { input: number; output: number }) { + const key = this.keys.find((k) => k.hash === keyHash); if (!key) return; - key.promptCount++; - key[`cohereTokens`] += tokens; - } + key.promptCount++; + + if (!key.tokenUsage) { + key.tokenUsage = {}; + } + // Cohere only has one model family "cohere" + if (!key.tokenUsage[modelFamily]) { + key.tokenUsage[modelFamily] = { input: 0, output: 0 }; + } + + const currentFamilyUsage = key.tokenUsage[modelFamily]!; + currentFamilyUsage.input += usage.input; + currentFamilyUsage.output += usage.output; + } /** * Upon being rate limited, a key will be locked out for this many milliseconds diff --git a/src/shared/key-management/deepseek/provider.ts b/src/shared/key-management/deepseek/provider.ts index e79802c..8ef3510 100644 --- a/src/shared/key-management/deepseek/provider.ts +++ b/src/shared/key-management/deepseek/provider.ts @@ -2,13 +2,10 @@ import { Key, KeyProvider, createGenericGetLockoutPeriod } from ".."; import { DeepseekKeyChecker } from "./checker"; import { config } from "../../../config"; import { logger } from "../../../logger"; -import { DeepseekModelFamily } from "../../models"; +import { DeepseekModelFamily, ModelFamily } from "../../models"; // Added ModelFamily -type DeepseekKeyUsage = { - "deepseekTokens": number; -}; - -export interface DeepseekKey extends Key, DeepseekKeyUsage { +// DeepseekKeyUsage is removed, tokenUsage from base Key interface will be used. +export interface DeepseekKey extends Key { readonly service: "deepseek"; readonly modelFamilies: DeepseekModelFamily[]; isOverQuota: boolean; @@ -42,7 +39,7 @@ export class DeepseekKeyProvider implements KeyProvider { hash: this.hashKey(key), rateLimitedAt: 0, rateLimitedUntil: 0, - "deepseekTokens": 0, + tokenUsage: {}, // Initialize new tokenUsage field isOverQuota: false, }); } @@ -99,13 +96,24 @@ export class DeepseekKeyProvider implements KeyProvider { return this.keys.filter((k) => !k.isDisabled).length; } - public incrementUsage(hash: string, model: string, tokens: number) { - const key = this.keys.find((k) => k.hash === hash); + public incrementUsage(keyHash: string, modelFamily: DeepseekModelFamily, usage: { input: number; output: number }) { + const key = this.keys.find((k) => k.hash === keyHash); if (!key) return; - key.promptCount++; - key[`deepseekTokens`] += tokens; - } + key.promptCount++; + + if (!key.tokenUsage) { + key.tokenUsage = {}; + } + // Deepseek only has one model family "deepseek" + if (!key.tokenUsage[modelFamily]) { + key.tokenUsage[modelFamily] = { input: 0, output: 0 }; + } + + const currentFamilyUsage = key.tokenUsage[modelFamily]!; + currentFamilyUsage.input += usage.input; + currentFamilyUsage.output += usage.output; + } /** * Upon being rate limited, a key will be locked out for this many milliseconds @@ -156,4 +164,4 @@ export class DeepseekKeyProvider implements KeyProvider { key.rateLimitedAt = now; key.rateLimitedUntil = Math.max(currentRateLimit, nextRateLimit); } -} \ No newline at end of file +} diff --git a/src/shared/key-management/gcp/provider.ts b/src/shared/key-management/gcp/provider.ts index 52398c6..a5a1879 100644 --- a/src/shared/key-management/gcp/provider.ts +++ b/src/shared/key-management/gcp/provider.ts @@ -7,11 +7,8 @@ import { createGenericGetLockoutPeriod, Key, KeyProvider } from ".."; import { prioritizeKeys } from "../prioritize-keys"; import { GcpKeyChecker } from "./checker"; -type GcpKeyUsage = { - [K in GcpModelFamily as `${K}Tokens`]: number; -}; - -export interface GcpKey extends Key, GcpKeyUsage { +// GcpKeyUsage is removed, tokenUsage from base Key interface will be used. +export interface GcpKey extends Key { readonly service: "gcp"; readonly modelFamilies: GcpModelFamily[]; sonnetEnabled: boolean; @@ -75,8 +72,7 @@ export class GcpKeyProvider implements KeyProvider { sonnet35Enabled: false, accessToken: "", accessTokenExpiresAt: 0, - ["gcp-claudeTokens"]: 0, - ["gcp-claude-opusTokens"]: 0, + tokenUsage: {}, // Initialize new tokenUsage field }; this.keys.push(newKey); } @@ -160,11 +156,22 @@ export class GcpKeyProvider implements KeyProvider { return this.keys.filter((k) => !k.isDisabled).length; } - public incrementUsage(hash: string, model: string, tokens: number) { - const key = this.keys.find((k) => k.hash === hash); + public incrementUsage(keyHash: string, modelFamily: GcpModelFamily, usage: { input: number; output: number }) { + const key = this.keys.find((k) => k.hash === keyHash); if (!key) return; + key.promptCount++; - key[`${getGcpModelFamily(model)}Tokens`] += tokens; + + if (!key.tokenUsage) { + key.tokenUsage = {}; + } + if (!key.tokenUsage[modelFamily]) { + key.tokenUsage[modelFamily] = { input: 0, output: 0 }; + } + + const currentFamilyUsage = key.tokenUsage[modelFamily]!; + currentFamilyUsage.input += usage.input; + currentFamilyUsage.output += usage.output; } getLockoutPeriod = createGenericGetLockoutPeriod(() => this.keys); diff --git a/src/shared/key-management/google-ai/provider.ts b/src/shared/key-management/google-ai/provider.ts index 39d2bdd..8607d2b 100644 --- a/src/shared/key-management/google-ai/provider.ts +++ b/src/shared/key-management/google-ai/provider.ts @@ -22,11 +22,8 @@ export type GoogleAIKeyUpdate = Omit< | "rateLimitedUntil" >; -type GoogleAIKeyUsage = { - [K in GoogleAIModelFamily as `${K}Tokens`]: number; -}; - -export interface GoogleAIKey extends Key, GoogleAIKeyUsage { +// GoogleAIKeyUsage is removed, tokenUsage from base Key interface will be used. +export interface GoogleAIKey extends Key { readonly service: "google-ai"; readonly modelFamilies: GoogleAIModelFamily[]; /** All detected model IDs on this key. */ @@ -84,9 +81,7 @@ export class GoogleAIKeyProvider implements KeyProvider { .digest("hex") .slice(0, 8)}`, lastChecked: 0, - "gemini-flashTokens": 0, - "gemini-proTokens": 0, - "gemini-ultraTokens": 0, + tokenUsage: {}, // Initialize new tokenUsage field modelIds: [], overQuotaFamilies: [], }; @@ -139,11 +134,22 @@ export class GoogleAIKeyProvider implements KeyProvider { return this.keys.filter((k) => !k.isDisabled).length; } - public incrementUsage(hash: string, model: string, tokens: number) { - const key = this.keys.find((k) => k.hash === hash); + public incrementUsage(keyHash: string, modelFamily: GoogleAIModelFamily, usage: { input: number; output: number }) { + const key = this.keys.find((k) => k.hash === keyHash); if (!key) return; + key.promptCount++; - key[`${getGoogleAIModelFamily(model)}Tokens`] += tokens; + + if (!key.tokenUsage) { + key.tokenUsage = {}; + } + if (!key.tokenUsage[modelFamily]) { + key.tokenUsage[modelFamily] = { input: 0, output: 0 }; + } + + const currentFamilyUsage = key.tokenUsage[modelFamily]!; + currentFamilyUsage.input += usage.input; + currentFamilyUsage.output += usage.output; } getLockoutPeriod = createGenericGetLockoutPeriod(() => this.keys); diff --git a/src/shared/key-management/index.ts b/src/shared/key-management/index.ts index e76240d..efd311e 100644 --- a/src/shared/key-management/index.ts +++ b/src/shared/key-management/index.ts @@ -36,6 +36,14 @@ export interface Key { rateLimitedAt: number; /** The time until which this key is rate limited. */ rateLimitedUntil: number; + /** Detailed token usage, separated by input and output, per model family. */ + tokenUsage?: { + [family in ModelFamily]?: { + input: number; + output: number; + legacy_total?: number; // To store migrated single-number totals + }; + }; } /* @@ -58,7 +66,7 @@ export interface KeyProvider { disable(key: T): void; update(hash: string, update: Partial): void; available(): number; - incrementUsage(hash: string, model: string, tokens: number): void; + incrementUsage(hash: string, modelFamily: ModelFamily, usage: { input: number; output: number }): void; getLockoutPeriod(model: ModelFamily): number; markRateLimited(hash: string): void; recheck(): void; diff --git a/src/shared/key-management/key-pool.ts b/src/shared/key-management/key-pool.ts index ebe9d27..b54047f 100644 --- a/src/shared/key-management/key-pool.ts +++ b/src/shared/key-management/key-pool.ts @@ -108,9 +108,30 @@ export class KeyPool { }, 0); } - public incrementUsage(key: Key, model: string, tokens: number): void { + public incrementUsage(key: Key, modelName: string, usage: { input: number; output: number }): void { const provider = this.getKeyProvider(key.service); - provider.incrementUsage(key.hash, model, tokens); + // Assuming the provider's incrementUsage expects a modelFamily. + // We need a robust way to get modelFamily from modelName here. + // This might involve calling a method similar to getModelFamilyForRequest from user-store, + // or enhancing getServiceForModel to also return family, or passing family directly. + // For now, let's assume the provider can handle the modelName or we derive family. + // This part is tricky as KeyPool's getServiceForModel is for service, not family directly from a generic model string. + // Let's assume for now the provider's incrementUsage can take modelName and derive family, + // or the KeyProvider interface's incrementUsage should take modelName. + // The KeyProvider interface was changed to modelFamily. So we MUST derive it. + // This requires a utility function similar to what's in user-store or models.ts. + // For now, I'll placeholder this derivation. This is a critical point. + // Placeholder: const modelFamily = this.getModelFamilyForModel(modelName, key.service); + // This is complex because getModelFamilyForModel needs the service context. + // Let's assume the `modelName` passed here is actually `modelFamily` for now, + // or that the caller will resolve it. + // The KeyProvider interface expects `modelFamily`. The caller in middleware/response/index.ts + // has `model` (name) and `req.outboundApi`. It should resolve to family there. + // So, `modelName` here should actually be `modelFamily`. + // I will assume the caller of KeyPool.incrementUsage will pass modelFamily. + // So, changing `model: string` to `modelFamily: ModelFamily` in signature. + // This change needs to be propagated to the caller. + provider.incrementUsage(key.hash, modelName as ModelFamily, usage); // Casting modelName, assuming caller provides family } public getLockoutPeriod(family: ModelFamily): number { @@ -247,4 +268,4 @@ export class KeyPool { ); this.recheckJobs["google-ai"] = googleJob; } -} \ No newline at end of file +} diff --git a/src/shared/key-management/mistral-ai/provider.ts b/src/shared/key-management/mistral-ai/provider.ts index a8460e6..1453c33 100644 --- a/src/shared/key-management/mistral-ai/provider.ts +++ b/src/shared/key-management/mistral-ai/provider.ts @@ -7,11 +7,8 @@ import { createGenericGetLockoutPeriod, Key, KeyProvider } from ".."; import { prioritizeKeys } from "../prioritize-keys"; import { MistralAIKeyChecker } from "./checker"; -type MistralAIKeyUsage = { - [K in MistralAIModelFamily as `${K}Tokens`]: number; -}; - -export interface MistralAIKey extends Key, MistralAIKeyUsage { +// MistralAIKeyUsage is removed, tokenUsage from base Key interface will be used. +export interface MistralAIKey extends Key { readonly service: "mistral-ai"; readonly modelFamilies: MistralAIModelFamily[]; } @@ -67,10 +64,7 @@ export class MistralAIKeyProvider implements KeyProvider { .digest("hex") .slice(0, 8)}`, lastChecked: 0, - "mistral-tinyTokens": 0, - "mistral-smallTokens": 0, - "mistral-mediumTokens": 0, - "mistral-largeTokens": 0, + tokenUsage: {}, // Initialize new tokenUsage field }; this.keys.push(newKey); } @@ -117,12 +111,22 @@ export class MistralAIKeyProvider implements KeyProvider { return this.keys.filter((k) => !k.isDisabled).length; } - public incrementUsage(hash: string, model: string, tokens: number) { - const key = this.keys.find((k) => k.hash === hash); + public incrementUsage(keyHash: string, modelFamily: MistralAIModelFamily, usage: { input: number; output: number }) { + const key = this.keys.find((k) => k.hash === keyHash); if (!key) return; + key.promptCount++; - const family = getMistralAIModelFamily(model); - key[`${family}Tokens`] += tokens; + + if (!key.tokenUsage) { + key.tokenUsage = {}; + } + if (!key.tokenUsage[modelFamily]) { + key.tokenUsage[modelFamily] = { input: 0, output: 0 }; + } + + const currentFamilyUsage = key.tokenUsage[modelFamily]!; + currentFamilyUsage.input += usage.input; + currentFamilyUsage.output += usage.output; } getLockoutPeriod = createGenericGetLockoutPeriod(() => this.keys); diff --git a/src/shared/key-management/openai/provider.ts b/src/shared/key-management/openai/provider.ts index 522d550..76bc858 100644 --- a/src/shared/key-management/openai/provider.ts +++ b/src/shared/key-management/openai/provider.ts @@ -3,16 +3,13 @@ import http from "http"; import { Key, KeyProvider } from "../index"; import { config } from "../../../config"; import { logger } from "../../../logger"; -import { getOpenAIModelFamily, OpenAIModelFamily } from "../../models"; +import { getOpenAIModelFamily, OpenAIModelFamily, ModelFamily } from "../../models"; // Added ModelFamily import { PaymentRequiredError } from "../../errors"; import { OpenAIKeyChecker } from "./checker"; import { prioritizeKeys } from "../prioritize-keys"; -type OpenAIKeyUsage = { - [K in OpenAIModelFamily as `${K}Tokens`]: number; -}; - -export interface OpenAIKey extends Key, OpenAIKeyUsage { +// OpenAIKeyUsage is removed, tokenUsage from base Key interface will be used. +export interface OpenAIKey extends Key { readonly service: "openai"; modelFamilies: OpenAIModelFamily[]; /** @@ -108,24 +105,7 @@ export class OpenAIKeyProvider implements KeyProvider { rateLimitedUntil: 0, rateLimitRequestsReset: 0, rateLimitTokensReset: 0, - turboTokens: 0, - gpt4Tokens: 0, - "gpt4-32kTokens": 0, - "gpt4-turboTokens": 0, - gpt4oTokens: 0, - gpt45Tokens: 0, - gpt41Tokens: 0, - "gpt41-miniTokens": 0, - "gpt41-nanoTokens": 0, - "o1Tokens": 0, - "o1-miniTokens": 0, - "o1-proTokens": 0, - "o3-miniTokens": 0, - "o3Tokens": 0, - "o4-miniTokens": 0, - "codex-miniTokens": 0, - "dall-eTokens": 0, - "gpt-imageTokens": 0, + tokenUsage: {}, // Initialize new tokenUsage field modelIds: [], }; this.keys.push(newKey); @@ -337,11 +317,22 @@ export class OpenAIKeyProvider implements KeyProvider { key.rateLimitedUntil = now + key.rateLimitRequestsReset; } - public incrementUsage(keyHash: string, model: string, tokens: number) { + public incrementUsage(keyHash: string, modelFamily: OpenAIModelFamily, usage: { input: number; output: number }) { const key = this.keys.find((k) => k.hash === keyHash); if (!key) return; + key.promptCount++; - key[`${getOpenAIModelFamily(model)}Tokens`] += tokens; + + if (!key.tokenUsage) { + key.tokenUsage = {}; + } + if (!key.tokenUsage[modelFamily]) { + key.tokenUsage[modelFamily] = { input: 0, output: 0 }; + } + + const currentFamilyUsage = key.tokenUsage[modelFamily]!; + currentFamilyUsage.input += usage.input; + currentFamilyUsage.output += usage.output; } public updateRateLimits(keyHash: string, headers: http.IncomingHttpHeaders) { diff --git a/src/shared/key-management/qwen/checker.ts b/src/shared/key-management/qwen/checker.ts index 5048de5..eec41d6 100644 --- a/src/shared/key-management/qwen/checker.ts +++ b/src/shared/key-management/qwen/checker.ts @@ -6,7 +6,7 @@ export interface QwenKey extends Key { readonly service: "qwen"; readonly modelFamilies: QwenModelFamily[]; isOverQuota: boolean; - "qwenTokens": number; + // "qwenTokens" is removed, tokenUsage from base Key interface will be used. } import { logger } from "../../../logger"; import { assertNever } from "../../utils"; diff --git a/src/shared/key-management/qwen/provider.ts b/src/shared/key-management/qwen/provider.ts index 98c346d..f4762a6 100644 --- a/src/shared/key-management/qwen/provider.ts +++ b/src/shared/key-management/qwen/provider.ts @@ -2,6 +2,7 @@ import { KeyProvider, createGenericGetLockoutPeriod } from ".."; import { QwenKeyChecker, QwenKey } from "./checker"; import { config } from "../../../config"; import { logger } from "../../../logger"; +import { QwenModelFamily, ModelFamily } from "../../models"; // Added ModelFamily // Re-export the QwenKey interface export type { QwenKey } from "./checker"; @@ -36,7 +37,7 @@ export class QwenKeyProvider implements KeyProvider { hash: this.hashKey(key), rateLimitedAt: 0, rateLimitedUntil: 0, - "qwenTokens": 0, + tokenUsage: {}, // Initialize new tokenUsage field isOverQuota: false, }); } @@ -93,11 +94,23 @@ export class QwenKeyProvider implements KeyProvider { return this.keys.filter((k) => !k.isDisabled).length; } - public incrementUsage(hash: string, model: string, tokens: number) { - const key = this.keys.find((k) => k.hash === hash); + public incrementUsage(keyHash: string, modelFamily: QwenModelFamily, usage: { input: number; output: number }) { + const key = this.keys.find((k) => k.hash === keyHash); if (!key) return; + key.promptCount++; - key[`qwenTokens`] += tokens; + + if (!key.tokenUsage) { + key.tokenUsage = {}; + } + // Qwen only has one model family "qwen" + if (!key.tokenUsage[modelFamily]) { + key.tokenUsage[modelFamily] = { input: 0, output: 0 }; + } + + const currentFamilyUsage = key.tokenUsage[modelFamily]!; + currentFamilyUsage.input += usage.input; + currentFamilyUsage.output += usage.output; } /** diff --git a/src/shared/key-management/xai/provider.ts b/src/shared/key-management/xai/provider.ts index da37550..dfabf1a 100644 --- a/src/shared/key-management/xai/provider.ts +++ b/src/shared/key-management/xai/provider.ts @@ -2,13 +2,10 @@ import { Key, KeyProvider, createGenericGetLockoutPeriod } from ".."; import { XaiKeyChecker } from "./checker"; import { config } from "../../../config"; import { logger } from "../../../logger"; -import { XaiModelFamily } from "../../models"; +import { XaiModelFamily, ModelFamily } from "../../models"; // Added ModelFamily -type XaiKeyUsage = { - "xaiTokens": number; -}; - -export interface XaiKey extends Key, XaiKeyUsage { +// XaiKeyUsage is removed, tokenUsage from base Key interface will be used. +export interface XaiKey extends Key { readonly service: "xai"; readonly modelFamilies: XaiModelFamily[]; isOverQuota: boolean; @@ -42,7 +39,7 @@ export class XaiKeyProvider implements KeyProvider { hash: this.hashKey(key), rateLimitedAt: 0, rateLimitedUntil: 0, - "xaiTokens": 0, + tokenUsage: {}, // Initialize new tokenUsage field isOverQuota: false, }); } @@ -99,13 +96,24 @@ export class XaiKeyProvider implements KeyProvider { return this.keys.filter((k) => !k.isDisabled).length; } - public incrementUsage(hash: string, model: string, tokens: number) { - const key = this.keys.find((k) => k.hash === hash); + public incrementUsage(keyHash: string, modelFamily: XaiModelFamily, usage: { input: number; output: number }) { + const key = this.keys.find((k) => k.hash === keyHash); if (!key) return; - key.promptCount++; - key[`xaiTokens`] += tokens; - } + key.promptCount++; + + if (!key.tokenUsage) { + key.tokenUsage = {}; + } + // Xai only has one model family "xai" + if (!key.tokenUsage[modelFamily]) { + key.tokenUsage[modelFamily] = { input: 0, output: 0 }; + } + + const currentFamilyUsage = key.tokenUsage[modelFamily]!; + currentFamilyUsage.input += usage.input; + currentFamilyUsage.output += usage.output; + } /** * Upon being rate limited, a key will be locked out for this many milliseconds @@ -156,4 +164,4 @@ export class XaiKeyProvider implements KeyProvider { key.rateLimitedAt = now; key.rateLimitedUntil = Math.max(currentRateLimit, nextRateLimit); } -} \ No newline at end of file +} diff --git a/src/shared/sqlite-db.ts b/src/shared/sqlite-db.ts new file mode 100644 index 0000000..e7dc4ef --- /dev/null +++ b/src/shared/sqlite-db.ts @@ -0,0 +1,62 @@ +import Database from 'better-sqlite3'; +import { config } from '../config'; +import { logger } from '../logger'; + +const log = logger.child({ module: 'sqlite-db' }); + +let db: Database.Database; + +export function initSQLiteDB(): Database.Database { + if (db) { + return db; + } + + const dbPath = config.sqliteUserStorePath; + if (!dbPath) { + log.error('SQLite user store DB path (SQLITE_USER_STORE_PATH) is not configured.'); + throw new Error('SQLite user store DB path is not configured.'); + } + + log.info({ path: dbPath }, 'Initializing SQLite database for user store...'); + db = new Database(dbPath, { verbose: config.logLevel === 'trace' ? console.log : undefined }); + + // Enable WAL mode for better concurrency and performance. + db.pragma('journal_mode = WAL'); + + // Create users table + // Note: JSON fields (ip, tokenCounts, etc.) are stored as TEXT. + // Timestamps are stored as INTEGER (Unix epoch milliseconds). + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + token TEXT PRIMARY KEY, + ip TEXT, /* JSON string array */ + nickname TEXT, + type TEXT NOT NULL CHECK(type IN ('normal', 'special', 'temporary')), + promptCount INTEGER NOT NULL DEFAULT 0, + tokenCounts TEXT, /* JSON string object */ + tokenLimits TEXT, /* JSON string object */ + tokenRefresh TEXT, /* JSON string object */ + createdAt INTEGER NOT NULL, + lastUsedAt INTEGER, + disabledAt INTEGER, + disabledReason TEXT, + expiresAt INTEGER, + maxIps INTEGER, + adminNote TEXT, + meta TEXT /* JSON string object */ + ); + `); + + log.info('SQLite database initialized and `users` table created/verified.'); + return db; +} + +export function getDB(): Database.Database { + if (!db) { + // This might happen if getDB is called before initSQLiteDB, + // though user-store should ensure init is called first. + log.warn('SQLite DB instance requested before initialization. Attempting to initialize now.'); + return initSQLiteDB(); + } + return db; +} diff --git a/src/shared/stats.ts b/src/shared/stats.ts index 8119c53..9d8f388 100644 --- a/src/shared/stats.ts +++ b/src/shared/stats.ts @@ -1,146 +1,88 @@ import { config } from "../config"; import { ModelFamily } from "./models"; -// Using weighted averages now for better guessing, thinking models use around 1:3 ratio for input:output -// for the thinking part, other models hover around 3:1 input output, still not the best, but reflects better to real proompting. -export function getTokenCostUsd(model: ModelFamily, tokens: number) { - let cost = 0; - switch (model) { - case "deepseek": - cost = 0.00000178; - // uncached r1 pricing, again the highest average - break; - case "xai": - cost = 0.000014; - // just using the highest input/output price aka grok-3 (because who cares about grok) - break; - case "gpt41": - case "azure-gpt41": - cost = 0.0000075; - // averaged the same wa* as 4.5 - break; - case "gpt41-mini": - case "azure-gpt41-mini": - cost = 0.0000015; - break; - case "gpt41-nano": - case "azure-gpt41-nano": - cost = 0.0000003; - break; - case "gpt45": - case "azure-gpt45": - // $75/$150 for 1M input/output tokens pricing, averaged to $112 - cost = 0.00009375; - break; - case "gpt4o": - case "azure-gpt4o": - cost = 0.0000075; - break; - case "azure-gpt4-turbo": - case "gpt4-turbo": - cost = 0.0000125; - break; - case "azure-o1-pro": - case "o1-pro": - // OpenAI o1-pro pricing $150/1M input tokens and $600/1M output tokens - cost = 0.0004875; - break; - case "azure-o1": - case "o1": - // Currently we do not track output tokens separately, and O1 uses - // considerably more output tokens that other models for its hidden - // reasoning. The official O1 pricing is $15/1M input tokens and $60/1M - // output tokens so we will return a higher estimate here. - cost = 0.00004875; - break; - case "azure-o1-mini": - case "o1-mini": - case "azure-o3-mini": - case "o3-mini": - cost = 0.000003575; // $1.1/1M input tokens, $4.4/1M output tokens - break; - case "azure-o3": - case "o3": - cost = 0.000032; // $10/1M input tokens, $40/1M output tokens - break; - case "azure-o4-mini": - case "o4-mini": - cost = 0.000003575; // $1.1/1M input tokens, $4.4/1M output tokens - break; - case "azure-codex-mini": - case "codex-mini": - // Codex Mini pricing: $1.5/1M input tokens, $6.0/1M output tokens - // Using weighted average for 1:3 input:output ratio - cost = 0.0000045; // Weighted average with output bias - break; - case "azure-gpt4-32k": - case "gpt4-32k": - cost = 0.000075; - break; - case "azure-gpt4": - case "gpt4": - cost = 0.0000375; - break; - case "azure-turbo": - case "turbo": - cost = 0.00000075; - break; - case "azure-dall-e": - case "dall-e": - cost = 0.00001; - break; - case "azure-gpt-image": - case "gpt-image": - // gpt-image-1 pricing: - // Text input tokens: $5 per 1M tokens - // Image input tokens: $10 per 1M tokens - // Image output tokens: $40 per 1M tokens - // Weighted average assuming a mix of text/image input and output - // Typical cost is $0.02-$0.19 per image depending on quality - cost = 0.000018; // Balanced estimate accounting for input/output mix - break; - case "aws-claude": - case "gcp-claude": - case "claude": - cost = 0.00001; - break; - case "aws-claude-opus": - case "gcp-claude-opus": - case "claude-opus": - cost = 0.00003; - break; - case "aws-mistral-tiny": - case "mistral-tiny": - // Using Ministral 3B pricing: $0.04/1M input tokens, $0.04/1M output tokens - // For edge/tiny models, a more balanced 1:1 ratio is used - cost = 0.00000004; - break; - case "aws-mistral-small": - case "mistral-small": - // Using Codestral pricing: $0.3/1M input, $0.9/1M output (highest in category) - // Weighted average for 1:3 input:output ratio - cost = 0.00000075; - break; - case "aws-mistral-medium": - case "mistral-medium": - // Using Mistral Saba pricing: $0.2/1M input, $0.6/1M output - // Weighted average for 1:3 input:output ratio - cost = 0.0000005; - break; - case "aws-mistral-large": - case "mistral-large": - // Using Mistral Large/Pixtral Large pricing: $2/1M input, $6/1M output - // Weighted average for 1:3 input:output ratio - cost = 0.000005; - break; - case "gemini-flash": - cost = 0.0000002326; - break; - case "gemini-pro": - cost = 0.00000344; - break; +// Prices are per 1 million tokens. +const MODEL_PRICING: Record = { + "deepseek": { input: 0.14, output: 0.28 }, // DeepSeek-V2: $0.14/$0.28 per 1M tokens + "xai": { input: 5.6, output: 16.8 }, // Grok: Derived from avg $14/1M (assuming 1:3 in/out ratio) - needs official pricing + "gpt41": { input: 2.00, output: 8.00 }, + "azure-gpt41": { input: 2.00, output: 8.00 }, + "gpt41-mini": { input: 0.40, output: 1.60 }, + "azure-gpt41-mini": { input: 0.40, output: 1.60 }, + "gpt41-nano": { input: 0.10, output: 0.40 }, + "azure-gpt41-nano": { input: 0.10, output: 0.40 }, + "gpt45": { input: 75.00, output: 150.00 }, // Example, needs verification if this model family is still current with this pricing + "azure-gpt45": { input: 75.00, output: 150.00 }, // Example, needs verification + "gpt4o": { input: 5.00, output: 20.00 }, + "azure-gpt4o": { input: 5.00, output: 20.00 }, + "gpt4-turbo": { input: 10.00, output: 30.00 }, + "azure-gpt4-turbo": { input: 10.00, output: 30.00 }, + "o1-pro": { input: 150.00, output: 600.00 }, + "azure-o1-pro": { input: 150.00, output: 600.00 }, + "o1": { input: 15.00, output: 60.00 }, + "azure-o1": { input: 15.00, output: 60.00 }, + "o1-mini": { input: 1.10, output: 4.40 }, + "azure-o1-mini": { input: 1.10, output: 4.40 }, + "o3-mini": { input: 1.10, output: 4.40 }, + "azure-o3-mini": { input: 1.10, output: 4.40 }, + "o3": { input: 10.00, output: 40.00 }, + "azure-o3": { input: 10.00, output: 40.00 }, + "o4-mini": { input: 1.10, output: 4.40 }, + "azure-o4-mini": { input: 1.10, output: 4.40 }, + "codex-mini": { input: 1.50, output: 6.00 }, + "azure-codex-mini": { input: 1.50, output: 6.00 }, + "gpt4-32k": { input: 60.00, output: 120.00 }, + "azure-gpt4-32k": { input: 60.00, output: 120.00 }, + "gpt4": { input: 30.00, output: 60.00 }, + "azure-gpt4": { input: 30.00, output: 60.00 }, + "turbo": { input: 0.60, output: 2.40 }, // Maps to GPT-4o mini + "azure-turbo": { input: 0.60, output: 2.40 }, + "dall-e": { input: 0, output: 0 }, // Pricing is per image, not token based in this context. + "azure-dall-e": { input: 0, output: 0 }, // Pricing is per image. + "gpt-image": { input: 0, output: 0 }, // Complex pricing (text, image input, image output tokens), handle separately. + "azure-gpt-image": { input: 0, output: 0 }, // Complex pricing. + "claude": { input: 3.00, output: 15.00 }, // Anthropic Claude Sonnet 4 + "aws-claude": { input: 3.00, output: 15.00 }, + "gcp-claude": { input: 3.00, output: 15.00 }, + "claude-opus": { input: 15.00, output: 75.00 }, // Anthropic Claude Opus 4 + "aws-claude-opus": { input: 15.00, output: 75.00 }, + "gcp-claude-opus": { input: 15.00, output: 75.00 }, + "mistral-tiny": { input: 0.04, output: 0.04 }, // Using old price if no new API price found + "aws-mistral-tiny": { input: 0.04, output: 0.04 }, + "mistral-small": { input: 0.10, output: 0.30 }, // Mistral Small 3.1 + "aws-mistral-small": { input: 0.10, output: 0.30 }, + "mistral-medium": { input: 0.40, output: 2.00 }, // Mistral Medium 3 + "aws-mistral-medium": { input: 0.40, output: 2.00 }, + "mistral-large": { input: 2.00, output: 6.00 }, + "aws-mistral-large": { input: 2.00, output: 6.00 }, + "gemini-flash": { input: 0.35, output: 1.05 }, // Gemini 1.5 Flash + "gemini-pro": { input: 0.125, output: 0.375 }, // Gemini 1.0 Pro + "gemini-ultra": { input: 25.00, output: 75.00 }, // Estimated based on Gemini Pro (5-10x) and character to token conversion. Official per-token pricing needed. + // Ensure all ModelFamily entries from models.ts are covered or have a default. + // Adding placeholders for families in models.ts but not yet priced here. + "cohere": { input: 0.25, output: 0.50 }, // Cohere Command R, as an example + "qwen": { input: 1.40, output: 2.80 }, // Qwen-plus, as an example +}; + +export function getTokenCostDetailsUsd(model: ModelFamily, inputTokens: number, outputTokens?: number): { inputCost: number, outputCost: number, totalCost: number } { + const pricing = MODEL_PRICING[model]; + + if (!pricing) { + console.warn(`Pricing not found for model family: ${model}. Returning 0 cost for all components.`); + return { inputCost: 0, outputCost: 0, totalCost: 0 }; } - return cost * Math.max(0, tokens); + + const costPerMillionInputTokens = pricing.input; + const costPerMillionOutputTokens = pricing.output; + + const inputCost = (costPerMillionInputTokens / 1_000_000) * Math.max(0, inputTokens); + const outputCost = (costPerMillionOutputTokens / 1_000_000) * Math.max(0, outputTokens ?? 0); + + return { inputCost, outputCost, totalCost: inputCost + outputCost }; +} + +export function getTokenCostUsd(model: ModelFamily, inputTokens: number, outputTokens?: number): number { + return getTokenCostDetailsUsd(model, inputTokens, outputTokens).totalCost; } export function prettyTokens(tokens: number): string { @@ -159,4 +101,4 @@ export function prettyTokens(tokens: number): string { export function getCostSuffix(cost: number) { if (!config.showTokenCosts) return ""; return ` ($${cost.toFixed(2)})`; -} \ No newline at end of file +} diff --git a/src/shared/users/schema.ts b/src/shared/users/schema.ts index b62936b..9572460 100644 --- a/src/shared/users/schema.ts +++ b/src/shared/users/schema.ts @@ -3,11 +3,21 @@ import { MODEL_FAMILIES, ModelFamily } from "../models"; import { makeOptionalPropsNullable } from "../utils"; // This just dynamically creates a Zod object type with a key for each model -// family and an optional number value. +// family and an optional number value for input and output tokens. export const tokenCountsSchema: ZodType = z.object( MODEL_FAMILIES.reduce( - (acc, family) => ({ ...acc, [family]: z.number().optional().default(0) }), - {} as Record> + (acc, family) => ({ + ...acc, + [family]: z + .object({ + input: z.number().optional().default(0), + output: z.number().optional().default(0), + legacy_total: z.number().optional(), // Added legacy_total + }) + .optional() + .default({ input: 0, output: 0 }), // Default will not have legacy_total + }), + {} as Record> ) ); @@ -33,7 +43,7 @@ export const UserSchema = z * Never used; retained for backwards compatibility. */ tokenCount: z.any().optional(), - /** Number of tokens the user has consumed, by model family. */ + /** Number of input and output tokens the user has consumed, by model family. */ tokenCounts: tokenCountsSchema, /** Maximum number of tokens the user can consume, by model family. */ tokenLimits: tokenCountsSchema, @@ -67,7 +77,7 @@ export const UserPartialSchema = makeOptionalPropsNullable(UserSchema) .extend({ token: z.string() }); export type UserTokenCounts = { - [K in ModelFamily]: number | undefined; + [K in ModelFamily]: { input: number; output: number; legacy_total?: number } | undefined; }; export type User = z.infer; export type UserUpdate = z.infer; diff --git a/src/shared/users/user-store.ts b/src/shared/users/user-store.ts index 09be8f4..4f646dd 100644 --- a/src/shared/users/user-store.ts +++ b/src/shared/users/user-store.ts @@ -10,9 +10,11 @@ import admin from "firebase-admin"; import schedule from "node-schedule"; import { v4 as uuid } from "uuid"; +import type { Database } from 'better-sqlite3'; import { config } from "../../config"; import { logger } from "../../logger"; import { getFirebaseApp } from "../firebase"; +import { initSQLiteDB, getDB } from "../sqlite-db"; // Added import { APIFormat } from "../key-management"; import { getAwsBedrockModelFamily, @@ -31,9 +33,45 @@ import { User, UserTokenCounts, UserUpdate } from "./schema"; const log = logger.child({ module: "users" }); const INITIAL_TOKENS: Required = MODEL_FAMILIES.reduce( - (acc, family) => ({ ...acc, [family]: 0 }), - {} as Record -); + (acc, family) => { + acc[family] = { input: 0, output: 0 }; // legacy_total is undefined by default + return acc; + }, + {} as Record +) as Required; + +const migrateTokenCountsProperty = ( + parsedProperty: any, // Data from DB (JSON.parse result for a specific user's property like tokenCounts) + defaultConfigForProperty: Record // e.g., INITIAL_TOKENS or config.tokenQuota +): UserTokenCounts => { + const result = {} as UserTokenCounts; + + for (const family of MODEL_FAMILIES) { + const dbValue = parsedProperty?.[family]; + const configValue = defaultConfigForProperty[family]; + + if (typeof dbValue === 'number') { + // Case 1: DB has old numeric format - migrate and add legacy_total + result[family] = { input: dbValue, output: 0, legacy_total: dbValue }; + } else if (typeof dbValue === 'object' && dbValue !== null && (typeof dbValue.input === 'number' || typeof dbValue.output === 'number')) { + // Case 2: DB has new object format (might or might not have legacy_total from a previous migration) + result[family] = { input: dbValue.input ?? 0, output: dbValue.output ?? 0, legacy_total: dbValue.legacy_total }; + } else { + // Case 3: DB value is missing or invalid, use default from config + if (typeof configValue === 'number') { + // Default from config is old numeric format (e.g., config.tokenQuota[family]) - migrate and add legacy_total + result[family] = { input: configValue, output: 0, legacy_total: configValue }; + } else if (typeof configValue === 'object' && configValue !== null && (typeof configValue.input === 'number' || typeof configValue.output === 'number')) { + // Default from config is new object format (e.g., INITIAL_TOKENS[family]) + result[family] = { input: configValue.input ?? 0, output: configValue.output ?? 0, legacy_total: configValue.legacy_total }; + } else { + // Ultimate fallback: if configValue is also missing or invalid for this family + result[family] = { input: 0, output: 0 }; // No legacy_total here + } + } + } + return result; +}; const users: Map = new Map(); const usersToFlush = new Set(); @@ -44,6 +82,8 @@ export async function init() { log.info({ store: config.gatekeeperStore }, "Initializing user store..."); if (config.gatekeeperStore === "firebase_rtdb") { await initFirebase(); + } else if (config.gatekeeperStore === "sqlite") { + await initSQLite(); // Added } if (config.quotaRefreshPeriod) { const crontab = getRefreshCrontab(); @@ -80,9 +120,14 @@ export function createUser(createOptions?: { ip: [], type: "normal", promptCount: 0, - tokenCounts: { ...INITIAL_TOKENS }, - tokenLimits: createOptions?.tokenLimits ?? { ...config.tokenQuota }, - tokenRefresh: createOptions?.tokenRefresh ?? { ...INITIAL_TOKENS }, + tokenCounts: { ...INITIAL_TOKENS }, // New counts don't have legacy_total + tokenLimits: createOptions?.tokenLimits ?? MODEL_FAMILIES.reduce((acc, family) => { + const quota = config.tokenQuota[family]; + // If quota is a number, it's a legacy total limit, store it as such + acc[family] = typeof quota === 'number' ? { input: quota, output: 0, legacy_total: quota } : (quota || { input: 0, output: 0 }); + return acc; + }, {} as UserTokenCounts), + tokenRefresh: createOptions?.tokenRefresh ?? { ...INITIAL_TOKENS }, // Refresh amounts typically start fresh createdAt: Date.now(), meta: {}, }; @@ -125,9 +170,14 @@ export function upsertUser(user: UserUpdate) { ip: [], type: "normal", promptCount: 0, - tokenCounts: { ...INITIAL_TOKENS }, - tokenLimits: { ...config.tokenQuota }, - tokenRefresh: { ...INITIAL_TOKENS }, + tokenCounts: { ...INITIAL_TOKENS }, // New counts don't have legacy_total + tokenLimits: MODEL_FAMILIES.reduce((acc, family) => { + const quota = config.tokenQuota[family]; + // If quota is a number, it's a legacy total limit, store it as such + acc[family] = typeof quota === 'number' ? { input: quota, output: 0, legacy_total: quota } : (quota || { input: 0, output: 0 }); + return acc; + }, {} as UserTokenCounts), + tokenRefresh: { ...INITIAL_TOKENS }, // Refresh amounts typically start fresh createdAt: Date.now(), meta: {}, }; @@ -146,21 +196,37 @@ export function upsertUser(user: UserUpdate) { if (updates.tokenCounts) { for (const family of MODEL_FAMILIES) { - updates.tokenCounts[family] ??= 0; + updates.tokenCounts[family] ??= { input: 0, output: 0 }; + // The property is now guaranteed to be an object, so the 'number' check is removed. + // Defaulting individual fields if they are missing. + const counts = updates.tokenCounts[family]!; // Should not be undefined here + counts.input ??= 0; + counts.output ??= 0; + // legacy_total is optional and not defaulted here if missing } } if (updates.tokenLimits) { for (const family of MODEL_FAMILIES) { - updates.tokenLimits[family] ??= 0; + updates.tokenLimits[family] ??= { input: 0, output: 0 }; + // The property is now guaranteed to be an object, so the 'number' check is removed. + // Defaulting individual fields if they are missing. + const limits = updates.tokenLimits[family]!; // Should not be undefined here + limits.input ??= 0; + limits.output ??= 0; + // legacy_total is optional and not defaulted here if missing } } // tokenRefresh is a special case where we want to merge the existing and // updated values for each model family, ignoring falsy values. if (updates.tokenRefresh) { - const merged = { ...existing.tokenRefresh }; + const merged = { ...existing.tokenRefresh } as UserTokenCounts; for (const family of MODEL_FAMILIES) { - merged[family] = - updates.tokenRefresh[family] || existing.tokenRefresh[family]; + const updateRefresh = updates.tokenRefresh[family]; + const existingRefresh = existing.tokenRefresh[family]; + merged[family] = { + input: (updateRefresh?.input || existingRefresh?.input) ?? 0, + output: (updateRefresh?.output || existingRefresh?.output) ?? 0, + }; } updates.tokenRefresh = merged; } @@ -168,9 +234,11 @@ export function upsertUser(user: UserUpdate) { users.set(user.token, Object.assign(existing, updates)); usersToFlush.add(user.token); - // Immediately schedule a flush to the database if we're using Firebase. + // Immediately schedule a flush to the database if a persistent store is used. if (config.gatekeeperStore === "firebase_rtdb") { setImmediate(flushUsers); + } else if (config.gatekeeperStore === "sqlite") { + setImmediate(flushUsersToSQLite); } return users.get(user.token); @@ -189,13 +257,16 @@ export function incrementTokenCount( token: string, model: string, api: APIFormat, - consumption: number + consumption: { input: number; output: number } ) { const user = users.get(token); if (!user) return; const modelFamily = getModelFamilyForQuotaUsage(model, api); - const existing = user.tokenCounts[modelFamily] ?? 0; - user.tokenCounts[modelFamily] = existing + consumption; + const existingCounts = user.tokenCounts[modelFamily] ?? { input: 0, output: 0 }; + user.tokenCounts[modelFamily] = { + input: (existingCounts.input ?? 0) + consumption.input, + output: (existingCounts.output ?? 0) + consumption.output, + }; usersToFlush.add(token); } @@ -251,12 +322,36 @@ export function hasAvailableQuota({ const modelFamily = getModelFamilyForQuotaUsage(model, api); const { tokenCounts, tokenLimits } = user; - const tokenLimit = tokenLimits[modelFamily]; + const limitConfig = tokenLimits[modelFamily]; + const currentUsage = tokenCounts[modelFamily] ?? { input: 0, output: 0 }; - if (!tokenLimit) return true; + // If no specific limit object for the family, or if it's essentially unlimited (e.g. input/output are 0 or not set) + // fall back to checking config.tokenQuota which is a number (total limit). + if (!limitConfig || (limitConfig.input === 0 && limitConfig.output === 0 && !config.tokenQuota[modelFamily])) { + return true; // No effective limit + } - const tokensConsumed = (tokenCounts[modelFamily] ?? 0) + requested; - return tokensConsumed < tokenLimit; + let effectiveLimit: number; + if (limitConfig && (limitConfig.input > 0 || limitConfig.output > 0)) { + // If a specific limit object exists and has positive values, sum them. + // This assumes the limit is a total limit. If input/output are separate, this logic needs change. + effectiveLimit = (limitConfig.input ?? Number.MAX_SAFE_INTEGER) + (limitConfig.output ?? Number.MAX_SAFE_INTEGER); + } else { + // Fallback to general numeric quota from config if specific limitObj is not effectively set. + const generalQuota = config.tokenQuota[modelFamily]; + if (typeof generalQuota === 'number' && generalQuota > 0) { + effectiveLimit = generalQuota; + } else { + return true; // No limit defined + } + } + + // Assuming 'requested' is for input tokens. If 'requested' can be input or output, + // this needs to be an object {input: number, output: number}. + // For now, we sum current input & output and add 'requested' to input for checking. + // This is a simplification. A more robust solution would involve 'requested' being an object. + const totalConsumed = (currentUsage.input ?? 0) + (currentUsage.output ?? 0) + requested; + return totalConsumed < effectiveLimit; } /** @@ -270,18 +365,33 @@ export function refreshQuota(token: string) { const { tokenQuota } = config; const { tokenCounts, tokenLimits, tokenRefresh } = user; - // Get default quotas for each model family. - const defaultQuotas = Object.entries(tokenQuota) as [ModelFamily, number][]; - // If any user-specific refresh quotas are present, override default quotas. - const userQuotas = defaultQuotas.map( - ([f, q]) => [f, (tokenRefresh[f] ?? 0) || q] as const /* narrow to tuple */ - ); + for (const family of MODEL_FAMILIES) { + const currentUsage = tokenCounts[family] ?? { input: 0, output: 0 }; + const userRefreshConfig = tokenRefresh[family] ?? { input: 0, output: 0 }; + const globalDefaultQuotaValue = config.tokenQuota[family]; // This is a number or undefined - userQuotas - // Ignore families with no global or user-specific refresh quota. - .filter(([, q]) => q > 0) - // Increase family token limit by the family's refresh amount. - .forEach(([f, q]) => (tokenLimits[f] = (tokenCounts[f] ?? 0) + q)); + let refreshInputAmount = 0; + let refreshOutputAmount = 0; + + // Prioritize user-specific refresh amounts if they are positive + if (userRefreshConfig.input > 0 || userRefreshConfig.output > 0) { + refreshInputAmount = userRefreshConfig.input; + refreshOutputAmount = userRefreshConfig.output; + } else if (typeof globalDefaultQuotaValue === 'number' && globalDefaultQuotaValue > 0) { + // If no user-specific refresh, use the global quota. + // Distribute the global quota. For simplicity, add to input, or define a rule. + // Here, let's assume the global quota is a total that primarily refreshes 'input'. + refreshInputAmount = globalDefaultQuotaValue; + refreshOutputAmount = 0; // Or some portion of globalDefaultQuotaValue + } + + if (refreshInputAmount > 0 || refreshOutputAmount > 0) { + tokenLimits[family] = { + input: (currentUsage.input ?? 0) + refreshInputAmount, + output: (currentUsage.output ?? 0) + refreshOutputAmount, + }; + } + } usersToFlush.add(token); } @@ -289,8 +399,9 @@ export function resetUsage(token: string) { const user = users.get(token); if (!user) return; const { tokenCounts } = user; - const counts = Object.entries(tokenCounts) as [ModelFamily, number][]; - counts.forEach(([model]) => (tokenCounts[model] = 0)); + for (const family of MODEL_FAMILIES) { + tokenCounts[family] = { input: 0, output: 0 }; // legacy_total is implicitly undefined/removed + } usersToFlush.add(token); } @@ -359,26 +470,56 @@ function refreshAllQuotas() { // store to sync it with Firebase when it changes. Will refactor to abstract // persistence layer later so we can support multiple stores. let firebaseTimeout: NodeJS.Timeout | undefined; +let sqliteInterval: NodeJS.Timeout | undefined; // Added +let flushingToSQLiteInProgress = false; // Added for JS-level lock const USERS_REF = process.env.FIREBASE_USERS_REF_NAME ?? "users"; +async function initSQLite() { // Added + log.info("Initializing SQLite user store..."); + initSQLiteDB(); // Initialize the DB connection and schema + await loadUsersFromSQLite(); + // Set up periodic flush for SQLite, similar to Firebase + sqliteInterval = setInterval(flushUsersToSQLite, 20 * 1000); + log.info("SQLite user store initialized and users loaded."); +} + async function initFirebase() { log.info("Connecting to Firebase..."); const app = getFirebaseApp(); const db = admin.database(app); const usersRef = db.ref(USERS_REF); const snapshot = await usersRef.once("value"); - const users: Record | null = snapshot.val(); + const usersData: Record | null = snapshot.val(); // Store as 'any' initially for migration firebaseTimeout = setInterval(flushUsers, 20 * 1000); - if (!users) { + + if (!usersData) { log.info("No users found in Firebase."); return; } - for (const token in users) { - upsertUser(users[token]); + + // migrateTokenCountsProperty is now defined at module scope + + for (const token in usersData) { + const rawUser = usersData[token]; + const migratedUser: User = { + ...rawUser, // Spread existing fields + token: rawUser.token || token, // Ensure token is present + ip: rawUser.ip || [], + type: rawUser.type || "normal", + promptCount: rawUser.promptCount || 0, + createdAt: rawUser.createdAt || Date.now(), + // Migrate token fields + tokenCounts: migrateTokenCountsProperty(rawUser.tokenCounts, INITIAL_TOKENS), + tokenLimits: migrateTokenCountsProperty(rawUser.tokenLimits, config.tokenQuota), + tokenRefresh: migrateTokenCountsProperty(rawUser.tokenRefresh, INITIAL_TOKENS), + meta: rawUser.meta || {}, + }; + // Use the internal map directly to avoid re-triggering upsertUser's default creations + users.set(token, migratedUser); } - usersToFlush.clear(); - const numUsers = Object.keys(users).length; - log.info({ users: numUsers }, "Loaded users from Firebase"); + usersToFlush.clear(); // Clear flush queue after initial load and migration + const numUsers = Object.keys(usersData).length; + log.info({ users: numUsers }, "Loaded and migrated users from Firebase"); } async function flushUsers() { @@ -412,6 +553,128 @@ async function flushUsers() { ); } +async function loadUsersFromSQLite() { // Added + log.info("Loading users from SQLite..."); + const db = getDB(); + const rows = db.prepare("SELECT * FROM users").all() as any[]; + for (const row of rows) { + const rawTokenCounts = row.tokenCounts ? JSON.parse(row.tokenCounts) : null; + const rawTokenLimits = row.tokenLimits ? JSON.parse(row.tokenLimits) : null; + const rawTokenRefresh = row.tokenRefresh ? JSON.parse(row.tokenRefresh) : null; + + const user: User = { + token: row.token, + ip: row.ip ? JSON.parse(row.ip) : [], + nickname: row.nickname, + type: row.type, + promptCount: row.promptCount, + tokenCounts: migrateTokenCountsProperty(rawTokenCounts, INITIAL_TOKENS), + tokenLimits: migrateTokenCountsProperty(rawTokenLimits, config.tokenQuota), + tokenRefresh: migrateTokenCountsProperty(rawTokenRefresh, INITIAL_TOKENS), + createdAt: row.createdAt, + lastUsedAt: row.lastUsedAt, + disabledAt: row.disabledAt, + disabledReason: row.disabledReason, + expiresAt: row.expiresAt, + maxIps: row.maxIps, + adminNote: row.adminNote, + meta: row.meta ? JSON.parse(row.meta) : {}, + }; + users.set(user.token, user); + } + usersToFlush.clear(); // Clear flush queue after initial load + log.info({ users: users.size }, "Loaded users from SQLite."); +} + +async function flushUsersToSQLite() { // Added + if (flushingToSQLiteInProgress) { + log.trace("Flush to SQLite already in progress, skipping."); + return; + } + if (usersToFlush.size === 0) { + return; + } + + flushingToSQLiteInProgress = true; + log.trace({ count: usersToFlush.size }, "Starting flush to SQLite."); + + const db = getDB(); + const insertStmt = db.prepare(` + INSERT OR REPLACE INTO users ( + token, ip, nickname, type, promptCount, tokenCounts, tokenLimits, + tokenRefresh, createdAt, lastUsedAt, disabledAt, disabledReason, + expiresAt, maxIps, adminNote, meta + ) VALUES ( + @token, @ip, @nickname, @type, @promptCount, @tokenCounts, @tokenLimits, + @tokenRefresh, @createdAt, @lastUsedAt, @disabledAt, @disabledReason, + @expiresAt, @maxIps, @adminNote, @meta + ) + `); + const deleteStmt = db.prepare("DELETE FROM users WHERE token = ?"); + + let updatedCount = 0; + let deletedCount = 0; + + const transaction = db.transaction(() => { + for (const token of usersToFlush) { + const user = users.get(token); + if (user) { + insertStmt.run({ + token: user.token, + ip: JSON.stringify(user.ip || []), + nickname: user.nickname ?? null, + type: user.type, + promptCount: user.promptCount, + tokenCounts: JSON.stringify(user.tokenCounts || INITIAL_TOKENS), + tokenLimits: JSON.stringify(user.tokenLimits || migrateTokenCountsProperty(null, config.tokenQuota)), + tokenRefresh: JSON.stringify(user.tokenRefresh || INITIAL_TOKENS), + createdAt: user.createdAt, + lastUsedAt: user.lastUsedAt ?? null, + disabledAt: user.disabledAt ?? null, + disabledReason: user.disabledReason ?? null, + expiresAt: user.expiresAt ?? null, + maxIps: user.maxIps ?? null, + adminNote: user.adminNote ?? null, + meta: JSON.stringify(user.meta || {}), + }); + updatedCount++; + } else { + // User was deleted from in-memory map + deleteStmt.run(token); + deletedCount++; + } + } + }); + + try { + transaction(); + usersToFlush.clear(); + if (updatedCount > 0 || deletedCount > 0) { + log.info({ updated: updatedCount, deleted: deletedCount }, "Flushed user changes to SQLite."); + } + } catch (error: any) { + log.error({ + message: error?.message || "Unknown error during SQLite flush", + stack: error?.stack, + code: error?.code, // SQLite errors often have a code + rawError: error // Log the raw error object for more details + }, "Error flushing users to SQLite."); + // Re-add tokens to flush queue if transaction failed, so we can retry + // This is a simplistic retry, might need more robust error handling + // Ensure usersToFlush still contains the tokens that failed to process + // The current logic inside the transaction means usersToFlush is cleared only on success. + // If transaction fails, usersToFlush would still contain the items from before the attempt. + // However, if items were added to usersToFlush *during* the failed transaction, + // they would be processed in the next attempt. + // For simplicity, the current re-add logic is okay, but could be refined if specific + // tokens fail consistently. + usersToFlush.forEach(token => usersToFlush.add(token)); + } finally { + flushingToSQLiteInProgress = false; + log.trace("Finished flush to SQLite attempt."); + } +} + function getModelFamilyForQuotaUsage( model: string, api: APIFormat diff --git a/src/shared/views/partials/shared_quota-info.ejs b/src/shared/views/partials/shared_quota-info.ejs index ae29393..c33198e 100644 --- a/src/shared/views/partials/shared_quota-info.ejs +++ b/src/shared/views/partials/shared_quota-info.ejs @@ -22,23 +22,64 @@ const quotaTableId = Math.random().toString(36).slice(2); - <% Object.entries(quota).forEach(([key, limit]) => { %> + <% Object.entries(quota).forEach(([key, configLimit]) => { %> + <% + const counts = user.tokenCounts[key] || { input: 0, output: 0 }; + const limits = user.tokenLimits[key] || { input: 0, output: 0 }; // Default if not set + const refresh = user.tokenRefresh[key] || { input: 0, output: 0 }; + + const usageInput = Number(counts.input) || 0; + const usageOutput = Number(counts.output) || 0; + const usageLegacy = Number(counts.legacy_total) || 0; + const displayUsage = usageInput + usageOutput || usageLegacy; // This is for total token display, not directly for cost calculation here + + const limitInput = Number(limits.input) || 0; + // If limit was from legacy config.tokenQuota (a number), it's in limits.legacy_total or limits.input + const displayLimit = limitInput || Number(limits.legacy_total) || 0; + + // Determine tokens to use for cost calculation + const costInputTokens = (usageInput + usageOutput > 0) ? usageInput : usageLegacy; + const costOutputTokens = (usageInput + usageOutput > 0) ? usageOutput : 0; // If using legacy, output is 0 for cost + const costDetails = tokenCostDetails(key, costInputTokens, costOutputTokens); + + let remaining = 0; + let limitIsSet = false; + if (displayLimit > 0) { + remaining = displayLimit - (usageInput + usageOutput); + limitIsSet = true; + } else if (typeof configLimit === 'number' && configLimit > 0) { + // Fallback to global config limit if user-specific limit is 0 or not set meaningfully + remaining = configLimit - (usageInput + usageOutput); + limitIsSet = true; + } + + + const refreshDisplayValue = (Number(refresh.input) || 0) + (Number(refresh.output) || 0) || configLimit || 0; + %> <%- key %> - <%- prettyTokens(user.tokenCounts[key]) %> + + In: <%- prettyTokens(usageInput) %>
+ Out: <%- prettyTokens(usageOutput) %> + <% if (usageLegacy && (usageInput + usageOutput === 0)) { %>
(Legacy: <%- prettyTokens(usageLegacy) %>)<% } %> + <% if (showTokenCosts) { %> - $<%- tokenCost(key, user.tokenCounts[key]).toFixed(2) %> + + In: $<%- costDetails.inputCost.toFixed(Math.max(2, (costDetails.inputCost.toString().split('.')[1] || '').length)) %>
+ Out: $<%- costDetails.outputCost.toFixed(Math.max(2, (costDetails.outputCost.toString().split('.')[1] || '').length)) %>
+ Total: $<%- costDetails.totalCost.toFixed(2) %> + <% } %> - <% if (!user.tokenLimits[key]) { %> + <% if (!limitIsSet) { %> unlimited <% } else { %> - <%- prettyTokens(user.tokenLimits[key]) %> - <%- prettyTokens(user.tokenLimits[key] - user.tokenCounts[key]) %> + <%- prettyTokens(displayLimit) %> + <%- prettyTokens(remaining) %> <% } %> <% if (user.type === "temporary") { %> N/A <% } else { %> - <%- prettyTokens(user.tokenRefresh[key] || quota[key]) %> + <%- prettyTokens(refreshDisplayValue) %> <% } %> <% if (showRefreshEdit) { %> From 74cbafbb3b7262949ae7191f6a08b61728ad60c4 Mon Sep 17 00:00:00 2001 From: Nopm Date: Tue, 3 Jun 2025 20:57:51 -0300 Subject: [PATCH 2/9] pro-exp BEGONE --- src/proxy/google-ai.ts | 30 ++++++- .../key-management/google-ai/checker.ts | 81 ++++++++++++------- .../key-management/google-ai/provider.ts | 40 +++++---- 3 files changed, 97 insertions(+), 54 deletions(-) diff --git a/src/proxy/google-ai.ts b/src/proxy/google-ai.ts index 737e49c..97a823a 100644 --- a/src/proxy/google-ai.ts +++ b/src/proxy/google-ai.ts @@ -1,4 +1,4 @@ -import { Request, RequestHandler, Router } from "express"; +import { Request, RequestHandler, Router, Response, NextFunction } from "express"; import { v4 } from "uuid"; import { GoogleAIKey, keyPool } from "../shared/key-management"; import { config } from "../config"; @@ -254,13 +254,35 @@ function maybeReassignModel(req: Request) { // If it's an invalid model, the Google AI API will return the appropriate error } +/** + * Middleware to check for and block requests to experimental models. + * This function is intended to be used as a RequestPreprocessor. + * It throws an error if an experimental model is detected, which should be + * caught by the proxy's onError handler. + */ +function checkAndBlockExperimentalModels(req: Request) { // Changed signature + const modelId = req.body.model as string | undefined; + + // Check if the model ID contains "exp" (case-insensitive) + if (modelId && modelId.toLowerCase().includes("exp")) { + req.log.warn({ modelId }, "Blocking request to experimental Google AI model."); + const err: any = new Error("Experimental models are too unstable to be supported in proxy code. Please use preview models instead."); + err.statusCode = 400; + throw err; + } + // If no experimental model, do nothing, allowing request to proceed. +} + // Native Google AI chat completion endpoint googleAIRouter.post( "/:apiVersion(v1alpha|v1beta)/models/:modelId:(generateContent|streamGenerateContent)", ipLimiter, createPreprocessorMiddleware( { inApi: "google-ai", outApi: "google-ai", service: "google-ai" }, - { beforeTransform: [maybeReassignModel], afterTransform: [setStreamFlag, processThinkingBudget] } + { + beforeTransform: [maybeReassignModel], + afterTransform: [checkAndBlockExperimentalModels, setStreamFlag, processThinkingBudget] + } ), googleAIProxy ); @@ -271,7 +293,9 @@ googleAIRouter.post( ipLimiter, createPreprocessorMiddleware( { inApi: "openai", outApi: "google-ai", service: "google-ai" }, - { afterTransform: [maybeReassignModel, processThinkingBudget] } + { + afterTransform: [maybeReassignModel, checkAndBlockExperimentalModels, processThinkingBudget] + } ), googleAIProxy ); diff --git a/src/shared/key-management/google-ai/checker.ts b/src/shared/key-management/google-ai/checker.ts index 3f442cf..804566c 100644 --- a/src/shared/key-management/google-ai/checker.ts +++ b/src/shared/key-management/google-ai/checker.ts @@ -145,67 +145,90 @@ export class GoogleAIKeyChecker extends KeyCheckerBase { /please enable billing/i, /api key not valid/i, /api key expired/i, - /pass a valid api/i, + /pass a valid api/i, // This may also indicate an invalid key. + /api key not found/i, // Explicitly for "not found" keys ]; const text = JSON.stringify(error.response.data.error); if (keyDeadMsgs.some((r) => r.test(text))) { this.log.warn( - { key: key.hash, error: text }, - "Key check returned a non-transient 400 error. Disabling key." + { key: key.hash, error: text, errorCode: code, httpStatus }, + "Key check returned a 400 error indicating a permanent key issue (e.g., invalid, expired, billing). Disabling and revoking key." ); this.updateKey(key.hash, { isDisabled: true, isRevoked: true }); return; } - break; - } - case 401: - case 403: + // If it's a 400 but not a key-revoking message, treat as transient. this.log.warn( - { key: key.hash, status, code, message, details }, - "Key check returned Forbidden/Unauthorized error. Disabling key." + { key: key.hash, error: text, errorCode: code, httpStatus }, + "Key check returned a generic 400 error. Treating as transient. Rechecking in 1 minute." + ); + const recheckInOneMinute = Date.now() - (KEY_CHECK_PERIOD - 60 * 1000); + this.updateKey(key.hash, { lastChecked: recheckInOneMinute }); + return; + } + case 401: // Unauthorized + case 403: // Forbidden / Permission Denied + this.log.warn( + { key: key.hash, status, code, message, details, httpStatus }, + "Key check returned Forbidden/Unauthorized error. Disabling and revoking key." ); this.updateKey(key.hash, { isDisabled: true, isRevoked: true }); return; - case 429: { + case 429: { // Resource Exhausted (Rate Limit / Quota) const text = JSON.stringify(error.response.data.error); - - const keyDeadMsgs = [ - /GenerateContentRequestsPerMinutePerProjectPerRegion/i, - /"quota_limit_value":"0"/i, + const hardQuotaMessages = [ + /GenerateContentRequestsPerMinutePerProjectPerRegion/i, // Often indicates a hard limit or misconfiguration + /"quota_limit_value":"0"/i, // Explicitly out of quota + /billing account not found/i, // Billing issue presented as 429 sometimes + /project has been suspended/i, // Project level issue ]; - if (keyDeadMsgs.some((r) => r.test(text))) { + if (hardQuotaMessages.some((r) => r.test(text))) { this.log.warn( - { key: key.hash, error: text }, - "Key check returned a non-transient 429 error. Disabling key." + { key: key.hash, error: text, errorCode: code, httpStatus }, + "Key check returned a 429 error indicating a hard quota limit or billing issue. Disabling, revoking, and marking as over quota." ); - this.updateKey(key.hash, { isDisabled: true, isRevoked: true }); + this.updateKey(key.hash, { isDisabled: true, isRevoked: true, isOverQuota: true }); return; } + // Transient 429 (e.g., TPM/RPM exceeded) this.log.warn( - { key: key.hash, status, code, message, details }, - "Key is rate limited. Rechecking key in 1 minute." + { key: key.hash, status, code, message, details, httpStatus }, + "Key is temporarily rate limited (429). Rechecking key in 1 minute." ); - const next = Date.now() - (KEY_CHECK_PERIOD - 60 * 1000); - this.updateKey(key.hash, { lastChecked: next }); + const nextTransient429 = Date.now() - (KEY_CHECK_PERIOD - 60 * 1000); + this.updateKey(key.hash, { lastChecked: nextTransient429 }); return; } + case 500: // Internal Server Error + case 503: // Service Unavailable + case 504: // Deadline Exceeded + this.log.warn( + { key: key.hash, status, code, message, details, httpStatus }, + `Key check encountered a server-side error (${httpStatus}). Treating as transient. Rechecking in 1 minute.` + ); + const recheck5xx = Date.now() - (KEY_CHECK_PERIOD - 60 * 1000); + this.updateKey(key.hash, { lastChecked: recheck5xx }); + return; } + // Fallthrough for other unexpected Google AI API errors this.log.error( - { key: key.hash, status, code, message, details }, - "Encountered unexpected error status while checking key. This may indicate a change in the API; please report this." + { key: key.hash, status, code, message, details, httpStatus }, + "Encountered unexpected Google AI error status while checking key. This may indicate a change in the API. Rechecking in 1 minute." ); - return this.updateKey(key.hash, { lastChecked: Date.now() }); + const recheckUnexpected = Date.now() - (KEY_CHECK_PERIOD - 60 * 1000); + this.updateKey(key.hash, { lastChecked: recheckUnexpected }); + return; } + // Network errors (not HTTP errors from Google AI) this.log.error( { key: key.hash, error: error.message }, - "Network error while checking key; trying this key again in a minute." + "Network error while checking key; trying this key again in 1 minute." ); - const oneMinute = 10 * 1000; - const next = Date.now() - (KEY_CHECK_PERIOD - oneMinute); - return this.updateKey(key.hash, { lastChecked: next }); + const recheckNetworkError = Date.now() - (KEY_CHECK_PERIOD - 60 * 1000); // Corrected to 60 * 1000 + return this.updateKey(key.hash, { lastChecked: recheckNetworkError }); } static errorIsGoogleAIError( diff --git a/src/shared/key-management/google-ai/provider.ts b/src/shared/key-management/google-ai/provider.ts index 8607d2b..c88a218 100644 --- a/src/shared/key-management/google-ai/provider.ts +++ b/src/shared/key-management/google-ai/provider.ts @@ -184,32 +184,28 @@ public recheck() { } keysToRecheck.forEach(key => { - // Restore the key's original model families if previously over quota - if (key.isOverQuota && key.overQuotaFamilies?.length) { - this.log.info( - { key: key.hash, overQuotaFamilies: key.overQuotaFamilies }, - "Rechecking over-quota Google AI key" - ); - - // Reset the over-quota status, but don't actually test the key yet - // The key checker will test it on its normal schedule - this.update(key.hash, { - isOverQuota: false, - isDisabled: false, - lastChecked: 0, // Force a recheck soon - overQuotaFamilies: [] - }); - } - // Handle generally disabled but not revoked keys - else if (key.isDisabled && !key.isRevoked) { + // Priority to keys marked as overQuota (and not revoked) + if (key.isOverQuota && !key.isRevoked) { this.log.info( { key: key.hash }, - "Rechecking disabled Google AI key" + "Rechecking over-quota Google AI key. Resetting isOverQuota, isDisabled, and overQuotaFamilies." ); - - // Mark the key for rechecking, but don't re-enable it yet this.update(key.hash, { - lastChecked: 0 // Force a recheck soon + isOverQuota: false, + isDisabled: false, // Was disabled due to being overQuota + lastChecked: 0, // Force a recheck soon + overQuotaFamilies: [] // Clear any specific family quotas + }); + } + // Handle other disabled (but not revoked) keys that weren't caught by the isOverQuota condition + else if (key.isDisabled && !key.isRevoked) { + this.log.info( + { key: key.hash }, + "Rechecking disabled (but not revoked or previously over-quota) Google AI key." + ); + this.update(key.hash, { + isDisabled: false, // Re-enable for checking + lastChecked: 0 // Force a recheck soon }); } }); From 7b3cf409e4ce81dcbf20524f854cace413f9a383 Mon Sep 17 00:00:00 2001 From: Nopm Date: Tue, 3 Jun 2025 21:05:12 -0300 Subject: [PATCH 3/9] google is dumb --- src/shared/key-management/google-ai/checker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/key-management/google-ai/checker.ts b/src/shared/key-management/google-ai/checker.ts index 804566c..9b550b1 100644 --- a/src/shared/key-management/google-ai/checker.ts +++ b/src/shared/key-management/google-ai/checker.ts @@ -185,9 +185,9 @@ export class GoogleAIKeyChecker extends KeyCheckerBase { if (hardQuotaMessages.some((r) => r.test(text))) { this.log.warn( { key: key.hash, error: text, errorCode: code, httpStatus }, - "Key check returned a 429 error indicating a hard quota limit or billing issue. Disabling, revoking, and marking as over quota." + "Key check returned a 429 error indicating a hard quota limit or billing issue. Disabling and marking as over quota, but not revoking." ); - this.updateKey(key.hash, { isDisabled: true, isRevoked: true, isOverQuota: true }); + this.updateKey(key.hash, { isDisabled: true, isRevoked: false, isOverQuota: true }); return; } From c066a7d46b476f3b7552638d67807fc6d40100b1 Mon Sep 17 00:00:00 2001 From: Nopm Date: Tue, 3 Jun 2025 21:44:43 -0300 Subject: [PATCH 4/9] password based service info auth (better than the first one we had) --- .env.example | 9 +++++++++ src/config.ts | 26 ++++++++++++++++++++++++++ src/info-page.ts | 43 +++++++++++++++++++++++++++++-------------- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index cc0fbd5..2d64415 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,15 @@ NODE_ENV=production # Defaults to true. Set to false to disable login and make the info page public. # ENABLE_INFO_PAGE_LOGIN=true +# Authentication mode for the service info page. (token | password) +# If 'token', any valid user token (via `getUser()`) is used (requires GATEKEEPER='user_token' mode). +# If 'password', SERVICE_INFO_PASSWORD is used. +# Defaults to 'token' if ENABLE_INFO_PAGE_LOGIN is true. +# SERVICE_INFO_AUTH_MODE=token + +# Password for the service info page if SERVICE_INFO_AUTH_MODE is 'password'. +# SERVICE_INFO_PASSWORD=your-service-info-password + # The route name used to proxy requests to APIs, relative to the Web site root. # PROXY_ENDPOINT_ROUTE=/proxy diff --git a/src/config.ts b/src/config.ts index 24253a3..d688f43 100644 --- a/src/config.ts +++ b/src/config.ts @@ -435,6 +435,10 @@ type Config = { loginImageUrl?: string; /** Whether to enable the token-based login page for the service info page. Defaults to true. */ enableInfoPageLogin?: boolean; + /** Authentication mode for the service info page. (token | password) */ + serviceInfoAuthMode: "token" | "password"; + /** Password for the service info page if serviceInfoAuthMode is 'password'. */ + serviceInfoPassword?: string; }; // To change configs, create a file called .env in the root directory. @@ -554,6 +558,8 @@ export const config: Config = { }, loginImageUrl: getEnvWithDefault("LOGIN_IMAGE_URL", ""), enableInfoPageLogin: getEnvWithDefault("ENABLE_INFO_PAGE_LOGIN", true), + serviceInfoAuthMode: getEnvWithDefault("SERVICE_INFO_AUTH_MODE", "token") as Config["serviceInfoAuthMode"], + serviceInfoPassword: getEnvWithDefault("SERVICE_INFO_PASSWORD", undefined), } as const; function generateSigningKey() { @@ -691,6 +697,25 @@ export async function assertConfigIsValid() { } } + if (config.enableInfoPageLogin) { + if (!["token", "password"].includes(config.serviceInfoAuthMode)) { + throw new Error( + `Invalid SERVICE_INFO_AUTH_MODE: ${config.serviceInfoAuthMode}. Must be 'token' or 'password'.` + ); + } + if (config.serviceInfoAuthMode === "password" && !config.serviceInfoPassword) { + throw new Error( + "SERVICE_INFO_AUTH_MODE is 'password' but SERVICE_INFO_PASSWORD is not set." + ); + } + // If service info login is token-based, gatekeeper must be 'user_token' mode for getUser() to be effective. + if (config.serviceInfoAuthMode === "token" && config.gatekeeper !== "user_token") { + throw new Error( + "SERVICE_INFO_AUTH_MODE is 'token' for info page login, but GATEKEEPER is not 'user_token'. User token authentication will not work." + ); + } + } + // Ensure forks which add new secret-like config keys don't unwittingly expose // them to users. for (const key of getKeys(config)) { @@ -765,6 +790,7 @@ export const OMITTED_KEYS = [ "powTokenPurgeHours", "loginImageUrl", "enableInfoPageLogin", + "serviceInfoPassword", ] satisfies (keyof Config)[]; type OmitKeys = (typeof OMITTED_KEYS)[number]; diff --git a/src/info-page.ts b/src/info-page.ts index b3dee0a..ffa2edf 100644 --- a/src/info-page.ts +++ b/src/info-page.ts @@ -110,7 +110,7 @@ function renderLoginPage(csrf: string, error?: string) { padding:30px;width:100%;max-width:400px;text-align:center;} .logo-image{max-width:200px;margin-bottom:20px;} .form-group{margin-bottom:20px;} - input[type=text]{width:100%;padding:10px;border:1px solid #ddd;border-radius:4px; + input[type=text], input[type=password]{width:100%;padding:10px;border:1px solid #ddd;border-radius:4px; box-sizing:border-box;font-size:16px;} button{background:#4caf50;color:#fff;border:none;padding:12px 20px;border-radius:4px; cursor:pointer;font-size:16px;width:100%;} @@ -120,8 +120,8 @@ function renderLoginPage(csrf: string, error?: string) { @media (prefers-color-scheme: dark) { body { background: #2c2c2c; color: #e0e0e0; } .login-container { background: #383838; box-shadow: 0 4px 12px rgba(0,0,0,0.4); border: 1px solid #4a4a4a; } - input[type=text] { background: #4a4a4a; color: #e0e0e0; border: 1px solid #5a5a5a; } - input[type=text]::placeholder { color: #999; } + input[type=text], input[type=password] { background: #4a4a4a; color: #e0e0e0; border: 1px solid #5a5a5a; } + input[type=text]::placeholder, input[type=password]::placeholder { color: #999; } button { background: #007bff; } /* Using a blue for dark mode button */ button:hover { background: #0056b3; } .error-message { color: #ff8a80; } /* Lighter red for errors in dark mode */ @@ -134,7 +134,9 @@ function renderLoginPage(csrf: string, error?: string) { ${errBlock}
- + ${config.serviceInfoAuthMode === "password" + ? `` + : ``}
@@ -343,17 +345,30 @@ infoPageRouter.use( /* login attempt */ infoPageRouter.post(LOGIN_ROUTE, (req, res) => { - const token = (req.body.token || "").trim(); - - const user = getUser(token); // returns undefined if invalid - if (!user) { - return res - .status(401) - .send(renderLoginPage(res.locals.csrfToken, "Invalid token. Please try again.")); + if (config.serviceInfoAuthMode === "password") { + const password = (req.body.password || "").trim(); + // Simple string comparison; for production, consider a timing-safe comparison library + if (config.serviceInfoPassword && password === config.serviceInfoPassword) { + req.session!.infoPageAuthed = true; + return res.redirect("/"); + } else { + return res + .status(401) + .send(renderLoginPage(res.locals.csrfToken, "Invalid password. Please try again.")); + } + } else { + // Token-based authentication (using any valid user token) + const token = (req.body.token || "").trim(); + const user = getUser(token); // returns undefined if invalid + if (user) { + req.session!.infoPageAuthed = true; + return res.redirect("/"); + } else { + return res + .status(401) + .send(renderLoginPage(res.locals.csrfToken, "Invalid token. Please try again.")); + } } - - req.session!.infoPageAuthed = true; - res.redirect("/"); }); /* GET / – either login form or info page */ From 2389b30e680115e8ea61da07aacc1085f43c4d50 Mon Sep 17 00:00:00 2001 From: Nopm Date: Tue, 3 Jun 2025 21:47:39 -0300 Subject: [PATCH 5/9] doc update --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 2d64415..e23902f 100644 --- a/.env.example +++ b/.env.example @@ -21,12 +21,12 @@ NODE_ENV=production # If not set, no image will be displayed. # LOGIN_IMAGE_URL=https://example.com/your-logo.png -# Whether to enable the token-based login for the main info page. +# Whether to enable the token-based or password-based login for the main info page. # Defaults to true. Set to false to disable login and make the info page public. # ENABLE_INFO_PAGE_LOGIN=true # Authentication mode for the service info page. (token | password) -# If 'token', any valid user token (via `getUser()`) is used (requires GATEKEEPER='user_token' mode). +# If 'token', any valid user token is used (requires GATEKEEPER='user_token' mode). # If 'password', SERVICE_INFO_PASSWORD is used. # Defaults to 'token' if ENABLE_INFO_PAGE_LOGIN is true. # SERVICE_INFO_AUTH_MODE=token From 8c98fca56d27379fe73c616bd1b3d687ecb1a7f0 Mon Sep 17 00:00:00 2001 From: Nopm Date: Tue, 3 Jun 2025 21:49:37 -0300 Subject: [PATCH 6/9] fix pointless alt text when logo is empty --- src/info-page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/info-page.ts b/src/info-page.ts index ffa2edf..bfd17a6 100644 --- a/src/info-page.ts +++ b/src/info-page.ts @@ -130,7 +130,7 @@ function renderLoginPage(csrf: string, error?: string) {