diff --git a/.changeset/codex-usage-visibility.md b/.changeset/codex-usage-visibility.md
new file mode 100644
index 00000000000..b6aca362473
--- /dev/null
+++ b/.changeset/codex-usage-visibility.md
@@ -0,0 +1,6 @@
+---
+"@kilocode/cli": minor
+"kilo-code": minor
+---
+
+Show ChatGPT-backed Codex quota and purchased-credit status alongside other provider usage.
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/balance-and-credits-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/balance-and-credits-chromium-linux.png
new file mode 100644
index 00000000000..c363d8597dc
--- /dev/null
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/balance-and-credits-chromium-linux.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8adfbe39b74c5645bd6016b8fb1751b7ae4e3d671f499d646cc154379a327038
+size 22208
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/empty-usage-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/empty-usage-chromium-linux.png
new file mode 100644
index 00000000000..0631c608fea
--- /dev/null
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/empty-usage-chromium-linux.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:07d7c47152645608f5488dc97649afb7f754a0f781bf624cbeae2be2fe05530f
+size 18666
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png
index d52ac47572d..f8c9fd65645 100644
--- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6485e2033c7eb88acf5347c392a1a789e471975ea086211c478d7229cc3c3dfd
-size 14652
+oid sha256:427fc878cda11bb0afc3a28e4da8ca64dcec1e0ae1526c71f6cdbaebe33cf4c0
+size 48891
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png
index 1fad2819c5c..afbf2de5032 100644
--- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f3cf6345efab3cefbc8d1ec2ae3b81103deb017bc930ede91fccb6efef59a65c
-size 10738
+oid sha256:46ffcf4731e4fc31ecc1372b78fa08ba3d180fe40129dffda00b44dc0a6b0da5
+size 47819
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/not-logged-in-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/not-logged-in-chromium-linux.png
index f49f4d723e7..0f7ea104422 100644
--- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/not-logged-in-chromium-linux.png
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/not-logged-in-chromium-linux.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b70dcaf2b3f89e0644fb47273881076cbb84cf5d36ce506685689968edb021dc
-size 5527
+oid sha256:d83c0657aa250d753087d9b5e7c7edd3b7075b35c0629dab1c4f1731c52e7b56
+size 18634
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/organization-context-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/organization-context-chromium-linux.png
new file mode 100644
index 00000000000..19435b0f19e
--- /dev/null
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/organization-context-chromium-linux.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:76b52dbe0abdd7c59584fd90e196fadeb3ea80566b9ad36ef176e63704884261
+size 22347
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/stale-and-unavailable-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/stale-and-unavailable-chromium-linux.png
new file mode 100644
index 00000000000..cdcf057114c
--- /dev/null
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/stale-and-unavailable-chromium-linux.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:adffa906b7d19dc55498be67dc2deefe71acfd5a7b7f1d31b7c7f9d4340623aa
+size 37158
diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md
index ab68d19a43b..fc8374e8069 100644
--- a/packages/kilo-docs/source-links.md
+++ b/packages/kilo-docs/source-links.md
@@ -46,6 +46,8 @@
-
+-
+
-
-
diff --git a/packages/kilo-vscode/webview-ui/src/components/profile/ProviderUsageCards.tsx b/packages/kilo-vscode/webview-ui/src/components/profile/ProviderUsageCards.tsx
index 5fafdd7dfdb..d7073e7b8c6 100644
--- a/packages/kilo-vscode/webview-ui/src/components/profile/ProviderUsageCards.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/profile/ProviderUsageCards.tsx
@@ -77,75 +77,80 @@ const UsageCard: Component<{
-
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
index 662187304fa..99dd8bf085e 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
@@ -1088,6 +1088,7 @@ export const dict = {
"profile.usage.description": "حصة الخطة الحالية والأرصدة",
"profile.usage.refresh": "تحديث استخدام مزودي الخدمة",
"profile.usage.empty": "لم يتم اكتشاف أي مصادر لاستخدام مزودي الخدمة.",
+ "profile.usage.detailsUnavailable": "لم يبلّغ مزود الخدمة عن تفاصيل الاستخدام.",
"profile.usage.source.viaKilo": "عبر Kilo",
"profile.usage.source.direct": "مباشر",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts
index 2ca607b28fd..8157d63bac1 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts
@@ -1105,6 +1105,7 @@ export const dict = {
"profile.usage.description": "Cota e saldos do plano atual",
"profile.usage.refresh": "Atualizar uso dos provedores",
"profile.usage.empty": "Nenhuma fonte de uso dos provedores detectada.",
+ "profile.usage.detailsUnavailable": "O provedor não informou detalhes de uso.",
"profile.usage.source.viaKilo": "via Kilo",
"profile.usage.source.direct": "Direto",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
index a63d1f45df2..0e9486eece7 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
@@ -1145,6 +1145,7 @@ export const dict = {
"profile.usage.description": "Kvota i stanja trenutnog plana",
"profile.usage.refresh": "Osvježi korištenje provajdera",
"profile.usage.empty": "Nisu otkriveni izvori korištenja provajdera.",
+ "profile.usage.detailsUnavailable": "Provajder nije prijavio detalje o korištenju.",
"profile.usage.source.viaKilo": "putem Kilo",
"profile.usage.source.direct": "Direktno",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts
index ff330bb1abe..11de7d1c393 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts
@@ -1138,6 +1138,7 @@ export const dict = {
"profile.usage.description": "Kvote og saldi for det aktuelle abonnement",
"profile.usage.refresh": "Opdatér udbyderforbrug",
"profile.usage.empty": "Ingen kilder til udbyderforbrug fundet.",
+ "profile.usage.detailsUnavailable": "Udbyderen har ikke rapporteret detaljer om forbruget.",
"profile.usage.source.viaKilo": "via Kilo",
"profile.usage.source.direct": "Direkte",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts
index 320217dcdc4..d91bb64db61 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts
@@ -1154,6 +1154,7 @@ export const dict = {
"profile.usage.description": "Kontingent und Guthaben des aktuellen Tarifs",
"profile.usage.refresh": "Anbieternutzung aktualisieren",
"profile.usage.empty": "Keine Quellen für Anbieternutzung erkannt.",
+ "profile.usage.detailsUnavailable": "Der Anbieter hat keine Nutzungsdetails gemeldet.",
"profile.usage.source.viaKilo": "über Kilo",
"profile.usage.source.direct": "Direkt",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts
index 3096d71f329..bcd88570546 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts
@@ -1063,6 +1063,7 @@ export const dict = {
"profile.usage.description": "Current plan quota and balances",
"profile.usage.refresh": "Refresh provider usage",
"profile.usage.empty": "No provider usage sources detected.",
+ "profile.usage.detailsUnavailable": "No usage details reported by provider for this plan.",
"profile.usage.source.viaKilo": "via Kilo",
"profile.usage.source.direct": "Direct",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts
index 7f292973288..d5a6b9a8960 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts
@@ -1150,6 +1150,7 @@ export const dict = {
"profile.usage.description": "Cuota y saldos del plan actual",
"profile.usage.refresh": "Actualizar uso del proveedor",
"profile.usage.empty": "No se detectaron fuentes de uso de proveedores.",
+ "profile.usage.detailsUnavailable": "El proveedor no informó detalles de uso.",
"profile.usage.source.viaKilo": "a través de Kilo",
"profile.usage.source.direct": "Directo",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
index aa1531c9945..12989cf86b7 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
@@ -1161,6 +1161,7 @@ export const dict = {
"profile.usage.description": "Quota et soldes du forfait actuel",
"profile.usage.refresh": "Actualiser l'utilisation du fournisseur",
"profile.usage.empty": "Aucune source d'utilisation de fournisseur détectée.",
+ "profile.usage.detailsUnavailable": "Le fournisseur n'a fourni aucun détail sur l'utilisation.",
"profile.usage.source.viaKilo": "via Kilo",
"profile.usage.source.direct": "Direct",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts
index 6ecdf2648b0..ad7f9227540 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts
@@ -953,6 +953,7 @@ export const dict = {
"profile.usage.description": "Quota e saldi del piano attuale",
"profile.usage.refresh": "Aggiorna l'utilizzo dei provider",
"profile.usage.empty": "Non è stata rilevata alcuna fonte di utilizzo dei provider.",
+ "profile.usage.detailsUnavailable": "Il provider non ha fornito dettagli sull'utilizzo.",
"profile.usage.source.viaKilo": "tramite Kilo",
"profile.usage.source.direct": "Diretto",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
index dfd886aeca0..080088ad330 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
@@ -1132,6 +1132,7 @@ export const dict = {
"profile.usage.description": "現在のプランの利用枠と残高",
"profile.usage.refresh": "プロバイダーの使用状況を更新",
"profile.usage.empty": "プロバイダー使用量の取得元が検出されませんでした。",
+ "profile.usage.detailsUnavailable": "プロバイダーから使用状況の詳細が報告されていません。",
"profile.usage.source.viaKilo": "Kilo経由",
"profile.usage.source.direct": "直接",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
index 4840835f3e8..fce8ad51826 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
@@ -1093,6 +1093,7 @@ export const dict = {
"profile.usage.description": "현재 요금제 할당량 및 잔액",
"profile.usage.refresh": "공급자 사용량 새로고침",
"profile.usage.empty": "감지된 공급자 사용량 소스가 없습니다.",
+ "profile.usage.detailsUnavailable": "공급자가 사용량 세부 정보를 보고하지 않았습니다.",
"profile.usage.source.viaKilo": "Kilo 경유",
"profile.usage.source.direct": "직접",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
index 763010bcefe..3c9bffe68ab 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
@@ -1102,6 +1102,7 @@ export const dict = {
"profile.usage.description": "Quota en saldi van het huidige abonnement",
"profile.usage.refresh": "Providergebruik vernieuwen",
"profile.usage.empty": "Geen bronnen voor providergebruik gedetecteerd.",
+ "profile.usage.detailsUnavailable": "De provider heeft geen gebruiksdetails gerapporteerd.",
"profile.usage.source.viaKilo": "via Kilo",
"profile.usage.source.direct": "Direct",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts
index aee550e6a8d..019e3a89f29 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts
@@ -1104,6 +1104,7 @@ export const dict = {
"profile.usage.description": "Kvote og saldoer for gjeldende abonnement",
"profile.usage.refresh": "Oppdater leverandørforbruk",
"profile.usage.empty": "Ingen kilder til leverandørforbruk oppdaget.",
+ "profile.usage.detailsUnavailable": "Leverandøren har ikke rapportert noen forbruksdetaljer.",
"profile.usage.source.viaKilo": "via Kilo",
"profile.usage.source.direct": "Direkte",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
index 31737e84986..0d2bebf709e 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
@@ -1104,6 +1104,7 @@ export const dict = {
"profile.usage.description": "Limit i salda bieżącego planu",
"profile.usage.refresh": "Odśwież wykorzystanie dostawców",
"profile.usage.empty": "Nie wykryto źródeł wykorzystania dostawców.",
+ "profile.usage.detailsUnavailable": "Dostawca nie przekazał szczegółów wykorzystania.",
"profile.usage.source.viaKilo": "przez Kilo",
"profile.usage.source.direct": "Bezpośrednio",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
index 9d97b6c9aac..8f870550e40 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
@@ -1144,6 +1144,7 @@ export const dict = {
"profile.usage.description": "Квота и балансы текущего тарифа",
"profile.usage.refresh": "Обновить данные об использовании провайдеров",
"profile.usage.empty": "Источники данных об использовании провайдеров не обнаружены.",
+ "profile.usage.detailsUnavailable": "Провайдер не сообщил подробности использования.",
"profile.usage.source.viaKilo": "через Kilo",
"profile.usage.source.direct": "Напрямую",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts
index 73ffc61da0f..556f5b39fe6 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts
@@ -1128,6 +1128,7 @@ export const dict = {
"profile.usage.description": "โควตาและยอดคงเหลือของแผนปัจจุบัน",
"profile.usage.refresh": "รีเฟรชการใช้งานของผู้ให้บริการ",
"profile.usage.empty": "ไม่พบแหล่งข้อมูลการใช้งานของผู้ให้บริการ",
+ "profile.usage.detailsUnavailable": "ผู้ให้บริการไม่ได้รายงานรายละเอียดการใช้งาน",
"profile.usage.source.viaKilo": "ผ่าน Kilo",
"profile.usage.source.direct": "โดยตรง",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
index e8c7f11400a..f24c14c8653 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
@@ -1101,6 +1101,7 @@ export const dict = {
"profile.usage.description": "Mevcut plan kotası ve bakiyeleri",
"profile.usage.refresh": "Sağlayıcı kullanımını yenile",
"profile.usage.empty": "Hiçbir sağlayıcı kullanım kaynağı algılanmadı.",
+ "profile.usage.detailsUnavailable": "Sağlayıcı kullanım ayrıntılarını bildirmedi.",
"profile.usage.source.viaKilo": "Kilo üzerinden",
"profile.usage.source.direct": "Doğrudan",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
index dbd0d60255e..f7bc5df375e 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
@@ -1099,6 +1099,7 @@ export const dict = {
"profile.usage.description": "Квота й баланси поточного плану",
"profile.usage.refresh": "Оновити дані про використання провайдерів",
"profile.usage.empty": "Джерел даних про використання провайдерів не виявлено.",
+ "profile.usage.detailsUnavailable": "Провайдер не надав детальної інформації про використання.",
"profile.usage.source.viaKilo": "через Kilo",
"profile.usage.source.direct": "Напряму",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
index 7092d5a99e6..3cec2f4ad4c 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
@@ -1112,6 +1112,7 @@ export const dict = {
"profile.usage.description": "当前套餐额度和余额",
"profile.usage.refresh": "刷新提供商用量",
"profile.usage.empty": "未检测到提供商用量来源。",
+ "profile.usage.detailsUnavailable": "提供商未报告用量详情。",
"profile.usage.source.viaKilo": "通过 Kilo",
"profile.usage.source.direct": "直接",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
index 182c577015b..530e1e113a7 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
@@ -1080,6 +1080,7 @@ export const dict = {
"profile.usage.description": "目前方案配額和餘額",
"profile.usage.refresh": "重新整理供應商用量",
"profile.usage.empty": "未偵測到供應商用量來源。",
+ "profile.usage.detailsUnavailable": "供應商未回報用量詳細資料。",
"profile.usage.source.viaKilo": "透過 Kilo",
"profile.usage.source.direct": "直接",
"profile.usage.source.chatgpt": "ChatGPT",
diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts
index 43bb1a5fabc..2aa6dea45b4 100755
--- a/packages/opencode/script/build.ts
+++ b/packages/opencode/script/build.ts
@@ -111,6 +111,8 @@ function smokeEnv(root: string) {
delete env.KILO_CONFIG_DIR
return {
...env,
+ HOME: path.join(root, "home"),
+ USERPROFILE: path.join(root, "home"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "config"),
diff --git a/packages/opencode/src/kilocode/components/dialog-provider-usage.tsx b/packages/opencode/src/kilocode/components/dialog-provider-usage.tsx
index 4918534ba7e..340d4705844 100644
--- a/packages/opencode/src/kilocode/components/dialog-provider-usage.tsx
+++ b/packages/opencode/src/kilocode/components/dialog-provider-usage.tsx
@@ -47,6 +47,11 @@ function Item(props: { item: ProviderUsageSnapshot }) {
{props.item.fetchState === "ready" ? props.item.planState : props.item.fetchState}
+
+ No usage details reported by provider for this plan.
+
{(window) => (
diff --git a/packages/opencode/src/kilocode/provider-usage/index.ts b/packages/opencode/src/kilocode/provider-usage/index.ts
index b849f8cac29..cf01a85857a 100644
--- a/packages/opencode/src/kilocode/provider-usage/index.ts
+++ b/packages/opencode/src/kilocode/provider-usage/index.ts
@@ -4,6 +4,7 @@ import { InstanceState } from "@/effect/instance-state"
import * as Provider from "@/provider/provider"
import * as Cloud from "./cloud"
import { direct } from "@/kilocode/provider/minimax/usage"
+import { codex } from "@/kilocode/provider/codex/usage"
import type { Info, KiloBilling, UsageSnapshot } from "./schema"
const successTtl = 60_000
@@ -11,7 +12,10 @@ const errorTtl = 10_000
export interface AdapterContext {
providers: Record
- auth: Auth.Info | undefined
+ auth: {
+ kilo: Auth.Info | undefined
+ openai: Auth.Info | undefined
+ }
cloud: (() => Promise) | undefined
token: string | undefined
fetch: typeof fetch
@@ -88,7 +92,30 @@ const minimax: ProviderUsageAdapter = {
},
}
-export const registry: readonly ProviderUsageAdapter[] = [pass, managed, minimax]
+const openai: ProviderUsageAdapter = {
+ id: "codex",
+ providerIDs: ["openai"],
+ cachePrefixes: ["codex-chatgpt"],
+ async run(ctx) {
+ const provider = ctx.providers.openai
+ if (
+ ctx.auth.openai?.type !== "oauth" ||
+ !provider ||
+ provider.source !== "custom" ||
+ typeof provider.options.fetch !== "function"
+ ) {
+ ctx.prune("codex-chatgpt", [])
+ return { items: [] }
+ }
+ const item = await ctx.source("codex-chatgpt", async () => {
+ const items = await codex(ctx.auth.openai, ctx.providers)
+ return items[0]
+ })
+ return { items: [item] }
+ },
+}
+
+export const registry: readonly ProviderUsageAdapter[] = [pass, managed, minimax, openai]
export class ServiceError extends Schema.TaggedErrorClass()("ProviderUsageServiceError", {
message: Schema.String,
@@ -211,14 +238,14 @@ export const layer = Layer.effect(
const state = yield* InstanceState.make(() => Effect.succeed({ sources: new Map(), cloud: { expires: 0 } }))
const evaluate = Effect.fn("ProviderUsage.evaluate")(function* (current: State, force: boolean) {
- const info = yield* auth
- .get("kilo")
- .pipe(Effect.mapError(() => new ServiceError({ message: "Unable to read provider authentication." })))
+ const [kilo, openai] = yield* Effect.all([auth.get("kilo"), auth.get("openai")]).pipe(
+ Effect.mapError(() => new ServiceError({ message: "Unable to read provider authentication." })),
+ )
const providers = yield* provider.list()
- const token = info?.type === "oauth" && !info.accountId && info.access ? info.access : undefined
+ const token = kilo?.type === "oauth" && !kilo.accountId && kilo.access ? kilo.access : undefined
const ctx: AdapterContext = {
providers,
- auth: info,
+ auth: { kilo, openai },
cloud: token ? () => cloud(current, token, force) : undefined,
token,
fetch,
diff --git a/packages/opencode/src/kilocode/provider/codex/native.ts b/packages/opencode/src/kilocode/provider/codex/native.ts
new file mode 100644
index 00000000000..704d0cc93ca
--- /dev/null
+++ b/packages/opencode/src/kilocode/provider/codex/native.ts
@@ -0,0 +1,116 @@
+import { Option, Schema } from "effect"
+
+export const Window = Schema.Struct({
+ used_percent: Schema.Finite,
+ limit_window_seconds: Schema.optional(Schema.Finite),
+ reset_after_seconds: Schema.optional(Schema.Finite),
+ reset_at: Schema.optional(Schema.Finite),
+})
+export type Window = typeof Window.Type
+
+export interface RateLimit {
+ allowed?: boolean
+ limit_reached?: boolean
+ primary_window?: Window | null
+ secondary_window?: Window | null
+}
+
+export interface Additional {
+ limit_name?: string
+ metered_feature?: string
+ rate_limit?: RateLimit | null
+}
+
+export const Credits = Schema.Struct({
+ has_credits: Schema.optional(Schema.Boolean),
+ unlimited: Schema.optional(Schema.Boolean),
+ overage_limit_reached: Schema.optional(Schema.Boolean),
+ balance: Schema.optional(Schema.NullOr(Schema.Union([Schema.String, Schema.Finite]))),
+})
+export type Credits = typeof Credits.Type
+
+export const SpendControl = Schema.Struct({
+ reached: Schema.optional(Schema.Boolean),
+ individual_limit: Schema.optional(Schema.NullOr(Schema.Finite)),
+})
+export type SpendControl = typeof SpendControl.Type
+
+const RateFields = Schema.Struct({
+ allowed: Schema.optional(Schema.Boolean),
+ limit_reached: Schema.optional(Schema.Boolean),
+ primary_window: Schema.optional(Schema.Unknown),
+ secondary_window: Schema.optional(Schema.Unknown),
+})
+
+const AdditionalFields = Schema.Struct({
+ limit_name: Schema.optional(Schema.String),
+ metered_feature: Schema.optional(Schema.String),
+ rate_limit: Schema.optional(Schema.Unknown),
+})
+
+const Root = Schema.Struct({
+ plan_type: Schema.optional(Schema.String),
+ rate_limit: Schema.optional(Schema.Unknown),
+ additional_rate_limits: Schema.optional(Schema.Unknown),
+ credits: Schema.optional(Schema.Unknown),
+ spend_control: Schema.optional(Schema.Unknown),
+})
+
+export interface Native {
+ plan_type?: string
+ rate_limit?: RateLimit
+ additional_rate_limits: Additional[]
+ credits?: Credits
+ spend_control?: SpendControl
+}
+
+const root = Schema.decodeUnknownSync(Root)
+const rateFields = Schema.decodeUnknownOption(RateFields)
+const window = Schema.decodeUnknownOption(Window)
+const additionalFields = Schema.decodeUnknownOption(AdditionalFields)
+const credits = Schema.decodeUnknownOption(Credits)
+const spend = Schema.decodeUnknownOption(SpendControl)
+
+function rate(input: unknown): RateLimit | undefined {
+ const value = Option.getOrUndefined(rateFields(input))
+ if (!value) return undefined
+ const primary = value.primary_window === null ? null : Option.getOrUndefined(window(value.primary_window))
+ const secondary = value.secondary_window === null ? null : Option.getOrUndefined(window(value.secondary_window))
+ return {
+ ...(value.allowed !== undefined ? { allowed: value.allowed } : {}),
+ ...(value.limit_reached !== undefined ? { limit_reached: value.limit_reached } : {}),
+ ...(primary !== undefined ? { primary_window: primary } : {}),
+ ...(secondary !== undefined ? { secondary_window: secondary } : {}),
+ }
+}
+
+function additional(input: unknown): Additional | undefined {
+ const value = Option.getOrUndefined(additionalFields(input))
+ if (!value) return undefined
+ const limit = value.rate_limit === null ? null : rate(value.rate_limit)
+ return {
+ ...(value.limit_name ? { limit_name: value.limit_name } : {}),
+ ...(value.metered_feature ? { metered_feature: value.metered_feature } : {}),
+ ...(limit !== undefined ? { rate_limit: limit } : {}),
+ }
+}
+
+export function decode(input: unknown): Native {
+ const value = root(input)
+ const limits = Array.isArray(value.additional_rate_limits)
+ ? value.additional_rate_limits.flatMap((item) => {
+ const result = additional(item)
+ return result ? [result] : []
+ })
+ : []
+ const rateLimit = rate(value.rate_limit)
+ const credit = Option.getOrUndefined(credits(value.credits))
+ const control = Option.getOrUndefined(spend(value.spend_control))
+ return {
+ ...(value.plan_type ? { plan_type: value.plan_type } : {}),
+ ...(rateLimit ? { rate_limit: rateLimit } : {}),
+ additional_rate_limits: limits,
+ ...(credit ? { credits: credit } : {}),
+ ...(control ? { spend_control: control } : {}),
+ }
+}
diff --git a/packages/opencode/src/kilocode/provider/codex/usage.ts b/packages/opencode/src/kilocode/provider/codex/usage.ts
new file mode 100644
index 00000000000..8ad59331774
--- /dev/null
+++ b/packages/opencode/src/kilocode/provider/codex/usage.ts
@@ -0,0 +1,244 @@
+import { InstallationVersion } from "@opencode-ai/core/installation/version"
+import type { Info as AuthInfo } from "@/auth"
+import type { UsageCredit, UsageSnapshot, UsageWindow } from "@/kilocode/provider-usage/schema"
+import type { Info as ProviderInfo } from "@/provider/provider"
+import { decode, type Native, type RateLimit, type Window } from "./native"
+
+const url = "https://chatgpt.com/backend-api/wham/usage"
+const manage = "https://chatgpt.com/codex/settings/usage"
+const timeout = 5_000
+const limit = 128 * 1024
+type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise
+
+export class CodexUsageError extends Error {
+ constructor(readonly code: "network" | "auth" | "http" | "too_large" | "invalid") {
+ super(code === "auth" ? "ChatGPT authentication is unavailable." : "Codex usage is temporarily unavailable.")
+ this.name = "CodexUsageError"
+ }
+}
+
+async function text(response: Response) {
+ const declared = Number(response.headers.get("content-length"))
+ if (Number.isFinite(declared) && declared > limit) {
+ response.body?.cancel().catch(() => undefined)
+ throw new CodexUsageError("too_large")
+ }
+ if (!response.body) {
+ const body = await response.arrayBuffer()
+ if (body.byteLength > limit) throw new CodexUsageError("too_large")
+ return new TextDecoder().decode(body)
+ }
+
+ const reader = response.body.getReader()
+ const chunks: Uint8Array[] = []
+ let size = 0
+ while (true) {
+ const chunk = await reader.read()
+ if (chunk.done) break
+ if (!chunk.value) continue
+ size += chunk.value.byteLength
+ if (size > limit) {
+ await reader.cancel().catch(() => undefined)
+ throw new CodexUsageError("too_large")
+ }
+ chunks.push(chunk.value)
+ }
+ const body = new Uint8Array(size)
+ let offset = 0
+ for (const chunk of chunks) {
+ body.set(chunk, offset)
+ offset += chunk.byteLength
+ }
+ return new TextDecoder().decode(body)
+}
+
+export async function query(fetcher: Fetcher): Promise {
+ const response = await fetcher(url, {
+ method: "GET",
+ headers: {
+ Accept: "application/json",
+ "User-Agent": `kilocode/${InstallationVersion}`,
+ },
+ cache: "no-store",
+ redirect: "error",
+ signal: AbortSignal.timeout(timeout),
+ }).catch(() => {
+ throw new CodexUsageError("network")
+ })
+ if (response.status === 401 || response.status === 403) {
+ response.body?.cancel().catch(() => undefined)
+ throw new CodexUsageError("auth")
+ }
+ if (!response.ok) {
+ response.body?.cancel().catch(() => undefined)
+ throw new CodexUsageError("http")
+ }
+ const body = await text(response)
+ try {
+ return decode(JSON.parse(body))
+ } catch {
+ throw new CodexUsageError("invalid")
+ }
+}
+
+const plans: Record = {
+ plus: "ChatGPT Plus",
+ pro: "ChatGPT Pro",
+ prolite: "ChatGPT Pro Lite",
+ business: "ChatGPT Business",
+ enterprise: "ChatGPT Enterprise",
+ edu: "ChatGPT Edu",
+ education: "ChatGPT Education",
+ team: "ChatGPT Team",
+ free: "ChatGPT Free",
+ go: "ChatGPT Go",
+}
+
+function label(name: string, window: Window, slot: "primary" | "secondary") {
+ const minutes = window.limit_window_seconds === undefined ? undefined : window.limit_window_seconds / 60
+ const suffix = minutes === 300 ? "5-hour" : minutes === 10_080 ? "weekly" : `${slot} window`
+ return name === "Codex" ? `Codex ${suffix}` : `${name} ${suffix}`
+}
+
+function reset(value: number | undefined, after: number | undefined, fetchedAt: string) {
+ if (value !== undefined && value > 0) return new Date(value * 1000).toISOString()
+ if (after !== undefined && after > 0) return new Date(Date.parse(fetchedAt) + after * 1000).toISOString()
+ return undefined
+}
+
+function windows(name: string, rate: RateLimit | undefined, fetchedAt: string): UsageWindow[] {
+ if (!rate) return []
+ return (
+ [
+ ["primary", rate.primary_window],
+ ["secondary", rate.secondary_window],
+ ] as const
+ ).flatMap(([slot, window]) => {
+ if (!window) return []
+ const resetAt = reset(window.reset_at, window.reset_after_seconds, fetchedAt)
+ return [
+ {
+ id: `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${slot}`,
+ label: label(name, window, slot),
+ resource: name,
+ kind: "quota" as const,
+ unit: "percent",
+ orientation: "used_percent" as const,
+ used: window.used_percent,
+ remaining: Math.max(0, 100 - window.used_percent),
+ limit: 100,
+ ...(window.limit_window_seconds !== undefined ? { durationMs: window.limit_window_seconds * 1000 } : {}),
+ ...(resetAt ? { resetAt } : {}),
+ state:
+ rate.allowed === false || rate.limit_reached || window.used_percent >= 100
+ ? ("exhausted" as const)
+ : ("active" as const),
+ },
+ ]
+ })
+}
+
+function credits(native: Native): UsageCredit[] {
+ if (!native.credits) return []
+ if (native.credits.unlimited) return [{ id: "purchased-credits", label: "Purchased credits", unlimited: true }]
+ const balance = native.credits.balance
+ return [
+ {
+ id: "purchased-credits",
+ label: native.credits.overage_limit_reached ? "Purchased credits (limit reached)" : "Purchased credits",
+ ...(balance !== undefined && balance !== null ? { balance: String(balance), unit: "credits" } : {}),
+ },
+ ]
+}
+
+export function normalize(native: Native): UsageSnapshot {
+ const fetchedAt = new Date().toISOString()
+ const general = windows("Codex", native.rate_limit, fetchedAt)
+ const additional = native.additional_rate_limits.flatMap((item) =>
+ windows(item.limit_name ?? item.metered_feature ?? "Additional quota", item.rate_limit ?? undefined, fetchedAt),
+ )
+ const quota = [...general, ...additional]
+ const spendReported =
+ native.spend_control?.reached === true ||
+ (native.spend_control?.individual_limit !== undefined && native.spend_control.individual_limit !== null) ||
+ native.credits?.overage_limit_reached === true
+ const spend: UsageWindow[] = spendReported
+ ? [
+ {
+ id: "spend-control",
+ label: "Spend control",
+ resource: "Purchased credits",
+ kind: "spend_control",
+ unit: "credits",
+ orientation: "amount",
+ ...(native.spend_control?.individual_limit !== undefined && native.spend_control.individual_limit !== null
+ ? { limit: native.spend_control.individual_limit }
+ : {}),
+ state: native.spend_control?.reached || native.credits?.overage_limit_reached ? "exhausted" : "active",
+ },
+ ]
+ : []
+ const plan = native.plan_type ? (plans[native.plan_type] ?? `ChatGPT ${native.plan_type}`) : "ChatGPT Codex"
+ return {
+ id: "codex-chatgpt",
+ providerID: "openai",
+ sourceKind: "codex",
+ providerLabel: "OpenAI",
+ planLabel: plan,
+ sourceLabel: "ChatGPT OAuth",
+ fetchState: "ready",
+ planState: "active",
+ routingState: "not_applicable",
+ availabilityState:
+ native.rate_limit?.allowed === false || native.rate_limit?.limit_reached
+ ? "exhausted"
+ : quota.some((item) => item.state === "active")
+ ? "available"
+ : quota.some((item) => item.state === "exhausted")
+ ? "exhausted"
+ : "unknown",
+ fetchedAt,
+ confidence: "high",
+ source: "provider_backend",
+ managementUrl: manage,
+ windows: [...quota, ...spend],
+ balances: [],
+ credits: credits(native),
+ }
+}
+
+function unavailable(code: CodexUsageError["code"]): UsageSnapshot {
+ return {
+ id: "codex-chatgpt",
+ providerID: "openai",
+ sourceKind: "codex",
+ providerLabel: "OpenAI",
+ planLabel: "ChatGPT Codex",
+ sourceLabel: "ChatGPT OAuth",
+ fetchState: "unavailable",
+ planState: "unknown",
+ routingState: "not_applicable",
+ availabilityState: "unavailable",
+ confidence: "high",
+ source: "provider_backend",
+ managementUrl: manage,
+ windows: [],
+ balances: [],
+ credits: [],
+ error: {
+ code: code === "auth" ? "codex_auth_unavailable" : "codex_usage_unavailable",
+ message: code === "auth" ? "Reconnect ChatGPT to view Codex usage." : "Usage unavailable.",
+ retryable: code !== "auth",
+ },
+ }
+}
+
+export async function codex(auth: AuthInfo | undefined, providers: Record) {
+ const provider = providers.openai
+ const fetcher = provider?.options.fetch
+ if (auth?.type !== "oauth" || !provider || provider.source !== "custom" || typeof fetcher !== "function") return []
+ return query(fetcher).then(
+ (native) => [normalize(native)],
+ (error) => [unavailable(error instanceof CodexUsageError ? error.code : "invalid")],
+ )
+}
diff --git a/packages/opencode/test/kilocode/provider/codex/usage.test.ts b/packages/opencode/test/kilocode/provider/codex/usage.test.ts
new file mode 100644
index 00000000000..e52479f07bf
--- /dev/null
+++ b/packages/opencode/test/kilocode/provider/codex/usage.test.ts
@@ -0,0 +1,205 @@
+import { describe, expect, mock, test } from "bun:test"
+import { decode } from "@/kilocode/provider/codex/native"
+import { codex, normalize, query } from "@/kilocode/provider/codex/usage"
+import { ProviderTest } from "../../../fake/provider"
+import { ProviderID } from "@/provider/schema"
+
+const response = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } })
+
+const fixture = {
+ plan_type: "prolite",
+ rate_limit: {
+ allowed: true,
+ limit_reached: false,
+ primary_window: {
+ used_percent: 41,
+ limit_window_seconds: 300 * 60,
+ reset_at: 1_781_631_830,
+ },
+ secondary_window: {
+ used_percent: 56,
+ limit_window_seconds: 10_080 * 60,
+ reset_at: 1_781_747_648,
+ },
+ },
+ additional_rate_limits: [
+ {
+ limit_name: "GPT-5.3-Codex-Spark",
+ metered_feature: "codex_bengalfox",
+ rate_limit: {
+ primary_window: { used_percent: 20, limit_window_seconds: 300 * 60, reset_at: 1_781_631_830 },
+ },
+ },
+ ],
+ credits: { has_credits: true, unlimited: false, balance: "9.99" },
+ unknown_private_field: "strip",
+}
+
+function provider(fetcher: typeof fetch) {
+ const providerID = ProviderID.make("openai")
+ return ProviderTest.info(
+ { id: providerID, name: "OpenAI", source: "custom", options: { fetch: fetcher } },
+ ProviderTest.model({ providerID }),
+ )
+}
+
+const oauth = {
+ type: "oauth" as const,
+ access: "private-access-token",
+ refresh: "private-refresh-token",
+ expires: Date.now() + 60_000,
+ accountId: "private-account-id",
+}
+
+describe("Codex native normalization", () => {
+ test("normalizes five-hour, weekly, and additional named buckets", () => {
+ const item = normalize(decode(fixture))
+
+ expect(item.planLabel).toBe("ChatGPT Pro Lite")
+ expect(item.windows.map((window) => window.label)).toEqual([
+ "Codex 5-hour",
+ "Codex weekly",
+ "GPT-5.3-Codex-Spark 5-hour",
+ ])
+ expect(item.windows[0]).toMatchObject({ orientation: "used_percent", used: 41, remaining: 59, limit: 100 })
+ expect(item.credits).toEqual([
+ { id: "purchased-credits", label: "Purchased credits", balance: "9.99", unit: "credits" },
+ ])
+ })
+
+ test("classifies windows by duration even when primary and secondary are reversed", () => {
+ const item = normalize(
+ decode({
+ rate_limit: {
+ primary_window: { used_percent: 10, limit_window_seconds: 10_080 * 60, reset_at: 1_781_747_648 },
+ secondary_window: { used_percent: 20, limit_window_seconds: 300 * 60, reset_at: 1_781_631_830 },
+ },
+ }),
+ )
+
+ expect(item.windows.map((window) => window.label)).toEqual(["Codex weekly", "Codex 5-hour"])
+ })
+
+ test("keeps a valid primary window when its secondary sibling is malformed", () => {
+ const item = normalize(
+ decode({
+ rate_limit: {
+ allowed: false,
+ primary_window: { used_percent: 12, limit_window_seconds: 300 * 60 },
+ secondary_window: { used_percent: "bad" },
+ },
+ }),
+ )
+
+ expect(item.windows).toHaveLength(1)
+ expect(item.windows[0]).toMatchObject({ used: 12, state: "exhausted" })
+ expect(normalize(decode({ rate_limit: { limit_reached: true } })).availabilityState).toBe("exhausted")
+ })
+
+ test("preserves unknown plans and ignores malformed sibling buckets", () => {
+ const item = normalize(
+ decode({
+ plan_type: "future_workspace",
+ rate_limit: { primary_window: { used_percent: 5, reset_at: 1_781_631_830 } },
+ additional_rate_limits: [
+ { limit_name: "broken", rate_limit: { primary_window: { used_percent: "bad" } } },
+ { limit_name: "valid", rate_limit: { secondary_window: { used_percent: 30 } } },
+ ],
+ }),
+ )
+
+ expect(item.planLabel).toBe("ChatGPT future_workspace")
+ expect(item.windows.map((window) => window.resource)).toEqual(["Codex", "valid"])
+ })
+
+ test("omits an empty spend-control object reported by Team accounts", () => {
+ const item = normalize(
+ decode({
+ plan_type: "team",
+ rate_limit: null,
+ additional_rate_limits: null,
+ credits: null,
+ spend_control: { reached: false, individual_limit: null },
+ }),
+ )
+
+ expect(item.planLabel).toBe("ChatGPT Team")
+ expect(item.windows).toEqual([])
+ expect(item.credits).toEqual([])
+ expect(item.availabilityState).toBe("unknown")
+ })
+
+ test("uses reset_after_seconds as fallback and preserves overage exhaustion", () => {
+ const before = Date.now()
+ const item = normalize(
+ decode({
+ rate_limit: { primary_window: { used_percent: 10, reset_after_seconds: 60 } },
+ credits: { has_credits: true, balance: "5", overage_limit_reached: true },
+ }),
+ )
+ const resetAt = Date.parse(item.windows.find((window) => window.id === "codex-primary")?.resetAt ?? "")
+
+ expect(resetAt).toBeGreaterThanOrEqual(before + 59_000)
+ expect(item.windows.find((window) => window.id === "spend-control")?.state).toBe("exhausted")
+ expect(item.credits[0]?.label).toContain("limit reached")
+ })
+
+ test.each([
+ [undefined, []],
+ [{ has_credits: true, balance: "0", unlimited: false }, [{ balance: "0", unit: "credits" }]],
+ [{ has_credits: true, balance: 12.5, unlimited: false }, [{ balance: "12.5", unit: "credits" }]],
+ [{ has_credits: true, balance: null, unlimited: false }, [{}]],
+ [{ has_credits: true, unlimited: true }, [{ unlimited: true }]],
+ ])("keeps purchased credit state distinct: %o", (credits, expected) => {
+ const item = normalize(decode({ credits }))
+ expect(item.credits).toHaveLength(expected.length)
+ if (expected[0]) expect(item.credits[0]).toMatchObject(expected[0])
+ expect(item.windows).toEqual([])
+ })
+})
+
+describe("Codex OAuth usage transport", () => {
+ test("uses the resolved authenticated transport with a fixed request", async () => {
+ const fn = mock((_input: string | URL | Request, init?: RequestInit) => {
+ expect(new Headers(init?.headers).has("authorization")).toBe(false)
+ return Promise.resolve(response(fixture))
+ })
+
+ const native = await query(fn as unknown as typeof fetch)
+
+ expect(native.plan_type).toBe("prolite")
+ const call = fn.mock.calls[0] as unknown as [string, RequestInit]
+ expect(call[0]).toBe("https://chatgpt.com/backend-api/wham/usage")
+ expect(call[1].method).toBe("GET")
+ expect(call[1].redirect).toBe("error")
+ expect(call[1].signal).toBeInstanceOf(AbortSignal)
+ expect(new Headers(call[1].headers).get("accept")).toBe("application/json")
+ expect(new Headers(call[1].headers).get("user-agent")).toMatch(/^kilocode\//)
+ })
+
+ test("detects only OAuth-backed effective OpenAI providers", async () => {
+ const fn = mock(() => Promise.resolve(response(fixture))) as unknown as typeof fetch
+ const providers = { openai: provider(fn) }
+
+ expect(await codex({ type: "api", key: "sk-api" }, providers)).toEqual([])
+ expect(fn).not.toHaveBeenCalled()
+ const items = await codex(oauth, providers)
+ expect(items).toHaveLength(1)
+ expect(items[0]?.fetchState).toBe("ready")
+ expect(JSON.stringify(items)).not.toContain("private-access-token")
+ expect(JSON.stringify(items)).not.toContain("private-account-id")
+ })
+
+ test.each([401, 403, 429, 500])("isolates HTTP %s failures", async (status) => {
+ const fn = mock(() => Promise.resolve(response({ private: "raw body" }, status))) as unknown as typeof fetch
+ const items = await codex(oauth, { openai: provider(fn) })
+
+ expect(items).toHaveLength(1)
+ expect(items[0]?.fetchState).toBe("unavailable")
+ expect(JSON.stringify(items)).not.toContain("raw body")
+ expect(items[0]?.error?.code).toBe(
+ status === 401 || status === 403 ? "codex_auth_unavailable" : "codex_usage_unavailable",
+ )
+ })
+})