From 7ada0c2ea794053ceff76a0f2262a2c8efea5b38 Mon Sep 17 00:00:00 2001 From: "Shawn L. Kiser" <35721408+slkiser@users.noreply.github.com> Date: Thu, 5 Mar 2026 02:32:09 +0100 Subject: [PATCH] Add Copilot PAT quota diagnostics and support Read and validate Copilot PAT quota configs from OpenCode runtime config candidate directories and add diagnostic metadata. Introduces helpers to discover candidate config paths, validate quota config content, classify PAT token kinds, and read quota config with metadata (readQuotaConfigWithMeta). Adds logic to select Copilot OAuth entries from auth.json and exposes getCopilotQuotaAuthDiagnostics that reports PAT vs OAuth configuration, effective source, and override behavior. queryCopilotQuota now prefers a valid PAT billing path, handles premium quota values more robustly, and uses a shared computePercentRemainingFromUsed helper. quota-status report is extended to include Copilot auth/diagnostic lines. README and types updated with PAT scope guidance and runtime config location notes. Tests updated to mock runtime paths, fs, and auth reading and a new test file for Copilot diagnostics was added. --- README.md | 11 + package-lock.json | 215 +++++++------- src/lib/copilot.ts | 276 ++++++++++++++---- src/lib/quota-status.ts | 26 ++ src/lib/types.ts | 5 +- tests/lib.copilot.test.ts | 227 +++++++++++--- ...b.quota-status.copilot-diagnostics.test.ts | 195 +++++++++++++ 7 files changed, 759 insertions(+), 196 deletions(-) create mode 100644 tests/lib.quota-status.copilot-diagnostics.test.ts 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"); + }); +});