diff --git a/bun.lock b/bun.lock index 990ccad9e7..ae8b4e6d60 100644 --- a/bun.lock +++ b/bun.lock @@ -200,11 +200,11 @@ "packages": { "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.70", "", { "dependencies": { "@ai-sdk/provider": "4.0.9", "@ai-sdk/provider-utils": "5.0.34", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0tzAH2vwXOs/kVktAZRS04dATEQJk1hf1QR+VuVfvo9QmW3UPgcjhhJD9QFgP8HZLxkrEGDImwLIQ7sUfQTIsA=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.69", "", { "dependencies": { "@ai-sdk/provider": "4.0.9", "@ai-sdk/provider-utils": "5.0.34", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W5MMdyqsaziQy/A4kxlK74iEQ+NuO6OaszH32cEQpUgBW0o15S2fAdP0aYSH2/5lrVZSMXLQLCzuMkRGHBua3A=="], "@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.36", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2eSw90hn32Je6n2a8Gf4dJ2EoecPJuOCWqwZCw+BkhPq2LOS01HX3s6ljgOm0iIkZiD5aAuMdpOw17rYKQF/Zg=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.35", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/5z8tRGuYXwFy0ID+WtiWiECJzH5x/rI/g/3H8x3GQvE4i4etnZfKWAiU23VZEymInh+4l7uMNQJyaNN/54QFw=="], "@auth/core": ["@auth/core@0.41.3", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7 || ^8.0.5" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw=="], @@ -242,15 +242,19 @@ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], - "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], - "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.6", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + "@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], @@ -390,23 +394,23 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], - "@next/env": ["@next/env@16.3.4", "", {}, "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q=="], + "@next/env": ["@next/env@16.3.3", "", {}, "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -474,8 +478,6 @@ "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], - "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], @@ -550,7 +552,7 @@ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "ai": ["ai@7.0.86", "", { "dependencies": { "@ai-sdk/gateway": "4.0.70", "@ai-sdk/provider": "4.0.9", "@ai-sdk/provider-utils": "5.0.34" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-11Hovs3BI98tPJiOuA85Be+ktxbZ2QUIqqLJqfHJ55zz4106pjRkEP9OQ95glyjBXPEtTdr6/z4ISsk6G13rvw=="], + "ai": ["ai@7.0.85", "", { "dependencies": { "@ai-sdk/gateway": "4.0.69", "@ai-sdk/provider": "4.0.9", "@ai-sdk/provider-utils": "5.0.34" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HVtPz0qLbTUad+QBnWWReIUmwk+U4PcRENMx+9PsHGVoinoc5CLDiVjNR+VBXTKOSaNgce8kSU/Rtbb4kjZsSw=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -560,7 +562,7 @@ "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], @@ -636,6 +638,8 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], "canvas": ["canvas@3.2.3", "", { "dependencies": { "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.3" } }, "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw=="], @@ -762,7 +766,7 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.9.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A=="], + "eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], "eslint-config-prettier": ["eslint-config-prettier@9.1.2", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ=="], @@ -774,11 +778,11 @@ "eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.4.1", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ=="], - "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -890,6 +894,8 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], @@ -904,6 +910,8 @@ "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], @@ -934,6 +942,8 @@ "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -1052,6 +1062,8 @@ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "log-update": ["log-update@8.0.0", "", { "dependencies": { "ansi-escapes": "^7.3.0", "cli-cursor": "^5.0.0", "slice-ansi": "^9.0.0", "string-width": "^8.2.0", "strip-ansi": "^7.2.0", "wrap-ansi": "^10.0.0" } }, "sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -1186,7 +1198,7 @@ "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], - "next": ["next@16.3.4", "", { "dependencies": { "@next/env": "16.3.4", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.4", "@next/swc-darwin-x64": "16.3.4", "@next/swc-linux-arm64-gnu": "16.3.4", "@next/swc-linux-arm64-musl": "16.3.4", "@next/swc-linux-x64-gnu": "16.3.4", "@next/swc-linux-x64-musl": "16.3.4", "@next/swc-win32-arm64-msvc": "16.3.4", "@next/swc-win32-x64-msvc": "16.3.4", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA=="], + "next": ["next@16.3.3", "", { "dependencies": { "@next/env": "16.3.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.3", "@next/swc-darwin-x64": "16.3.3", "@next/swc-linux-arm64-gnu": "16.3.3", "@next/swc-linux-arm64-musl": "16.3.3", "@next/swc-linux-x64-gnu": "16.3.3", "@next/swc-linux-x64-musl": "16.3.3", "@next/swc-win32-arm64-msvc": "16.3.3", "@next/swc-win32-x64-msvc": "16.3.3", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g=="], "next-auth": ["next-auth@4.24.15", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^11.1.1" }, "peerDependencies": { "@auth/core": "0.34.3", "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", "nodemailer": "^7.0.7", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-NnjYtjrSOAx/TIVFGTX4IfI/9yHnNpi4B7FuLUwuV20v2Zxgr2OGP/YN0ynJuI7y8QOnTBPitfOdEXZrVvhIuA=="], @@ -1254,6 +1266,8 @@ "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], @@ -1398,6 +1412,8 @@ "resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -1502,7 +1518,7 @@ "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "stripe": ["stripe@16.12.0", "", { "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" } }, "sha512-H7eFVLDxeTNNSn4JTRfL2//LzCbDrMSZ+2q1c7CanVWgK2qIW5TwS+0V7N9KcKZZNpYh/uCqK0PyZh/2UsaAtQ=="], @@ -1510,6 +1526,8 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-terminal-graphics": ["supports-terminal-graphics@0.1.0", "", {}, "sha512-+KdfozhS0Fw8y5Sghw8kkZNGT8nWYzJ1EzcoIvVjxhl+26TJTs26y02yfBgvc1jh5AS/c8jcI3xtahhR95KRyQ=="], @@ -1542,7 +1560,7 @@ "ts-pattern": ["ts-pattern@5.9.0", "", {}, "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg=="], - "tsc-alias": ["tsc-alias@1.9.3", "", { "dependencies": { "chokidar": "^3.5.3", "commander": "^9.0.0", "get-tsconfig": "^4.10.0", "globby": "^11.0.4", "mylas": "^2.1.9", "normalize-path": "^3.0.0", "plimit-lit": "^1.2.6" }, "bin": { "tsc-alias": "dist/bin/index.js" } }, "sha512-GKrkA/K5hwae80rlfJRazukMMMIUsIHRyb75lbEp+qaUP57sYmur2Z05dosZNBspByX3ZrxbLHkMRgLfVuUcYg=="], + "tsc-alias": ["tsc-alias@1.9.2", "", { "dependencies": { "chokidar": "^3.5.3", "commander": "^9.0.0", "get-tsconfig": "^4.10.0", "globby": "^11.0.4", "mylas": "^2.1.9", "normalize-path": "^3.0.0", "plimit-lit": "^1.2.6" }, "bin": { "tsc-alias": "dist/bin/index.js" } }, "sha512-VTWQGMv0xXCEyHDLpmV2DEvGYHMxwsyx87dZeou2ynkM0+WOFdHe+KWiRucavMPUEdQysr7xSu60Y/WY0R4YKA=="], "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], @@ -1680,7 +1698,11 @@ "@codebuff/sdk/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@eslint/config-array/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "@eslint/eslintrc/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "@eslint/eslintrc/js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], @@ -1688,8 +1710,6 @@ "@opentui/react/react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - "@types/diff/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@7.18.0", "", { "dependencies": { "@typescript-eslint/types": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0" } }, "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA=="], @@ -1724,11 +1744,11 @@ "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - "eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "eslint/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - "eslint/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -1740,7 +1760,7 @@ "eslint-plugin-import/tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], - "espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], "execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -1770,12 +1790,16 @@ "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], "react-reconciler/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "ts-node/diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="], @@ -1790,6 +1814,8 @@ "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@7.18.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", "@typescript-eslint/typescript-estree": "7.18.0" }, "peerDependencies": { "eslint": "^8.56.0" } }, "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "wrap-ansi/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1798,7 +1824,9 @@ "@codebuff/evals/pino/process-warning": ["process-warning@5.1.0", "", {}, "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw=="], - "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "@eslint/eslintrc/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -1812,14 +1840,10 @@ "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "eslint/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], - "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -1848,12 +1872,8 @@ "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], - "eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@7.18.0", "", {}, "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ=="], "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@7.18.0", "", { "dependencies": { "@typescript-eslint/types": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA=="], diff --git a/cli/src/commands/__tests__/doctor.test.ts b/cli/src/commands/__tests__/doctor.test.ts new file mode 100644 index 0000000000..3de6150740 --- /dev/null +++ b/cli/src/commands/__tests__/doctor.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test, mock, afterEach, beforeEach } from 'bun:test' + +import { collectDoctorReport, formatDoctorReport } from '../doctor' + +// Mock fetch to avoid network calls in tests +const originalFetch = globalThis.fetch + +describe('/doctor command', () => { + beforeEach(() => { + // Mock fetch to return a successful response + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ status: 'ok' }), + }) + ) as unknown as typeof fetch + }) + + afterEach(() => { + // Restore original fetch + globalThis.fetch = originalFetch + }) + + test('collectDoctorReport returns a valid report', async () => { + const report = await collectDoctorReport() + + expect(report).toBeDefined() + expect(report.product).toBeDefined() + expect(report.results).toBeInstanceOf(Array) + expect(report.results.length).toBeGreaterThan(0) + expect(report.summary).toBeDefined() + expect(report.summary.ok).toBeDefined() + expect(report.summary.warnings).toBeDefined() + expect(report.summary.errors).toBeDefined() + }) + + test('collectDoctorReport includes platform check', async () => { + const report = await collectDoctorReport() + + const platformCheck = report.results.find((r) => r.name === 'platform') + expect(platformCheck).toBeDefined() + expect(platformCheck!.status).toBe('ok') + }) + + test('collectDoctorReport includes runtime check', async () => { + const report = await collectDoctorReport() + + const runtimeCheck = report.results.find((r) => r.name === 'runtime') + expect(runtimeCheck).toBeDefined() + expect(runtimeCheck!.status).toBe('ok') + }) + + test('collectDoctorReport includes connectivity check', async () => { + const report = await collectDoctorReport() + + const connectivityCheck = report.results.find((r) => r.name === 'API connectivity') + expect(connectivityCheck).toBeDefined() + expect(connectivityCheck!.status).toBe('ok') + }) + + test('formatDoctorReport returns a string', async () => { + const report = await collectDoctorReport() + const formatted = formatDoctorReport(report) + + expect(typeof formatted).toBe('string') + expect(formatted.length).toBeGreaterThan(0) + }) + + test('formatDoctorReport includes product name', async () => { + const report = await collectDoctorReport() + const formatted = formatDoctorReport(report) + + expect(formatted).toContain(report.product) + expect(formatted).toContain('doctor') + }) + + test('formatDoctorReport includes summary icon', async () => { + const report = await collectDoctorReport() + const formatted = formatDoctorReport(report) + + // Should contain either ✅, ⚠️, or ❌ + expect(formatted).toMatch(/[✅⚠️❌]/) + }) + + test('handles connectivity failure gracefully', async () => { + // Mock fetch to return a failed response + globalThis.fetch = mock(() => + Promise.resolve({ + ok: false, + status: 500, + json: () => Promise.resolve({ error: 'Internal Server Error' }), + }) + ) as unknown as typeof fetch + + const report = await collectDoctorReport() + const connectivityCheck = report.results.find((r) => r.name === 'API connectivity') + + expect(connectivityCheck).toBeDefined() + expect(connectivityCheck!.status).toBe('warning') + expect(connectivityCheck!.message).toContain('status 500') + }) + + test('handles network error gracefully', async () => { + // Mock fetch to throw an error + globalThis.fetch = mock(() => + Promise.reject(new Error('Network error')) + ) as unknown as typeof fetch + + const report = await collectDoctorReport() + const connectivityCheck = report.results.find((r) => r.name === 'API connectivity') + + expect(connectivityCheck).toBeDefined() + expect(connectivityCheck!.status).toBe('error') + expect(connectivityCheck!.message).toBe('Failed to connect') + }) +}) diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index b308c7bb91..4a75f96efe 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -9,6 +9,10 @@ import { collectProcessDiagnostics, formatProcessDiagnostics, } from './process-diagnostics' +import { + collectDoctorReport, + formatDoctorReport, +} from './doctor' import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs, buildSkillPrompt } from './prompt-builders' import { handleReasoningCommand } from './reasoning' import { runBashCommand } from './router' @@ -233,6 +237,17 @@ const ALL_COMMANDS: CommandDefinition[] = [ clearInput(params) }, }), + defineCommand({ + name: 'doctor', + aliases: ['check', 'health'], + handler: async (params) => { + const report = await collectDoctorReport() + const formatted = formatDoctorReport(report) + params.setMessages((prev) => [...prev, getSystemMessage(formatted)]) + params.saveToHistory(params.inputValue.trim()) + clearInput(params) + }, + }), defineCommand({ name: 'copy', aliases: ['copy-chat', 'export'], diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts new file mode 100644 index 0000000000..9f87ab61a2 --- /dev/null +++ b/cli/src/commands/doctor.ts @@ -0,0 +1,282 @@ +import { execSync } from 'child_process' +import { existsSync } from 'fs' +import { join } from 'path' + +import { IS_FREEBUFF } from '../utils/constants' +import { getWebsiteUrl } from '@codebuff/sdk' + +export type DiagnosticResult = { + name: string + status: 'ok' | 'warning' | 'error' + message: string + details?: string +} + +export type DoctorReport = { + product: string + results: DiagnosticResult[] + summary: { + ok: number + warnings: number + errors: number + } +} + +/** + * Check if a command is available in PATH + */ +function isCommandAvailable(command: string): boolean { + try { + const which = process.platform === 'win32' ? 'where' : 'which' + execSync(`${which} ${command}`, { stdio: 'ignore', timeout: 5000 }) + return true + } catch { + return false + } +} + +/** + * Check if ripgrep (rg) is available and executable + */ +function checkRipgrep(): DiagnosticResult { + const rgAvailable = isCommandAvailable('rg') + + if (!rgAvailable) { + return { + name: 'ripgrep (rg)', + status: 'error', + message: 'ripgrep not found in PATH', + details: 'Code search requires ripgrep. Install it:\n - macOS: brew install ripgrep\n - Ubuntu/Debian: sudo apt install ripgrep\n - Windows: scoop install ripgrep', + } + } + + return { + name: 'ripgrep (rg)', + status: 'ok', + message: 'Available', + } +} + +/** + * Check git availability and repository status + */ +function checkGit(): DiagnosticResult[] { + const results: DiagnosticResult[] = [] + + // Check git availability + const gitAvailable = isCommandAvailable('git') + if (!gitAvailable) { + results.push({ + name: 'git', + status: 'error', + message: 'git not found in PATH', + details: 'Git is required for version control features. Install it:\n - macOS: xcode-select --install\n - Ubuntu/Debian: sudo apt install git\n - Windows: https://git-scm.com/download/win', + }) + return results + } + + results.push({ + name: 'git', + status: 'ok', + message: 'Available', + }) + + // Check if we're in a git repository + try { + execSync('git rev-parse --is-inside-work-tree', { + stdio: 'ignore', + timeout: 5000, + cwd: process.cwd() + }) + results.push({ + name: 'git repository', + status: 'ok', + message: 'Current directory is a git repository', + }) + } catch { + results.push({ + name: 'git repository', + status: 'warning', + message: 'Current directory is not a git repository', + details: 'Some features may not work correctly outside a git repository.', + }) + } + + return results +} + +/** + * Check API connectivity + */ +async function checkConnectivity(): Promise { + const websiteUrl = getWebsiteUrl() + + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 5000) + + const response = await fetch(`${websiteUrl}/api/healthz`, { + method: 'GET', + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + if (response.ok) { + return { + name: 'API connectivity', + status: 'ok', + message: `Connected to ${websiteUrl}`, + } + } else { + return { + name: 'API connectivity', + status: 'warning', + message: `API responded with status ${response.status}`, + details: 'The API is reachable but returned an error. This may indicate a temporary issue.', + } + } + } catch (error) { + const isTimeout = error instanceof Error && error.name === 'AbortError' + return { + name: 'API connectivity', + status: 'error', + message: isTimeout ? 'Connection timed out' : 'Failed to connect', + details: `Could not reach ${websiteUrl}. Check your internet connection and firewall settings.`, + } + } +} + +/** + * Check if tmux is available + */ +function checkTmux(): DiagnosticResult { + const tmuxAvailable = isCommandAvailable('tmux') + + if (!tmuxAvailable) { + return { + name: 'tmux', + status: 'warning', + message: 'tmux not found', + details: 'tmux is optional but recommended for multiplexing and detached sessions. Install it:\n - macOS: brew install tmux\n - Ubuntu/Debian: sudo apt install tmux\n - Windows: Available through WSL', + } + } + + return { + name: 'tmux', + status: 'ok', + message: 'Available', + } +} + +/** + * Check Node.js/Bun runtime + */ +function checkRuntime(): DiagnosticResult { + if (typeof Bun !== 'undefined') { + return { + name: 'runtime', + status: 'ok', + message: `Bun ${Bun.version}`, + } + } + + return { + name: 'runtime', + status: 'ok', + message: `Node.js ${process.version}`, + } +} + +/** + * Check platform and architecture + */ +function checkPlatform(): DiagnosticResult { + return { + name: 'platform', + status: 'ok', + message: `${process.platform} ${process.arch}`, + } +} + +/** + * Collect all diagnostic information + */ +export async function collectDoctorReport(): Promise { + const results: DiagnosticResult[] = [] + + // Add basic info + results.push(checkPlatform()) + results.push(checkRuntime()) + + // Check dependencies + results.push(checkRipgrep()) + results.push(...checkGit()) + results.push(checkTmux()) + + // Check connectivity (async) + const connectivity = await checkConnectivity() + results.push(connectivity) + + // Calculate summary + const summary = { + ok: results.filter((r) => r.status === 'ok').length, + warnings: results.filter((r) => r.status === 'warning').length, + errors: results.filter((r) => r.status === 'error').length, + } + + return { + product: IS_FREEBUFF ? 'Freebuff' : 'Codebuff', + results, + summary, + } +} + +/** + * Format the doctor report for display + */ +export function formatDoctorReport(report: DoctorReport): string { + const lines: string[] = [] + + lines.push(`### ${report.product} doctor`) + lines.push('') + + // Summary + const { ok, warnings, errors } = report.summary + if (errors === 0 && warnings === 0) { + lines.push('✅ All checks passed!') + } else if (errors === 0) { + lines.push(`⚠️ ${warnings} warning(s) found`) + } else { + lines.push(`❌ ${errors} error(s), ${warnings} warning(s) found`) + } + lines.push('') + + // Detailed results + for (const result of report.results) { + const icon = result.status === 'ok' ? '✅' : result.status === 'warning' ? '⚠️' : '❌' + lines.push(`${icon} **${result.name}**: ${result.message}`) + if (result.details) { + // Indent details + const detailLines = result.details.split('\n') + for (const line of detailLines) { + lines.push(` ${line}`) + } + } + } + + lines.push('') + + // Recommendations + if (errors > 0) { + lines.push('### Recommendations') + lines.push('') + lines.push('Fix the errors above to ensure full functionality.') + } else if (warnings > 0) { + lines.push('### Notes') + lines.push('') + lines.push('The warnings above are optional but may improve your experience.') + } + + return lines.join('\n') +} diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index c6e187a0c4..8bdde57a0a 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -62,6 +62,12 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [ description: 'Show local CLI resource usage and terminal tool process IDs', aliases: ['diag', 'processes'], }, + { + id: 'doctor', + label: 'doctor', + description: 'Diagnose local environment and dependencies (rg, git, tmux, API)', + aliases: ['check', 'health'], + }, { id: 'ads:enable', label: 'ads:enable', diff --git a/common/src/__tests__/freebuff-models.test.ts b/common/src/__tests__/freebuff-models.test.ts index ed03b46c60..f6ce0581c4 100644 --- a/common/src/__tests__/freebuff-models.test.ts +++ b/common/src/__tests__/freebuff-models.test.ts @@ -437,9 +437,9 @@ describe('freebuff model availability', () => { test('GLM 5.3 Flash is UNMETERED, and the two flags that say so agree', () => { // Unmetered on 2026-08-28, matching DeepSeek V4 Flash and MiMo. It was // premium-pooled while its cost was unknown; measured prod spend settled - // that as the cheapest row we serve, 8.9x under the already-unmetered - // V4 Flash. Capping the cheapest model while the dearer ones run uncapped - // inverts the reason caps exist. + // that at $0.000249/msg — the cheapest row we serve, 8.9x under the + // already-unmetered V4 Flash. Capping the cheapest model while the dearer + // ones run uncapped inverts the reason caps exist. expect( getFreebuffPerModelSessionCap(FREEBUFF_GLM_V53_FLASH_MODEL_ID), ).toBeUndefined() @@ -791,7 +791,7 @@ describe('freebuff model availability', () => { test('Kimi K2.7 Code is fully removed from Freebuff', () => { // Removed 2026-07-31 (client pickers went first, on 2026-07-30). The server // half is gone too, so a stale client selection is no longer admitted — - // that tail was still a material daily spend. Paid/BYOK Kimi is unaffected; + // that tail was still spending ~$2.3k/day. Paid/BYOK Kimi is unaffected; // it never resolves through these helpers. expect(SUPPORTED_FREEBUFF_MODELS.map((model) => model.id)).not.toContain( FREEBUFF_KIMI_MODEL_ID, @@ -1235,8 +1235,8 @@ describe('freebuff model availability', () => { }) test('MiniMax M3 is withdrawn: recognised, refused, served to nobody', () => { - // Withdrawn from free mode entirely on 2026-08-20 after its hourly burn - // became the largest single line on the bill. Out of every picker and pool... + // Withdrawn from free mode entirely on 2026-08-20 after reaching $213/hr. + // Out of every picker and pool... expect(FREEBUFF_MODELS.map((model) => model.id)).not.toContain( MINIMAX_M3_MODEL_ID, ) diff --git a/common/src/__tests__/freebuff-trust.test.ts b/common/src/__tests__/freebuff-trust.test.ts new file mode 100644 index 0000000000..e93d58875d --- /dev/null +++ b/common/src/__tests__/freebuff-trust.test.ts @@ -0,0 +1,534 @@ +import { describe, expect, it } from 'bun:test' + +import { + assessFreebuffTrust, + FREEBUFF_TRUST_FALLBACK_LEVEL, + FREEBUFF_TRUST_LEVELS, + FREEBUFF_TRUST_EARNED, + FREEBUFF_TRUST_LIMITS, + FREEBUFF_TRUST_THRESHOLDS, + freebuffTrustLimits, + isAtLeastTrustLevel, + toFreebuffStandingInfo, + type FreebuffTrustSignals, +} from '../constants/freebuff-trust' +import { FREEBUFF_PREMIUM_SESSION_LIMIT } from '../constants/freebuff-models' + +const NOW = new Date('2026-08-11T00:00:00Z') +const DAY_MS = 24 * 60 * 60 * 1000 + +function daysAgo(days: number): Date { + return new Date(NOW.getTime() - days * DAY_MS) +} + +/** An account we know nothing about: every optional signal unknown. This is + * what most pre-provenance accounts actually look like. */ +const UNKNOWN: FreebuffTrustSignals = { + accountCreatedAt: null, + githubAccountCreatedAt: null, + githubOldestRepoCreatedAt: null, + githubPublicRepos: null, + githubFollowers: null, + githubTwoFactorEnabled: null, + activeDays: 0, + approvedBounties: 0, + qualifiedReferrals: 0, + hasPaid: false, + signupPrivacySignals: null, + signupIpSource: null, + signupPrefixAccountCount: null, + mailboxAccountCount: null, + hasUnreversedBanEvent: false, + privacyFlaggedAt: null, + privacyCorroboratedAt: null, + thirdPartyClientAt: null, + currentRiskScore: null, +} + +function signals(overrides: Partial) { + return { ...UNKNOWN, ...overrides } +} + +describe('level ordering', () => { + it('orders least- to most-established', () => { + expect(FREEBUFF_TRUST_LEVELS).toEqual([ + 'new', + 'verified', + 'established', + 'core', + ]) + }) + + it('compares by position, not alphabetically', () => { + expect(isAtLeastTrustLevel('core', 'new')).toBe(true) + expect(isAtLeastTrustLevel('new', 'verified')).toBe(false) + expect(isAtLeastTrustLevel('established', 'established')).toBe(true) + }) +}) + +describe('limit matrix', () => { + it('matches the flat fallback limits at established/full', () => { + // The flat pool and the enforced fallback must not silently meter the same + // established account differently. + const full = freebuffTrustLimits('full', 'established') + expect(full.messagesPerDay).toBe(5_000) + expect(full.messagesPer5Hours).toBe(3_000) + expect(full.dailySpendUsd).toBe(50) + // Premium is deliberately NOT part of that equality any more. It left this + // matrix when Levels shipped (common/src/constants/freebuff-levels.ts): + // the floor here is one session, and everything above it is earned and + // added on top. Pinning it to the old flat 5 would re-assert exactly the + // thing that change undid. + expect(full.premiumSessionsPerDay).toBe( + FREEBUFF_PREMIUM_SESSION_LIMIT, + ) + + const limited = freebuffTrustLimits('limited', 'established') + expect(limited.messagesPerDay).toBe(3_000) + expect(limited.messagesPer5Hours).toBe(2_000) + }) + + it('is monotonic in level on every axis, in both regions', () => { + for (const tier of ['full', 'limited'] as const) { + for (let i = 1; i < FREEBUFF_TRUST_LEVELS.length; i++) { + const lower = FREEBUFF_TRUST_LIMITS[tier][FREEBUFF_TRUST_LEVELS[i - 1]] + const higher = FREEBUFF_TRUST_LIMITS[tier][FREEBUFF_TRUST_LEVELS[i]] + for (const key of Object.keys(lower) as (keyof typeof lower)[]) { + expect(higher[key]).toBeGreaterThanOrEqual(lower[key]) + } + } + } + }) + + it('does not scale session-shape controls, only cost controls', () => { + // Browser sessions and Desktop tabs were deliberately removed: an open + // session costs nothing until it generates, and the generating is already + // bounded. If either reappears here, something re-added a limit that takes + // visible capability from new users and saves nothing. + expect(Object.keys(FREEBUFF_TRUST_LIMITS.full.new).sort()).toEqual([ + 'dailySpendUsd', + 'messagesPer5Hours', + 'messagesPerDay', + 'premiumSessionsPerDay', + 'userMessagesPerDay', + ]) + }) + + it('never lets the limited tier reach a premium session', () => { + // The model gate already refuses it; a non-zero number here would be a + // promise the rest of the system cannot keep. + for (const level of FREEBUFF_TRUST_LEVELS) { + expect(FREEBUFF_TRUST_LIMITS.limited[level].premiumSessionsPerDay).toBe(0) + } + }) + + it('lets a limited-region core member beat a full-region verified user', () => { + // The promise the region split has to make to a real developer abroad. + const limitedCore = freebuffTrustLimits('limited', 'core') + const fullVerified = freebuffTrustLimits('full', 'verified') + expect(limitedCore.messagesPerDay).toBeGreaterThan( + fullVerified.messagesPerDay, + ) + expect(limitedCore.userMessagesPerDay).toBeGreaterThan( + fullVerified.userMessagesPerDay, + ) + expect(limitedCore.dailySpendUsd).toBeGreaterThan( + fullVerified.dailySpendUsd, + ) + }) + + it('keeps every new-account daily budget above the measured p90', () => { + // Sizing anchor from free-mode-rate-limiter.ts: full-tier per-user-per-day + // p90 is 837. A brand-new account doing genuinely heavy work must fit. + expect( + freebuffTrustLimits('full', 'new').messagesPerDay, + ).toBeGreaterThanOrEqual(837) + }) +}) + +describe('scoring', () => { + it('leaves an unknown account at the floor', () => { + const result = assessFreebuffTrust(UNKNOWN, NOW) + expect(result.level).toBe('new') + expect(result.score).toBe(0) + expect(result.cappedBy).toBeNull() + }) + + it('treats null signals as unknown, never as suspicious', () => { + // A pre-provenance account (null everything) must score the same as one + // explicitly checked and found to share nothing. + const unknownProvenance = assessFreebuffTrust( + signals({ githubAccountCreatedAt: daysAgo(400) }), + NOW, + ) + const cleanProvenance = assessFreebuffTrust( + signals({ + githubAccountCreatedAt: daysAgo(400), + signupPrefixAccountCount: 1, + mailboxAccountCount: 1, + }), + NOW, + ) + expect(unknownProvenance.level).toBe(cleanProvenance.level) + expect(unknownProvenance.cappedBy).toBeNull() + }) + + it('reaches established on GitHub age plus ordinary account history', () => { + const result = assessFreebuffTrust( + signals({ + accountCreatedAt: daysAgo(120), + githubAccountCreatedAt: daysAgo(3 * 365 + 10), + githubOldestRepoCreatedAt: daysAgo(400), + githubPublicRepos: 12, + activeDays: 40, + }), + NOW, + ) + // 10 linked + 20 age + 10 repo + 5 repos + 15 acct age + 10 active + expect(result.score).toBe(70) + expect(result.level).toBe('established') + }) + + it('lets a brand-new limited-region account climb with earned signals alone', () => { + // The route that does not require owning an aged GitHub account: this is + // what the Earn page has to be able to promise. + const result = assessFreebuffTrust( + signals({ + accountCreatedAt: daysAgo(10), + approvedBounties: 4, + qualifiedReferrals: 5, + }), + NOW, + ) + // 5 acct age + 20 bounties + 15 referrals + expect(result.score).toBe(40) + expect(result.level).toBe('verified') + }) + + it('caps bounty and referral contributions', () => { + const capped = assessFreebuffTrust( + signals({ approvedBounties: 50, qualifiedReferrals: 50 }), + NOW, + ) + expect(capped.score).toBe( + FREEBUFF_TRUST_EARNED.BOUNTY_POINTS * FREEBUFF_TRUST_EARNED.BOUNTY_CAP + + FREEBUFF_TRUST_EARNED.REFERRAL_POINTS * + FREEBUFF_TRUST_EARNED.REFERRAL_CAP, + ) + }) + + it('lets contribution alone reach core, with no GitHub and no payment', () => { + // THE property the earned caps exist for. Before they were raised this + // route peaked at 70 against a threshold of 75, so `core` was reachable + // only by owning an aged GitHub account or by paying — which is backwards + // for a program meant to give developers in unsupported regions a way to + // raise their own limits. + const earned = assessFreebuffTrust( + signals({ + accountCreatedAt: daysAgo(120), + activeDays: 40, + approvedBounties: FREEBUFF_TRUST_EARNED.BOUNTY_CAP, + qualifiedReferrals: FREEBUFF_TRUST_EARNED.REFERRAL_CAP, + signupPrivacySignals: [], + signupIpSource: 'cloudflare', + }), + NOW, + ) + expect(earned.factors.map((f) => f.id)).not.toContain('github_linked') + expect(earned.score).toBeGreaterThanOrEqual(FREEBUFF_TRUST_THRESHOLDS.core) + expect(earned.level).toBe('core') + }) + + it('keeps paying past the point someone has proved they are real', () => { + // A flat incentive is not an incentive. The tenth referral and the sixth + // bounty must still be worth something, or the program stops pulling + // exactly where it should pull hardest. + const few = assessFreebuffTrust( + signals({ approvedBounties: 4, qualifiedReferrals: 5 }), + NOW, + ) + const many = assessFreebuffTrust( + signals({ approvedBounties: 6, qualifiedReferrals: 10 }), + NOW, + ) + expect(many.score).toBeGreaterThan(few.score) + }) + + it('never returns a score outside 0..100', () => { + const maxed = assessFreebuffTrust( + signals({ + accountCreatedAt: daysAgo(1000), + githubAccountCreatedAt: daysAgo(4000), + githubOldestRepoCreatedAt: daysAgo(3000), + githubPublicRepos: 100, + githubFollowers: 500, + githubTwoFactorEnabled: true, + activeDays: 300, + approvedBounties: 20, + qualifiedReferrals: 20, + hasPaid: true, + signupPrivacySignals: [], + signupIpSource: 'cloudflare', + }), + NOW, + ) + expect(maxed.score).toBe(100) + expect(maxed.level).toBe('core') + }) + + it('ignores a future-dated timestamp rather than crediting it', () => { + const skewed = assessFreebuffTrust( + signals({ + githubAccountCreatedAt: new Date(NOW.getTime() + 10 * DAY_MS), + }), + NOW, + ) + // Linked (10) but no age credit. + expect(skewed.score).toBe(10) + }) +}) + +describe('caps', () => { + const HIGH_SCORE: Partial = { + accountCreatedAt: daysAgo(400), + githubAccountCreatedAt: daysAgo(2000), + githubOldestRepoCreatedAt: daysAgo(1000), + githubPublicRepos: 20, + githubFollowers: 50, + githubTwoFactorEnabled: true, + activeDays: 100, + approvedBounties: 4, + } + + it('caps a live anonymous network at verified however high the score', () => { + const result = assessFreebuffTrust( + signals({ ...HIGH_SCORE, currentRiskScore: 90 }), + NOW, + ) + expect(result.uncappedLevel).toBe('core') + expect(result.level).toBe('verified') + expect(result.cappedBy).toBe('anonymous_network') + }) + + it('does not cap on a low current risk score', () => { + const result = assessFreebuffTrust( + signals({ ...HIGH_SCORE, currentRiskScore: 10 }), + NOW, + ) + expect(result.level).toBe('core') + expect(result.cappedBy).toBeNull() + }) + + it('caps a VPN signup at established, not lower', () => { + const result = assessFreebuffTrust( + signals({ ...HIGH_SCORE, signupPrivacySignals: ['vpn'] }), + NOW, + ) + expect(result.level).toBe('established') + expect(result.cappedBy).toBe('signup_privacy_egress') + }) + + it('credits a clean signup rather than capping it', () => { + const result = assessFreebuffTrust( + signals({ ...HIGH_SCORE, signupPrivacySignals: [] }), + NOW, + ) + expect(result.cappedBy).toBeNull() + expect(result.factors.some((f) => f.id === 'clean_signup')).toBe(true) + }) + + it('applies the lowest cap when several bind', () => { + const result = assessFreebuffTrust( + signals({ + ...HIGH_SCORE, + signupPrivacySignals: ['vpn'], + mailboxAccountCount: 5, + }), + NOW, + ) + expect(result.level).toBe('verified') + expect(result.cappedBy).toBe('shared_mailbox') + }) + + it('leads the next steps with the cap, since points cannot clear it', () => { + const result = assessFreebuffTrust( + signals({ ...HIGH_SCORE, currentRiskScore: 90 }), + NOW, + ) + expect(result.nextSteps[0]?.id).toBe('cap_anonymous_network') + expect(result.nextSteps[0]?.label).toMatch(/VPN/i) + }) + + it('caps an account with unreversed enforcement history', () => { + const result = assessFreebuffTrust( + signals({ ...HIGH_SCORE, hasUnreversedBanEvent: true }), + NOW, + ) + expect(result.level).toBe('verified') + expect(result.cappedBy).toBe('past_enforcement') + }) +}) + +describe('next steps', () => { + it('leads with connecting GitHub for an account that has none', () => { + const result = assessFreebuffTrust(UNKNOWN, NOW) + expect(result.nextSteps[0]?.id).toBe('connect_github') + expect(result.nextSteps[0]?.points).toBe(30) + }) + + it('stops offering steps the user has already exhausted', () => { + const result = assessFreebuffTrust( + signals({ + approvedBounties: FREEBUFF_TRUST_EARNED.BOUNTY_CAP, + qualifiedReferrals: FREEBUFF_TRUST_EARNED.REFERRAL_CAP, + }), + NOW, + ) + expect(result.nextSteps.map((s) => s.id)).not.toContain('bounties') + expect(result.nextSteps.map((s) => s.id)).not.toContain('referrals') + }) + + it('offers the remaining value, not the full value, of a partial step', () => { + const result = assessFreebuffTrust(signals({ approvedBounties: 2 }), NOW) + const remaining = + (FREEBUFF_TRUST_EARNED.BOUNTY_CAP - 2) * + FREEBUFF_TRUST_EARNED.BOUNTY_POINTS + expect(result.nextSteps.find((s) => s.id === 'bounties')?.points).toBe( + remaining, + ) + }) +}) + +describe('wire shape', () => { + it('never puts a raw limit on the wire', () => { + // A published limit is a published target: the abuse pattern here is + // sustained pacing just under the caps, so the caps stay server-side. This + // asserts the payload rather than the component, because the payload is + // what a future surface would reach for. + const info = toFreebuffStandingInfo( + assessFreebuffTrust(signals({ approvedBounties: 4 }), NOW), + 'limited', + ) + expect(info).not.toHaveProperty('limits') + + // Scoped to the distinctive values. Small ones (a $3 spend cap, 5 premium + // sessions) collide with legitimate point values in the copy — "worth 3 + // points" is not a leaked limit, and asserting on them would fail for the + // wrong reason. + const serialized = JSON.stringify(info) + const distinctive = Object.values( + freebuffTrustLimits('limited', info.level), + ).filter((value) => value >= 100) + expect(distinctive.length).toBeGreaterThan(0) + for (const value of distinctive) { + expect(serialized).not.toContain(String(value)) + } + expect(info.accessTier).toBe('limited') + }) + + it('describes each axis in words', () => { + const info = toFreebuffStandingInfo( + assessFreebuffTrust(signals({ approvedBounties: 4 }), NOW), + 'limited', + ) + expect(info.highlights.map((h) => h.label)).toEqual([ + 'Prompts a day', + 'Work per prompt', + 'Premium models', + ]) + for (const highlight of info.highlights) { + expect(highlight.value).not.toMatch(/\d/) + } + }) + + it('frames premium as a region fact where no level can unlock it', () => { + // Every limited-row level has 0 premium sessions, so calling it a + // shortfall would send the user chasing points that cannot buy it. + const limited = toFreebuffStandingInfo( + assessFreebuffTrust(signals({ approvedBounties: 4 }), NOW), + 'limited', + ) + expect( + limited.highlights.find((h) => h.label === 'Premium models')?.value, + ).toMatch(/region/i) + + const full = toFreebuffStandingInfo( + assessFreebuffTrust(signals({ approvedBounties: 4 }), NOW), + 'full', + ) + expect( + full.highlights.find((h) => h.label === 'Premium models')?.value, + ).not.toMatch(/region/i) + }) + + it('reports the next threshold, and nothing beyond core', () => { + const verified = toFreebuffStandingInfo( + assessFreebuffTrust( + signals({ approvedBounties: 4, activeDays: 10 }), + NOW, + ), + 'full', + ) + expect(verified.level).toBe('verified') + expect(verified.nextLevel).toBe('established') + expect(verified.nextLevelAt).toBe(FREEBUFF_TRUST_THRESHOLDS.established) + + const core = toFreebuffStandingInfo( + assessFreebuffTrust( + signals({ + accountCreatedAt: daysAgo(400), + githubAccountCreatedAt: daysAgo(2000), + githubOldestRepoCreatedAt: daysAgo(1000), + githubPublicRepos: 20, + githubFollowers: 50, + githubTwoFactorEnabled: true, + activeDays: 100, + approvedBounties: 4, + }), + NOW, + ), + 'full', + ) + expect(core.level).toBe('core') + expect(core.nextLevel).toBeNull() + expect(core.nextLevelAt).toBeNull() + }) + + it('explains a cap in the copy the client renders', () => { + const info = toFreebuffStandingInfo( + assessFreebuffTrust( + signals({ + accountCreatedAt: daysAgo(400), + githubAccountCreatedAt: daysAgo(2000), + activeDays: 100, + currentRiskScore: 99, + }), + NOW, + ), + 'full', + ) + expect(info.cappedBy).toBe('anonymous_network') + expect(info.cappedReason).toMatch(/VPN/i) + }) + + it('reports no cap when the cap sits at or above the earned level', () => { + // An account scoring 'new' is not "capped at verified" — nothing bound. + const info = toFreebuffStandingInfo( + assessFreebuffTrust(signals({ currentRiskScore: 99 }), NOW), + 'full', + ) + expect(info.level).toBe('new') + expect(info.cappedBy).toBeNull() + }) +}) + +describe('failure behaviour', () => { + it('falls back to the level that matches the flat limits', () => { + // A broken resolver must cost us the enforcement, never the users: if this + // were 'new', one degraded query would throttle the whole product. + expect(FREEBUFF_TRUST_FALLBACK_LEVEL).toBe('established') + expect(freebuffTrustLimits('full', FREEBUFF_TRUST_FALLBACK_LEVEL)).toEqual( + freebuffTrustLimits('full', 'established'), + ) + }) +}) diff --git a/common/src/constants/__tests__/freebuff-freebucks.test.ts b/common/src/constants/__tests__/freebuff-freebucks.test.ts new file mode 100644 index 0000000000..1c9c932ce1 --- /dev/null +++ b/common/src/constants/__tests__/freebuff-freebucks.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'bun:test' + +import { + FREEBUCKS_FREE_ALLOWANCE, + FREEBUCKS_PLAN_ALLOWANCE, + FREEBUCKS_PURCHASABLE_MODEL_IDS, + FREEBUCKS_SESSION_PRICES, + freebucksBlockingWindow, + freebucksSessionPrice, + freebucksTotalAllowance, + isFreebucksPurchasableModelId, +} from '../freebuff-freebucks' + +describe('Freebucks allowances', () => { + // The whole cost control. If a window ever becomes additive, a daily grant + // silently becomes a monthly one for exactly the heaviest accounts — which + // is the failure this test exists to prevent, not a style rule. + it('keeps every allowance sub-additive across windows', () => { + const all = [ + ...Object.values(FREEBUCKS_FREE_ALLOWANCE), + ...Object.values(FREEBUCKS_PLAN_ALLOWANCE), + ] + expect(all.length).toBeGreaterThan(0) + for (const a of all) { + expect(a.weekly).toBeLessThan(a.daily * 7) + expect(a.monthly).toBeLessThan(a.daily * 30) + // A week must still be worth more than a day, or the week cap is the + // only one that ever binds and the daily figure is decorative. + expect(a.weekly).toBeGreaterThan(a.daily) + expect(a.monthly).toBeGreaterThan(a.weekly) + } + }) + + // The lower weekly bound, which is the easy one to break silently: set the + // week too tight and the advertised monthly total can never be reached, so + // the plan promises a number it will not honour. + it('keeps every monthly total reachable within its weekly cap', () => { + const WEEKS_PER_MONTH = 30 / 7 + for (const [name, a] of Object.entries(FREEBUCKS_PLAN_ALLOWANCE)) { + expect( + a.weekly * WEEKS_PER_MONTH, + `${name}: weekly cap binds before the monthly total is reachable`, + ).toBeGreaterThanOrEqual(a.monthly) + } + }) + + it('gives the limited tier less than full access', () => { + expect(FREEBUCKS_FREE_ALLOWANCE.limited.daily).toBeLessThan( + FREEBUCKS_FREE_ALLOWANCE.full.daily, + ) + }) + + it('stacks the plan on top of the free grant rather than replacing it', () => { + const free = freebucksTotalAllowance({ accessTier: 'full' }) + const paid = freebucksTotalAllowance({ + accessTier: 'full', + tierId: 'starter', + }) + expect(free.daily).toBe(FREEBUCKS_FREE_ALLOWANCE.full.daily) + expect(paid.daily).toBe( + FREEBUCKS_FREE_ALLOWANCE.full.daily + FREEBUCKS_PLAN_ALLOWANCE.starter.daily, + ) + }) + + it('ignores an unknown tier instead of dropping the free grant', () => { + const a = freebucksTotalAllowance({ + accessTier: 'full', + tierId: 'no-such-tier', + }) + expect(a).toEqual(freebucksTotalAllowance({ accessTier: 'full' })) + }) + + it('keeps Plus at 2.5x Starter on the day and week windows', () => { + const { starter, plus } = FREEBUCKS_PLAN_ALLOWANCE + expect(plus.daily / starter.daily).toBeCloseTo(2.5, 5) + expect(plus.weekly / starter.weekly).toBeCloseTo(2.5, 5) + }) + + // The month is deliberately MORE generous than the headline ratio — it is + // the window a heavy user lives in. Asserted as a floor so the extra stays a + // choice; an equality here would forbid the choice, and dropping the + // assertion entirely would let the month silently fall behind the week. + it('never makes the Plus month worth less than 2.5x Starter', () => { + const { starter, plus } = FREEBUCKS_PLAN_ALLOWANCE + expect(plus.monthly / starter.monthly).toBeGreaterThanOrEqual(2.5) + }) + + // The free daily grant has to buy at least one of SOMETHING, or the currency + // is visible to every free user and spendable by none of them. + it('affords the cheapest premium session on the free daily grant', () => { + const cheapest = Math.min(...Object.values(FREEBUCKS_SESSION_PRICES)) + expect(FREEBUCKS_FREE_ALLOWANCE.full.daily).toBeGreaterThanOrEqual(cheapest) + expect(FREEBUCKS_FREE_ALLOWANCE.limited.daily).toBeGreaterThanOrEqual( + cheapest, + ) + }) +}) + +describe('Freebucks prices', () => { + it('treats the price map as the purchasable allowlist', () => { + expect(isFreebucksPurchasableModelId('z-ai/glm-5.3-flash')).toBe(true) + // In the free pools, never sold. + expect(isFreebucksPurchasableModelId('mimo/mimo-v2.5')).toBe(false) + expect(freebucksSessionPrice('mimo/mimo-v2.5')).toBeUndefined() + }) + + it('prices every purchasable model as a positive whole number', () => { + expect(FREEBUCKS_PURCHASABLE_MODEL_IDS.length).toBe( + Object.keys(FREEBUCKS_SESSION_PRICES).length, + ) + for (const id of FREEBUCKS_PURCHASABLE_MODEL_IDS) { + const price = freebucksSessionPrice(id) + expect(price).toBeGreaterThan(0) + expect(Number.isInteger(price)).toBe(true) + } + }) + + it('lists purchasable models cheapest first', () => { + const prices = FREEBUCKS_PURCHASABLE_MODEL_IDS.map( + (id) => FREEBUCKS_SESSION_PRICES[id], + ) + expect(prices).toEqual([...prices].sort((a, b) => a - b)) + }) +}) + +describe('freebucksBlockingWindow', () => { + it('returns null when every window can absorb the cost', () => { + expect( + freebucksBlockingWindow({ + cost: 15, + remaining: { daily: 20, weekly: 40, monthly: 100 }, + }), + ).toBeNull() + }) + + // Daily first, so the message names the window that reopens soonest. + it('names the soonest-reopening window when several are short', () => { + expect( + freebucksBlockingWindow({ + cost: 25, + remaining: { daily: 10, weekly: 10, monthly: 10 }, + }), + ).toBe('daily') + }) + + it('reports a weekly block when only the week is short', () => { + expect( + freebucksBlockingWindow({ + cost: 25, + remaining: { daily: 40, weekly: 10, monthly: 100 }, + }), + ).toBe('weekly') + }) + + it('treats an exactly-affordable cost as affordable', () => { + expect( + freebucksBlockingWindow({ + cost: 20, + remaining: { daily: 20, weekly: 20, monthly: 20 }, + }), + ).toBeNull() + }) +}) diff --git a/common/src/constants/free-agents.ts b/common/src/constants/free-agents.ts index 651adba81d..5933acd8b0 100644 --- a/common/src/constants/free-agents.ts +++ b/common/src/constants/free-agents.ts @@ -500,7 +500,7 @@ export const FREE_MODE_AGENT_MODELS: Record> = { // been hidden from every client picker in 75fb0ade6 (2026-07-30) while // deliberately staying valid here and in session admission, so released // clients weren't broken mid-session. That tail kept costing real spend - // (a double-digit share of free-mode cost) because CLI builds older than 75fb0ade6 + // (~$2.3k/day, 19% of free-mode cost) because CLI builds older than 75fb0ade6 // never drop a saved Kimi preference, and nothing forces those users to // upgrade. Every remaining free-mode Kimi request now 403s with // 'free_mode_invalid_agent_model'. Paid/BYOK Kimi is unaffected: the diff --git a/common/src/constants/freebuff-freebucks.ts b/common/src/constants/freebuff-freebucks.ts new file mode 100644 index 0000000000..d961a94687 --- /dev/null +++ b/common/src/constants/freebuff-freebucks.ts @@ -0,0 +1,265 @@ +/** + * Freebucks — the spendable currency that unlocks premium models. + * + * ## What it is, and what it is NOT + * + * A Freebuck is one US cent of provider spend, priced a little above what a + * session actually costs us. It is deliberately NOT Trust + * (`freebuff_trust_balance`): Trust is EARNED standing that buys Levels and is + * debited per prompt; Freebucks are a GRANTED, expiring budget that buys + * sessions on models the free pools do not carry. Two currencies, two sinks — + * keep the vocabulary separate everywhere a user can see it. + * + * ## Why session PRICES are flat, and why they sit above the mean + * + * Measured 2026-08-27 over 25,133 real sessions (each admit joined to its own + * messages within `[admit, admit+1h)`), the per-session cost distribution is + * violently skewed — p99/p50 runs from 5.9x to 17.6x: + * + * model avg p50 p90 p99 max msg/session + * glm-5.2 $1.366 0.738 3.861 6.147 7.82 84 + * v4-flash $0.208 0.142 0.477 1.011 5.20 105 + * glm-5.3-flash $0.130 0.094 0.314 0.553 1.04 48 + * luna $0.115 0.041 0.263 0.718 8.71 49 + * mimo $0.046 0.030 0.113 0.199 1.68 59 + * + * A price at the MEDIAN loses money in aggregate, because the mean exceeds the + * median for every model. A price at the MEAN is covered on average but the top + * 1% of sessions cost 5-8x what they paid. So each price below sits between the + * mean and p90, and {@link FREEBUCKS_SESSION_SPEND_CEILING_USD} caps the tail + * that no flat price can cover — the same shape as the plan's monthly dollar + * ceiling, for the same reason. + * + * Do not "correct" these to the measured averages. The gap IS the margin, and + * it is what makes a granted Freebuck cost us less than a cent. + */ + +import { + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, +} from './freebuff-model-ids' + +/** Shown next to every balance. The coin glyph is drawn client-side. */ +export const FREEBUCKS_LABEL = 'Freebucks' +export const FREEBUCKS_LABEL_SINGULAR = 'Freebuck' + +/** + * Cents per Freebuck. One, and it must stay one: the whole point of the unit is + * that a grant of N Freebucks is an upper bound of N cents of provider spend, + * so the allowance tables below double as the cost model. If this ever needs to + * change, change the PRICES instead. + */ +export const FREEBUCKS_CENTS_PER_UNIT = 1 + +/** + * Per-session price by model, in Freebucks. + * + * Every entry is `round(price)` where price sits between the measured mean and + * p90 for that model — see the table in the file header. A model absent from + * this map is NOT purchasable with Freebucks; that is the allowlist, so adding + * a model here is what makes it sellable. + */ +export const FREEBUCKS_SESSION_PRICES: Readonly> = + Object.freeze({ + // avg 11 FB, p90 26. Cheapest premium session we sell. + 'openai/gpt-5.6-luna': 15, + // avg 13 FB, p90 31. Sessions run short (48 messages) which is why this + // lands beside Luna rather than near V4 Flash. + 'z-ai/glm-5.3-flash': 15, + // avg 21 FB, p90 48. The longest sessions we sell (105 messages). + [FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID]: 25, + // avg 18 FB measured 2026-08-24, the last day it carried free traffic + // before leaving free mode. STALE BY CONSTRUCTION — it has had no free + // sessions since, so there is nothing newer to price against. Re-measure + // once Freebucks puts traffic back on it. + [FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]: 25, + // avg 137 FB, p90 386. An order of magnitude dearer than everything else + // here, and priced to say so. + 'z-ai/glm-5.2': 150, + }) + +/** + * The `free_session_admit.pool` token for a session bought with Freebucks. + * + * Opaque to clients, which group by it and never match on the value. Its job + * server-side is to keep three accountings apart: free pools, `subscription:%` + * plan windows, and bought sessions. The plan windows filter on + * `subscription:%`, so a Freebucks session never eats plan allowance — and the + * debit path finds its work by looking for this token. + */ +export const FREEBUCKS_ADMIT_POOL = 'freebucks' + +export function freebucksSessionPrice(modelId: string): number | undefined { + return FREEBUCKS_SESSION_PRICES[modelId] +} + +export function isFreebucksPurchasableModelId(modelId: string): boolean { + return freebucksSessionPrice(modelId) !== undefined +} + +/** Every model Freebucks can buy, in ascending price then id order. */ +export const FREEBUCKS_PURCHASABLE_MODEL_IDS: readonly string[] = Object.freeze( + Object.keys(FREEBUCKS_SESSION_PRICES).sort( + (a, b) => + FREEBUCKS_SESSION_PRICES[a] - FREEBUCKS_SESSION_PRICES[b] || + a.localeCompare(b), + ), +) + +/** + * The dollar ceiling ONE session may reach before its Freebucks lane is cut. + * + * A flat price cannot cover a p99 that is 17.6x the median, and the measured + * maxima are worse still — a single Luna session reached $8.71 and one + * user-hour reached $20.34. Without this, one runaway session eats a month's + * grant and we eat the difference. At the cap the Freebucks lane stops and the + * caller falls back to the free pools, exactly like the plan's spend ceiling: + * they are not cut off, they simply stop spending Freebucks. + * + * Set at ~4x the dearest non-GLM-5.2 p99 so it only ever catches the tail. + */ +export const FREEBUCKS_SESSION_SPEND_CEILING_USD = 4 + +/** Pacific, matching every other Freebuff reset window. */ +export const FREEBUCKS_RESET_TIMEZONE = 'America/Los_Angeles' +/** Rolling, not calendar — same rule as the plan's 5-day window. */ +export const FREEBUCKS_WEEK_WINDOW_DAYS = 7 + +/** + * A Freebucks allowance across the three windows. + * + * The windows are deliberately SUB-ADDITIVE: `week < 7 x day` and + * `month < 30 x day`. That is the whole cost control. 104 users took between 20 + * and 748 session-units in a single day, and without a week and month bound a + * daily grant simply becomes a monthly one for exactly the accounts that cost + * the most. + */ +export interface FreebucksAllowance { + daily: number + weekly: number + monthly: number +} + +/** + * What a FREE account gets, by access tier. + * + * Funded rather than invented: retiring free DeepSeek V4 Pro returned + * ~$2,293/day, which over 26,491 daily actives is ~8.7 FB/user/day, and the + * measured provider re-routes (V4 Flash to luminal, GLM 5.2 off infron) are + * worth another ~$2,511/day, or ~9.5 FB/user/day. 20 FB/day sits inside that + * headroom at a realistic redemption rate and buys one premium session a day — + * which is the entire pitch. + * + * `limited` is half of `full` for the same reason the session pools are: the + * tier exists because those regions cost more per session to serve. + */ +export const FREEBUCKS_FREE_ALLOWANCE: Readonly< + Record<'full' | 'limited', FreebucksAllowance> +> = Object.freeze({ + full: Object.freeze({ daily: 20, weekly: 80, monthly: 200 }), + // 15, not 10, and the floor is load-bearing: the cheapest session we sell is + // 15 FB, so a 10 FB grant would show this tier a currency it could never + // spend on anything. A daily grant must buy at least one session or it is + // just a number that makes people feel poor. Enforced by a test. + limited: Object.freeze({ daily: 15, weekly: 60, monthly: 150 }), +}) + +/** + * What each PAID tier adds ON TOP of the free allowance. + * + * Starter 200 / 800 / 2,500 (plus a separate $40 token cap) + * Plus 500 / 2,000 / 7,000 (plus a separate $100 token cap) + * + * ## Two caps, deliberately not one + * + * Freebucks and the tier's `monthlySpendLimitUsd` are SEPARATE ceilings and a + * session must clear both. They measure different things: Freebucks price a + * session at a flat rate set from its MEDIAN-to-p90 cost, while the token cap + * bounds what the tail actually bills. Collapsing them into one number would + * make the flat price load-bearing for the tail it is explicitly not designed + * to cover — the whole reason {@link FREEBUCKS_SESSION_SPEND_CEILING_USD} + * exists. So Starter's 2,500 Freebucks ($25 of sessions at list price) sits + * under a $40 token ceiling, and the gap absorbs the sessions that overrun + * their price. Marketing must state both; one without the other is a promise + * we cannot keep. + * + * Plus is 2.5x Starter on the daily and weekly windows but 2.8x on the month + * (7,000 against 2,500), which is intentional: the monthly window is the one a + * heavy user actually lives in, so the upgrade is worth more there than the + * headline ratio suggests. The tests assert 2.5x on day and week and only a + * FLOOR on the month, so this stays a deliberate choice rather than drift. + * + * The WEEKLY figure has to clear two bounds at once: + * - below `7 x daily` (1,400 for Starter), or the week stops capping anything + * and a daily grant becomes a monthly one for the heaviest accounts; + * - above `monthly / 4.348` (575 for Starter), or the WEEK binds first and + * the advertised monthly total is unreachable. + * Any re-tune has to re-check both; the tests assert them. + * + * Same free-first rule the session pools follow: a subscriber spends their free + * Freebucks first, and the plan tops the pool up rather than replacing it. + */ +export const FREEBUCKS_PLAN_ALLOWANCE: Readonly< + Record +> = Object.freeze({ + starter: Object.freeze({ daily: 200, weekly: 800, monthly: 2500 }), + plus: Object.freeze({ daily: 500, weekly: 2000, monthly: 7000 }), +}) + +export function freebucksPlanAllowance( + tierId: string | null | undefined, +): FreebucksAllowance | undefined { + if (!tierId) return undefined + return FREEBUCKS_PLAN_ALLOWANCE[tierId] +} + +/** Free allowance + whatever the caller's plan adds. */ +export function freebucksTotalAllowance(params: { + accessTier: 'full' | 'limited' + tierId?: string | null +}): FreebucksAllowance { + const free = + FREEBUCKS_FREE_ALLOWANCE[params.accessTier] ?? FREEBUCKS_FREE_ALLOWANCE.full + const plan = freebucksPlanAllowance(params.tierId) + if (!plan) return { ...free } + return { + daily: free.daily + plan.daily, + weekly: free.weekly + plan.weekly, + monthly: free.monthly + plan.monthly, + } +} + +/** Which window a debit of `cost` would breach first, or null if it fits. */ +export type FreebucksWindow = 'daily' | 'weekly' | 'monthly' + +export function freebucksBlockingWindow(params: { + cost: number + remaining: Readonly> +}): FreebucksWindow | null { + const order: FreebucksWindow[] = ['daily', 'weekly', 'monthly'] + // Daily first so the message a user sees names the window that will reopen + // soonest — the same rule the session quotas use for `resetAt`. + for (const w of order) { + if (params.remaining[w] < params.cost) return w + } + return null +} + +export function formatFreebucks(amount: number): string { + return Math.max(0, Math.round(amount)).toLocaleString() +} + +export function freebucksWindowLabel(window: FreebucksWindow): string { + return window === 'daily' + ? 'today' + : window === 'weekly' + ? 'this week' + : 'this month' +} + +/** Plain-language allowance summary, used on the plans page and in settings. */ +export function freebucksAllowanceSummary(a: FreebucksAllowance): string { + return `${formatFreebucks(a.daily)}/day · ${formatFreebucks( + a.weekly, + )}/week · ${formatFreebucks(a.monthly)}/month` +} diff --git a/common/src/constants/freebuff-models.ts b/common/src/constants/freebuff-models.ts index bee3bc77a2..111609f5eb 100644 --- a/common/src/constants/freebuff-models.ts +++ b/common/src/constants/freebuff-models.ts @@ -388,10 +388,10 @@ export const FREEBUFF_FABLE_5_MODEL_ID = 'anthropic/claude-fable-5' * (see docs/freebuff-muse-spark.md) that the browser can render a wait for. * The CLI has no such queue and would just surface 429s. * - * Contributor pricing (a small fraction of Standard's published per-M rates; - * the negotiated numbers stay out of this exported file) is bought with - * training rights over prompts and completions, which is why this is - * `dataUse: 'training'` and carries the AI-training warning. + * Contributor pricing ($0.10/$0.002/$0.20 per M against Standard's + * $1.25/$0.15/$4.25) is bought with training rights over prompts and + * completions, which is why this is `dataUse: 'training'` and carries the + * AI-training warning. */ export const FREEBUFF_MUSE_SPARK_12_CONTRIBUTOR_MODEL_ID = 'meta/muse-spark-1.2-contributor' @@ -1066,12 +1066,12 @@ const DEEPSEEK_V4_FLASH_MODEL = { // can hold the peak window exists again, and Flash is once more the row whose // whole cost doubles inside it. // - // Flash is a large share of fleet spend and DeepSeek doubles its price for - // ten hours a day. Measured 2026-08-24 09:00Z, inside the window (per-message - // figures in the internal cost notes — measured $ numbers do not belong in - // this file, which is exported to the public repo): Pro at Cheaper Inference - // cost within 2% of peak Flash, so redirecting saved nothing, while Luna ran - // at roughly half. + // Flash is ~46% of fleet spend and DeepSeek doubles it for ten hours a day. + // Measured 2026-08-24 09:00Z, inside the window: + // + // Flash @ DeepSeek peak $0.005621/msg + // Pro @ Cheaper Inf. $0.005731/msg (1.02x — saves nothing) + // Luna @ Cheaper Inf. $0.002659/msg (2.11x CHEAPER) // // Hence the fallback points at LUNA, not Pro. The old pointer named Pro from // when Pro was the flat-priced row; it is now merely the same price as the @@ -1080,18 +1080,24 @@ const DEEPSEEK_V4_FLASH_MODEL = { // REOPENED 2026-08-28. The closure above was correct on its own measurement // and was invalidated by its own effect. // - // That 08-24 reading caught Luna at its WARM price, taken before Flash's - // traffic was displaced onto it. Closing Flash is what moved a flood of - // unfamiliar prefixes onto Luna's lane, and a prefix cache is the whole cost - // of these rows: Luna's cache rate collapsed inside the window and its price - // went with it. Re-measured 2026-08-28, hourly: absorbing Luna became the - // DEAREST of the three per message; peak Flash about half of that; and Flash - // on Luminal — which is not DeepSeek and so has no peak surcharge at all — - // cheaper than both by ~4x (~8x at the hour peak pricing begins, same model, - // same minute). + // That 08-24 reading priced Luna at $0.002659/msg -- its WARM price, taken + // before Flash's traffic was displaced onto it. Closing Flash is what moved + // ~30k msg/hr of unfamiliar prefixes onto Luna's lane, and a prefix cache is + // the whole cost of these rows: Luna fell from ~95% cache to 59-68% inside + // the window and its price went with it. Re-measured 2026-08-28, hourly: + // + // Luna @ Cheaper Inf. (absorbing) $0.00925/msg 59-68% cache + // Flash @ DeepSeek peak $0.0042-0.0057/msg + // Flash @ LUMINAL $0.00103/msg 96% cache, NO peak card + // + // The ordering inverted: the row we closed to save money is now half the + // price of the row we sent its traffic to, and Luminal -- which is not + // DeepSeek and so has no peak surcharge at all -- is cheaper than both by 4x. + // Measured at 01:00Z, the hour peak pricing begins: DeepSeek $0.00416, + // Luminal $0.00053, same model, same minute. // - // The closure therefore cost a meaningful daily sum of excess Luna spend, - // against a saving premised on a price that no longer existed. + // Cost of the closure, measured over 04:00-12:00Z: ~$1,180/day of excess Luna + // spend, against a saving premised on a price that no longer exists. // // A closure justified by a measurement must be rechecked when the thing it // measured is downstream of the closure itself. This one was not, for four @@ -1338,10 +1344,13 @@ const GLM_V53_FLASH_MODEL = { // UNMETERED, like DeepSeek V4 Flash and MiMo — the two other rows in // FREEBUFF_STANDARD_MODEL_IDS. It was premium-pooled while its true cost was // unknown; measured production spend has now settled that, and it is the - // CHEAPEST row we serve (per-message and per-session figures live in the - // internal cost notes, not in this exported file). + // CHEAPEST row we serve: // - // This row is 4.6x cheaper per session than MiMo and 8.9x cheaper than V4 + // glm-5.3-flash (Merge, 91.7% cache) $0.000249/msg $0.0196/session + // deepseek-v4-flash (already unmetered) $0.002223/msg $0.1752/session + // mimo-v2.5 (already unmetered) $0.001151/msg $0.0907/session + // + // So this row is 4.6x cheaper per session than MiMo and 8.9x cheaper than V4 // Flash, both of which already run with no ceiling at all. Keeping a session // cap on the cheapest model while the dearer ones are uncapped inverts the // reason caps exist. @@ -1642,8 +1651,10 @@ export const FREEBUFF_MODELS = [ // a new user's first send cannot fail because a pool ran dry. // // And it is the cheapest row we serve, by a wide margin — measured production - // spend per message puts MiMo at 4.6x this row and V4 Flash at 8.9x (exact - // figures in the internal cost notes, not in this exported file). + // spend, per message: + // glm-5.3-flash $0.000249 (this row) + // mimo-v2.5 $0.001151 4.6x + // deepseek-v4-flash $0.002223 8.9x // // WHAT THIS GIVES UP, stated plainly because the previous ordering note was // written to prevent exactly this move: this row is the DEEP one, and depth @@ -1697,9 +1708,9 @@ export const FREEBUFF_MODELS = [ // nothing else and sit outside every number the picker shows. export const FREEBUFF_PREMIUM_MODEL_IDS = [ FREEBUFF_GPT_5_6_LUNA_MODEL_ID, - // GLM 5.3 Flash left on 2026-08-28: measured production spend made it the - // cheapest row we serve, 8.9x under the already-unmetered V4 Flash. - // See GLM_V53_FLASH_MODEL for what leaving here also + // GLM 5.3 Flash left on 2026-08-28: measured production spend put it at + // $0.000249/msg, the cheapest row we serve and 8.9x under the already- + // unmetered V4 Flash. See GLM_V53_FLASH_MODEL for what leaving here also // drops. This list and that entry's `premium` flag must always agree — // isFreebuffPremiumModelId reads this one while FREEBUFF_STANDARD_MODEL_IDS // is derived from the flag, so a disagreement makes a row premium for the @@ -1758,10 +1769,10 @@ export const FREEBUFF_PER_MODEL_SESSION_CAPS: Readonly< // EMPTY SINCE 2026-08-27, and deliberately kept as a table rather than // deleted. GLM 5.3 Flash was the only entry — capped at 2/day as a // MEASUREMENT WINDOW while its true cost was unknown, exactly as the comment - // above describes. That window has now closed: the lane held a high cache - // rate on its pinned vendor and came in ~6x under the OpenRouter route it - // replaced (measured figures in docs/freebuff-merge-gateway.md, which is - // not exported). The cap has therefore come off, and the + // above describes. That window has now closed: the lane was measured at + // 93.6% cache on its pinned vendor and ~$0.00059 per model call, which is + // 6.2x under the OpenRouter route it replaced (see + // docs/freebuff-merge-gateway.md). The cap has therefore come off, and the // model is metered by the SHARED premium pool alone — it is in // FREEBUFF_WEB_PREMIUM_MODEL_IDS via FREEBUFF_PREMIUM_MODEL_IDS, so a // full-access account may spend any of its FREEBUFF_PREMIUM_SESSION_LIMIT @@ -1814,8 +1825,8 @@ export const FREEBUFF_DEEPSEEK_SESSION_WINDOW_HOURS = * clients that need it are the ones already installed. */ export const FREEBUFF_PAUSED_FREE_MODEL_IDS: readonly string[] = [ - // Withdrawn from free mode entirely on 2026-08-20. Its hourly burn became - // the largest single line on the bill — and is not worth that at any tier. + // Withdrawn from free mode entirely on 2026-08-20. It reached $213/hr — the + // largest single line on the bill — and is not worth that at any tier. // // PAUSED rather than deleted, which is the difference between withdrawing a // model and breaking the clients that still ask for it. Every released CLI and @@ -2108,9 +2119,11 @@ export const FREEBUFF_DESKTOP_PREMIUM_BUCKET_MODEL_IDS = [ // GLM 5.3 Flash LEFT on 2026-08-29, and on this list's own criterion rather // than as a side effect of unmetering it the day before. Membership is "a // bill we would not want to underwrite at three at once", and measured - // production spend puts it at the CHEAPEST row we serve — well under both - // MiMo and V4 Flash per session, and both of those already run 3 tabs - // (figures in the internal cost notes, not in this exported file). + // production spend puts it at $0.000249/msg — the CHEAPEST row we serve: + // + // glm-5.3-flash $0.000249/msg $0.0196/session <- 3 tabs, now + // mimo-v2.5 $0.001151/msg $0.0907/session <- 3 tabs already + // deepseek-v4-flash $0.002223/msg $0.1752/session <- 3 tabs already // // Three concurrent tabs of it is a smaller bill than three of either row this // list has always allowed, so keeping it here failed the test on its face. @@ -2320,7 +2333,7 @@ export type FreebuffWebPremiumModelId = * want of quota. * * It is also `availability: 'always'` and the cheapest row we serve - * (measured per-message, 4.6x under MiMo and 8.9x under V4 Flash). The cost + * ($0.000249/msg measured, 4.6x under MiMo and 8.9x under V4 Flash). The cost * and availability arguments are therefore both strictly better than the Luna * it replaces; the argument it LOSES is latency, since this is the deep row * running `defaultEffort: 'max'`. See FREEBUFF_MODELS for that trade in full. @@ -2358,9 +2371,9 @@ export const DEFAULT_FREEBUFF_MODEL_ID: FreebuffModelId = * availability wins and is the change this comment expects to be made. * * The cost half is not close. Cache reads are ~98% of browser tokens, and this - * row's list cache-read rate is nearly double Luna's — but measured per - * message on the traffic that actually runs it bills an order of magnitude - * LESS than Luna (figures in the internal cost notes, not here). + * row reads cache at $0.015/M against Luna's $0.008/M list — but it bills + * $0.000249/msg measured against Luna's $0.002659, an order of magnitude + * apart on the traffic that actually runs. * * Kept as its own constant from DEFAULT_FREEBUFF_MODEL_ID (CLI/Desktop) so the * browser surfaces can steer independently. They name the same model today and diff --git a/common/src/constants/freebuff-standing.ts b/common/src/constants/freebuff-standing.ts deleted file mode 100644 index 2a3515982e..0000000000 --- a/common/src/constants/freebuff-standing.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Freebuff account standing ("Access Level") — the PRESENTATIONAL half. - * - * This file carries everything a client may see: the level names, their - * user-facing labels and blurbs, and the wire shapes for standing info. The - * actual limit matrix, thresholds and scorer live in - * `./freebuff-trust.ts`, which is deliberately EXCLUDED from the public-repo - * export (see scripts/public-export-manifest.txt) — a published limit is a - * published target, so the numbers must never ship in a public file. Keep - * that split when adding here: names, labels and shapes only, never numbers. - */ - -import type { FreebuffAccessTier } from './freebuff-models' - -/** - * Ordered least- to most-established. The order is load-bearing: - * `FREEBUFF_TRUST_LEVELS.indexOf` is how "at least X" comparisons are done, so - * inserting a level in the middle re-ranks every comparison in one edit rather - * than requiring each call site to be found. - */ -export const FREEBUFF_TRUST_LEVELS = [ - 'new', - 'verified', - 'established', - 'core', -] as const - -export type FreebuffTrustLevel = (typeof FREEBUFF_TRUST_LEVELS)[number] - -/** The level an account holds before anything is known about it. Every failure - * path in the resolver must land somewhere DEFINITE, and this is not it — see - * `FREEBUFF_TRUST_FALLBACK_LEVEL`. */ -export const FREEBUFF_TRUST_MIN_LEVEL: FreebuffTrustLevel = 'new' - -/** - * The level used when signals cannot be loaded (database error, timeout). - * - * `established` and NOT `new`, and this is the single most consequential - * constant in the file. This resolver runs on the free-mode hot path; if a - * Postgres hiccup dropped every caller to `new`, one degraded dependency would - * throttle the entire product to a fifth of its capacity, and it would look - * exactly like an outage nobody could attribute. Failing to the level that - * reproduces roughly today's flat limits means a broken resolver costs us the - * enforcement, never the users. Same reasoning as the signup gate's fail-open. - */ -export const FREEBUFF_TRUST_FALLBACK_LEVEL: FreebuffTrustLevel = 'established' - -export function isAtLeastTrustLevel( - level: FreebuffTrustLevel, - minimum: FreebuffTrustLevel, -): boolean { - return ( - FREEBUFF_TRUST_LEVELS.indexOf(level) >= - FREEBUFF_TRUST_LEVELS.indexOf(minimum) - ) -} - -/** User-facing name. Never says "trust", "risk" or "score" — a user reading - * their own level is reading an explanation of their limits, not a verdict on - * their character. */ -export const FREEBUFF_TRUST_LEVEL_LABELS: Record = { - new: 'Getting started', - verified: 'Verified', - established: 'Established', - core: 'Core member', -} - -/** One line of user-facing copy per level, shown under the label. */ -export const FREEBUFF_TRUST_LEVEL_BLURBS: Record = { - new: 'Welcome! Your account is brand new, so limits start small. They open up quickly — the steps below take a few minutes.', - verified: - 'Your account is verified. You have solid daily limits, and a bit of history unlocks the next level.', - established: - 'You are an established Freebuff user with generous limits on messages, spend and premium sessions.', - core: 'You are a core member. You get the highest free limits we offer, in every region.', -} - -/** A signal that moved the score, in user-facing language. */ -export interface FreebuffTrustFactor { - id: string - label: string - points: number -} - -/** Something the user can do to move up, with what it is worth. */ -export interface FreebuffTrustNextStep { - id: string - label: string - detail: string - points: number - /** Where the UI should send them. Relative to the freebuff web app. */ - href?: string -} - -export interface FreebuffStandingHighlight { - label: string - value: string -} - -/** - * NOTE FOR CALLERS: `highlights` is what the level WOULD grant, which is only - * what the account actually gets once `FREEBUFF_TRUST_LEVELS=enforce`. Both - * producers gate on that (the Earn route and the session `standing` field), so - * a client that receives this can render it as fact. A third producer must do - * the same — see the comment in freebuff/web/src/app/api/web/standing/route.ts - * for what happens otherwise. - */ -export interface FreebuffStandingInfo { - level: FreebuffTrustLevel - label: string - blurb: string - score: number - /** Score at which the next level starts, or null at `core`. */ - nextLevelAt: number | null - nextLevel: FreebuffTrustLevel | null - cappedBy: string | null - cappedReason: string | null - factors: FreebuffTrustFactor[] - nextSteps: FreebuffTrustNextStep[] - accessTier: FreebuffAccessTier - /** Semantic, never numeric — see FreebuffStandingHighlight. */ - highlights: FreebuffStandingHighlight[] -} diff --git a/common/src/constants/freebuff-subscriptions.ts b/common/src/constants/freebuff-subscriptions.ts index 24a53e7803..fe362ac4f6 100644 --- a/common/src/constants/freebuff-subscriptions.ts +++ b/common/src/constants/freebuff-subscriptions.ts @@ -44,9 +44,8 @@ export const FREEBUFF_SUBSCRIPTION_MODEL_IDS: readonly string[] = Object.freeze( /** * The expensive half of the pool, sub-capped within each day. * - * Measured 2026-08-21: Luna and DeepSeek V4 Pro each cost roughly 4-5x Flash - * per hour-session (dollar figures live in the internal cost notes, not in - * this exported file). Without a sub-cap a subscriber + * Measured 2026-08-21: Luna $0.758 and DeepSeek V4 Pro $0.605 per hour-session, + * against Flash at $0.156 — roughly 4-5x. Without a sub-cap a subscriber * spending every daily session on Luna costs 5x one spending them on Flash, at * the same price, so the daily allowance would have to be priced for the worst * case and would be small for everyone. @@ -184,9 +183,8 @@ export const FREEBUFF_SUBSCRIPTION_TIERS: readonly FreebuffSubscriptionTier[] = introPriceUsd: 5, // Resized 2026-08-27, before the public rollout. The pre-rollout // figures were sized against god-only testing and priced most of a - // month of premium use into $8 — at Luna's measured per-session cost, - // 4/day was an order of magnitude more compute than the price - // (figures in the internal cost notes). The 5-DAY window is what actually bounds a + // month of premium use into $8 — at Luna's measured $0.758/session, + // 4/day was ~$91 of compute. The 5-DAY window is what actually bounds a // heavy week (3/day would allow 15 in five days; 10 is the real cap), // which is why the two numbers are not simply proportional. dailySessions: 3, diff --git a/common/src/constants/freebuff-trust.ts b/common/src/constants/freebuff-trust.ts new file mode 100644 index 0000000000..674e4fb22c --- /dev/null +++ b/common/src/constants/freebuff-trust.ts @@ -0,0 +1,1011 @@ +/** + * Freebuff account standing ("Access Level") — the per-account policy layer + * that decides how much free capacity an account gets. + * + * ## Why this exists + * + * Every free-mode control before this was keyed on the ACCOUNT and applied the + * same number to every account: the same 5,000 requests/day, the same $50 + * spend budget, the same 6 premium sessions. That is only a bound if accounts + * are scarce, and `docs/freebuff-signup-gate.md` is the record of them not + * being. A relay pooling 100 minted accounts is entitled, entirely within the + * rules, to 100x every per-user limit. + * + * The signup gate raised the price of minting an account. This raises the + * price of a FRESH one being worth anything: a brand-new account from an + * unverifiable network gets a small fraction of the capacity, and an account + * that has demonstrably existed and done work for months gets considerably + * MORE than the flat limits ever gave it. Same fleet spend, redistributed + * toward the people we actually want to serve. + * + * ## Two axes, deliberately separate + * + * `FreebuffAccessTier` (full / limited) is a REGION property, resolved per + * request from the caller's IP country. `FreebuffTrustLevel` is an ACCOUNT + * property, resolved from durable facts about the account. They multiply: + * limits are a matrix, not a sum. A limited-region user can climb to `core` + * and get more than a full-region `new` account — which is the whole point of + * shipping this alongside the region split rather than instead of it. + * + * ## What the level must never be + * + * A ban input, or a reason to serve a degraded model. Everything here produces + * a NUMBER — a limit — and a limit that is reached produces a retryable 429 + * naming the remedy. `docs/freebuff-abuse-detection.md` records what it cost + * the last time a soft signal was allowed to convict (659 wrongly-banned + * accounts, 2026-08-03); none of the signals below is stronger than the ones + * that did it. + * + * ## Naming + * + * User-facing copy says "Access Level" and never "trust", because a user shown + * a low trust score reads an accusation. The code says `trustLevel` because + * that is what it is and a euphemism in an identifier costs a reader time. + * `FREEBUFF_TRUST_LEVEL_LABELS` is the one bridge between the two. + */ + +import type { FreebuffAccessTier } from './freebuff-models' + +// --------------------------------------------------------------------------- +// Levels +// --------------------------------------------------------------------------- + +/** + * Ordered least- to most-established. The order is load-bearing: + * `FREEBUFF_TRUST_LEVELS.indexOf` is how "at least X" comparisons are done, so + * inserting a level in the middle re-ranks every comparison in one edit rather + * than requiring each call site to be found. + */ +export const FREEBUFF_TRUST_LEVELS = [ + 'new', + 'verified', + 'established', + 'core', +] as const + +export type FreebuffTrustLevel = (typeof FREEBUFF_TRUST_LEVELS)[number] + +/** The level an account holds before anything is known about it. Every failure + * path in the resolver must land somewhere DEFINITE, and this is not it — see + * `FREEBUFF_TRUST_FALLBACK_LEVEL`. */ +export const FREEBUFF_TRUST_MIN_LEVEL: FreebuffTrustLevel = 'new' + +/** + * The level used when signals cannot be loaded (database error, timeout). + * + * `established` and NOT `new`, and this is the single most consequential + * constant in the file. This resolver runs on the free-mode hot path; if a + * Postgres hiccup dropped every caller to `new`, one degraded dependency would + * throttle the entire product to a fifth of its capacity, and it would look + * exactly like an outage nobody could attribute. Failing to the level that + * reproduces roughly today's flat limits means a broken resolver costs us the + * enforcement, never the users. Same reasoning as the signup gate's fail-open. + */ +export const FREEBUFF_TRUST_FALLBACK_LEVEL: FreebuffTrustLevel = 'established' + +export function isAtLeastTrustLevel( + level: FreebuffTrustLevel, + minimum: FreebuffTrustLevel, +): boolean { + return ( + FREEBUFF_TRUST_LEVELS.indexOf(level) >= + FREEBUFF_TRUST_LEVELS.indexOf(minimum) + ) +} + +function lowerOf( + a: FreebuffTrustLevel, + b: FreebuffTrustLevel, +): FreebuffTrustLevel { + return isAtLeastTrustLevel(a, b) ? b : a +} + +/** User-facing name. Never says "trust", "risk" or "score" — a user reading + * their own level is reading an explanation of their limits, not a verdict on + * their character. */ +export const FREEBUFF_TRUST_LEVEL_LABELS: Record = { + new: 'Getting started', + verified: 'Verified', + established: 'Established', + core: 'Core member', +} + +/** One line of user-facing copy per level, shown under the label. */ +export const FREEBUFF_TRUST_LEVEL_BLURBS: Record = { + new: 'Welcome! Your account is brand new, so limits start small. They open up quickly — the steps below take a few minutes.', + verified: + 'Your account is verified. You have solid daily limits, and a bit of history unlocks the next level.', + established: + 'You are an established Freebuff user with generous limits on messages, spend and premium sessions.', + core: 'You are a core member. You get the highest free limits we offer, in every region.', +} + +// --------------------------------------------------------------------------- +// Limits +// --------------------------------------------------------------------------- + +/** + * Everything a level controls, in one place. + * + * Every field is a per-account ceiling over a time window. None is a global + * budget and none is a concurrency cap — read each doc comment rather than + * inferring from the name. + */ +export interface FreebuffTrustLimits { + /** + * User prompts per Pacific day. NOT requests: one prompt is one root agent + * run, and an agentic turn behind it may make dozens of LLM calls. + * + * This is the limit that means what a person thinks "messages" means, and it + * is the one an honest heavy user should be able to feel without hitting. + * Counted only when a call OPENS a root run (`isNewPromptWindow`), so a + * caller that reuses one run id to hide many prompts pays the run-reuse + * guard's ceiling instead. + */ + userMessagesPerDay: number + /** Total free-mode LLM requests in any 5-hour window: prompts plus every + * subagent, tool loop and retry underneath them. */ + messagesPer5Hours: number + /** Total free-mode LLM requests per day. The overall ceiling — a premium + * request consumes this budget as well as its own. */ + messagesPerDay: number + /** + * Settled non-BYOK provider cost, in USD, since midnight Pacific, at or + * above which no FRESH session is admitted. Live sessions and reclaims are + * never interrupted, so the honest reading is "how much we will spend + * starting new work for this account today". + */ + dailySpendUsd: number + /** + * Premium-model sessions per Pacific day (the shared premium pool's base + * entitlement). Referral, streak and operator entitlement is ADDED on top of + * this, so a level never takes away something a user earned. + * + * Zero at every limited-region level: the limited tier cannot reach a + * premium model at all, and the number would be decoration. + */ + premiumSessionsPerDay: number +} + +/** + * Why the `premiumSessionsPerDay` column was compressed. + * + * It used to run 2 / 4 / 5 / 10. It now runs 2 / 3 / 4 / 5, and the change is + * at the TOP rather than the bottom: `established` tracks + * `FREEBUFF_PREMIUM_SESSION_LIMIT` as it always has, and `core` came down from + * 10 to 5. + * + * `core` was doing two jobs. It was the abuse control's verdict — this account + * is demonstrably real — and it was also the only reward in the product big + * enough to notice, reachable only through facts a user cannot act on today + * (an aged GitHub account, months of history). "Why do I only get four" had no + * answer anybody could act on this afternoon. + * + * The reward half moved to `freebuff-levels.ts`, which is denominated in + * something a user can go and do right now, and which tops out at + * `FREEBUFF_LEVEL_SESSION_CEILING` (7) — above every value in this column. So + * a core member is no worse off than before once they engage at all, and the + * route to the ceiling is open to a brand-new account in an unsupported + * region, which is exactly who it was closed to before. + * + * This column only selects anything under `FREEBUFF_TRUST_LEVELS=enforce`; + * under the default `observe` the flat base applies, so these numbers and the + * `FREEBUFF_LEVEL_SESSIONS` revert do not interact. + * + * The other four axes are unchanged and stay here, because they are cost + * controls rather than rewards. A Level must never be able to buy its way into + * a bigger daily SPEND budget, or the incentive and the abuse control end up + * pointing at the same dial. + *//** + * Two axes were deliberately REMOVED from this interface, and the reasoning is + * worth keeping so they are not quietly re-added. + * + * **Concurrent Desktop tabs** (`FREEBUFF_DESKTOP_SESSION_LIMITS.unlimited`) are + * still enforced but not level-scaled. A PAID PLAN does raise them + * (`freebuffDesktopSessionLimits`), which is not a contradiction: that is a + * purchase, not a reward for engagement, and it moves both ceilings together + * rather than metering one axis of a free account. That is a session-SHAPE control rather + * than a cost control: starting a session costs nothing, and a session that + * sits idle costs nothing either. What costs money is the traffic inside it, + * and that is already bounded four different ways by the fields above. + * + * Scaling it by level would therefore have taken visible, immediate capability + * away from exactly the users we most need to keep — a new user discovers "you + * may only open one tab" instantly, and it reads as a product that is broken + * rather than as a budget — in exchange for no measurable saving at all. The + * premium-session pool stays level-scaled because a premium session is the one + * whose mere existence commits us to expensive inference. + * + * **Browser sessions per day** used to be the other example here, capped at 6 + * on Web and Cloud and unlimited everywhere else. That pool was removed on + * 2026-08-18 by this same argument taken one step further: if session count is + * the wrong thing to meter, it is the wrong thing to meter on one surface + * too. + */ + +/** + * The matrix. Region tier picks the row, account level picks the column. + * + * ## How these numbers were chosen + * + * `established` × `full` is the current flat-limit fallback (5,000/day, + * 3,000/5h, $50, 5 premium), and `established` × `limited` reproduces the + * limited row (3,000/day, 2,000/5h). Keeping the matrix aligned with the flat + * fallback is deliberate: observe/off mode, resolver failures and enforced + * `established` accounts must all receive the same baseline. `core` remains + * the raise, while the levels below `established` tighten newer accounts. + * + * Sizing for the two new levels below `established` is anchored on the + * per-user-per-day distributions in `free-mode-rate-limiter.ts` (full tier p50 + * 131, p90 837, p99 2,351): `verified` at 3,000/day sits above the full tier's + * p99, and `new` at 1,200/day sits above its p90. So a genuinely new user + * doing genuinely heavy work still fits, and the accounts that do not fit are + * the ones doing several times what any measured human does on their first + * day. + * + * `core` is roughly 1.6x `established` rather than unbounded. It is a reward, + * not an exemption — an account that reaches `core` and is then compromised or + * sold should still cost a bounded amount, and the fleet-wide spend has to + * survive every core member using their allowance on the same day. + * + * ## The limited row is not merely the full row scaled down + * + * Its floor is deliberately harsher (`new` × limited is a third of `new` × + * full) because that intersection — brand-new account, unsupported region, + * often VPN — is the exact shape of the reselling farms. Its ceiling is + * deliberately generous (`core` × limited beats `verified` × full on every + * axis it can — premium is region-gated, not level-gated) because the entire + * promise this makes to a real developer in an unsupported country is that the + * region is a starting point and not a cage. + */ +export const FREEBUFF_TRUST_LIMITS: Record< + FreebuffAccessTier, + Record +> = { + full: { + new: { + userMessagesPerDay: 120, + messagesPer5Hours: 800, + messagesPerDay: 1_200, + dailySpendUsd: 8, + premiumSessionsPerDay: 2, + }, + verified: { + userMessagesPerDay: 300, + messagesPer5Hours: 1_800, + messagesPerDay: 3_000, + dailySpendUsd: 20, + premiumSessionsPerDay: 3, + }, + established: { + userMessagesPerDay: 600, + messagesPer5Hours: 3_000, + messagesPerDay: 5_000, + dailySpendUsd: 50, + premiumSessionsPerDay: 4, + }, + core: { + userMessagesPerDay: 1_000, + messagesPer5Hours: 5_000, + messagesPerDay: 8_000, + dailySpendUsd: 90, + premiumSessionsPerDay: 5, + }, + }, + limited: { + new: { + userMessagesPerDay: 40, + messagesPer5Hours: 400, + messagesPerDay: 500, + dailySpendUsd: 3, + premiumSessionsPerDay: 0, + }, + verified: { + userMessagesPerDay: 120, + messagesPer5Hours: 1_000, + messagesPerDay: 1_500, + dailySpendUsd: 10, + premiumSessionsPerDay: 0, + }, + established: { + userMessagesPerDay: 350, + messagesPer5Hours: 2_000, + messagesPerDay: 3_000, + dailySpendUsd: 25, + premiumSessionsPerDay: 0, + }, + core: { + userMessagesPerDay: 700, + messagesPer5Hours: 3_500, + messagesPerDay: 5_500, + dailySpendUsd: 55, + premiumSessionsPerDay: 0, + }, + }, +} + +export function freebuffTrustLimits( + accessTier: FreebuffAccessTier, + level: FreebuffTrustLevel, +): FreebuffTrustLimits { + return FREEBUFF_TRUST_LIMITS[accessTier][level] +} + +// --------------------------------------------------------------------------- +// Signals +// --------------------------------------------------------------------------- + +/** + * Everything the score is computed from. + * + * **`null` means "unknown", never "clean" and never "suspicious".** Every + * account created before `docs/freebuff-signup-gate.md` shipped has null + * provenance, and every account that never linked GitHub has null GitHub + * facts. A scorer that read null as bad would demote most of the existing user + * base overnight; one that read it as good would hand every fresh signup a + * clean slate. Unknown earns nothing and costs nothing — which lands those + * accounts at whatever their other, positive signals justify. + */ +export interface FreebuffTrustSignals { + /** `user.created_at`. */ + accountCreatedAt: Date | null + /** `referral_qualification.github_account_created_at` — set by GitHub + * server-side and not backdatable, which is what makes it worth points at + * all. A commit author date, by contrast, forges in one command. */ + githubAccountCreatedAt: Date | null + /** Oldest public repo's creation date. Same non-forgeability. */ + githubOldestRepoCreatedAt: Date | null + githubPublicRepos: number | null + githubFollowers: number | null + githubTwoFactorEnabled: boolean | null + /** Distinct Pacific days the account has used free mode + * (`freebuff_daily_usage`). Cheap history that cannot be bought. */ + activeDays: number + /** Approved bounty submissions. Reviewed proof-of-work — the single + * strongest earned signal here, and the one a limited-region user can act + * on today without owning an aged GitHub account. */ + approvedBounties: number + /** Qualified referrals GIVEN (`referral_v2`, active + qualified). */ + qualifiedReferrals: number + /** Has ever paid us anything. Wired now and worth points now; payments are + * planned, and the day they land this needs no scorer change. */ + hasPaid: boolean + /** Privacy signals recorded at SIGNUP (`user.signup_privacy_signals`), split + * on comma. Empty array = checked and clean; null = never checked. */ + signupPrivacySignals: readonly string[] | null + /** `user.signup_ip_source`. Anything other than `edge_secret`/`cloudflare` + * means the caller had some influence over the address. */ + signupIpSource: string | null + /** Accounts sharing this account's signup /24 or /48. Includes this account, + * so 1 is the clean value. */ + signupPrefixAccountCount: number | null + /** Accounts sharing this account's normalized mailbox. Includes this + * account. */ + mailboxAccountCount: number | null + /** A ban event that was NOT reversed. Live bans never reach here (banned + * accounts are refused before any of this runs), so this is history: an + * account that was actioned and then unbanned on appeal. */ + hasUnreversedBanEvent: boolean + /** `user.privacy_flagged_at` — first request ever seen on an ipinfo-flagged + * anonymizing egress, uncorroborated. Sticky by construction: written once, + * never cleared by code. The WEAK member of the sticky trio, so it carries + * the mildest cap below. */ + privacyFlaggedAt: Date | null + /** `user.privacy_corroborated_at` — first request where a second provider + * agreed the egress was anonymizing. Sticky. */ + privacyCorroboratedAt: Date | null + /** `user.third_party_client_at` — first free-mode request carrying a tool + * schema no Freebuff client ships. Sticky, and behavioural rather than + * network-derived, which is what makes it worth a hard cap. */ + thirdPartyClientAt: Date | null + /** + * The privacy verdict on the CURRENT request, from `getFreeModeRiskScore`. + * The one live signal in an otherwise durable set, and the one a user can + * change in a second by toggling a VPN — which is why it can only CAP a + * level, never contribute points. + */ + currentRiskScore: number | null +} + +/** A signal that moved the score, in user-facing language. */ +export interface FreebuffTrustFactor { + id: string + label: string + points: number +} + +/** Something the user can do to move up, with what it is worth. */ +export interface FreebuffTrustNextStep { + id: string + label: string + detail: string + points: number + /** Where the UI should send them. Relative to the freebuff web app. */ + href?: string +} + +export interface FreebuffTrustAssessment { + level: FreebuffTrustLevel + /** 0-100. Exposed so a user can see movement between levels, and so the + * thresholds below are auditable from the outside. */ + score: number + /** The level the score alone earned, before caps. Equal to `level` unless a + * cap applied — which is how the UI knows to explain the cap rather than + * telling someone with 80 points to keep earning points. */ + uncappedLevel: FreebuffTrustLevel + /** Which cap bound, if any. */ + cappedBy: string | null + factors: FreebuffTrustFactor[] + nextSteps: FreebuffTrustNextStep[] +} + +// --------------------------------------------------------------------------- +// Scoring +// --------------------------------------------------------------------------- + +/** + * The two signals a user can act on TODAY, from any country, with no aged + * GitHub account and no money. + * + * ## Why the caps are where they are + * + * They were originally 4 bounties (20) and 5 referrals (15). Together that is + * 35 points against a `core` threshold of 75, and the whole earned-only route + * — bounties, referrals, 90 days of account age, 30 active days, a clean + * residential signup — topped out at **70**. Five points short. `core` was + * literally unreachable by contribution: it required either an aged GitHub + * account or a payment. + * + * That is backwards for a program whose stated purpose is to give developers + * in unsupported regions a way to raise their own limits. It also made the + * incentive flat exactly where it should be steep — a user who completed ten + * bounties and referred twenty people scored the same as one who did four and + * five. + * + * At 6 and 10 the earned-only route reaches 95, so `core` is attainable by + * work alone, and each additional bounty or referral keeps paying well past + * the point where someone has proved they are real. + * + * ## Why raising them costs nothing against abuse + * + * Neither is cheap to manufacture. A bounty is reviewed proof-of-work, daily + * capped, and carries an anti-fraud agreement the claimant signs + * (docs/freebuff-bounties.md). A qualified referral requires the REFERRED + * account to hold a GitHub account four calendar months old and to actually + * use the product, and referral farming has its own detector and clawback path + * (docs/freebuff-abuse-referral-farming.md). An operator who can produce ten + * qualified referrals has already cleared a higher bar than this scorer sets. + */ +export const FREEBUFF_TRUST_EARNED = { + BOUNTY_POINTS: 5, + BOUNTY_CAP: 6, + REFERRAL_POINTS: 3, + REFERRAL_CAP: 10, +} as const + +const MAX_BOUNTY_POINTS = + FREEBUFF_TRUST_EARNED.BOUNTY_POINTS * FREEBUFF_TRUST_EARNED.BOUNTY_CAP +const MAX_REFERRAL_POINTS = + FREEBUFF_TRUST_EARNED.REFERRAL_POINTS * FREEBUFF_TRUST_EARNED.REFERRAL_CAP + +/** Minimum score for each level. `new` is the floor and needs no entry. */ +export const FREEBUFF_TRUST_THRESHOLDS: Record< + Exclude, + number +> = { + verified: 25, + established: 50, + core: 75, +} + +const DAY_MS = 24 * 60 * 60 * 1000 +const MONTH_MS = 30 * DAY_MS +const YEAR_MS = 365 * DAY_MS + +function ageMs(date: Date | null, now: Date): number | null { + if (!date) return null + const age = now.getTime() - date.getTime() + // A future date is a clock skew or a bad backfill, not an old account. + return age >= 0 ? age : 0 +} + +/** Highest threshold `score` clears. */ +function levelForScore(score: number): FreebuffTrustLevel { + if (score >= FREEBUFF_TRUST_THRESHOLDS.core) return 'core' + if (score >= FREEBUFF_TRUST_THRESHOLDS.established) return 'established' + if (score >= FREEBUFF_TRUST_THRESHOLDS.verified) return 'verified' + return 'new' +} + +const PRIVACY_EGRESS_SIGNALS = new Set(['vpn', 'proxy', 'tor', 'hosting']) + +function hasPrivacyEgressAtSignup( + signals: readonly string[] | null, +): boolean | null { + if (signals === null) return null + return signals.some((signal) => + PRIVACY_EGRESS_SIGNALS.has(signal.trim().toLowerCase()), + ) +} + +/** + * Score an account and resolve its level. + * + * Pure, so the policy is unit-testable and so the same function can run on the + * server (to enforce) and be reasoned about from a test (to check nobody moved + * a threshold by accident). Every I/O concern lives in the caller. + * + * ## Points earn, penalties cap + * + * Positive signals add points. Negative signals mostly do NOT subtract — they + * impose a CEILING on the resulting level. The difference matters: a + * subtracting penalty is defeated by accumulating enough of anything else, + * which is precisely what a farm operator with 200 aged GitHub accounts can + * do. A ceiling is not, and it also degrades honestly — a VPN user who cannot + * exceed `established` still gets `established`, which is today's limits. + */ +export function assessFreebuffTrust( + signals: FreebuffTrustSignals, + now: Date = new Date(), +): FreebuffTrustAssessment { + const factors: FreebuffTrustFactor[] = [] + const nextSteps: FreebuffTrustNextStep[] = [] + + const add = (id: string, label: string, points: number) => { + if (points === 0) return + factors.push({ id, label, points }) + } + const step = (s: FreebuffTrustNextStep) => nextSteps.push(s) + + // --- GitHub ------------------------------------------------------------- + // The heaviest block (up to 45) because it is the only one an abuser has to + // BUY. `docs/referrals.md` sets the economic invariant this inherits: keep + // the reward worth less than the grey-market price of an aged, qualifying + // GitHub account, and farming stops penciling out. + const githubAge = ageMs(signals.githubAccountCreatedAt, now) + if (githubAge === null) { + step({ + id: 'connect_github', + label: 'Connect your GitHub account', + detail: + 'Linking a GitHub account you have had for a while is the fastest way to raise your limits. We read the account and oldest-repo creation dates, which GitHub sets and nobody can backdate.', + points: 30, + href: '/web/settings', + }) + } else { + add('github_linked', 'GitHub account connected', 10) + if (githubAge >= 3 * YEAR_MS) { + add('github_age', 'GitHub account over 3 years old', 20) + } else if (githubAge >= YEAR_MS) { + add('github_age', 'GitHub account over a year old', 15) + } else if (githubAge >= 6 * MONTH_MS) { + add('github_age', 'GitHub account over 6 months old', 10) + } else { + step({ + id: 'github_age', + label: 'Your GitHub account is still new', + detail: + 'Account age is worth up to 20 points and grows on its own — nothing to do here but keep the same account connected.', + points: 10, + }) + } + + const repoAge = ageMs(signals.githubOldestRepoCreatedAt, now) + if (repoAge !== null && repoAge >= 6 * MONTH_MS) { + add('github_repo', 'Public repo over 6 months old', 10) + } + if ((signals.githubPublicRepos ?? 0) >= 3) { + add('github_repos', '3 or more public repos', 5) + } + if ((signals.githubFollowers ?? 0) >= 5) { + add('github_followers', '5 or more GitHub followers', 5) + } + if (signals.githubTwoFactorEnabled) { + add('github_2fa', 'Two-factor auth enabled on GitHub', 5) + } else if (signals.githubTwoFactorEnabled === false) { + step({ + id: 'github_2fa', + label: 'Turn on two-factor auth for GitHub', + detail: + 'Worth 5 points, and it protects the account your Freebuff limits now depend on.', + points: 5, + href: 'https://github.com/settings/security', + }) + } + } + + // --- Account history ---------------------------------------------------- + // Time and use, which cost an operator real calendar days per account and + // are the only signals a user gets for free by simply being real. + const accountAge = ageMs(signals.accountCreatedAt, now) + if (accountAge !== null) { + if (accountAge >= 90 * DAY_MS) { + add('account_age', 'Freebuff account over 90 days old', 15) + } else if (accountAge >= 30 * DAY_MS) { + add('account_age', 'Freebuff account over 30 days old', 10) + } else if (accountAge >= 7 * DAY_MS) { + add('account_age', 'Freebuff account over 7 days old', 5) + } + } + + if (signals.activeDays >= 30) { + add('active_days', 'Used Freebuff on 30+ days', 10) + } else if (signals.activeDays >= 7) { + add('active_days', 'Used Freebuff on 7+ days', 5) + } + + // --- Earned ------------------------------------------------------------- + // The routes that work from anywhere, on any account age. This is the answer + // to "I am in an unsupported region and my account is new": both of these + // are available today and together are worth 35 points, which is a level and + // a half. + const bountyPoints = + Math.min(signals.approvedBounties, FREEBUFF_TRUST_EARNED.BOUNTY_CAP) * + FREEBUFF_TRUST_EARNED.BOUNTY_POINTS + if (bountyPoints > 0) { + add( + 'bounties', + `${signals.approvedBounties} approved ${signals.approvedBounties === 1 ? 'bounty' : 'bounties'}`, + bountyPoints, + ) + } + if (bountyPoints < MAX_BOUNTY_POINTS) { + step({ + id: 'bounties', + label: 'Complete a bounty', + detail: `Approved bounties are worth ${FREEBUFF_TRUST_EARNED.BOUNTY_POINTS} points each, up to ${MAX_BOUNTY_POINTS}. They are reviewed, they work from any country, and they pay session grants on top.`, + points: MAX_BOUNTY_POINTS - bountyPoints, + href: '/web/earn', + }) + } + + const referralPoints = + Math.min(signals.qualifiedReferrals, FREEBUFF_TRUST_EARNED.REFERRAL_CAP) * + FREEBUFF_TRUST_EARNED.REFERRAL_POINTS + if (referralPoints > 0) { + add( + 'referrals', + `${signals.qualifiedReferrals} qualified ${signals.qualifiedReferrals === 1 ? 'referral' : 'referrals'}`, + referralPoints, + ) + } + if (referralPoints < MAX_REFERRAL_POINTS) { + step({ + id: 'referrals', + label: 'Invite other developers', + detail: `Each friend who signs up with a real GitHub account and uses Freebuff is worth ${FREEBUFF_TRUST_EARNED.REFERRAL_POINTS} points, up to ${MAX_REFERRAL_POINTS} — plus the referral rewards themselves.`, + points: MAX_REFERRAL_POINTS - referralPoints, + href: '/web/earn', + }) + } + + if (signals.hasPaid) { + add('paid', 'Supported Freebuff with a purchase', 25) + } + + // --- Provenance --------------------------------------------------------- + // Small positives only. These cannot earn a level on their own; their job is + // to let a clean, ordinary signup reach `verified` without owning anything. + const signupPrivacy = hasPrivacyEgressAtSignup(signals.signupPrivacySignals) + if (signupPrivacy === false) { + add('clean_signup', 'Signed up from a residential connection', 5) + } + if ( + signals.signupIpSource === 'edge_secret' || + signals.signupIpSource === 'cloudflare' + ) { + add('verified_signup_ip', 'Verified network at signup', 5) + } + + const score = Math.max( + 0, + Math.min( + 100, + factors.reduce((sum, factor) => sum + factor.points, 0), + ), + ) + const uncappedLevel = levelForScore(score) + + // --- Caps --------------------------------------------------------------- + // Applied after scoring, lowest wins. Each one names itself so the UI can + // explain a cap instead of telling a user with a high score to earn more. + let level = uncappedLevel + let cappedBy: string | null = null + const cap = (limit: FreebuffTrustLevel, reason: string) => { + const capped = lowerOf(level, limit) + if (capped !== level) { + level = capped + cappedBy = reason + } + } + + // A reversed ban is invisible here by construction — only unreversed events + // reach this field — so this is an account we actioned and did not take + // back. Not a ban (they are already unbanned) and not permanent, but not + // something to hand extra capacity to either. + if (signals.hasUnreversedBanEvent) { + cap('verified', 'past_enforcement') + } + + // Signed up behind a VPN/proxy/Tor/hosting egress. Capped, not zeroed: this + // describes a lot of privacy-conscious developers as well as every farm, and + // `established` is what everyone had before this file existed. + if (signupPrivacy === true) { + cap('established', 'signup_privacy_egress') + } + + // The live request is on an anonymizing network. Deliberately the only cap + // driven by a per-request signal, and deliberately the harshest, because it + // is the one an abuser toggles: without it, a farm signs up cleanly once and + // then runs everything through a proxy pool at core-member limits. + if (signals.currentRiskScore !== null && signals.currentRiskScore >= 75) { + cap('verified', 'anonymous_network') + } + + // The sticky flags: things this account has DONE, remembered past the + // request that revealed them. Without these, every network cap above is + // defeated by toggling the VPN off for a day — the exact wash-trading of + // signals the caps exist to prevent. They cap rather than subtract for the + // standard reason (see "Points earn, penalties cap"), and they grade by + // evidence weight: + // + // corroborated egress -> verified two providers agreed + // foreign tool schema -> verified behavioural, not network luck + // ipinfo-only egress -> established one provider, the weak signal -- + // `established` is what every + // account had before trust levels + // existed, so this cap forfeits + // only the `core` upside + // + // The 2026-08-03 mass-reversal is the reason none of these goes lower: + // network-derived evidence has wrongly actioned real users before, and a + // permanent flag with a harsh cap would make that mistake permanent too. + if (signals.privacyCorroboratedAt !== null) { + cap('verified', 'past_corroborated_egress') + } + if (signals.thirdPartyClientAt !== null) { + cap('verified', 'third_party_client') + } + if (signals.privacyFlaggedAt !== null) { + cap('established', 'past_privacy_egress') + } + + // Signup networks and mailboxes that many accounts share. `?? 1` matters: + // null is unknown (pre-provenance accounts), and unknown must not cap. + if ((signals.signupPrefixAccountCount ?? 1) >= 8) { + cap('established', 'shared_signup_network') + } + if ((signals.mailboxAccountCount ?? 1) >= 3) { + cap('verified', 'shared_mailbox') + } + + // Steps are ordered by what they are worth, EXCEPT that a binding cap goes + // first regardless. A capped account told to "complete a bounty for 20 + // points" when points are not what binds them is being sent on an errand, so + // the cap is prepended after the sort rather than competing in it — it + // carries no points and would otherwise sink to the bottom. + const earnedSteps = nextSteps.sort((a, b) => b.points - a.points) + const actionableSteps = + cappedBy === null + ? earnedSteps + : [ + { + id: `cap_${cappedBy}`, + label: CAP_REMEDIES[cappedBy]?.label ?? 'Your level is limited', + detail: + CAP_REMEDIES[cappedBy]?.detail ?? + 'Something about this account limits how high your level can go.', + points: 0, + }, + ...earnedSteps, + ] + + return { + level, + score, + uncappedLevel, + cappedBy, + factors: factors.sort((a, b) => b.points - a.points), + nextSteps: actionableSteps, + } +} + +/** + * User-facing explanation of each cap. + * + * Two of these describe something the user can fix in under a minute (turn the + * VPN off, use a different network) and are written to say exactly that. The + * other two describe history, and say so honestly rather than implying an + * action that does not exist — a "next step" a user cannot take is worse than + * no step at all. + */ +const CAP_REMEDIES: Record = { + past_corroborated_egress: { + label: 'This account has used an anonymizing network', + detail: + 'Requests from this account were confirmed to come through a VPN, proxy or similar exit. That history caps this account at Verified. Everything else still counts toward your level.', + }, + past_privacy_egress: { + label: 'This account has connected over a flagged network', + detail: + 'A connection from this account looked like an anonymizing network. That caps this account at Established. If this seems wrong — some office and university networks are misread — contact support.', + }, + third_party_client: { + label: 'A non-Freebuff client has used this account', + detail: + 'Requests from this account carried a client we do not ship. That caps this account at Verified. Only official Freebuff apps are supported on free mode.', + }, + anonymous_network: { + label: 'Turn off your VPN or proxy', + detail: + 'We cannot tell where requests from a VPN, proxy or Tor exit node come from, so those connections are capped at Verified no matter how much you have earned. Reconnect from your normal network and your level updates within a few minutes.', + }, + signup_privacy_egress: { + label: 'You signed up over a VPN or proxy', + detail: + 'That caps this account at Established. Everything else still counts, and the cap applies to this account only — it is not a strike against you.', + }, + shared_signup_network: { + label: 'Many accounts signed up from your network', + detail: + 'Shared offices, campuses and carrier NATs all look like this, so it caps rather than blocks. Approved bounties and referrals still raise your limits within the cap.', + }, + shared_mailbox: { + label: 'Several accounts share your email address', + detail: + 'Address variations that reach one inbox (dots, or anything after a +) count as one mailbox. Using a single account raises your level.', + }, + past_enforcement: { + label: 'This account was actioned in the past', + detail: + 'Your access is fully restored, but the level is capped at Verified. Contact support if you think that is wrong.', + }, +} + +// --------------------------------------------------------------------------- +// Wire shape +// --------------------------------------------------------------------------- + +/** + * What a client is told about its own standing. + * + * Carries the resolved LIMITS as well as the level, because a client that had + * to map level → limits itself would hold a second copy of the matrix above + * and drift from it on the first tuning pass. The server owns the numbers; the + * client renders whatever it is sent. + */ +/** + * What a level means, in words. + * + * ## Why the numbers do not leave the server + * + * Three reasons, and the first is the one that matters most: + * + * 1. **A published limit is a published target.** `docs/freebuff-abuse- + * detection.md` records that the abuse pattern here is not bursting, it is + * "sustained pacing just under the daily caps" — so telling an operator + * exactly where the cap sits is telling them exactly how to sit under it. + * Every threshold in this file is a number we would rather they had to + * discover. + * 2. **The numbers are ours, not theirs.** `dailySpendUsd` in particular is + * our provider cost, and a user shown "$25/day" learns something about our + * margins and nothing about what they may do. + * 3. **Exact figures invite exactly the wrong conversation.** The first + * version showed them and produced people comparing screenshots and asking + * whether a smaller number meant they had been punished. A limit is meant + * to answer "can I get my work done", and that question has a qualitative + * answer. + * + * So `FreebuffStandingInfo` carries these phrases and NOT `FreebuffTrustLimits` + * — the raw matrix never crosses the wire, which means no client can render it + * by accident and no future surface has to remember not to. + * + * Where a user genuinely needs a count, they already have an exact one: the + * model picker renders "N of M sessions used" from the live quota snapshot, + * which is authoritative and per-model. Duplicating it here could only + * disagree with it. + */ +export interface FreebuffStandingHighlight { + label: string + value: string +} + +const LIMIT_PHRASES: Record< + FreebuffTrustLevel, + { prompts: string; depth: string; premium: string } +> = { + new: { + prompts: 'Enough to get a project started', + depth: 'Focused, shorter agent runs', + premium: 'Occasional access', + }, + verified: { + prompts: 'Comfortable for everyday work', + depth: 'Full agent runs', + premium: 'Regular access', + }, + established: { + prompts: 'Comfortable on heavy days', + depth: 'Long runs with plenty of subagents', + premium: 'Generous access', + }, + core: { + prompts: 'The most we offer', + depth: 'The most we offer', + premium: 'The most we offer', + }, +} + +export function freebuffStandingHighlights( + accessTier: FreebuffAccessTier, + level: FreebuffTrustLevel, +): FreebuffStandingHighlight[] { + const phrases = LIMIT_PHRASES[level] + return [ + { label: 'Prompts a day', value: phrases.prompts }, + { label: 'Work per prompt', value: phrases.depth }, + { + label: 'Premium models', + // Stated as a region fact rather than as something this account lacks: + // no level in the limited row can reach a premium model, so framing it + // as a level shortfall would send the user chasing points that cannot + // buy it. + value: + freebuffTrustLimits(accessTier, level).premiumSessionsPerDay > 0 + ? phrases.premium + : 'Not available in your region yet', + }, + ] +} + +/** + * NOTE FOR CALLERS: `highlights` is what the level WOULD grant, which is only + * what the account actually gets once `FREEBUFF_TRUST_LEVELS=enforce`. Both + * producers gate on that (the Earn route and the session `standing` field), so + * a client that receives this can render it as fact. A third producer must do + * the same — see the comment in freebuff/web/src/app/api/web/standing/route.ts + * for what happens otherwise. + */ +export interface FreebuffStandingInfo { + level: FreebuffTrustLevel + label: string + blurb: string + score: number + /** Score at which the next level starts, or null at `core`. */ + nextLevelAt: number | null + nextLevel: FreebuffTrustLevel | null + cappedBy: string | null + cappedReason: string | null + factors: FreebuffTrustFactor[] + nextSteps: FreebuffTrustNextStep[] + accessTier: FreebuffAccessTier + /** Semantic, never numeric — see FreebuffStandingHighlight. */ + highlights: FreebuffStandingHighlight[] +} + +export function toFreebuffStandingInfo( + assessment: FreebuffTrustAssessment, + accessTier: FreebuffAccessTier, +): FreebuffStandingInfo { + const index = FREEBUFF_TRUST_LEVELS.indexOf(assessment.level) + const nextLevel = FREEBUFF_TRUST_LEVELS[index + 1] ?? null + return { + level: assessment.level, + label: FREEBUFF_TRUST_LEVEL_LABELS[assessment.level], + blurb: FREEBUFF_TRUST_LEVEL_BLURBS[assessment.level], + score: assessment.score, + nextLevel, + nextLevelAt: + nextLevel && nextLevel !== 'new' + ? FREEBUFF_TRUST_THRESHOLDS[nextLevel] + : null, + cappedBy: assessment.cappedBy, + cappedReason: assessment.cappedBy + ? (CAP_REMEDIES[assessment.cappedBy]?.detail ?? null) + : null, + factors: assessment.factors, + nextSteps: assessment.nextSteps, + accessTier, + highlights: freebuffStandingHighlights(accessTier, assessment.level), + } +} diff --git a/common/src/constants/provider-routes.ts b/common/src/constants/provider-routes.ts new file mode 100644 index 0000000000..4f3a0bde3f --- /dev/null +++ b/common/src/constants/provider-routes.ts @@ -0,0 +1,730 @@ +export const PROVIDER_ROUTE_IDS = [ + 'fireworks/deployment', + 'fireworks/serverless', + 'minimax/official', + 'xiaomi/official', + 'openrouter/novita/fp8', + 'mimo/openrouter', + 'infron/makora', + 'glm/crof', + 'glm/infron', + 'glm-5-3-flash/crof', + 'glm-5-3-flash/fallback', + 'deepseek/openrouter', + 'deepseek/crof', + 'deepseek/cheaper-inference', + 'deepseek/luminal', + 'deepseek/fusioncode', + 'deepseek/runinfra', + 'deepseek/official', + 'luna/fallback', + 'luna/primary', +] as const + +export type ProviderRouteId = (typeof PROVIDER_ROUTE_IDS)[number] + +/** + * A GPT-5.6 Luna session that diverted off its primary lane because that lane + * was OUT OF CAPACITY, and should now enter on the fallback instead. + * + * Named for the ROLE, not the upstream, and deliberately so: this value is + * persisted in `free_session.provider_route` and read back unvalidated, so an + * id naming its provider either forces a migration or becomes a name that lies + * the moment the fallback moves. {@link MIMO_OPENROUTER_PROVIDER_ROUTE} carries + * the same warning for the same reason. {@link LUNA_FALLBACK_UPSTREAM} below + * says who currently serves it. + * + * Why pin at all: Luna's lanes each keep their OWN prompt cache, and an agent + * turn re-sends its whole prefix every step, so ~96.5% of its tokens are cache + * reads. Moving a live session between providers therefore costs a full cold + * prefill on a prefix it has already paid to warm. Pinning moves SESSIONS, not + * requests: the session that overflowed stays on the fallback and warms there + * once, instead of re-paying at every turn. + */ +/** + * A Flash session GRANTED the FusionCode front lane by the admission + * controller — the same shape as the Luminal grant, and granted only when + * Luminal declined, since Luminal is ~45% cheaper. Like Luminal's pin this is + * a grant, not a cohort mark: its own env switch is the drain. + */ +export const DEEPSEEK_FUSIONCODE_PROVIDER_ROUTE = + 'deepseek/fusioncode' satisfies ProviderRouteId + +export const LUNA_FALLBACK_PROVIDER_ROUTE = + 'luna/fallback' satisfies ProviderRouteId + +/** + * A Luna session explicitly returned to the primary lane after the fallback + * failed it. + * + * Distinct from UNPINNED, which also enters on the primary: an unpinned session + * simply never diverted, while this one diverted and came back. Keeping them + * apart is what lets the fallback rate be read as "sessions that overflowed" + * rather than "sessions that overflowed and stayed overflowed". + */ +export const LUNA_PRIMARY_PROVIDER_ROUTE = + 'luna/primary' satisfies ProviderRouteId + +/** + * Who serves {@link LUNA_FALLBACK_PROVIDER_ROUTE} today. Repointing this moves + * every session already pinned there, with no migration — which is the whole + * reason the id above does not name a provider. + */ +export const LUNA_FALLBACK_UPSTREAM = 'cheaper-inference' as const + +export const FIREWORKS_DEPLOYMENT_PROVIDER_ROUTE = + 'fireworks/deployment' satisfies ProviderRouteId +export const FIREWORKS_SERVERLESS_PROVIDER_ROUTE = + 'fireworks/serverless' satisfies ProviderRouteId +export const MINIMAX_OFFICIAL_PROVIDER_ROUTE = + 'minimax/official' satisfies ProviderRouteId +/** + * MiMo 2.5's OpenRouter lane — the ENTRY lane since 2026-08-23, when Xiaomi's + * direct rate limit made it untenable as the primary (see mimo-router.ts for + * the measured 429 rate that settled it). + * + * THE PIN IS NOW INERT, exactly as {@link GLM_CROF_PROVIDER_ROUTE} became when + * its lane was promoted: it names the lane a session would enter anyway. It is + * still recognized on READ, and must stay so, because ~48k sessions a day were + * pinned here under the old order and their pins outlive the deploy. Reading it + * as "start at the primary" is exactly right for them — they are already warm + * on this lane, so they carry on with no cold prefill. + * + * Like {@link DEEPSEEK_INFRON_MAKORA_PROVIDER_ROUTE} it says *which lane*, NOT + * which upstream serves it — that is {@link MIMO_OPENROUTER_UPSTREAM_ORDER} + * below, so repointing the upstream moves every session here with no migration. + * + * Named generically on purpose. Its predecessor + * {@link MIMO_NOVITA_PROVIDER_ROUTE} baked the upstream into a value that gets + * persisted in `free_session.provider_route` and read back unvalidated, so + * changing upstreams meant either a migration or a name that lies. + */ +export const MIMO_OPENROUTER_PROVIDER_ROUTE = + 'mimo/openrouter' satisfies ProviderRouteId +/** + * Marks a MiMo session as diverted OFF the OpenRouter lane onto Xiaomi's direct + * API — the backup since the 2026-08-23 swap, and the direction this pin has + * pointed only since then. + * + * It exists for the reason {@link GLM_CROF_PROVIDER_ROUTE} does: depth bought + * not with money but with a SECOND ACCOUNT AND A SECOND BALANCE. Both MiMo + * lanes ran dry inside 14 hours (Xiaomi 2026-08-22 17:30Z, OpenRouter + * 2026-08-23 07:35Z), which is the whole argument for keeping two funded + * accounts and a 402 that can cross between them. + * + * Reuses the long-declared but never-written `xiaomi/official` id, so no + * `PROVIDER_ROUTE_IDS` entry had to be added. + */ +export const MIMO_XIAOMI_PROVIDER_ROUTE = + 'xiaomi/official' satisfies ProviderRouteId +/** + * The upstreams that serve {@link MIMO_OPENROUTER_PROVIDER_ROUTE}, preferred + * first: Xiaomi's OWN endpoint, reached through OpenRouter's account rather + * than our direct API key, with Novita behind it purely as depth. + * + * It lives next to the route id because the two are meant to be read together — + * the id says which lane a session is pinned to, this says who serves it — and + * in `common/` rather than in mimo-router.ts so `scripts/mimo-smoke.ts` can + * assert against the REAL value without importing the billing chain. A smoke + * test that restates this config would keep passing after the upstream moved, + * reporting a lane it no longer covers. + * + * Novita served the lane until 2026-08-01, which was most of what made the + * fallback expensive. Measured over 6h of prod that day, attributing rows by + * whether their cost reproduces our Xiaomi formula: + * + * openrouter/novita 18,268 reqs (42.6%) 19.2% cache $364.11 $0.138/M in + * xiaomi direct 24,631 reqs (57.4%) 90.5% cache $63.76 $0.018/M in + * + * Novita is also 20% dearer per token before caching ($0.168/$0.336 against + * Xiaomi's $0.14/$0.28) and prices cache reads at $0.0034/M against $0.0028/M. + * Xiaomi's OpenRouter endpoint is priced identically to our direct rate, so the + * lane costs the same as the primary and differs only in whose rate limit and + * prompt cache it draws on — which is, since 2026-08-23, the entire reason this + * lane is the ENTRY rather than the backup. + */ +export const MIMO_OPENROUTER_UPSTREAM_ORDER = [ + 'xiaomi/fp8', + 'novita/fp8', +] as const + +/** + * Fresh OpenRouter `provider` block for the MiMo lane. + * + * TWO ENTRIES DELIBERATELY, and it must never go back to one. A pinned session + * has no health check and no un-pin path — `routeWithStickyFallback` short + * -circuits straight to the fallback — so if the lane's only upstream is down, + * one transient Xiaomi blip wedges every remaining request in that session + * rather than degrading a single one. That is not hypothetical: a one-deep pin + * on DeepSeek's Infron lane took out 1,160 requests across 191 users for ~32h + * when `makora` went offline (2026-07-26/27, fixed in #1045). + * + * Verified live on this lane 2026-08-01: within an `allow_fallbacks:false` + * order list, an unroutable first entry is SKIPPED rather than failing the + * request — `order:['nosuchprovider','xiaomi/fp8']` returned 200 from Xiaomi — + * while `order:['xiaomi/fp8','novita/fp8']` still serves from Xiaomi when it is + * healthy. So the depth costs nothing in the normal case and is the difference + * between a degraded session and a wedged one in the bad case. Novita is second + * because it is the best-understood host for this model: it served ~43% of MiMo + * traffic through late July, so its compatibility with our request shape is + * proven, and it only ever serves when Xiaomi cannot. + * + * Returns a NEW object with a NEW array every call. These arrays get aliased + * into an outgoing request body, and this file's peer + * `INFRON_PROVIDER_ORDER` was made copy-on-assignment in #1045 for exactly that + * reason — one downstream mutation would corrupt routing process-wide. + */ +export function mimoOpenRouterProvider(): Record { + return { + order: [...MIMO_OPENROUTER_UPSTREAM_ORDER], + allow_fallbacks: false, + } +} +/** + * LEGACY MiMo fallback pin, written while the OpenRouter lane was hardcoded to + * Novita FP8. Still recognized on READ so sessions pinned before + * {@link MIMO_OPENROUTER_PROVIDER_ROUTE} shipped keep serving from that lane + * instead of silently reverting to a Xiaomi-direct attempt they already failed. + * Never written anymore; expires with its session. Inert since the 2026-08-23 + * swap for the same reason its successor is — see there. + */ +export const MIMO_NOVITA_PROVIDER_ROUTE = + 'openrouter/novita/fp8' satisfies ProviderRouteId +/** + * GLM 5.2's CrofAI lane. Was the backup for one day (the 2026-08-20 Infron + * cutover); the ENTRY lane again since 2026-08-21, when production measurement + * showed Infron costs 2x — see GLM_INFRON_PROVIDER_ROUTE for the numbers. + * + * Still recognized on READ so the handful of sessions pinned here during that + * day keep working. The pin is now inert: it names the lane they would enter + * anyway. + * + * GLM 5.2 entered the day served by CrofAI alone. It now runs a two-lane sticky + * cascade — Infron's Alibaba group first, CrofAI behind it — so this id exists + * for the same reason {@link MIMO_OPENROUTER_PROVIDER_ROUTE} does: to record + * that a session left the entry lane and must not be sent back, because each + * upstream keeps its own prompt cache and flapping between them re-pays a cold + * prefill every turn. + * + * NOTE THE DIRECTION OF THE MONEY, because it is the reverse of every other + * cascade in this file: CrofAI — the BACKUP — is cheaper on all three terms, + * by 1.83x on input and output and 2.75x on cache reads. So this lane is not + * depth bought with money, it is depth bought with a SECOND ACCOUNT AND A + * SECOND BALANCE — the property {@link DEEPSEEK_RUNINFRA_PROVIDER_ROUTE} exists + * for, and the one CrofAI's own 401 "Not Enough Credits" proved GLM needed. The + * order was set by request; the measured table behind those ratios is in + * web/src/llm-api/glm-router.ts. + * + * Like its peers the id names the LANE, not the upstream — that is + * INFRON_PROVIDER_ORDER, keyed by model — so repointing which Alibaba region + * serves the entry lane needs no migration, while renaming this value would: + * it is persisted in `free_session.provider_route` and read back unvalidated. + */ +export const GLM_CROF_PROVIDER_ROUTE = 'glm/crof' satisfies ProviderRouteId +/** + * GLM 5.2's Infron lane — the BACKUP again as of 2026-08-21, after one hour of + * head-to-head production traffic settled the question the cutover opened. + * + * Both lanes served comparable work in the same window (147k vs 143k average + * input tokens, 95 vs 89 distinct users): + * + * msgs cache $/msg $/M input median $/prompt + * Infron 2,014 94.6% 0.024241 0.1640 0.1265 + * CrofAI 1,203 89.0% 0.012063 0.0842 0.0615 + * + * Infron really does cache BETTER — 94.6% against 89.0% — and is still 2.0x + * dearer per message and 2.06x per user prompt, because its cache reads cost + * 2.75x. The measurement matches the rate card to within a percent, so this is + * price, not noise. + * + * IT CANNOT BE FIXED BY WARMING. Solving h*0.1375 + (1-h)*0.55 = 0.0842 for + * Infron's break-even hit rate gives h = 1.129 — above 100%. At a perfect cache + * Infron would still cost $0.1375/M against CrofAI's measured $0.0842. The + * order can only flip if CrofAI falls below ~65% cache while Infron holds + * ~100%, so do not re-litigate this on a single bad CrofAI hour. + * + * What Infron is still here for is what it was always actually buying: a second + * account and a second prepaid balance behind a model whose entitlement is + * earned. CrofAI answers 401 "Not Enough Credits" when its balance runs dry, + * and that used to be a total outage. + */ +export const GLM_INFRON_PROVIDER_ROUTE = 'glm/infron' satisfies ProviderRouteId +/** + * A GLM 5.3 Flash session that diverted OFF the Merge Gateway lane and should + * now enter on the OpenRouter route it was served from before 2026-08-27. + * + * Named for the ROLE, not the upstream, for the reason spelled out on + * {@link LUNA_FALLBACK_UPSTREAM}: this value is persisted in + * `free_session.provider_route` and read back unvalidated, so an id naming its + * provider either forces a migration or becomes a name that lies the moment the + * fallback moves. + * + * WHY THERE IS A FALLBACK AT ALL, when Merge is cheaper on every term by 5-6x + * (table below): Merge bills a PREPAID BALANCE, and it reports the remaining + * one on every response as `x-credit-balance-usd`. A prepaid balance behind a + * single-lane model is the exact outage this repo has already taken twice — + * CrofAI's 401 "Not Enough Credits" made GLM 5.2 a total outage, which is why + * {@link GLM_INFRON_PROVIDER_ROUTE} exists, and the same shape took DeepSeek's + * cascade to three lanes. The balance on this account was $20 at integration, + * which is a trial, not a runway. + * + * THE PRICE TABLE, read from the gateway's own /v1/models on 2026-08-27 and + * confirmed against billed `cost` on live requests to the cent: + * + * input/M cache read/M output/M + * Merge Gateway $0.012 $0.003 $0.04 + * OpenRouter (cheap) $0.075 $0.015 $0.25 + * ratio 6.25x 5.0x 6.25x + * + * THE LINE THAT DECIDES IT: Merge's FRESH INPUT ($0.012/M) is below + * OpenRouter's CACHE READ ($0.015/M). So Merge at a 0% hit rate is still + * cheaper than OpenRouter at a perfect one, and no cache-rate regression can + * make this lane the expensive choice. That is the opposite of every previous + * cutover in this file — the CrofAI DeepSeek lane needed ~90% cache to break + * even and delivered 60-85%, and Infron above cannot break even at any hit rate + * — so the usual "compare lanes at their MEASURED hit rate or not at all" trap + * has nothing to bite on here. Measured anyway, and note WHICH number to quote: + * a tight loop on a byte-identical prefix gives 95.8%, but a growing 14-turn + * conversation — the shape an agent turn actually has — gives 57.0%, and that + * is the one the bill follows. Measured saving on that run: 5.00x. Even so, + * Merge at 0% cache cost less than OpenRouter would have at 100% (1.27x), which + * is the whole argument. + * + * The fallback is therefore bought with money, unlike Infron's: diverting costs + * ~6x. It is worth it only because the alternative is serving nothing. + */ +/** + * A GLM 5.3 Flash session that diverted off Merge Gateway onto CrofAI — the + * MIDDLE rung, added 2026-08-27. + * + * COST PARITY WITH THE LANE BEHIND IT, not an improvement on it, and the + * difference matters. Costed at an EQUAL 82.3% cache against the repo's + * reference agent turn, CrofAI looks 1.24x cheaper than the OpenRouter band. + * Measured at the rates the two lanes ACTUALLY deliver on identical work, + * CrofAI hit 74.2% and OpenRouter 85.6%, which puts them at $0.02548 and + * $0.02364 per M of input — OpenRouter 1.08x ahead. The card advantage + * reverses, so do not justify this rung on price. + * + * It is here for DEPTH: a second account and a second prepaid balance between + * Merge and OpenRouter, the same thing {@link GLM_INFRON_PROVIDER_ROUTE} buys + * for GLM 5.2 — and it is worth more than usual here because Merge's + * availability is poor. On the day this was added Merge's two vendors took + * turns failing (`zai` 14-of-20 errors in the morning, `particle` 0-of-24 by + * evening) and unpinned traffic did NOT route around the sick one, while CrofAI + * was 6/6 at every point one of them was not. CrofAI is also much faster: p50 + * 3.4s against OpenRouter's 14.4s on the same 14 turns. + * + * Merge's OTHER vendor is deliberately not a rung. `zai` is priced at exactly + * OpenRouter's cheap band, so it is dominated by both lanes below it. + * + * The lane that IS a large saving is the one in front: Merge at $0.015/M of + * fresh input beats both of these at their measured cache rates (1.58x under + * OpenRouter) even at a 0% hit rate, and 3-5x under it at any normal one. + */ +/** + * GLM 5.3 Flash's OpenRouter endpoint preference, healthiest first. + * + * ADDED 2026-08-28 IN RESPONSE TO USER REPORTS ("very unstable and stops + * sometimes"), which the telemetry bore out precisely. Over 24h of prod: + * + * stream-interrupt rate 0.78% the WORST of any model in the catalog — + * 4.6x MiMo and V4 Flash, 5.6x Luna + * provider failures 1,285 = 4.29% of all GLM 5.3 traffic + * of which GMICloud 1,170 91.1%, nearly all `Backend request failed + * with status 400 / backend_error` + * of which Z.AI 98 7.6%, Z.AI's OWN account: error 1113, + * "Insufficient balance or no resource + * package" — upstream of us, not our key + * + * The route previously carried a ceiling and nothing else, on the reasoning + * that "there is no endpoint worth PREFERRING — they are the same price". + * Production falsified the premise: the three endpoints under + * FREEBUFF_GLM_V53_FLASH_MAX_PRICE are the same price and are emphatically NOT + * the same reliability. OpenRouter itself had already deranked GMICloud to + * `status: -2` while continuing to send it most of our traffic. + * + * PREFERENCE, NOT EXCLUSION, and that distinction is the whole design. Only + * THREE endpoints sit under the ceiling (Z.AI, Novita, GMICloud); everything + * else on this model is exactly 2x. Dropping GMICloud with `ignore` would leave + * two — one of which is the Z.AI account that is already out of credit — and + * when every endpoint under a ceiling is unavailable OpenRouter returns 404 + * rather than serving above it. That is the Ox Alpha trap, and its documented + * fix is never to raise the number. So GMICloud stays reachable as a last + * resort and simply stops being the default. + * + * All three serve the same `fp8` quantization of the same dated build + * (`z-ai/glm-5.3-flash-20260826`), so this reorders reliability without + * touching output quality. + * + * A SECOND BENEFIT, since each endpoint keeps its own prompt cache: preferring + * one stops the route spraying across three of them. A measured 16-turn run on + * this lane was served by GMICloud x10, Novita x3 and Z.AI x1 and cached 85.4%; + * concentrating the traffic should raise that as well as steady it. + */ +export const GLM_V53_FLASH_OPENROUTER_UPSTREAM_ORDER = [ + // Healthy at status 0, and carries no failures in the 24h window above. + 'novita/fp8', + // Also status 0, but its own balance is dry — worth second place rather than + // first until that clears, and worth keeping ahead of the deranked one. + 'z-ai/fp8', + // Deliberately LAST rather than absent. See the Ox Alpha note above. + 'gmicloud/fp8', +] as const + +export const GLM_V53_FLASH_CROF_PROVIDER_ROUTE = + 'glm-5-3-flash/crof' satisfies ProviderRouteId +export const GLM_V53_FLASH_FALLBACK_PROVIDER_ROUTE = + 'glm-5-3-flash/fallback' satisfies ProviderRouteId +/** + * DeepSeek V4 Flash's CrofAI lane — and, since the 2026-08-15 cutover, the + * COHORT MARK that says a session belongs on it. + * + * This id now carries two meanings that deliberately coincide. Written at + * ADMISSION it means "this session was admitted after the cutover, so it enters + * the cascade on CrofAI". Written by the CASCADE it means "this session + * diverted onto CrofAI". Both want the same thing — enter on CrofAI — which is + * why pins written by the pre-cutover code needed no migration when the + * cutover shipped, and why nothing has to distinguish them on read. + * + * A session with NO pin is, by construction, one admitted before the cutover + * shipped: it runs the pre-cutover order and keeps the prompt cache it has + * already paid to warm. See `deepseekEntryLane` and + * docs/freebuff-deepseek-provider-cutover.md. + * + * Reference prices per M — CrofAI/Infron/OpenRouter from live billing + * 2026-08-04, RunInfra from runinfra.ai/pricing and Infron re-read from its + * catalog on 2026-08-16, DeepSeek from its published card after the 16:00 UTC + * 2026-08-16 repricing: + * + * input cache read output + * CrofAI 0731 0.1200 0.0030 0.2100 + * RunInfra 0731 0.1300 0.0100 0.2700 + * Infron alibaba 0.2120 0.0210 0.6360 + * DeepSeek off-peak 0.2200 0.0070 0.6600 + * DeepSeek peak 0.4400 0.0140 1.3200 + * (retired) OpenRouter 0.0881 0.0176 0.1761 + * + * A coding turn re-sends its whole prefix every step, so cache reads are most + * of the tokens and that is the term that decides the bill. Infron's own + * 2026-08-16 repricing (from 0.0690/0.0144/0.1375) turned it from the cheapest + * lane into the dearest, which is what put {@link + * DEEPSEEK_RUNINFRA_PROVIDER_ROUTE} ahead of it. + * + * The DeepSeek repricing also made CrofAI the cheapest lane outright rather + * than a near-tie: it is now cheaper than DeepSeek direct on every term, by + * 2.3x on cache reads off-peak and 4.7x at peak. The lane ORDER has not been + * revisited to match — see docs/freebuff-deepseek-provider-cutover.md. + * + * It also serves `deepseek-v4-flash-0731` — the GA build, the same one + * DeepSeek's own API serves — where Infron's undated slug is a frozen preview + * snapshot. So this lane matches the DeepSeek-direct lane behind it in + * behaviour as well as price. + */ +export const DEEPSEEK_CROF_PROVIDER_ROUTE = + 'deepseek/crof' satisfies ProviderRouteId +/** + * DeepSeek's own API — the lane a cutover session diverts to, and the lane + * every PRE-cutover session still enters on. + * + * It was the unpinned default until 2026-08-15, which is why it had no route id + * before: a lane nothing ever moves to needs no name. Now that a cutover + * session can divert here, it has to be able to say so, and to stay — its + * prompt cache is warm on this upstream, and sending the next turn back to a + * CrofAI that just failed would pay a cold prefill to reach a lane we already + * know is unhealthy. The pin skips CrofAI as an ENTRY point only: it stays in + * the order behind this lane, because by the next turn the blip has usually + * cleared and CrofAI is still far cheaper than the lanes below. + * + * Behind CrofAI for cutover sessions because DeepSeek is the side that + * repriced — as of 16:00 UTC 2026-08-16 its cache reads are $0.0070/M off-peak + * and $0.0140/M at peak against CrofAI's $0.0030, so what used to be a + * marginal loss on that term is now a 2.3-4.7x one — and because of the + * failure record that made this a cascade at all: on 2026-08-03/04 it shed peak + * load with 3,934 x 503 "Service is too busy" and diverted 4,997 sessions at + * once, and on 2026-08-11 it accepted 650 requests in six hours and then sent + * nothing, tripping the four-minute first-token watchdog. Note that the + * watchdog is DeepSeek-direct-only (see `handleDeepSeekStream`); no equivalent + * guards the CrofAI lane now serving in front of it. + */ +/** + * DeepSeek V4 Flash's Luminal lane — a small FREE grant, and the only pin in + * this file that is RATIONED rather than reactive. + * + * Every other route id here records where a session ENDED UP after something + * failed. This one records that a session WON a slot: Luminal donated a slice + * of Flash capacity far below our volume, so the pin is minted by an admission + * controller (web/src/server/free-session/luminal-admission.ts) that hands out + * a bounded number of them and stops when Luminal starts refusing. + * + * It is FIRST in the cascade for the sessions that carry it, which no other + * cheap-lane experiment has earned, because it is free and the lanes behind it + * are not: CrofAI's cache reads are $0.0030/M, DeepSeek direct's are + * $0.0070-0.0140/M, and this is $0. The OpenRouter retirement on {@link + * DEEPSEEK_CROF_PROVIDER_ROUTE} is the cautionary tale for adding a lane for + * depth; this is the opposite — a lane added for price, capped so it cannot + * become depth. + * + * Measured against the endpoint on 2026-08-20, which is what made this + * routable at all: + * + * - It SHEDS rather than queues: HTTP 429 in 87-98ms with `retry-after: 1` + * and a structured `rate_limit_error` body. A refused session costs one + * fast round trip and diverts. + * - REQUEST-bound, not token-bound. 32k prompts shed at ~175-224k tok/s + * while 128k prompts sustained 498k tok/s untouched, so the served budget + * (~5-7 req/s) does not shrink as prompts grow. + * - Prefix caching holds at 99.4-99.9% across concurrency, against + * production Flash's 98.8% — which is why admission is per SESSION. A + * request-level share would make every request a cold prefill and consume + * the grant on prefill alone. + * + * A 429 here is TERMINAL for the session, unlike a divert off any other lane: + * the cascade re-pins it onward and it never comes back. That is deliberate. + * The lanes behind this one are sized to take our whole volume, so there is + * nothing to gain by retrying a rationed lane and one wasted round trip per + * turn to lose. + */ +export const DEEPSEEK_LUMINAL_PROVIDER_ROUTE = + 'deepseek/luminal' satisfies ProviderRouteId +export const DEEPSEEK_OFFICIAL_PROVIDER_ROUTE = + 'deepseek/official' satisfies ProviderRouteId +/** + * DeepSeek V4 Pro's entry lane as of 2026-08-21: the Cheaper Inference gateway. + * + * It replaces CrofAI at the front of Pro's cascade AND removes it from the + * cascade entirely — CrofAI's Pro lane was the dated `deepseek-v4-pro-0813` + * slug, stood down the same day as too dear and too inconsistent. So Pro's two + * lanes are now this and DeepSeek direct. + * + * Cheaper on every term than the direct lane behind it ($0.3045/$0.002538/$0.609 + * per M against $0.66/$0.022/$1.98 off-peak) and, unlike direct, FLAT — no peak + * card. That last point is most of the value: direct doubles for ten hours a + * day and those windows carry 26% of tokens against 46% of spend. + * + * The cache question this file's other entries keep raising was measured here + * rather than assumed, and the first answer was wrong. A 13-sample probe showed + * ~70% and read as disqualifying; at scale, warm, it is 100% over 60 sequential + * and 95% over 40 concurrent, holding on both upstreams this gateway routes + * between. Cold-start misses, not routing instability. Compare lanes at their + * MEASURED WARM hit rate — the rule that has now been arrived at three times in + * this file. + */ +export const DEEPSEEK_CHEAPER_INFERENCE_PROVIDER_ROUTE = + 'deepseek/cheaper-inference' satisfies ProviderRouteId +/** + * DeepSeek V4 Flash's LAST resort: the Infron lane, now tier 4 of four. + * + * DEMOTED FROM TIER 3 ON 2026-08-16. It was the cheapest route we had — + * measured live 2026-08-04 on a 45,008-token prompt at $0.069/M input and + * ~$0.0144/M cache read — and Infron then repriced its Alibaba Cloud Int. + * group to $0.212/M input, $0.021/M cache read and $0.636/M output. That is + * 3.1x, 1.4x and 4.6x, and it turns the cheapest lane into the dearest one. + * + * {@link DEEPSEEK_RUNINFRA_PROVIDER_ROUTE} is cheaper on input, cache reads and + * output alike, and a measured agent turn confirms the list prices rather than + * contradicting them: costed on one real 29-call buffbench turn, RunInfra came + * to $0.0444 against this lane's $0.0820 — 1.85x. So Infron ahead of RunInfra + * is wrong on both paper and practice. + * + * So it sits behind {@link DEEPSEEK_CROF_PROVIDER_ROUTE}, {@link + * DEEPSEEK_OFFICIAL_PROVIDER_ROUTE} and {@link + * DEEPSEEK_RUNINFRA_PROVIDER_ROUTE}, and it must never be the lane a session + * settles on. Its own failure mode is why nothing cheap may sit below it: a + * single aggregator account behind one Alibaba provider group, which when + * 4,997 sessions diverted onto it in one window returned 13,286 saturation + * 429s and then ran out of credits entirely, leaking the raw billing error to + * 1,086 users. This only works because the cascade RE-PINS on each hop — a + * session that finds Infron saturated does not pay a doomed Infron attempt on + * every later turn. + * + * The `makora` in the name is historical (that upstream went offline in + * 2026-07). The id says only *that* a session is on this lane, never which + * upstream serves it — that is INFRON_PROVIDER_ORDER, keyed by model — so + * repointing the upstream also moves sessions already pinned here. Renaming the + * value itself would need a migration; it is persisted in + * `free_session.provider_route` and read back unvalidated. + */ +export const DEEPSEEK_INFRON_MAKORA_PROVIDER_ROUTE = + 'infron/makora' satisfies ProviderRouteId +/** + * DeepSeek V4 Flash's SECOND backup: the RunInfra lane, tier 3 of four. + * + * Added because the three lanes ahead of it have each failed in the one way a + * cascade cannot absorb — by running out of money. DeepSeek shed peak load, + * Infron's aggregator account went dry and leaked its billing error to 1,086 + * users, and CrofAI returned 401 "Not Enough Credits" off its own prepaid + * balance. Three lanes whose failures are that correlated with a divert storm + * are, on the worst day, one lane. RunInfra is a fourth independent account + * and balance behind them; that independence, not its price, is the reason it + * exists. + * + * List prices are on {@link DEEPSEEK_CROF_PROVIDER_ROUTE}. It is dearer than + * CrofAI on all three terms, so it can never sit above it; it sits above Infron + * because Infron repriced on 2026-08-16 into the dearest lane we have. + * + * VALIDATED ON A REAL AGENT TURN rather than a price table, which is unusual + * for this file and worth the words. One buffbench task was run end to end + * through this lane against a local server: 29 model calls, 1,202,712 input + * tokens (82.3% cached), 25,287 output tokens. Costing that exact token + * profile against each lane's card: + * + * CrofAI $0.0338 + * RunInfra $0.0444 + * DeepSeek off-peak $0.0705 + * Infron $0.0820 + * DeepSeek peak $0.1409 + * + * So RunInfra ahead of Infron is right by 1.85x on measured traffic, which is + * what this lane's placement rests on. Note the DeepSeek rows sit BELOW + * RunInfra on this workload since the 2026-08-16 repricing — that is a question + * about the entry lane, not about this one, and it belongs to + * docs/freebuff-deepseek-provider-cutover.md rather than here. + * + * The cache has a COLD START. Over that turn it served 82.3% of input tokens + * from cache, but the aggregate hides the shape: the first few calls missed + * outright despite sharing a large prefix, then it held at 98-99% for the + * remaining ~25 calls. A synthetic probe showed the same thing (three misses, + * then 99.1% hits), with ~600ms latency on a hit against ~1,500ms on a miss. + * `prompt_cache_key` did not pin routing. The practical consequence is that a + * long session gets the list rate and a very short one pays closer to fresh + * input — acceptable for a lane only sustained failure reaches. + * + * Two further constraints, both measured rather than published: its hard output + * ceiling is 32,768 tokens (see RUNINFRA_DEEPSEEK_V4_FLASH_MAX_TOKENS — below + * the 48,000 budget the product considers safe, so expect more empty + * length-capped answers here), and it returns no `cost`, so its price table + * bills every request rather than catching a rare gap. + * + * What makes it a better tier-3 than the OpenRouter lane it replaced in the + * cascade: that lane priced cache reads at $0.0176/M and was reached 1,205 + * times in 24h, which is how DeepSeek's daily bill went from $9k to $39.7k. + * RunInfra is 1.76x under it on exactly that term. Depth still costs something + * — 3.3x CrofAI's cache read — which is why it leaves NO RESUMABLE PIN (see + * `asDeepSeekLane`): a session that touches it starts its next turn from the + * primary again rather than settling here for the rest of its hour. + * + * Like its peers the id names the LANE, not the upstream, and it is persisted + * in `free_session.provider_route` and read back unvalidated — so renaming the + * value would need a migration, while repointing what serves it would not. + */ +export const DEEPSEEK_RUNINFRA_PROVIDER_ROUTE = + 'deepseek/runinfra' satisfies ProviderRouteId +/** + * RETIRED as a DeepSeek lane on 2026-08-11. Kept as a recognized id because it + * is persisted in `free_session.provider_route` and read back unvalidated — + * sessions still carrying the pin must not crash; they simply start from the + * primary again, which is the right answer for a lane that no longer exists. + * + * Why it went: it prices cache reads at $0.0176/M against CrofAI's $0.0030, + * and an agent turn re-sends its whole prefix every step, so ~98% of the + * tokens land on exactly that term. Being "last resort" did not bound the + * damage — the pin only moved forward, so one transient 429 on the lane above + * parked a session here for the rest of its hour. It was reached 1,205 times + * in 24h, more often than the lane ahead of it, and DeepSeek's daily bill went + * from $9k to $39.7k over four days while volume rose only 41%. Depth that + * costs 5.9x on the dominant token class is not depth. + * + * The replacement for that depth is retries: the two cheap lanes are attempted + * three times each before anything diverts. + * + * (Historical, for the MiMo lane which still uses OpenRouter:) + * DeepSeek V4 Flash's LAST resort: the OpenRouter lane, tier 4 of four. + * + * Reached when DeepSeek direct and then {@link + * DEEPSEEK_INFRON_MAKORA_PROVIDER_ROUTE} have both failed retryably. Dearer per + * token than Infron but backed by many independent upstreams and a balance that + * is not one account's, which is exactly what a divert storm needs — see the + * Infron route's doc for why the cheap lane cannot be the last one. + * + * Like its MiMo peer it names the LANE, not the upstream — that is {@link + * DEEPSEEK_OPENROUTER_UPSTREAM_ORDER} below, so repointing the order also moves + * every session already pinned here, with no migration. + */ +export const DEEPSEEK_OPENROUTER_PROVIDER_ROUTE = + 'deepseek/openrouter' satisfies ProviderRouteId +/** + * The upstreams that serve {@link DEEPSEEK_OPENROUTER_PROVIDER_ROUTE}, + * preferred first. + * + * Chosen as *cheapest that still preserves the prompt cache*, which for an + * agent workload are not the same axis. Cache-read price is what actually + * drives this bill — a coding turn re-sends a long prefix every step, so most + * input tokens are cache reads — and the OpenRouter catalog splits cleanly on + * it (checked live 2026-08-04, per M): + * + * streamlake/fp8 $0.0881 in $0.0176 cache $0.1761 out fp8 384k max out + * baidu/fp8 $0.0882 in $0.0176 cache $0.1764 out fp8 131k max out + * gmicloud/fp8 $0.0938 in $0.0188 cache $0.1876 out fp8 no stated cap + * ── everything below is >=1.5x the cache-read price ── + * most of the tail $0.14 in $0.0280 cache $0.2800 out + * parasail/coreweave/phala $0.0700 cache (4x) + * morph, mancer/fp4 NO cache read at all + * + * So the three cheapest on input are also the three cheapest on cache read; + * there is no tradeoff to make here, which is why the list is short. + * + * DELIBERATELY NOT `deepseek` (OpenRouter's DeepSeek-first-party endpoint). + * It had by far the best cache read of any entry here, and was tempting for + * that alone, but it is the same upstream whose failure triggers this fallback, + * reached through a middleman — pointing the lane there would divert an outage + * onto itself. The Infron lane can use the 0731 model without this problem + * because it pins independent Alibaba upstreams. (The price argument has since + * evaporated anyway: DeepSeek's 2026-08-16 repricing put first-party cache + * reads at $0.0070/M off-peak and $0.0140/M at peak, at or above the tail + * quoted above. The routing reason is the one that still stands.) + * + * `deepinfra/fp4` is skipped despite sitting third on price: fp4 quantization, + * and a 65,536-token output cap that would truncate long agent turns. fp8 is + * the floor for this lane. + * + * THREE ENTRIES, and it must never go to one — see the identical warning on + * {@link mimoOpenRouterProvider}. A pinned session has no health check and no + * un-pin path, so a one-deep lane turns a single upstream blip into a wedged + * session. That is not hypothetical here: this model's previous fallback was + * pinned one-deep to `makora` and took out 1,160 requests across 191 users for + * ~32h when it went offline (2026-07-26/27, #1045). + * + * Caveat on the third entry: `gmicloud/fp8` does not list `stop` in its + * supported parameters. Without `require_parameters` OpenRouter drops the + * unsupported field rather than refusing to route, so a turn served there does + * not honor the global stop sequence. Accepted for depth — it only serves when + * both fp8 upstreams above it are unavailable — but do not promote it. + */ +export const DEEPSEEK_OPENROUTER_UPSTREAM_ORDER = [ + 'streamlake/fp8', + 'baidu/fp8', + 'gmicloud/fp8', +] as const + +/** + * The OpenRouter output cap this lane requests. + * + * Matches `streamlake/fp8`'s 384,000-token ceiling — the first upstream in the + * order — so a caller's explicit budget can never make the preferred endpoint + * ineligible. Slightly under DeepSeek direct's 393,216, same as the Infron lane + * this replaces: keep fallback requests inside the contract of the route that + * will actually serve them. + */ +export const DEEPSEEK_OPENROUTER_MAX_TOKENS = 384_000 + +/** + * Fresh OpenRouter `provider` block for the DeepSeek V4 Flash lane. + * + * Returns a NEW object with a NEW array every call — these arrays get aliased + * into an outgoing request body, and one downstream mutation would corrupt + * routing process-wide (the bug `INFRON_PROVIDER_ORDER` was made + * copy-on-assignment for in #1045). + * + * `allow_fallbacks: false` is what preserves the prompt cache: it holds the + * session to this order instead of letting OpenRouter spread turns across + * twenty endpoints that each keep their own cache. Cache fragmentation is the + * expensive failure mode, not a slightly dearer per-token rate — measured on + * the MiMo lane, a scattered fallback averaged 27.6k cache_read against ~150k + * prompts, paying a full cold prefill per divert. + */ +export function deepseekOpenRouterProvider(): Record { + return { + order: [...DEEPSEEK_OPENROUTER_UPSTREAM_ORDER], + allow_fallbacks: false, + } +} diff --git a/common/src/types/freebuff-session.ts b/common/src/types/freebuff-session.ts index a9c751279d..deebfacbd0 100644 --- a/common/src/types/freebuff-session.ts +++ b/common/src/types/freebuff-session.ts @@ -1,5 +1,5 @@ import type { FreebuffAccessTier } from '../constants/freebuff-models' -import type { FreebuffStandingInfo } from '../constants/freebuff-standing' +import type { FreebuffStandingInfo } from '../constants/freebuff-trust' /** * Wire-level shapes returned by `/api/v1/freebuff/session`. Source of truth diff --git a/common/src/util/freebuff-model-availability.ts b/common/src/util/freebuff-model-availability.ts index 5c14f2d92c..faedc56827 100644 --- a/common/src/util/freebuff-model-availability.ts +++ b/common/src/util/freebuff-model-availability.ts @@ -54,8 +54,8 @@ export const FREEBUFF_PAUSED_MODEL_NOTICE = * * 2026-08-28: GLM 5.3 Flash is now UNMETERED, joining MiMo and DeepSeek V4 * Flash. The 2-a-day cap came off on 08-27 when its measurement window closed; - * a day of production spend then settled the cost question outright — it is - * the cheapest row we serve per message, 4.6x under MiMo and 8.9x under V4 + * a day of production spend then settled the cost question outright — it bills + * $0.000249/msg, the cheapest row we serve, 4.6x under MiMo and 8.9x under V4 * Flash, both of which already ran uncapped. Keeping a ceiling on the cheapest * model while the dearer ones had none inverted the reason ceilings exist. * diff --git a/test/setup-scm-loader.ts b/test/setup-scm-loader.ts new file mode 100644 index 0000000000..6acafba756 --- /dev/null +++ b/test/setup-scm-loader.ts @@ -0,0 +1,15 @@ +import { plugin } from 'bun' +import { readFile } from 'fs/promises' + +plugin({ + name: 'scm-text-loader', + setup(build) { + build.onLoad({ filter: /\.scm$/ }, async (args) => { + const text = await readFile(args.path, 'utf8') + return { + exports: { default: text }, + loader: 'object', + } + }) + }, +})