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

-
- - {(window) => { - const progress = () => windowProgress(window) - return ( + 0 || props.item.balances.length > 0 || props.item.credits.length > 0} + fallback={

{props.language.t("profile.usage.detailsUnavailable")}

} + > +
+ + {(window) => { + const progress = () => windowProgress(window) + return ( +
+
+ {window.label} + {formatWindowValue(window, labels(props.language))} +
+ + + + + {(reset) => ( + + {props.language.t("profile.usage.reset", { date: new Date(reset()).toLocaleString() })} + + )} + +
+ ) + }} +
+ + + {(balance) => (
- {window.label} - {formatWindowValue(window, labels(props.language))} + {balance.label} + + {balance.total} {balance.currency} + {balance.available === false ? ` ${props.language.t("profile.usage.balance.unavailable")}` : ""} +
- - - - - {(reset) => ( - - {props.language.t("profile.usage.reset", { date: new Date(reset()).toLocaleString() })} - - )} + + + {props.language.t("profile.usage.balance.breakdown", { + granted: balance.granted ?? props.language.t("profile.usage.status.unknown"), + toppedUp: balance.toppedUp ?? props.language.t("profile.usage.status.unknown"), + })} +
- ) - }} -
+ )} + - - {(balance) => ( -
+ + {(credit) => (
- {balance.label} + {credit.label} - {balance.total} {balance.currency} - {balance.available === false ? ` ${props.language.t("profile.usage.balance.unavailable")}` : ""} + {credit.unlimited + ? props.language.t("profile.usage.status.unlimited") + : credit.balance !== undefined + ? `${credit.balance}${credit.unit ? ` ${credit.unit}` : ""}` + : credit.availableResets !== undefined + ? props.language.t("profile.usage.credits.resets", { count: String(credit.availableResets) }) + : props.language.t("profile.usage.status.unknown")}
- - - {props.language.t("profile.usage.balance.breakdown", { - granted: balance.granted ?? props.language.t("profile.usage.status.unknown"), - toppedUp: balance.toppedUp ?? props.language.t("profile.usage.status.unknown"), - })} - - -
- )} -
- - - {(credit) => ( -
- {credit.label} - - {credit.unlimited - ? props.language.t("profile.usage.status.unlimited") - : credit.balance !== undefined - ? `${credit.balance}${credit.unit ? ` ${credit.unit}` : ""}` - : credit.availableResets !== undefined - ? props.language.t("profile.usage.credits.resets", { count: String(credit.availableResets) }) - : props.language.t("profile.usage.status.unknown")} - -
- )} -
-
+ )} +
+
+

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", + ) + }) +})