diff --git a/README.md b/README.md index 11e01e0..bf740e0 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,17 @@ Both fine-grained PATs (`github_pat_...`) and classic PATs (`ghp_...`) should wo Tier options: `free`, `pro`, `pro+`, `business`, `enterprise` +PAT scope guidance (read-only first): + +- Personal/user-billed Copilot usage: fine-grained PAT with **Account permissions > Plan > Read**. +- Organization-managed Copilot usage metrics: classic token with **`read:org`** (or fine-grained org permission **Organization Copilot metrics: read** when using org metrics endpoints). +- Enterprise-managed Copilot usage metrics: classic token with **`read:enterprise`**. + +GitHub notes that user-level billing endpoints may not include usage for org/enterprise-managed seats; use org/enterprise metrics endpoints in that case. + +When both Copilot OAuth auth and `copilot-quota-token.json` are present, the plugin prefers the PAT billing path for quota metrics. +Run `/quota_status` and check `copilot_quota_auth` to confirm `pat_state`, candidate paths checked, and `effective_source`/`override`. +
diff --git a/package-lock.json b/package-lock.json index 3d6cef7..4fefc33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "2.4.0", "license": "MIT", "dependencies": { - "opencode-antigravity-auth": "^1.2.8" + "opencode-antigravity-auth": "^1.2.8", + "xdg-basedir": "^5.1.0" }, "devDependencies": { "@opencode-ai/plugin": "^1.1.14", @@ -562,9 +563,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz", - "integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -576,9 +577,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz", - "integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -590,9 +591,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz", - "integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -604,9 +605,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz", - "integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -618,9 +619,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz", - "integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -632,9 +633,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz", - "integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -646,9 +647,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz", - "integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -660,9 +661,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz", - "integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -674,9 +675,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz", - "integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -688,9 +689,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz", - "integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -702,9 +703,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz", - "integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -716,9 +717,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz", - "integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -730,9 +731,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz", - "integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -744,9 +745,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz", - "integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -758,9 +759,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz", - "integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -772,9 +773,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz", - "integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -786,9 +787,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz", - "integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -800,9 +801,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz", - "integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -814,9 +815,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz", - "integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -828,9 +829,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz", - "integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -842,9 +843,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz", - "integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -856,9 +857,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz", - "integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -870,9 +871,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz", - "integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -884,9 +885,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz", - "integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -898,9 +899,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz", - "integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1352,9 +1353,9 @@ "license": "ISC" }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", + "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", "license": "MIT", "peer": true, "engines": { @@ -1725,9 +1726,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz", - "integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -1741,31 +1742,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.2", - "@rollup/rollup-android-arm64": "4.55.2", - "@rollup/rollup-darwin-arm64": "4.55.2", - "@rollup/rollup-darwin-x64": "4.55.2", - "@rollup/rollup-freebsd-arm64": "4.55.2", - "@rollup/rollup-freebsd-x64": "4.55.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.2", - "@rollup/rollup-linux-arm-musleabihf": "4.55.2", - "@rollup/rollup-linux-arm64-gnu": "4.55.2", - "@rollup/rollup-linux-arm64-musl": "4.55.2", - "@rollup/rollup-linux-loong64-gnu": "4.55.2", - "@rollup/rollup-linux-loong64-musl": "4.55.2", - "@rollup/rollup-linux-ppc64-gnu": "4.55.2", - "@rollup/rollup-linux-ppc64-musl": "4.55.2", - "@rollup/rollup-linux-riscv64-gnu": "4.55.2", - "@rollup/rollup-linux-riscv64-musl": "4.55.2", - "@rollup/rollup-linux-s390x-gnu": "4.55.2", - "@rollup/rollup-linux-x64-gnu": "4.55.2", - "@rollup/rollup-linux-x64-musl": "4.55.2", - "@rollup/rollup-openbsd-x64": "4.55.2", - "@rollup/rollup-openharmony-arm64": "4.55.2", - "@rollup/rollup-win32-arm64-msvc": "4.55.2", - "@rollup/rollup-win32-ia32-msvc": "4.55.2", - "@rollup/rollup-win32-x64-gnu": "4.55.2", - "@rollup/rollup-win32-x64-msvc": "4.55.2", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/src/lib/copilot.ts b/src/lib/copilot.ts index 4252e34..b286160 100644 --- a/src/lib/copilot.ts +++ b/src/lib/copilot.ts @@ -10,6 +10,7 @@ */ import type { + AuthData, CopilotAuthData, CopilotQuotaConfig, CopilotTier, @@ -20,9 +21,9 @@ import type { } from "./types.js"; import { fetchWithTimeout } from "./http.js"; import { readAuthFile } from "./opencode-auth.js"; +import { getOpencodeRuntimeDirCandidates } from "./opencode-runtime-paths.js"; import { existsSync, readFileSync } from "fs"; -import { homedir } from "os"; import { join } from "path"; // ============================================================================= @@ -39,11 +40,7 @@ const EDITOR_VERSION = "vscode/1.107.0"; const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}`; const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`; -const COPILOT_QUOTA_CONFIG_PATH = join( - process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), - "opencode", - "copilot-quota-token.json", -); +const COPILOT_QUOTA_CONFIG_FILENAME = "copilot-quota-token.json"; // ============================================================================= // Helpers @@ -77,6 +74,61 @@ function buildLegacyTokenHeaders(token: string): Record { type GitHubRestAuthScheme = "bearer" | "token"; +type CopilotAuthKeyName = "github-copilot" | "copilot" | "copilot-chat"; + +type CopilotPatTokenKind = "github_pat" | "ghp" | "other"; + +export type CopilotPatState = "absent" | "invalid" | "valid"; + +export interface CopilotPatReadResult { + state: CopilotPatState; + checkedPaths: string[]; + selectedPath?: string; + config?: CopilotQuotaConfig; + error?: string; + tokenKind?: CopilotPatTokenKind; +} + +export interface CopilotQuotaAuthDiagnostics { + pat: CopilotPatReadResult; + oauth: { + configured: boolean; + keyName: CopilotAuthKeyName | null; + hasRefreshToken: boolean; + hasAccessToken: boolean; + }; + effectiveSource: "pat" | "oauth" | "none"; + override: "pat_overrides_oauth" | "none"; +} + +function classifyPatTokenKind(token: string): CopilotPatTokenKind { + const trimmed = token.trim(); + if (trimmed.startsWith("github_pat_")) return "github_pat"; + if (trimmed.startsWith("ghp_")) return "ghp"; + return "other"; +} + +function dedupePaths(paths: string[]): string[] { + const out: string[] = []; + const seen = new Set(); + + for (const path of paths) { + if (!path) continue; + if (seen.has(path)) continue; + seen.add(path); + out.push(path); + } + + return out; +} + +export function getCopilotPatConfigCandidatePaths(): string[] { + const candidates = getOpencodeRuntimeDirCandidates(); + return dedupePaths( + candidates.configDirs.map((configDir) => join(configDir, COPILOT_QUOTA_CONFIG_FILENAME)), + ); +} + function buildGitHubRestHeaders( token: string, scheme: GitHubRestAuthScheme, @@ -124,6 +176,93 @@ async function readGitHubRestErrorMessage(response: Response): Promise { return text.slice(0, 160); } +function validateQuotaConfig(raw: unknown): { config: CopilotQuotaConfig | null; error?: string } { + if (!raw || typeof raw !== "object") { + return { config: null, error: "Config must be a JSON object" }; + } + + const obj = raw as Record; + const token = typeof obj.token === "string" ? obj.token.trim() : ""; + const tierRaw = typeof obj.tier === "string" ? obj.tier.trim() : ""; + const usernameRaw = obj.username; + + if (!token) { + return { config: null, error: "Missing required string field: token" }; + } + + const validTiers: CopilotTier[] = ["free", "pro", "pro+", "business", "enterprise"]; + if (!validTiers.includes(tierRaw as CopilotTier)) { + return { + config: null, + error: "Invalid tier; expected one of: free, pro, pro+, business, enterprise", + }; + } + + let username: string | undefined; + if (usernameRaw != null) { + if (typeof usernameRaw !== "string") { + return { config: null, error: "username must be a non-empty string when provided" }; + } + const trimmed = usernameRaw.trim(); + if (!trimmed) { + return { config: null, error: "username must be a non-empty string when provided" }; + } + username = trimmed; + } + + return { + config: { + token, + tier: tierRaw as CopilotTier, + username, + }, + }; +} + +export function readQuotaConfigWithMeta(): CopilotPatReadResult { + const checkedPaths = getCopilotPatConfigCandidatePaths(); + + for (const path of checkedPaths) { + if (!existsSync(path)) continue; + + try { + const content = readFileSync(path, "utf-8"); + const parsed = JSON.parse(content) as unknown; + const validated = validateQuotaConfig(parsed); + + if (!validated.config) { + return { + state: "invalid", + checkedPaths, + selectedPath: path, + error: validated.error ?? "Invalid config", + }; + } + + return { + state: "valid", + checkedPaths, + selectedPath: path, + config: validated.config, + tokenKind: classifyPatTokenKind(validated.config.token), + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + state: "invalid", + checkedPaths, + selectedPath: path, + error: msg, + }; + } + } + + return { + state: "absent", + checkedPaths, + }; +} + async function fetchGitHubRestJsonOnce( url: string, token: string, @@ -154,51 +293,66 @@ async function fetchGitHubRestJsonOnce( */ async function readCopilotAuth(): Promise { const authData = await readAuthFile(); - if (!authData) return null; - - // Try known key names in priority order - const copilotAuth = - authData["github-copilot"] ?? - (authData as Record)["copilot"] ?? - (authData as Record)["copilot-chat"]; - - if (!copilotAuth || copilotAuth.type !== "oauth" || !copilotAuth.refresh) { - return null; - } - - return copilotAuth; + return selectCopilotAuth(authData).auth; } /** - * Read optional Copilot quota config from user's config file. - * Returns null if file doesn't exist or is invalid. + * Select Copilot OAuth auth entry from auth.json-shaped data. */ -function readQuotaConfig(): CopilotQuotaConfig | null { - try { - if (!existsSync(COPILOT_QUOTA_CONFIG_PATH)) { - return null; - } +function selectCopilotAuth( + authData: AuthData | null, +): { auth: CopilotAuthData | null; keyName: CopilotAuthKeyName | null } { + if (!authData) { + return { auth: null, keyName: null }; + } - const content = readFileSync(COPILOT_QUOTA_CONFIG_PATH, "utf-8"); - const parsed = JSON.parse(content) as CopilotQuotaConfig; + const candidates: Array<[CopilotAuthKeyName, CopilotAuthData | undefined]> = [ + ["github-copilot", authData["github-copilot"]], + ["copilot", (authData as Record).copilot], + ["copilot-chat", (authData as Record)["copilot-chat"]], + ]; + + for (const [keyName, candidate] of candidates) { + if (!candidate) continue; + if (candidate.type !== "oauth") continue; + if (!candidate.refresh) continue; + return { auth: candidate, keyName }; + } - if (!parsed || typeof parsed !== "object") return null; + return { auth: null, keyName: null }; +} - if (typeof parsed.token !== "string" || parsed.token.trim() === "") return null; - if (typeof parsed.tier !== "string" || parsed.tier.trim() === "") return null; +export function getCopilotQuotaAuthDiagnostics(authData: AuthData | null): CopilotQuotaAuthDiagnostics { + const pat = readQuotaConfigWithMeta(); + const { auth, keyName } = selectCopilotAuth(authData); + const oauthConfigured = Boolean(auth); - // Username is optional now that we prefer the /user/... billing endpoint. - if (parsed.username != null) { - if (typeof parsed.username !== "string" || parsed.username.trim() === "") return null; - } + let effectiveSource: "pat" | "oauth" | "none" = "none"; + if (pat.state === "valid") { + effectiveSource = "pat"; + } else if (oauthConfigured) { + effectiveSource = "oauth"; + } - const validTiers: CopilotTier[] = ["free", "pro", "pro+", "business", "enterprise"]; - if (!validTiers.includes(parsed.tier as CopilotTier)) return null; + return { + pat, + oauth: { + configured: oauthConfigured, + keyName, + hasRefreshToken: Boolean(auth?.refresh), + hasAccessToken: Boolean(auth?.access), + }, + effectiveSource, + override: pat.state === "valid" && oauthConfigured ? "pat_overrides_oauth" : "none", + }; +} - return parsed; - } catch { - return null; - } +function computePercentRemainingFromUsed(params: { used: number; total: number }): number { + const { used, total } = params; + if (!Number.isFinite(total) || total <= 0) return 0; + if (!Number.isFinite(used) || used <= 0) return 100; + const usedPct = Math.max(0, Math.min(100, Math.ceil((used / total) * 100))); + return 100 - usedPct; } // Public billing API response types (keep local; only used here) @@ -305,12 +459,12 @@ function toQuotaResultFromBilling( throw new Error(`Unsupported Copilot tier: ${tier}`); } - const remaining = Math.max(0, total - used); - const percentRemaining = Math.max(0, Math.min(100, Math.round((remaining / total) * 100))); + const normalizedUsed = Math.max(0, used); + const percentRemaining = computePercentRemainingFromUsed({ used: normalizedUsed, total }); return { success: true, - used, + used: normalizedUsed, total, percentRemaining, resetTimeIso: getApproxNextResetIso(), @@ -418,11 +572,11 @@ async function fetchCopilotUsage(authData: CopilotAuthData): Promise { // Strategy 1: Try public billing API with user's fine-grained PAT. - const quotaConfig = readQuotaConfig(); - if (quotaConfig) { + const quotaConfigRead = readQuotaConfigWithMeta(); + if (quotaConfigRead.state === "valid" && quotaConfigRead.config) { try { - const billing = await fetchPublicBillingUsage(quotaConfig); - return toQuotaResultFromBilling(billing, quotaConfig.tier); + const billing = await fetchPublicBillingUsage(quotaConfigRead.config); + return toQuotaResultFromBilling(billing, quotaConfigRead.config.tier); } catch (err) { return { success: false, @@ -459,8 +613,30 @@ export async function queryCopilotQuota(): Promise { } const total = premium.entitlement; - const used = total - premium.remaining; - const percentRemaining = Math.round(premium.percent_remaining); + if (!Number.isFinite(total) || total <= 0) { + return { + success: false, + error: "Invalid premium quota entitlement", + } as QuotaError; + } + + const remainingRaw = + typeof premium.remaining === "number" + ? premium.remaining + : typeof premium.quota_remaining === "number" + ? premium.quota_remaining + : NaN; + + if (!Number.isFinite(remainingRaw)) { + return { + success: false, + error: "Invalid premium quota remaining value", + } as QuotaError; + } + + const remaining = Math.max(0, Math.min(total, remainingRaw)); + const used = Math.max(0, total - remaining); + const percentRemaining = computePercentRemainingFromUsed({ used, total }); return { success: true, diff --git a/src/lib/quota-status.ts b/src/lib/quota-status.ts index a25e980..6b41bfd 100644 --- a/src/lib/quota-status.ts +++ b/src/lib/quota-status.ts @@ -6,6 +6,7 @@ import { getGoogleTokenCachePath } from "./google-token-cache.js"; import { getAntigravityAccountsCandidatePaths, readAntigravityAccounts } from "./google.js"; import { getFirmwareKeyDiagnostics } from "./firmware.js"; import { getChutesKeyDiagnostics } from "./chutes.js"; +import { getCopilotQuotaAuthDiagnostics } from "./copilot.js"; import { computeQwenQuota, getQwenLocalQuotaPath, @@ -334,6 +335,31 @@ export async function buildQuotaStatusReport(params: { lines.push(`- chutes api key checked: ${chutesDiag.checkedPaths.join(" | ")}`); } + const copilotDiag = getCopilotQuotaAuthDiagnostics(authData); + lines.push(""); + lines.push("copilot_quota_auth:"); + lines.push(`- pat_state: ${copilotDiag.pat.state}`); + if (copilotDiag.pat.selectedPath) { + lines.push(`- pat_path: ${copilotDiag.pat.selectedPath}`); + } + if (copilotDiag.pat.tokenKind) { + lines.push(`- pat_token_kind: ${copilotDiag.pat.tokenKind}`); + } + if (copilotDiag.pat.config?.tier) { + lines.push(`- pat_tier: ${copilotDiag.pat.config.tier}`); + } + if (copilotDiag.pat.error) { + lines.push(`- pat_error: ${copilotDiag.pat.error}`); + } + lines.push( + `- pat_checked_paths: ${copilotDiag.pat.checkedPaths.length ? copilotDiag.pat.checkedPaths.join(" | ") : "(none)"}`, + ); + lines.push( + `- oauth_configured: ${copilotDiag.oauth.configured ? "true" : "false"} key=${copilotDiag.oauth.keyName ?? "(none)"} refresh=${copilotDiag.oauth.hasRefreshToken ? "true" : "false"} access=${copilotDiag.oauth.hasAccessToken ? "true" : "false"}`, + ); + lines.push(`- effective_source: ${copilotDiag.effectiveSource}`); + lines.push(`- override: ${copilotDiag.override}`); + const googleTokenCachePath = getGoogleTokenCachePath(); lines.push( `- google token cache: ${googleTokenCachePath}${(await pathExists(googleTokenCachePath)) ? "" : " (missing)"}`, diff --git a/src/lib/types.ts b/src/lib/types.ts index 4152a70..e65e424 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -117,8 +117,9 @@ export type CopilotTier = "free" | "pro" | "pro+" | "business" | "enterprise"; * Copilot quota token configuration. * * Stored locally in: - * - $XDG_CONFIG_HOME/opencode/copilot-quota-token.json, or - * - ~/.config/opencode/copilot-quota-token.json + * - OpenCode runtime config candidate directories as + * `.../opencode/copilot-quota-token.json` + * (for example `$XDG_CONFIG_HOME/opencode` or `~/.config/opencode`) * * Users can create a fine-grained PAT with "Plan" read permission * to enable quota checking via GitHub's public billing API. diff --git a/tests/lib.copilot.test.ts b/tests/lib.copilot.test.ts index d57f23b..3af9884 100644 --- a/tests/lib.copilot.test.ts +++ b/tests/lib.copilot.test.ts @@ -1,34 +1,52 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("fs", async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - // Prevent test environment from accidentally using a real local PAT config. - existsSync: vi.fn(() => false), - }; -}); +const fsMocks = vi.hoisted(() => ({ + existsSync: vi.fn<(path: string) => boolean>(() => false), + readFileSync: vi.fn<(path: string, encoding: BufferEncoding) => string>(() => ""), +})); -vi.mock("../src/lib/opencode-runtime-paths.js", () => ({ - getOpencodeRuntimeDirCandidates: () => ({ +const runtimeMocks = vi.hoisted(() => ({ + getOpencodeRuntimeDirCandidates: vi.fn(() => ({ dataDirs: ["/home/test/.local/share/opencode"], - configDirs: ["/home/test/.config/opencode"], + configDirs: [ + "/home/test/.config/opencode", + "/home/test/Library/Application Support/opencode", + ], cacheDirs: ["/home/test/.cache/opencode"], stateDirs: ["/home/test/.local/state/opencode"], - }), - getOpencodeRuntimeDirs: () => ({ + })), + getOpencodeRuntimeDirs: vi.fn(() => ({ dataDir: "/home/test/.local/share/opencode", configDir: "/home/test/.config/opencode", cacheDir: "/home/test/.cache/opencode", stateDir: "/home/test/.local/state/opencode", - }), + })), })); -vi.mock("../src/lib/opencode-auth.js", () => ({ +const authMocks = vi.hoisted(() => ({ readAuthFile: vi.fn(), })); +vi.mock("fs", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + existsSync: fsMocks.existsSync, + readFileSync: fsMocks.readFileSync, + }; +}); + +vi.mock("../src/lib/opencode-runtime-paths.js", () => ({ + getOpencodeRuntimeDirCandidates: runtimeMocks.getOpencodeRuntimeDirCandidates, + getOpencodeRuntimeDirs: runtimeMocks.getOpencodeRuntimeDirs, +})); + +vi.mock("../src/lib/opencode-auth.js", () => ({ + readAuthFile: authMocks.readAuthFile, +})); + const realEnv = process.env; +const patPath = "/home/test/.config/opencode/copilot-quota-token.json"; describe("queryCopilotQuota", () => { beforeEach(() => { @@ -36,6 +54,14 @@ describe("queryCopilotQuota", () => { vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z")); process.env = { ...realEnv }; vi.resetModules(); + + fsMocks.existsSync.mockReset(); + fsMocks.readFileSync.mockReset(); + authMocks.readAuthFile.mockReset(); + + fsMocks.existsSync.mockReturnValue(false); + fsMocks.readFileSync.mockReturnValue(""); + authMocks.readAuthFile.mockResolvedValue({}); }); afterEach(() => { @@ -44,29 +70,80 @@ describe("queryCopilotQuota", () => { it("returns null when not configured and no PAT config", async () => { const { queryCopilotQuota } = await import("../src/lib/copilot.js"); - const { readAuthFile } = await import("../src/lib/opencode-auth.js"); - (readAuthFile as any).mockResolvedValueOnce({}); + authMocks.readAuthFile.mockResolvedValueOnce({}); await expect(queryCopilotQuota()).resolves.toBeNull(); }); - it("uses token exchange when legacy internal call fails", async () => { + it("uses PAT billing API when PAT config exists and overrides OAuth auth", async () => { const { queryCopilotQuota } = await import("../src/lib/copilot.js"); - const { readAuthFile } = await import("../src/lib/opencode-auth.js"); - (readAuthFile as any).mockResolvedValueOnce({ - "github-copilot": { type: "oauth", refresh: "gho_abc" }, + + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + token: "github_pat_123456789", + tier: "pro", + }), + ); + + authMocks.readAuthFile.mockResolvedValueOnce({ + "github-copilot": { type: "oauth", refresh: "gho_oauth_token" }, }); - const fetchMock = vi.fn(async (url: any, _opts: any) => { + const fetchMock = vi.fn(async (url: unknown, opts: RequestInit | undefined) => { const s = String(url); - if (s.includes("/copilot_internal/user")) { - // first attempt: legacy auth call fails, second attempt: bearer works - const auth = _opts?.headers?.Authorization || _opts?.headers?.authorization; - if (typeof auth === "string" && auth.startsWith("token ")) { - return new Response("forbidden", { status: 403 }); - } + if (s.includes("/user/settings/billing/premium_request/usage")) { + expect((opts?.headers as Record | undefined)?.Authorization).toBe( + "Bearer github_pat_123456789", + ); + return new Response( + JSON.stringify({ + timePeriod: { year: 2026, month: 1 }, + user: "halfwalker", + usageItems: [ + { + product: "copilot", + sku: "Copilot Premium Request", + unitType: "count", + grossQuantity: 1, + netQuantity: 1, + limit: 300, + }, + ], + }), + { status: 200 }, + ); + } + + return new Response("not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fetchMock as any); + + const out = await queryCopilotQuota(); + expect(out && out.success ? out.total : -1).toBe(300); + expect(out && out.success ? out.used : -1).toBe(1); + expect(out && out.success ? out.percentRemaining : -1).toBe(99); + expect(authMocks.readAuthFile).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("falls back to OAuth/internal flow when PAT config is invalid", async () => { + const { getCopilotQuotaAuthDiagnostics, queryCopilotQuota } = await import("../src/lib/copilot.js"); + + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue("{bad-json"); + + const oauthAuth = { + "github-copilot": { type: "oauth", refresh: "gho_abc" }, + }; + authMocks.readAuthFile.mockResolvedValueOnce(oauthAuth); + + const fetchMock = vi.fn(async (url: unknown) => { + const s = String(url); + if (s.includes("/copilot_internal/user")) { return new Response( JSON.stringify({ copilot_plan: "pro", @@ -74,8 +151,8 @@ describe("queryCopilotQuota", () => { quota_snapshots: { premium_interactions: { entitlement: 300, - remaining: 200, - percent_remaining: 66.7, + remaining: 299, + percent_remaining: 100, unlimited: false, overage_count: 0, overage_permitted: false, @@ -88,13 +165,89 @@ describe("queryCopilotQuota", () => { ); } - if (s.includes("/copilot_internal/v2/token")) { + return new Response("not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fetchMock as any); + + const out = await queryCopilotQuota(); + expect(out && out.success ? out.total : -1).toBe(300); + expect(out && out.success ? out.used : -1).toBe(1); + expect(out && out.success ? out.percentRemaining : -1).toBe(99); + + const diag = getCopilotQuotaAuthDiagnostics(oauthAuth as any); + expect(diag.pat.state).toBe("invalid"); + expect(diag.pat.selectedPath).toBe(patPath); + expect(diag.effectiveSource).toBe("oauth"); + expect(diag.override).toBe("none"); + }); + + it("returns PAT error and does not fall back to OAuth when PAT is rejected", async () => { + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + token: "github_pat_123456789", + tier: "pro", + }), + ); + + authMocks.readAuthFile.mockResolvedValueOnce({ + "github-copilot": { type: "oauth", refresh: "gho_should_not_be_used" }, + }); + + const fetchMock = vi.fn(async (url: unknown) => { + const s = String(url); + + if (s.includes("/user/settings/billing/premium_request/usage")) { + return new Response(JSON.stringify({ message: "Forbidden" }), { status: 403 }); + } + + if (s.includes("/copilot_internal/user")) { + return new Response("unexpected oauth fallback", { status: 200 }); + } + + return new Response("not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fetchMock as any); + + const out = await queryCopilotQuota(); + expect(out && !out.success ? out.error : "").toContain("GitHub API error 403"); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("/copilot_internal/user"))).toBe( + false, + ); + expect(authMocks.readAuthFile).not.toHaveBeenCalled(); + }); + + it("computes remaining percentage from entitlement/remaining when OAuth response percent is stale", async () => { + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + + authMocks.readAuthFile.mockResolvedValueOnce({ + "github-copilot": { type: "oauth", refresh: "gho_abc" }, + }); + + const fetchMock = vi.fn(async (url: unknown) => { + const s = String(url); + + if (s.includes("/copilot_internal/user")) { return new Response( JSON.stringify({ - token: "cpt_sess", - expires_at: Date.now() + 60_000, - refresh_in: 30_000, - endpoints: { api: "https://api.github.com" }, + copilot_plan: "pro", + quota_reset_date: "2026-02-01T00:00:00.000Z", + quota_snapshots: { + premium_interactions: { + entitlement: 300, + remaining: 299, + percent_remaining: 100, + unlimited: false, + overage_count: 0, + overage_permitted: false, + quota_id: "x", + quota_remaining: 299, + }, + }, }), { status: 200 }, ); @@ -106,7 +259,7 @@ describe("queryCopilotQuota", () => { vi.stubGlobal("fetch", fetchMock as any); const out = await queryCopilotQuota(); - expect(out && out.success ? out.total : -1).toBe(300); - expect(out && out.success ? out.used : -1).toBe(100); + expect(out && out.success ? out.used : -1).toBe(1); + expect(out && out.success ? out.percentRemaining : -1).toBe(99); }); }); diff --git a/tests/lib.quota-status.copilot-diagnostics.test.ts b/tests/lib.quota-status.copilot-diagnostics.test.ts new file mode 100644 index 0000000..5fb3c8a --- /dev/null +++ b/tests/lib.quota-status.copilot-diagnostics.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const copilotMocks = vi.hoisted(() => ({ + getCopilotQuotaAuthDiagnostics: vi.fn(), +})); + +vi.mock("fs/promises", () => ({ + stat: vi.fn(async () => ({ mtimeMs: Date.now() })), +})); + +vi.mock("../src/lib/opencode-auth.js", () => ({ + getAuthPath: () => "/home/test/.config/opencode/auth.json", + getAuthPaths: () => ["/home/test/.config/opencode/auth.json"], + readAuthFileCached: vi.fn(async () => ({ + "github-copilot": { type: "oauth", refresh: "gho_test" }, + })), +})); + +vi.mock("../src/lib/opencode-runtime-paths.js", () => ({ + getOpencodeRuntimeDirs: () => ({ + dataDir: "/home/test/.local/share/opencode", + configDir: "/home/test/.config/opencode", + cacheDir: "/home/test/.cache/opencode", + stateDir: "/home/test/.local/state/opencode", + }), +})); + +vi.mock("../src/lib/google-token-cache.js", () => ({ + getGoogleTokenCachePath: () => "/home/test/.cache/opencode/google-token-cache.json", +})); + +vi.mock("../src/lib/google.js", () => ({ + getAntigravityAccountsCandidatePaths: () => ["/home/test/.config/opencode/antigravity-accounts.json"], + readAntigravityAccounts: vi.fn(async () => []), +})); + +vi.mock("../src/lib/firmware.js", () => ({ + getFirmwareKeyDiagnostics: vi.fn(async () => ({ configured: false, source: null, checkedPaths: [] })), +})); + +vi.mock("../src/lib/chutes.js", () => ({ + getChutesKeyDiagnostics: vi.fn(async () => ({ configured: false, source: null, checkedPaths: [] })), +})); + +vi.mock("../src/lib/qwen-local-quota.js", () => ({ + computeQwenQuota: () => ({ + day: { used: 0, limit: 1000 }, + rpm: { used: 0, limit: 60 }, + }), + getQwenLocalQuotaPath: () => "/home/test/.local/state/opencode/opencode-quota/qwen-local-quota.json", + readQwenLocalQuotaState: vi.fn(async () => ({ day: { used: 0 }, minute: [] })), +})); + +vi.mock("../src/lib/qwen-auth.js", () => ({ + hasQwenOAuthAuth: () => false, +})); + +vi.mock("../src/lib/modelsdev-pricing.js", () => ({ + getPricingSnapshotHealth: () => ({ ageMs: 0, maxAgeMs: 259200000, stale: false }), + getPricingRefreshPolicy: () => ({ maxAgeMs: 259200000 }), + getPricingSnapshotMeta: () => ({ + source: "bundled", + generatedAt: Date.parse("2026-01-01T00:00:00.000Z"), + units: "usd_1m_tokens", + }), + getPricingSnapshotSource: () => "bundled", + getRuntimePricingRefreshStatePath: () => "/home/test/.cache/opencode/opencode-quota/pricing-refresh-state.json", + getRuntimePricingSnapshotPath: () => "/home/test/.cache/opencode/opencode-quota/pricing.json", + listProviders: () => [], + getProviderModelCount: () => 0, + hasProvider: () => false, + readPricingRefreshState: vi.fn(async () => null), +})); + +vi.mock("../src/providers/registry.js", () => ({ + getProviders: () => [], +})); + +vi.mock("../src/lib/version.js", () => ({ + getPackageVersion: vi.fn(async () => "2.4.0-test"), +})); + +vi.mock("../src/lib/opencode-storage.js", () => ({ + getOpenCodeDbPath: () => "/home/test/.local/share/opencode/opencode.db", + getOpenCodeDbPathCandidates: () => ["/home/test/.local/share/opencode/opencode.db"], + getOpenCodeDbStats: vi.fn(async () => ({ + sessionCount: 0, + messageCount: 0, + assistantMessageCount: 0, + })), +})); + +vi.mock("../src/lib/quota-stats.js", () => ({ + aggregateUsage: vi.fn(async () => ({ + byModel: [], + unknown: [], + unpriced: [], + bySourceProvider: [], + totals: { + unknown: { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 }, + unpriced: { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 }, + }, + })), +})); + +vi.mock("../src/lib/copilot.js", () => ({ + getCopilotQuotaAuthDiagnostics: copilotMocks.getCopilotQuotaAuthDiagnostics, +})); + +describe("buildQuotaStatusReport copilot diagnostics", () => { + beforeEach(() => { + copilotMocks.getCopilotQuotaAuthDiagnostics.mockReset(); + }); + + it("renders PAT override details when PAT is effective source", async () => { + copilotMocks.getCopilotQuotaAuthDiagnostics.mockReturnValue({ + pat: { + state: "valid", + checkedPaths: [ + "/home/test/.config/opencode/copilot-quota-token.json", + "/home/test/Library/Application Support/opencode/copilot-quota-token.json", + ], + selectedPath: "/home/test/.config/opencode/copilot-quota-token.json", + tokenKind: "github_pat", + config: { + token: "github_pat_hidden", + tier: "enterprise", + }, + }, + oauth: { + configured: true, + keyName: "github-copilot", + hasRefreshToken: true, + hasAccessToken: false, + }, + effectiveSource: "pat", + override: "pat_overrides_oauth", + }); + + const { buildQuotaStatusReport } = await import("../src/lib/quota-status.js"); + const report = await buildQuotaStatusReport({ + configSource: "test", + configPaths: [], + enabledProviders: "auto", + onlyCurrentModel: false, + sessionModelLookup: "no_session", + providerAvailability: [], + googleRefresh: { attempted: false }, + }); + + expect(report).toContain("copilot_quota_auth:"); + expect(report).toContain("- pat_state: valid"); + expect(report).toContain( + "- pat_checked_paths: /home/test/.config/opencode/copilot-quota-token.json | /home/test/Library/Application Support/opencode/copilot-quota-token.json", + ); + expect(report).toContain("- effective_source: pat"); + expect(report).toContain("- override: pat_overrides_oauth"); + expect(copilotMocks.getCopilotQuotaAuthDiagnostics).toHaveBeenCalledOnce(); + }); + + it("renders invalid PAT and oauth effective source details", async () => { + copilotMocks.getCopilotQuotaAuthDiagnostics.mockReturnValue({ + pat: { + state: "invalid", + checkedPaths: ["/home/test/.config/opencode/copilot-quota-token.json"], + selectedPath: "/home/test/.config/opencode/copilot-quota-token.json", + error: "Unexpected token b in JSON at position 1", + }, + oauth: { + configured: true, + keyName: "github-copilot", + hasRefreshToken: true, + hasAccessToken: true, + }, + effectiveSource: "oauth", + override: "none", + }); + + const { buildQuotaStatusReport } = await import("../src/lib/quota-status.js"); + const report = await buildQuotaStatusReport({ + configSource: "test", + configPaths: [], + enabledProviders: "auto", + onlyCurrentModel: false, + sessionModelLookup: "no_session", + providerAvailability: [], + googleRefresh: { attempted: false }, + }); + + expect(report).toContain("- pat_state: invalid"); + expect(report).toContain("- pat_error: Unexpected token b in JSON at position 1"); + expect(report).toContain("- effective_source: oauth"); + expect(report).toContain("- override: none"); + }); +});