diff --git a/package.json b/package.json index 68fa350c..fe1d242a 100644 --- a/package.json +++ b/package.json @@ -42,11 +42,11 @@ "vitest": "^4.0.18" }, "dependencies": { - "@multica/sdk": "workspace:*", "@mariozechner/pi-agent-core": "^0.50.3", "@mariozechner/pi-ai": "^0.50.3", "@mariozechner/pi-coding-agent": "^0.50.3", "@mozilla/readability": "^0.6.0", + "@multica/sdk": "workspace:*", "@nestjs/common": "^11.1.12", "@nestjs/core": "^11.1.12", "@nestjs/platform-express": "^11.1.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 567bffb0..7e74a4c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,24 +9,9 @@ catalogs: '@types/node': specifier: ^25.0.10 version: 25.0.10 - '@types/react': - specifier: ^19 - version: 19.2.10 - '@types/react-dom': - specifier: ^19 - version: 19.2.3 - react: - specifier: 19.2.3 - version: 19.2.3 - react-dom: - specifier: 19.2.3 - version: 19.2.3 typescript: specifier: ^5.9.3 version: 5.9.3 - zustand: - specifier: ^5.0.0 - version: 5.0.10 importers: @@ -34,13 +19,13 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-ai': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-coding-agent': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -261,7 +246,7 @@ importers: version: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-router: specifier: ~6.0.23 - version: 6.0.23(w7u2e3gmpia3npio76ytuzityu) + version: 6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3) expo-splash-screen: specifier: ~31.0.13 version: 31.0.13(expo@54.0.33) @@ -1844,89 +1829,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2091,24 +2092,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.0': resolution: {integrity: sha512-4BC08CIaOXSSAGRZLEjqJmQfioED8ohAzwt0k2amZPEbH96YKoBNorq5EdwPf5VT+odS0DeyCwhwtxokRLZIvQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.0': resolution: {integrity: sha512-GpNY5Y9nOzr0Vt0Qi5U88qwe6piiIHk44kSMexl8ns90LluN5UTNYmyfi7Xq3/lmPZCpnB2xvBTYbsXCxnopIA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.0': resolution: {integrity: sha512-+PnR48/x9GMY5Kh8BLjzHMx6trOegMtxAuqTM9X/bhV3QuW6sLLd7nojDHSGj/ZueK6i0tcQxvOrgNLozVtNDA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.0': resolution: {integrity: sha512-+dy2vZ1Ph4EYj0cotB+bVUVk/uKl2bh9LOp/zlnFqoCCYDN6sm+L0VyIOPPo3hjoEVdGpHe1MUxp3qG/OLwXgg==} @@ -2270,24 +2275,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} @@ -2748,66 +2757,79 @@ packages: resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.0': resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.0': resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.0': resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.0': resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.0': resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.0': resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.0': resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.0': resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.0': resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.0': resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.0': resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.0': resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.0': resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==} @@ -3132,24 +3154,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -3501,41 +3527,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -6221,48 +6255,56 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.30.2: resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.27.0: resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.27.0: resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.27.0: resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.27.0: resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==} @@ -9051,11 +9093,11 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@anthropic-ai/sdk@0.71.2(zod@3.25.76)': + '@anthropic-ai/sdk@0.71.2(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: - zod: 3.25.76 + zod: 4.3.6 '@aws-crypto/crc32@5.2.0': dependencies: @@ -10579,7 +10621,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.23(w7u2e3gmpia3npio76ytuzityu) + expo-router: 6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3) react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) transitivePeerDependencies: - bufferutil @@ -10828,12 +10870,12 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))': + '@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))': dependencies: google-auth-library: 10.5.0 ws: 8.18.3 optionalDependencies: - '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@4.3.6) transitivePeerDependencies: - bufferutil - supports-color @@ -11168,9 +11210,9 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': + '@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-tui': 0.50.3 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -11181,21 +11223,21 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': + '@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.71.2(zod@3.25.76) + '@anthropic-ai/sdk': 0.71.2(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.978.0 - '@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76)) + '@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)) '@mistralai/mistralai': 1.10.0 '@sinclair/typebox': 0.34.48 ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) chalk: 5.6.2 - openai: 6.10.0(ws@8.18.3)(zod@3.25.76) + openai: 6.10.0(ws@8.18.3)(zod@4.3.6) partial-json: 0.1.7 proxy-agent: 6.5.0 undici: 7.19.2 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -11205,12 +11247,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': + '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: '@mariozechner/clipboard': 0.3.0 '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) - '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-tui': 0.50.3 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 @@ -11268,6 +11310,29 @@ snapshots: - hono - supports-color + '@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.11.7) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 7.5.1(express@5.2.1) + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - hono + - supports-color + optional: true + '@mozilla/readability@0.6.0': {} '@mswjs/interceptors@0.40.0': @@ -14346,7 +14411,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -14377,7 +14442,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -14702,7 +14767,7 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) - expo-router@6.0.23(w7u2e3gmpia3npio76ytuzityu): + expo-router@6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@expo/schema-utils': 0.1.8 @@ -17089,10 +17154,10 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.10.0(ws@8.18.3)(zod@3.25.76): + openai@6.10.0(ws@8.18.3)(zod@4.3.6): optionalDependencies: ws: 8.18.3 - zod: 3.25.76 + zod: 4.3.6 optionator@0.9.4: dependencies: @@ -19376,6 +19441,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/src/agent/auth-profiles/constants.ts b/src/agent/auth-profiles/constants.ts new file mode 100644 index 00000000..d519a246 --- /dev/null +++ b/src/agent/auth-profiles/constants.ts @@ -0,0 +1,45 @@ +/** + * Auth Profile Constants + * + * Cooldown timings, store version, and file names. + */ + +/** Store format version */ +export const AUTH_STORE_VERSION = 1; + +/** Runtime store filename (inside ~/.super-multica/) */ +export const AUTH_PROFILE_STORE_FILENAME = "auth-profiles.json"; + +// ============================================================ +// Non-billing cooldown (rate_limit, auth, timeout, unknown) +// Progression: 1min -> 5min -> 25min -> 1hr (cap) +// Formula: min(MAX, BASE * FACTOR ^ min(errorCount - 1, 3)) +// ============================================================ + +/** Base cooldown duration in milliseconds (1 minute) */ +export const COOLDOWN_BASE_MS = 60_000; + +/** Exponential factor for cooldown progression */ +export const COOLDOWN_FACTOR = 5; + +/** Maximum cooldown duration in milliseconds (1 hour) */ +export const COOLDOWN_MAX_MS = 3_600_000; + +// ============================================================ +// Billing disable (longer backoff for payment/quota issues) +// Progression: 5h -> 10h -> 20h -> 24h (cap) +// Formula: min(MAX_HOURS, BASE_HOURS * 2 ^ (count - 1)) +// ============================================================ + +/** Base billing disable duration in hours */ +export const BILLING_BACKOFF_HOURS = 5; + +/** Maximum billing disable duration in hours */ +export const BILLING_MAX_HOURS = 24; + +// ============================================================ +// Failure window +// ============================================================ + +/** Failure window in milliseconds (24 hours) — errors older than this are forgotten */ +export const FAILURE_WINDOW_MS = 24 * 60 * 60 * 1000; diff --git a/src/agent/auth-profiles/error-classification.test.ts b/src/agent/auth-profiles/error-classification.test.ts new file mode 100644 index 00000000..c771c6b4 --- /dev/null +++ b/src/agent/auth-profiles/error-classification.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { classifyError, isRotatableError } from "../runner.js"; + +// ============================================================ +// classifyError +// ============================================================ + +describe("classifyError", () => { + it("classifies 401/403/unauthorized as auth", () => { + expect(classifyError(new Error("HTTP 401 Unauthorized"))).toBe("auth"); + expect(classifyError(new Error("403 Forbidden"))).toBe("auth"); + expect(classifyError(new Error("Invalid API key provided"))).toBe("auth"); + expect(classifyError(new Error("Authentication failed"))).toBe("auth"); + }); + + it("classifies 400/malformed as format", () => { + expect(classifyError(new Error("400 Bad Request"))).toBe("format"); + expect(classifyError(new Error("Invalid request body"))).toBe("format"); + expect(classifyError(new Error("Malformed JSON in request"))).toBe("format"); + expect(classifyError(new Error("Schema validation failed"))).toBe("format"); + }); + + it("classifies 429/rate limit as rate_limit", () => { + expect(classifyError(new Error("429 Too Many Requests"))).toBe("rate_limit"); + expect(classifyError(new Error("Rate limit exceeded"))).toBe("rate_limit"); + expect(classifyError(new Error("rate_limit_error"))).toBe("rate_limit"); + }); + + it("classifies billing/quota as billing", () => { + expect(classifyError(new Error("Billing quota exceeded"))).toBe("billing"); + expect(classifyError(new Error("Insufficient credits"))).toBe("billing"); + expect(classifyError(new Error("Payment required"))).toBe("billing"); + }); + + it("classifies timeout/connection errors as timeout", () => { + expect(classifyError(new Error("Request timed out"))).toBe("timeout"); + expect(classifyError(new Error("ETIMEDOUT"))).toBe("timeout"); + expect(classifyError(new Error("ECONNRESET"))).toBe("timeout"); + expect(classifyError(new Error("Connection timeout"))).toBe("timeout"); + }); + + it("classifies unknown errors as unknown", () => { + expect(classifyError(new Error("Something went wrong"))).toBe("unknown"); + expect(classifyError("string error")).toBe("unknown"); + expect(classifyError(42)).toBe("unknown"); + }); +}); + +// ============================================================ +// isRotatableError +// ============================================================ + +describe("isRotatableError", () => { + it("considers auth, rate_limit, billing, timeout as rotatable", () => { + expect(isRotatableError("auth")).toBe(true); + expect(isRotatableError("rate_limit")).toBe(true); + expect(isRotatableError("billing")).toBe(true); + expect(isRotatableError("timeout")).toBe(true); + }); + + it("does not rotate on format or unknown errors", () => { + expect(isRotatableError("format")).toBe(false); + expect(isRotatableError("unknown")).toBe(false); + }); +}); diff --git a/src/agent/auth-profiles/index.ts b/src/agent/auth-profiles/index.ts new file mode 100644 index 00000000..9458ea55 --- /dev/null +++ b/src/agent/auth-profiles/index.ts @@ -0,0 +1,48 @@ +/** + * Auth Profiles — barrel export + */ + +export type { + AuthProfileFailureReason, + AuthProfileStore, + ProfileUsageStats, + ResolvedProfileAuth, +} from "./types.js"; + +export { + AUTH_STORE_VERSION, + AUTH_PROFILE_STORE_FILENAME, + COOLDOWN_BASE_MS, + COOLDOWN_FACTOR, + COOLDOWN_MAX_MS, + BILLING_BACKOFF_HOURS, + BILLING_MAX_HOURS, + FAILURE_WINDOW_MS, +} from "./constants.js"; + +export { + resolveAuthStorePath, + coerceStore, + ensureAuthStoreFile, + loadAuthProfileStore, + saveAuthProfileStore, + updateAuthProfileStore, +} from "./store.js"; + +export { + listProfilesForProvider, + resolveAuthProfileOrder, + type AuthProfileOrderOptions, +} from "./order.js"; + +export { + isProfileInCooldown, + resolveProfileUnusableUntil, + calculateCooldownMs, + calculateBillingDisableMs, + computeNextProfileUsageStats, + markAuthProfileFailure, + markAuthProfileUsed, + markAuthProfileGood, + clearAuthProfileCooldown, +} from "./usage.js"; diff --git a/src/agent/auth-profiles/order.test.ts b/src/agent/auth-profiles/order.test.ts new file mode 100644 index 00000000..905724d8 --- /dev/null +++ b/src/agent/auth-profiles/order.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolveAuthProfileOrder, listProfilesForProvider } from "./order.js"; +import type { AuthProfileStore } from "./types.js"; + +// Track mock profiles for credential validation +let _profiles: Record = {}; +let _order: Record = {}; + +// Mock credentialManager +vi.mock("../credentials.js", () => { + return { + credentialManager: { + listProfileIdsForProvider(provider: string): string[] { + return Object.keys(_profiles).filter( + (key) => key === provider || key.startsWith(`${provider}:`), + ); + }, + getLlmOrder(provider: string): string[] | undefined { + return _order[provider]; + }, + getLlmProviderConfig(profileId: string): { apiKey?: string } | undefined { + return _profiles[profileId]; + }, + }, + }; +}); + +// Mock providers/registry — all test profiles are API-key based +vi.mock("../providers/registry.js", () => ({ + isOAuthProvider: (_provider: string) => false, +})); + +// Mock providers/resolver — delegate to our mock profiles +vi.mock("../providers/resolver.js", () => ({ + resolveApiKeyForProfile: (profileId: string) => _profiles[profileId]?.apiKey, +})); + +function setProfiles(profiles: Record) { + _profiles = profiles; +} +function setOrder(order: Record) { + _order = order; +} + +beforeEach(() => { + _profiles = {}; + _order = {}; +}); + +// ============================================================ +// listProfilesForProvider +// ============================================================ + +describe("listProfilesForProvider", () => { + it("returns profiles matching the provider", () => { + setProfiles({ + anthropic: { apiKey: "sk-1" }, + "anthropic:backup": { apiKey: "sk-2" }, + openai: { apiKey: "sk-3" }, + }); + expect(listProfilesForProvider("anthropic")).toEqual([ + "anthropic", + "anthropic:backup", + ]); + }); + + it("returns empty array when no profiles match", () => { + setProfiles({ openai: { apiKey: "sk-1" } }); + expect(listProfilesForProvider("anthropic")).toEqual([]); + }); +}); + +// ============================================================ +// resolveAuthProfileOrder +// ============================================================ + +describe("resolveAuthProfileOrder", () => { + const now = 1_000_000; + + it("returns round-robin order by lastUsed when no explicit order", () => { + setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + const store: AuthProfileStore = { + version: 1, + usageStats: { + "anthropic": { lastUsed: 300 }, + "anthropic:b": { lastUsed: 100 }, + "anthropic:c": { lastUsed: 200 }, + }, + }; + + const order = resolveAuthProfileOrder("anthropic", store, now); + // Sorted by lastUsed ascending: b(100) -> c(200) -> default(300) + expect(order).toEqual(["anthropic:b", "anthropic:c", "anthropic"]); + }); + + it("respects explicit order from config", () => { + setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + setOrder({ anthropic: ["anthropic:c", "anthropic", "anthropic:b"] }); + + const store: AuthProfileStore = { version: 1 }; + const order = resolveAuthProfileOrder("anthropic", store, now); + expect(order).toEqual(["anthropic:c", "anthropic", "anthropic:b"]); + }); + + it("pushes cooldown profiles to the end", () => { + setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + const store: AuthProfileStore = { + version: 1, + usageStats: { + "anthropic": { lastUsed: 100 }, + "anthropic:b": { lastUsed: 200, cooldownUntil: now + 5000 }, + "anthropic:c": { lastUsed: 300 }, + }, + }; + + const order = resolveAuthProfileOrder("anthropic", store, now); + // anthropic and anthropic:c are available; anthropic:b is in cooldown -> pushed to end + expect(order).toEqual(["anthropic", "anthropic:c", "anthropic:b"]); + }); + + it("sorts cooldown profiles by earliest recovery", () => { + setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + const store: AuthProfileStore = { + version: 1, + usageStats: { + "anthropic": { cooldownUntil: now + 10_000 }, + "anthropic:b": { cooldownUntil: now + 1_000 }, + "anthropic:c": { cooldownUntil: now + 5_000 }, + }, + }; + + const order = resolveAuthProfileOrder("anthropic", store, now); + // All in cooldown, sorted by soonest recovery + expect(order).toEqual(["anthropic:b", "anthropic:c", "anthropic"]); + }); + + it("deduplicates profile IDs", () => { + setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + }); + // Explicit order has duplicate + setOrder({ anthropic: ["anthropic", "anthropic", "anthropic:b"] }); + + const store: AuthProfileStore = { version: 1 }; + const order = resolveAuthProfileOrder("anthropic", store, now); + expect(order).toEqual(["anthropic", "anthropic:b"]); + }); + + it("appends unlisted profiles to explicit order", () => { + setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + // Only lists one profile in explicit order + setOrder({ anthropic: ["anthropic:b"] }); + + const store: AuthProfileStore = { version: 1 }; + const order = resolveAuthProfileOrder("anthropic", store, now); + // anthropic:b first (explicit), then the rest + expect(order[0]).toBe("anthropic:b"); + expect(order).toHaveLength(3); + expect(order).toContain("anthropic"); + expect(order).toContain("anthropic:c"); + }); + + it("filters out profiles with no valid API key", () => { + setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:empty": {}, // no apiKey + "anthropic:c": { apiKey: "sk-3" }, + }); + const store: AuthProfileStore = { version: 1 }; + const order = resolveAuthProfileOrder("anthropic", store, now); + expect(order).toEqual(["anthropic", "anthropic:c"]); + }); + + it("moves preferredProfile to front", () => { + setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + const store: AuthProfileStore = { version: 1 }; + const order = resolveAuthProfileOrder("anthropic", store, now, { + preferredProfile: "anthropic:c", + }); + expect(order[0]).toBe("anthropic:c"); + expect(order).toHaveLength(3); + }); +}); diff --git a/src/agent/auth-profiles/order.ts b/src/agent/auth-profiles/order.ts new file mode 100644 index 00000000..09c99a32 --- /dev/null +++ b/src/agent/auth-profiles/order.ts @@ -0,0 +1,147 @@ +/** + * Auth Profile Ordering + * + * Determines the order in which auth profiles are tried for a given provider. + * Supports explicit ordering (from credentials.json5) and automatic round-robin + * with two-level sort: credential type priority (OAuth > API key), then lastUsed. + * Profiles in cooldown are pushed to the end. + */ + +import { credentialManager } from "../credentials.js"; +import { isOAuthProvider } from "../providers/registry.js"; +import { resolveApiKeyForProfile } from "../providers/resolver.js"; +import type { AuthProfileStore } from "./types.js"; +import { isProfileInCooldown, resolveProfileUnusableUntil } from "./usage.js"; + +// ============================================================ +// Profile discovery +// ============================================================ + +/** + * List all profile IDs from credentials.json5 that belong to a given provider. + * A profile matches if its key equals the provider exactly or starts with "provider:". + */ +export function listProfilesForProvider(provider: string): string[] { + return credentialManager.listProfileIdsForProvider(provider); +} + +// ============================================================ +// Type priority +// ============================================================ + +/** + * Get the type-based priority for a profile. + * OAuth providers (e.g. claude-code, openai-codex) get priority 0 (preferred), + * API-key providers get priority 1. + * Lower number = higher priority. + */ +function getProfileTypePriority(profileId: string): number { + // Extract the provider portion from profileId (before ":" if present) + const provider = profileId.includes(":") ? profileId.split(":")[0]! : profileId; + return isOAuthProvider(provider) ? 0 : 1; +} + +// ============================================================ +// Ordering +// ============================================================ + +export interface AuthProfileOrderOptions { + /** Preferred profile to put first (used when user or agent selects a profile) */ + preferredProfile?: string | undefined; +} + +/** + * Resolve the ordered list of profile IDs to try for a given provider. + * + * Strategy: + * 1. If credentials.json5 has `llm.order[provider]`, use that explicit order. + * 2. Otherwise, use round-robin with two-level sort: + * - First by credential type priority (OAuth > API key) + * - Then by `lastUsed` ascending within each type (oldest first) + * + * In both cases: + * - Profiles with invalid/missing credentials are filtered out + * - Profiles currently in cooldown are pushed to the end, + * sorted by earliest cooldown expiry (soonest-to-recover first) + * - If `preferredProfile` is set, it is moved to the front + */ +export function resolveAuthProfileOrder( + provider: string, + store: AuthProfileStore, + now?: number, + options?: AuthProfileOrderOptions, +): string[] { + const ts = now ?? Date.now(); + + // Gather candidates + const explicitOrder = credentialManager.getLlmOrder(provider); + const allProfiles = listProfilesForProvider(provider); + + let candidates: string[]; + if (explicitOrder && explicitOrder.length > 0) { + // Use explicit order, filter to only existing profiles + const profileSet = new Set(allProfiles); + candidates = explicitOrder.filter((id) => profileSet.has(id)); + // Append any profiles not in the explicit order + for (const id of allProfiles) { + if (!candidates.includes(id)) { + candidates.push(id); + } + } + } else { + // Two-level sort: type priority first, then lastUsed within same type + candidates = [...allProfiles].sort((a, b) => { + const priorityDiff = getProfileTypePriority(a) - getProfileTypePriority(b); + if (priorityDiff !== 0) return priorityDiff; + + const statsA = store.usageStats?.[a]; + const statsB = store.usageStats?.[b]; + return (statsA?.lastUsed ?? 0) - (statsB?.lastUsed ?? 0); + }); + } + + // Deduplicate + candidates = [...new Set(candidates)]; + + // Filter out profiles with invalid/missing credentials + candidates = candidates.filter((id) => { + // For OAuth providers, resolveApiKeyForProfile won't find them in credentials.json5 + // but they are still valid candidates (resolved at runtime via OAuth flow) + const provider = id.includes(":") ? id.split(":")[0]! : id; + if (isOAuthProvider(provider)) return true; + + return resolveApiKeyForProfile(id) !== undefined; + }); + + // Partition into available and in-cooldown + const available: string[] = []; + const inCooldown: string[] = []; + + for (const id of candidates) { + const stats = store.usageStats?.[id]; + if (stats && isProfileInCooldown(stats, ts)) { + inCooldown.push(id); + } else { + available.push(id); + } + } + + // Sort cooldown profiles by soonest recovery + inCooldown.sort((a, b) => { + const statsA = store.usageStats?.[a] ?? {}; + const statsB = store.usageStats?.[b] ?? {}; + return resolveProfileUnusableUntil(statsA) - resolveProfileUnusableUntil(statsB); + }); + + let result = [...available, ...inCooldown]; + + // Move preferred profile to front if specified + if (options?.preferredProfile && result.includes(options.preferredProfile)) { + result = [ + options.preferredProfile, + ...result.filter((id) => id !== options.preferredProfile), + ]; + } + + return result; +} diff --git a/src/agent/auth-profiles/store.test.ts b/src/agent/auth-profiles/store.test.ts new file mode 100644 index 00000000..4e2d78f5 --- /dev/null +++ b/src/agent/auth-profiles/store.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { coerceStore, loadAuthProfileStore, saveAuthProfileStore, updateAuthProfileStore } from "./store.js"; +import { AUTH_STORE_VERSION } from "./constants.js"; +import type { AuthProfileStore } from "./types.js"; + +// Use a temp directory for tests to avoid touching real store +const TEST_DIR = join(import.meta.dirname ?? ".", "__test_store_tmp__"); +const TEST_STORE_PATH = join(TEST_DIR, "auth-profiles.json"); + +// We need to mock resolveAuthStorePath to point to our test dir +import { vi } from "vitest"; + +vi.mock("../../shared/paths.js", () => ({ + DATA_DIR: join(import.meta.dirname ?? ".", "__test_store_tmp__"), +})); + +beforeEach(() => { + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true }); + } +}); + +afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +// ============================================================ +// coerceStore +// ============================================================ + +describe("coerceStore", () => { + it("returns empty store for null", () => { + const store = coerceStore(null); + expect(store.version).toBe(AUTH_STORE_VERSION); + expect(store.lastGood).toBeUndefined(); + expect(store.usageStats).toBeUndefined(); + }); + + it("returns empty store for non-object", () => { + expect(coerceStore("hello").version).toBe(AUTH_STORE_VERSION); + expect(coerceStore(42).version).toBe(AUTH_STORE_VERSION); + expect(coerceStore(undefined).version).toBe(AUTH_STORE_VERSION); + }); + + it("preserves valid store data", () => { + const raw = { + version: 1, + lastGood: { anthropic: "anthropic:backup" }, + usageStats: { + "anthropic": { lastUsed: 1000, errorCount: 0 }, + }, + }; + const store = coerceStore(raw); + expect(store.version).toBe(1); + expect(store.lastGood?.anthropic).toBe("anthropic:backup"); + expect(store.usageStats?.anthropic?.lastUsed).toBe(1000); + }); + + it("defaults version when missing", () => { + const store = coerceStore({ lastGood: {} }); + expect(store.version).toBe(AUTH_STORE_VERSION); + }); +}); + +// ============================================================ +// loadAuthProfileStore / saveAuthProfileStore +// ============================================================ + +describe("loadAuthProfileStore / saveAuthProfileStore", () => { + it("returns empty store when file does not exist", () => { + const store = loadAuthProfileStore(); + expect(store.version).toBe(AUTH_STORE_VERSION); + }); + + it("round-trips save and load", () => { + const original: AuthProfileStore = { + version: 1, + lastGood: { anthropic: "anthropic:main" }, + usageStats: { + "anthropic:main": { lastUsed: 5000, errorCount: 1 }, + }, + }; + saveAuthProfileStore(original); + const loaded = loadAuthProfileStore(); + expect(loaded).toEqual(original); + }); + + it("handles corrupted JSON gracefully", () => { + writeFileSync(TEST_STORE_PATH, "not valid json{{{", "utf8"); + const store = loadAuthProfileStore(); + expect(store.version).toBe(AUTH_STORE_VERSION); + }); +}); + +// ============================================================ +// updateAuthProfileStore +// ============================================================ + +describe("updateAuthProfileStore", () => { + it("creates file and applies update when file does not exist", () => { + const result = updateAuthProfileStore((store) => { + if (!store.lastGood) store.lastGood = {}; + store.lastGood.openai = "openai:primary"; + }); + expect(result.lastGood?.openai).toBe("openai:primary"); + + // Verify persisted + const loaded = loadAuthProfileStore(); + expect(loaded.lastGood?.openai).toBe("openai:primary"); + }); + + it("preserves existing data across updates", () => { + saveAuthProfileStore({ + version: 1, + lastGood: { anthropic: "anthropic" }, + }); + + updateAuthProfileStore((store) => { + if (!store.usageStats) store.usageStats = {}; + store.usageStats["anthropic"] = { lastUsed: 9999 }; + }); + + const loaded = loadAuthProfileStore(); + expect(loaded.lastGood?.anthropic).toBe("anthropic"); + expect(loaded.usageStats?.anthropic?.lastUsed).toBe(9999); + }); +}); diff --git a/src/agent/auth-profiles/store.ts b/src/agent/auth-profiles/store.ts new file mode 100644 index 00000000..f50f2788 --- /dev/null +++ b/src/agent/auth-profiles/store.ts @@ -0,0 +1,214 @@ +/** + * Auth Profile Store + * + * Persistence layer for auth profile runtime state. + * Stores usage stats, cooldowns, and last-good info in ~/.super-multica/auth-profiles.json. + * Uses a custom file lock (exclusive-create based) for safe concurrent access. + */ + +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + openSync, + closeSync, + rmSync, + statSync, + constants as fsConstants, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { DATA_DIR } from "../../shared/paths.js"; +import { AUTH_STORE_VERSION, AUTH_PROFILE_STORE_FILENAME } from "./constants.js"; +import type { AuthProfileStore } from "./types.js"; + +// ============================================================ +// Custom file lock (synchronous, exclusive-create based) +// ============================================================ + +const LOCK_STALE_MS = 30_000; +const LOCK_RETRY_COUNT = 10; +const LOCK_RETRY_BASE_MS = 50; +const LOCK_RETRY_MAX_MS = 1_000; + +type LockPayload = { pid: number; createdAt: string }; + +function isProcessAlive(pid: number): boolean { + if (!Number.isFinite(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function readLockPayloadSync(lockPath: string): LockPayload | null { + try { + const raw = readFileSync(lockPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.pid !== "number" || typeof parsed.createdAt !== "string") return null; + return { pid: parsed.pid, createdAt: parsed.createdAt }; + } catch { + return null; + } +} + +function isLockStale(lockPath: string): boolean { + const payload = readLockPayloadSync(lockPath); + if (payload) { + const age = Date.now() - Date.parse(payload.createdAt); + if (!Number.isFinite(age) || age > LOCK_STALE_MS) return true; + return !isProcessAlive(payload.pid); + } + // No payload readable — check file mtime + try { + const stat = statSync(lockPath); + return Date.now() - stat.mtimeMs > LOCK_STALE_MS; + } catch { + return true; // Can't stat — treat as stale + } +} + +/** + * Acquire a synchronous exclusive file lock. + * Returns a release function. Throws if lock cannot be acquired after retries. + */ +function acquireLockSync(filePath: string): () => void { + const lockPath = `${filePath}.lock`; + const payload = JSON.stringify( + { pid: process.pid, createdAt: new Date().toISOString() }, + null, + 2, + ); + + for (let attempt = 0; attempt < LOCK_RETRY_COUNT; attempt++) { + try { + // O_WRONLY | O_CREAT | O_EXCL — fails if file already exists + const fd = openSync(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL); + writeFileSync(fd, payload, "utf8"); + closeSync(fd); + return () => { + try { rmSync(lockPath, { force: true }); } catch { /* best effort */ } + }; + } catch (err) { + const code = (err as { code?: string }).code; + if (code !== "EEXIST") throw err; + + // Lock file exists — check if stale + if (isLockStale(lockPath)) { + try { rmSync(lockPath, { force: true }); } catch { /* ignore */ } + continue; + } + + // Wait and retry (synchronous busy-wait via Atomics for minimal overhead) + const delay = Math.min(LOCK_RETRY_MAX_MS, LOCK_RETRY_BASE_MS * (attempt + 1)); + const buf = new SharedArrayBuffer(4); + Atomics.wait(new Int32Array(buf), 0, 0, delay); + } + } + + throw new Error(`Failed to acquire lock after ${LOCK_RETRY_COUNT} retries: ${filePath}`); +} + +// ============================================================ +// Paths +// ============================================================ + +/** Resolve the auth profile store file path */ +export function resolveAuthStorePath(): string { + return join(DATA_DIR, AUTH_PROFILE_STORE_FILENAME); +} + +// ============================================================ +// Load / Save +// ============================================================ + +function createEmptyStore(): AuthProfileStore { + return { version: AUTH_STORE_VERSION }; +} + +/** Coerce raw JSON into a valid AuthProfileStore, defensive against malformed data */ +export function coerceStore(raw: unknown): AuthProfileStore { + if (!raw || typeof raw !== "object") return createEmptyStore(); + + const obj = raw as Record; + const store: AuthProfileStore = { + version: typeof obj.version === "number" ? obj.version : AUTH_STORE_VERSION, + }; + + if (obj.lastGood && typeof obj.lastGood === "object") { + store.lastGood = obj.lastGood as Record; + } + if (obj.usageStats && typeof obj.usageStats === "object") { + store.usageStats = obj.usageStats as AuthProfileStore["usageStats"]; + } + + return store; +} + +/** Ensure the store file exists on disk (creates it if missing) */ +export function ensureAuthStoreFile(): string { + const storePath = resolveAuthStorePath(); + const dir = dirname(storePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + if (!existsSync(storePath)) { + writeFileSync(storePath, JSON.stringify(createEmptyStore(), null, 2), "utf8"); + } + return storePath; +} + +/** Load auth profile store from disk. Returns empty store if file doesn't exist. */ +export function loadAuthProfileStore(): AuthProfileStore { + const storePath = resolveAuthStorePath(); + if (!existsSync(storePath)) return createEmptyStore(); + + try { + const raw = readFileSync(storePath, "utf8"); + return coerceStore(JSON.parse(raw)); + } catch { + return createEmptyStore(); + } +} + +/** Save auth profile store to disk */ +export function saveAuthProfileStore(store: AuthProfileStore): void { + const storePath = resolveAuthStorePath(); + const dir = dirname(storePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8"); +} + +/** + * Atomic load-update-save cycle with file locking. + * Acquires a lock on the store file, loads current state, runs the updater, + * and saves. Falls back to unlocked update if the lock cannot be acquired. + * Returns the updated store. + */ +export function updateAuthProfileStore( + updater: (store: AuthProfileStore) => void, +): AuthProfileStore { + const storePath = ensureAuthStoreFile(); + + try { + const release = acquireLockSync(storePath); + try { + const store = loadAuthProfileStore(); + updater(store); + saveAuthProfileStore(store); + return store; + } finally { + release(); + } + } catch { + // Fallback: unlocked update (better than losing the write entirely) + const store = loadAuthProfileStore(); + updater(store); + saveAuthProfileStore(store); + return store; + } +} diff --git a/src/agent/auth-profiles/types.ts b/src/agent/auth-profiles/types.ts new file mode 100644 index 00000000..036341be --- /dev/null +++ b/src/agent/auth-profiles/types.ts @@ -0,0 +1,48 @@ +/** + * Auth Profile Types + * + * Type definitions for the auth profile rotation and cooldown system. + */ + +/** Reason for an auth profile failure, determines cooldown behavior */ +export type AuthProfileFailureReason = + | "auth" + | "format" + | "rate_limit" + | "billing" + | "timeout" + | "unknown"; + +/** Per-profile usage and cooldown state (persisted in auth-profiles.json) */ +export type ProfileUsageStats = { + /** Timestamp of last successful use */ + lastUsed?: number | undefined; + /** Cooldown expiry for non-billing failures (rate_limit, auth, timeout, unknown) */ + cooldownUntil?: number | undefined; + /** Disable expiry for billing failures (longer backoff) */ + disabledUntil?: number | undefined; + /** Reason for the current disable period */ + disabledReason?: AuthProfileFailureReason | undefined; + /** Consecutive error count (resets on success or after failure window) */ + errorCount?: number | undefined; + /** Per-reason failure counts within the failure window */ + failureCounts?: Partial> | undefined; + /** Timestamp of the last failure (used for failure window expiry) */ + lastFailureAt?: number | undefined; +}; + +/** Persisted runtime store for auth profile state */ +export type AuthProfileStore = { + version: number; + /** Last known good profile per provider */ + lastGood?: Record | undefined; + /** Per-profile usage/cooldown stats */ + usageStats?: Record | undefined; +}; + +/** Resolved auth info returned by profile-aware key resolution */ +export type ResolvedProfileAuth = { + apiKey: string; + profileId: string; + provider: string; +}; diff --git a/src/agent/auth-profiles/usage.test.ts b/src/agent/auth-profiles/usage.test.ts new file mode 100644 index 00000000..185f8adf --- /dev/null +++ b/src/agent/auth-profiles/usage.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest"; +import { + calculateCooldownMs, + calculateBillingDisableMs, + computeNextProfileUsageStats, + isProfileInCooldown, + resolveProfileUnusableUntil, +} from "./usage.js"; +import { + COOLDOWN_BASE_MS, + COOLDOWN_MAX_MS, + FAILURE_WINDOW_MS, +} from "./constants.js"; +import type { ProfileUsageStats } from "./types.js"; + +// ============================================================ +// calculateCooldownMs +// ============================================================ + +describe("calculateCooldownMs", () => { + it("applies exponential backoff with a 1h cap", () => { + const max = () => 1; // equal-jitter max + expect(calculateCooldownMs(1, max)).toBe(60_000); // 1 min + expect(calculateCooldownMs(2, max)).toBe(5 * 60_000); // 5 min + expect(calculateCooldownMs(3, max)).toBe(25 * 60_000); // 25 min + expect(calculateCooldownMs(4, max)).toBe(60 * 60_000); // 1 hour (cap) + expect(calculateCooldownMs(5, max)).toBe(60 * 60_000); // 1 hour (cap) + expect(calculateCooldownMs(100, max)).toBe(60 * 60_000); // still capped + }); + + it("returns 0 for errorCount <= 0", () => { + expect(calculateCooldownMs(0)).toBe(0); + expect(calculateCooldownMs(-1)).toBe(0); + }); + + it("applies equal jitter with a 50% floor", () => { + const min = () => 0; + expect(calculateCooldownMs(1, min)).toBe(30_000); // 50% of 1 min + }); +}); + +// ============================================================ +// calculateBillingDisableMs +// ============================================================ + +describe("calculateBillingDisableMs", () => { + it("applies exponential backoff with a 24h cap", () => { + const h = 60 * 60 * 1000; + const max = () => 1; + expect(calculateBillingDisableMs(1, max)).toBe(5 * h); // 5h + expect(calculateBillingDisableMs(2, max)).toBe(10 * h); // 10h + expect(calculateBillingDisableMs(3, max)).toBe(20 * h); // 20h + expect(calculateBillingDisableMs(4, max)).toBe(24 * h); // 24h (cap) + expect(calculateBillingDisableMs(5, max)).toBe(24 * h); // still capped + }); + + it("returns 0 for count <= 0", () => { + expect(calculateBillingDisableMs(0)).toBe(0); + expect(calculateBillingDisableMs(-1)).toBe(0); + }); +}); + +// ============================================================ +// isProfileInCooldown / resolveProfileUnusableUntil +// ============================================================ + +describe("isProfileInCooldown", () => { + const now = 1_000_000; + + it("returns false for empty stats", () => { + expect(isProfileInCooldown({}, now)).toBe(false); + }); + + it("returns true when cooldownUntil is in the future", () => { + expect(isProfileInCooldown({ cooldownUntil: now + 1000 }, now)).toBe(true); + }); + + it("returns false when cooldownUntil has passed", () => { + expect(isProfileInCooldown({ cooldownUntil: now - 1 }, now)).toBe(false); + }); + + it("returns true when disabledUntil is in the future", () => { + expect(isProfileInCooldown({ disabledUntil: now + 1000 }, now)).toBe(true); + }); + + it("uses max of cooldownUntil and disabledUntil", () => { + const stats: ProfileUsageStats = { + cooldownUntil: now - 1, + disabledUntil: now + 5000, + }; + expect(isProfileInCooldown(stats, now)).toBe(true); + expect(resolveProfileUnusableUntil(stats)).toBe(now + 5000); + }); +}); + +// ============================================================ +// computeNextProfileUsageStats +// ============================================================ + +describe("computeNextProfileUsageStats", () => { + const now = 1_700_000_000_000; + + it("increments errorCount and sets cooldown for non-billing failure", () => { + const next = computeNextProfileUsageStats({}, "rate_limit", now, () => 1); + expect(next.errorCount).toBe(1); + expect(next.lastFailureAt).toBe(now); + expect(next.cooldownUntil).toBe(now + COOLDOWN_BASE_MS); + expect(next.failureCounts?.rate_limit).toBe(1); + expect(next.disabledUntil).toBeUndefined(); + }); + + it("applies exponential backoff on consecutive failures", () => { + const stats: ProfileUsageStats = { + errorCount: 2, + lastFailureAt: now - 1000, + failureCounts: { rate_limit: 2 }, + }; + const next = computeNextProfileUsageStats(stats, "rate_limit", now, () => 1); + expect(next.errorCount).toBe(3); + // Error 3 -> 25 min cooldown + expect(next.cooldownUntil).toBe(now + 25 * 60_000); + }); + + it("sets disabledUntil for billing failures (~5h by default)", () => { + const next = computeNextProfileUsageStats({}, "billing", now, () => 1); + expect(next.errorCount).toBe(1); + expect(next.disabledUntil).toBe(now + 5 * 60 * 60 * 1000); + expect(next.disabledReason).toBe("billing"); + expect(next.failureCounts?.billing).toBe(1); + }); + + it("resets counters when lastFailureAt is outside the failure window", () => { + const oldFailure = now - FAILURE_WINDOW_MS - 1000; + const stats: ProfileUsageStats = { + errorCount: 5, + lastFailureAt: oldFailure, + failureCounts: { auth: 3, rate_limit: 2 }, + }; + const next = computeNextProfileUsageStats(stats, "auth", now, () => 1); + // Counters reset, so this is treated as error #1 + expect(next.errorCount).toBe(1); + expect(next.failureCounts?.auth).toBe(1); + expect(next.cooldownUntil).toBe(now + COOLDOWN_BASE_MS); + }); + + it("caps cooldown at COOLDOWN_MAX_MS", () => { + const stats: ProfileUsageStats = { + errorCount: 10, + lastFailureAt: now - 1000, + }; + const next = computeNextProfileUsageStats(stats, "unknown", now, () => 1); + expect(next.cooldownUntil).toBe(now + COOLDOWN_MAX_MS); + }); +}); diff --git a/src/agent/auth-profiles/usage.ts b/src/agent/auth-profiles/usage.ts new file mode 100644 index 00000000..5aad992b --- /dev/null +++ b/src/agent/auth-profiles/usage.ts @@ -0,0 +1,179 @@ +/** + * Auth Profile Usage Tracking + * + * Tracks per-profile usage, computes cooldown durations with exponential backoff, + * and manages failure/success state transitions. + */ + +import { + COOLDOWN_BASE_MS, + COOLDOWN_FACTOR, + COOLDOWN_MAX_MS, + BILLING_BACKOFF_HOURS, + BILLING_MAX_HOURS, + FAILURE_WINDOW_MS, +} from "./constants.js"; +import { updateAuthProfileStore } from "./store.js"; +import type { + AuthProfileFailureReason, + AuthProfileStore, + ProfileUsageStats, +} from "./types.js"; + +// ============================================================ +// Cooldown checks +// ============================================================ + +/** Returns the timestamp until which a profile is unusable (0 if available) */ +export function resolveProfileUnusableUntil(stats: ProfileUsageStats): number { + return Math.max(stats.cooldownUntil ?? 0, stats.disabledUntil ?? 0); +} + +/** Check if a profile is currently in cooldown or disabled */ +export function isProfileInCooldown(stats: ProfileUsageStats, now?: number): boolean { + return resolveProfileUnusableUntil(stats) > (now ?? Date.now()); +} + +// ============================================================ +// Cooldown duration calculation +// ============================================================ + +/** + * Calculate non-billing cooldown duration in milliseconds. + * Exponential backoff: 1min -> 5min -> 25min -> 1hr (cap). + * + * Formula: min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * COOLDOWN_FACTOR ^ min(errorCount - 1, 3)) + */ +function applyEqualJitter(baseMs: number, rng?: () => number): number { + if (baseMs <= 0) return 0; + const rand = Math.min(1, Math.max(0, (rng ?? Math.random)())); + const half = Math.floor(baseMs / 2); + return half + Math.floor(rand * (baseMs - half)); +} + +export function calculateCooldownMs(errorCount: number, rng?: () => number): number { + if (errorCount <= 0) return 0; + const exponent = Math.min(errorCount - 1, 3); + const base = Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * COOLDOWN_FACTOR ** exponent); + return applyEqualJitter(base, rng); +} + +/** + * Calculate billing disable duration in milliseconds. + * Exponential backoff: 5h -> 10h -> 20h -> 24h (cap). + * + * Formula: min(BILLING_MAX_HOURS, BILLING_BACKOFF_HOURS * 2 ^ (count - 1)) * hours_to_ms + */ +export function calculateBillingDisableMs(billingFailCount: number, rng?: () => number): number { + if (billingFailCount <= 0) return 0; + const hours = Math.min( + BILLING_MAX_HOURS, + BILLING_BACKOFF_HOURS * 2 ** (billingFailCount - 1), + ); + const base = hours * 60 * 60 * 1000; + return applyEqualJitter(base, rng); +} + +// ============================================================ +// State transitions +// ============================================================ + +function ensureUsageStats(store: AuthProfileStore, profileId: string): ProfileUsageStats { + if (!store.usageStats) store.usageStats = {}; + if (!store.usageStats[profileId]) store.usageStats[profileId] = {}; + return store.usageStats[profileId]; +} + +/** + * Compute updated usage stats after a failure. + * Pure function — does not mutate the input stats. + */ +export function computeNextProfileUsageStats( + stats: ProfileUsageStats, + reason: AuthProfileFailureReason, + now?: number, + rng?: () => number, +): ProfileUsageStats { + const ts = now ?? Date.now(); + const next = { ...stats }; + + // Reset counters if last failure is outside the failure window + if (next.lastFailureAt && ts - next.lastFailureAt > FAILURE_WINDOW_MS) { + next.errorCount = 0; + next.failureCounts = {}; + } + + // Increment counters + next.errorCount = (next.errorCount ?? 0) + 1; + next.lastFailureAt = ts; + + if (!next.failureCounts) next.failureCounts = {}; + next.failureCounts = { + ...next.failureCounts, + [reason]: (next.failureCounts[reason] ?? 0) + 1, + }; + + // Apply cooldown based on failure reason + if (reason === "billing") { + const billingCount = next.failureCounts.billing ?? 1; + const disableMs = calculateBillingDisableMs(billingCount, rng); + next.disabledUntil = ts + disableMs; + next.disabledReason = "billing"; + } else { + const cooldownMs = calculateCooldownMs(next.errorCount, rng); + next.cooldownUntil = ts + cooldownMs; + } + + return next; +} + +/** + * Mark a profile as having failed. Persists updated stats to disk. + */ +export function markAuthProfileFailure( + profileId: string, + reason: AuthProfileFailureReason, + now?: number, +): void { + updateAuthProfileStore((store) => { + const current = ensureUsageStats(store, profileId); + const next = computeNextProfileUsageStats(current, reason, now); + store.usageStats![profileId] = next; + }); +} + +/** + * Mark a profile as successfully used. Resets all cooldown/error state. + */ +export function markAuthProfileUsed(profileId: string, now?: number): void { + updateAuthProfileStore((store) => { + const stats = ensureUsageStats(store, profileId); + stats.lastUsed = now ?? Date.now(); + stats.errorCount = 0; + stats.cooldownUntil = undefined; + stats.disabledUntil = undefined; + stats.disabledReason = undefined; + stats.failureCounts = undefined; + }); +} + +/** + * Mark a profile as the last known good for a provider. + */ +export function markAuthProfileGood(provider: string, profileId: string): void { + updateAuthProfileStore((store) => { + if (!store.lastGood) store.lastGood = {}; + store.lastGood[provider] = profileId; + }); +} + +/** + * Clear cooldown for a specific profile. + */ +export function clearAuthProfileCooldown(profileId: string): void { + updateAuthProfileStore((store) => { + const stats = ensureUsageStats(store, profileId); + stats.errorCount = 0; + stats.cooldownUntil = undefined; + }); +} diff --git a/src/agent/credentials.ts b/src/agent/credentials.ts index 6e1e3dc5..5f4c7555 100644 --- a/src/agent/credentials.ts +++ b/src/agent/credentials.ts @@ -21,6 +21,8 @@ export type CredentialsConfig = { llm?: { provider?: string | undefined; providers?: Record | undefined; + /** Explicit profile ordering per provider (e.g. { anthropic: ["anthropic", "anthropic:backup"] }) */ + order?: Record | undefined; } | undefined; tools?: Record | undefined; }; @@ -185,6 +187,30 @@ export class CredentialManager { return name in process.env; } + /** + * Get explicit profile order for a provider from credentials.json5 `llm.order`. + * Returns undefined if no explicit order is configured. + */ + getLlmOrder(provider: string): string[] | undefined { + this.loadCore(); + return this.coreConfig?.llm?.order?.[provider]; + } + + /** + * List all profile IDs from `llm.providers` that belong to a given provider. + * A profile matches if its key equals the provider exactly or starts with "provider:". + */ + listProfileIdsForProvider(provider: string): string[] { + this.loadCore(); + const providers = this.coreConfig?.llm?.providers; + if (!providers) return []; + + const prefix = `${provider}:`; + return Object.keys(providers).filter( + (key) => key === provider || key.startsWith(prefix), + ); + } + getResolvedEnvSnapshot(): Record { return { ...this.getResolvedSkillsEnv() }; } diff --git a/src/agent/providers/index.ts b/src/agent/providers/index.ts index 25108916..9cce3f56 100644 --- a/src/agent/providers/index.ts +++ b/src/agent/providers/index.ts @@ -28,6 +28,8 @@ export { type ProviderConfig, resolveProviderConfig, resolveApiKey, + resolveApiKeyForProfile, + resolveApiKeyForProvider, resolveBaseUrl, resolveModelId, resolveModel, diff --git a/src/agent/providers/resolver.ts b/src/agent/providers/resolver.ts index 7ec8dd14..7a18ef88 100644 --- a/src/agent/providers/resolver.ts +++ b/src/agent/providers/resolver.ts @@ -18,6 +18,12 @@ import { isOAuthProvider, } from "./registry.js"; import type { AgentOptions } from "../types.js"; +import { + loadAuthProfileStore, + resolveAuthProfileOrder, + isProfileInCooldown, +} from "../auth-profiles/index.js"; +import type { ResolvedProfileAuth } from "../auth-profiles/index.js"; // ============================================================ // Types @@ -128,6 +134,71 @@ export function resolveModelId(provider: string, explicitModel?: string): string return credentialManager.getLlmProviderConfig(provider)?.model ?? getDefaultModel(provider); } +// ============================================================ +// Profile-aware API Key Resolution +// ============================================================ + +/** + * Resolve API key for a specific auth profile ID. + * Profile IDs follow the convention: "provider" or "provider:label". + */ +export function resolveApiKeyForProfile(profileId: string): string | undefined { + const config = credentialManager.getLlmProviderConfig(profileId); + return config?.apiKey; +} + +/** + * Resolve API key by iterating auth profiles for a provider. + * Returns the first available (non-cooldown) profile with a valid key. + * Falls back to the legacy single-key resolution if no profiles are configured. + */ +export function resolveApiKeyForProvider( + provider: string, + explicitKey?: string, +): ResolvedProfileAuth | undefined { + if (explicitKey) { + return { apiKey: explicitKey, profileId: provider, provider }; + } + + // Try OAuth providers first + const providerConfig = resolveProviderConfig(provider); + if (providerConfig?.apiKey || providerConfig?.accessToken) { + const key = providerConfig.apiKey ?? providerConfig.accessToken; + if (key) return { apiKey: key, profileId: provider, provider }; + } + + // Try auth profiles (multi-key rotation) + const store = loadAuthProfileStore(); + const candidates = resolveAuthProfileOrder(provider, store); + + if (candidates.length > 0) { + for (const profileId of candidates) { + const stats = store.usageStats?.[profileId]; + if (stats && isProfileInCooldown(stats)) continue; + + const apiKey = resolveApiKeyForProfile(profileId); + if (apiKey) { + return { apiKey, profileId, provider }; + } + } + // All in cooldown — return the first one (will be retried when cooldown expires) + for (const profileId of candidates) { + const apiKey = resolveApiKeyForProfile(profileId); + if (apiKey) { + return { apiKey, profileId, provider }; + } + } + } + + // Fall back to single-key credentials.json5 + const fallbackKey = credentialManager.getLlmProviderConfig(provider)?.apiKey; + if (fallbackKey) { + return { apiKey: fallbackKey, profileId: provider, provider }; + } + + return undefined; +} + // ============================================================ // Model Resolution // ============================================================ diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 9ae9bf27..cc49c567 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -3,7 +3,13 @@ import { v7 as uuidv7 } from "uuid"; import type { AgentOptions, AgentRunResult } from "./types.js"; import { createAgentOutput } from "./cli/output.js"; import { resolveModel, resolveTools } from "./tools.js"; -import { resolveApiKey, resolveBaseUrl, resolveModelId } from "./providers/index.js"; +import { + resolveApiKey, + resolveApiKeyForProfile, + resolveApiKeyForProvider, + resolveBaseUrl, + resolveModelId, +} from "./providers/index.js"; import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js"; import { SkillManager } from "./skills/index.js"; @@ -14,6 +20,47 @@ import { type ContextWindowGuardResult, } from "./context-window/index.js"; import { mergeToolsConfig, type ToolsConfig } from "./tools/policy.js"; +import { + loadAuthProfileStore, + resolveAuthProfileOrder, + isProfileInCooldown, + markAuthProfileFailure, + markAuthProfileUsed, + markAuthProfileGood, +} from "./auth-profiles/index.js"; +import type { AuthProfileFailureReason } from "./auth-profiles/index.js"; + +// ============================================================ +// Error classification for auth profile rotation +// ============================================================ + +/** Classify an error into an auth profile failure reason */ +export function classifyError(error: unknown): AuthProfileFailureReason { + const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + + if (msg.includes("401") || msg.includes("403") || msg.includes("unauthorized") || msg.includes("invalid api key") || msg.includes("authentication")) { + return "auth"; + } + if (msg.includes("400") || msg.includes("invalid request") || msg.includes("malformed") || msg.includes("bad request") || msg.includes("schema")) { + return "format"; + } + if (msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests")) { + return "rate_limit"; + } + if (msg.includes("billing") || msg.includes("quota") || msg.includes("insufficient") || msg.includes("payment")) { + return "billing"; + } + if (msg.includes("timeout") || msg.includes("timed out") || msg.includes("econnreset") || msg.includes("etimedout")) { + return "timeout"; + } + return "unknown"; +} + +/** Check if an error is potentially retryable via profile rotation */ +export function isRotatableError(reason: AuthProfileFailureReason): boolean { + // timeout is rotatable because some providers hang on rate limit instead of returning 429 + return reason === "auth" || reason === "rate_limit" || reason === "billing" || reason === "timeout"; +} export class Agent { private readonly agent: PiAgentCore; @@ -23,25 +70,82 @@ export class Agent { private readonly skillManager?: SkillManager; private readonly contextWindowGuard: ContextWindowGuardResult; private readonly debug: boolean; + private readonly stderr: NodeJS.WritableStream; private initialized = false; + // Auth profile rotation state + private readonly resolvedProvider: string; + private currentApiKey: string | undefined; + private currentProfileId: string | undefined; + private profileCandidates: string[]; + private profileIndex: number; + private readonly pinnedProfile: boolean; + /** Current session ID */ readonly sessionId: string; constructor(options: AgentOptions = {}) { const stdout = options.logger?.stdout ?? process.stdout; - const stderr = options.logger?.stderr ?? process.stderr; - this.output = createAgentOutput({ stdout, stderr }); + this.stderr = options.logger?.stderr ?? process.stderr; + this.output = createAgentOutput({ stdout, stderr: this.stderr }); this.debug = options.debug ?? false; // Resolve provider and model from options > env vars > defaults - const resolvedProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding"; - const resolvedModel = resolveModelId(resolvedProvider, options.model); - const apiKey = resolveApiKey(resolvedProvider, options.apiKey); + const defaultProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding"; + if (options.authProfileId) { + const profileProvider = options.authProfileId.includes(":") + ? options.authProfileId.split(":")[0]! + : options.authProfileId; + if (options.provider && options.provider !== profileProvider) { + throw new Error( + `authProfileId provider mismatch: authProfileId="${options.authProfileId}" ` + + `does not match provider="${options.provider}"`, + ); + } + this.resolvedProvider = profileProvider; + } else { + this.resolvedProvider = defaultProvider; + } + const resolvedModel = resolveModelId(this.resolvedProvider, options.model); + + // === Auth profile resolution === + this.pinnedProfile = !!(options.authProfileId || options.apiKey); + + if (options.apiKey) { + // Explicit API key — no rotation + this.currentApiKey = options.apiKey; + this.currentProfileId = this.resolvedProvider; + this.profileCandidates = []; + this.profileIndex = 0; + } else if (options.authProfileId) { + // Pinned profile — no rotation + this.currentApiKey = resolveApiKeyForProfile(options.authProfileId) + ?? resolveApiKey(this.resolvedProvider); + this.currentProfileId = options.authProfileId; + this.profileCandidates = []; + this.profileIndex = 0; + } else { + // Profile-aware resolution with rotation support + const resolved = resolveApiKeyForProvider(this.resolvedProvider); + if (resolved) { + this.currentApiKey = resolved.apiKey; + this.currentProfileId = resolved.profileId; + } else { + this.currentApiKey = undefined; + this.currentProfileId = undefined; + } + + // Load full candidate list for rotation + const store = loadAuthProfileStore(); + this.profileCandidates = resolveAuthProfileOrder(this.resolvedProvider, store); + this.profileIndex = this.currentProfileId + ? Math.max(0, this.profileCandidates.indexOf(this.currentProfileId)) + : 0; + } this.agent = new PiAgentCore( - apiKey - ? { getApiKey: (_provider: string) => apiKey } + this.currentApiKey + ? { getApiKey: (_provider: string) => this.currentApiKey! } : {}, ); @@ -87,7 +191,7 @@ export class Agent { return tempSession.getMeta(); })(); - const effectiveProvider = resolvedModel ? resolvedProvider : (options.provider ?? storedMeta?.provider); + const effectiveProvider = resolvedModel ? this.resolvedProvider : (options.provider ?? storedMeta?.provider); const effectiveModel = resolvedModel ?? options.model ?? storedMeta?.model; let model = resolveModel({ ...options, provider: effectiveProvider, model: effectiveModel }); @@ -113,7 +217,7 @@ export class Agent { // 警告:context window 较小 if (this.contextWindowGuard.shouldWarn) { - stderr.write( + this.stderr.write( `[Context Window Guard] WARNING: Low context window: ${this.contextWindowGuard.tokens} tokens (source: ${this.contextWindowGuard.source})\n`, ); } @@ -131,7 +235,7 @@ export class Agent { // 获取 API Key(用于 summary 模式) const summaryApiKey = compactionMode === "summary" - ? resolveApiKey(resolvedProvider, options.apiKey) + ? resolveApiKey(this.resolvedProvider, options.apiKey) : undefined; // 创建 SessionManager(带 context window 配置) @@ -184,6 +288,10 @@ export class Agent { this.output.handleEvent(event); this.handleSessionEvent(event); }); + + if (this.debug && this.currentProfileId) { + console.error(`[debug] Auth profile: ${this.currentProfileId} (pinned=${this.pinnedProfile}, candidates=${this.profileCandidates.length})`); + } } /** Subscribe to raw AgentEvent from the underlying engine */ @@ -221,10 +329,87 @@ export class Agent { this.initialized = true; } this.output.state.lastAssistantText = ""; - await this.agent.prompt(prompt); + + const canRotate = !this.pinnedProfile && this.profileCandidates.length > 1; + let lastError: unknown; + + // Loop to exhaust all candidate profiles on rotatable errors + while (true) { + try { + await this.agent.prompt(prompt); + break; // success — exit loop + } catch (error) { + lastError = error; + + const reason = classifyError(error); + if (this.currentProfileId && isRotatableError(reason)) { + markAuthProfileFailure(this.currentProfileId, reason); + } + + if (!canRotate || !this.currentProfileId) throw error; + if (!isRotatableError(reason)) throw error; + + if (this.debug) { + this.stderr.write( + `[auth-profile] Profile "${this.currentProfileId}" failed (${reason}), attempting rotation...\n`, + ); + } + + if (!this.advanceAuthProfile()) { + throw lastError; // All profiles exhausted + } + + if (this.debug) { + this.stderr.write( + `[auth-profile] Rotated to profile "${this.currentProfileId}"\n`, + ); + } + + // Reset output for retry + this.output.state.lastAssistantText = ""; + // continue loop with new profile + } + } + + // Mark success + if (this.currentProfileId) { + markAuthProfileUsed(this.currentProfileId); + markAuthProfileGood(this.resolvedProvider, this.currentProfileId); + } + return { text: this.output.state.lastAssistantText, error: this.agent.state.error }; } + /** + * Advance to the next non-cooldown auth profile. + * Returns true if a new profile was activated, false if exhausted. + */ + private advanceAuthProfile(): boolean { + const store = loadAuthProfileStore(); + const startIndex = this.profileIndex; + + for (let i = 1; i < this.profileCandidates.length; i++) { + const nextIndex = (startIndex + i) % this.profileCandidates.length; + const candidateId = this.profileCandidates[nextIndex] as string | undefined; + if (!candidateId) continue; + + // Skip profiles in cooldown + const stats = store.usageStats?.[candidateId]; + if (stats && isProfileInCooldown(stats)) continue; + + // Try to resolve API key + const apiKey = resolveApiKeyForProfile(candidateId); + if (!apiKey) continue; + + this.currentApiKey = apiKey; + this.currentProfileId = candidateId; + this.profileIndex = nextIndex; + return true; + } + + return false; + } + private handleSessionEvent(event: AgentEvent) { if (event.type === "message_end") { const message = event.message as AgentMessage; diff --git a/src/agent/types.ts b/src/agent/types.ts index 75e53ad1..c7e37658 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -21,6 +21,8 @@ export type AgentOptions = { model?: string | undefined; /** Custom API key (overrides environment variable) */ apiKey?: string | undefined; + /** Pin a specific auth profile ID (e.g. "anthropic:backup"). Disables rotation. */ + authProfileId?: string | undefined; /** Custom base URL for the provider endpoint */ baseUrl?: string | undefined; /** System prompt, if profileId is set will auto-construct from profile */