Merge pull request #72 from multica-ai/auth-profile-rotation

feat(agent): auth profile rotation and cooldown
This commit is contained in:
LinYushen 2026-02-03 18:32:36 +08:00 committed by GitHub
commit dbe57f093b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1646 additions and 52 deletions

View file

@ -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",

147
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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;

View file

@ -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);
});
});

View file

@ -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";

View file

@ -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<string, { apiKey?: string }> = {};
let _order: Record<string, string[]> = {};
// 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<string, { apiKey?: string }>) {
_profiles = profiles;
}
function setOrder(order: Record<string, string[]>) {
_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);
});
});

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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<LockPayload>;
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<string, unknown>;
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<string, string>;
}
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;
}
}

View file

@ -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<Record<AuthProfileFailureReason, number>> | 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<string, string> | undefined;
/** Per-profile usage/cooldown stats */
usageStats?: Record<string, ProfileUsageStats> | undefined;
};
/** Resolved auth info returned by profile-aware key resolution */
export type ResolvedProfileAuth = {
apiKey: string;
profileId: string;
provider: string;
};

View file

@ -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);
});
});

View file

@ -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;
});
}

View file

@ -21,6 +21,8 @@ export type CredentialsConfig = {
llm?: {
provider?: string | undefined;
providers?: Record<string, ProviderConfig> | undefined;
/** Explicit profile ordering per provider (e.g. { anthropic: ["anthropic", "anthropic:backup"] }) */
order?: Record<string, string[]> | undefined;
} | undefined;
tools?: Record<string, ToolConfig> | 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<string, string> {
return { ...this.getResolvedSkillsEnv() };
}

View file

@ -28,6 +28,8 @@ export {
type ProviderConfig,
resolveProviderConfig,
resolveApiKey,
resolveApiKeyForProfile,
resolveApiKeyForProvider,
resolveBaseUrl,
resolveModelId,
resolveModel,

View file

@ -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
// ============================================================

View file

@ -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;

View file

@ -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 */