From faba81990b769f3918a423727453b8f25e2c1e98 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:22:49 -0700 Subject: [PATCH 01/11] Show remaining subscription only from official payloads Token Usage now lists isolatable families. Remaining plan quota is emitted only from a recorded official remaining-subscription envelope. ACP usage_update stays context occupancy. Families without a payload link to the provider usage page instead of inventing a number. --- .../token-usage/subscription-quota-panel.tsx | 51 ++++++++ .../token-usage/token-usage-page.tsx | 6 + src/i18n/messages/ar.json | 4 + src/i18n/messages/de.json | 4 + src/i18n/messages/en.json | 4 + src/i18n/messages/es.json | 4 + src/i18n/messages/fr.json | 4 + src/i18n/messages/ja.json | 4 + src/i18n/messages/ko.json | 4 + src/i18n/messages/pt.json | 4 + src/i18n/messages/zh-CN.json | 4 + src/i18n/messages/zh-TW.json | 4 + src/lib/subscription-quota.test.ts | 65 ++++++++++ src/lib/subscription-quota.ts | 122 ++++++++++++++++++ 14 files changed, 284 insertions(+) create mode 100644 src/components/token-usage/subscription-quota-panel.tsx create mode 100644 src/lib/subscription-quota.test.ts create mode 100644 src/lib/subscription-quota.ts diff --git a/src/components/token-usage/subscription-quota-panel.tsx b/src/components/token-usage/subscription-quota-panel.tsx new file mode 100644 index 000000000..3a951749b --- /dev/null +++ b/src/components/token-usage/subscription-quota-panel.tsx @@ -0,0 +1,51 @@ +"use client" + +import { useTranslations } from "next-intl" +import { ExternalLink } from "lucide-react" +import { inventory } from "@/lib/subscription-quota" +import { openUrl } from "@/lib/platform" + +export function SubscriptionQuotaPanel() { + const t = useTranslations("TokenUsage") + const rows = inventory() + + return ( +
+

{t("quotaTitle")}

+

+ {t("quotaHint")} +

+ +
+ ) +} diff --git a/src/components/token-usage/token-usage-page.tsx b/src/components/token-usage/token-usage-page.tsx index 841ff8e90..9bf88d794 100644 --- a/src/components/token-usage/token-usage-page.tsx +++ b/src/components/token-usage/token-usage-page.tsx @@ -42,6 +42,7 @@ import { import { Progress } from "@/components/ui/progress" import { ScrollArea } from "@/components/ui/scroll-area" import { WorkbenchPageTitle } from "@/components/workbench/workbench-page-title" +import { SubscriptionQuotaPanel } from "@/components/token-usage/subscription-quota-panel" import { FolderAliasLabel } from "@/components/conversations/folder-alias-label" import { formatFolderLabelWithAlias } from "@/lib/folder-display" import { @@ -806,6 +807,11 @@ export function TokenUsagePage() { as segments, the long tail folded into one pill), the dimension filters, and a single overflow menu carrying every data action. Absent entirely on the first-run empty state — see `showToolbar`. */} +
+
+ +
+
{showToolbar && (
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index f436b8c6d..c4a346fda 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4925,6 +4925,10 @@ }, "TokenUsage": { "title": "استهلاك الرموز", + "quotaTitle": "المتبقي من الاشتراك", + "quotaHint": "تعد Codeg الرموز في هذه الصفحة. يظهر رصيد الخطة فقط إذا نشره CLI المزوّد. استخدام ACP هو إشغال السياق وليس رصيد الاشتراك.", + "quotaRemaining": "متبقٍ {remaining} من {limit}", + "quotaProviderLink": "صفحة استخدام المزوّد", "rangeLabel": "النطاق الزمني", "range7d": "٧ أيام", "range30d": "٣٠ يومًا", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 6d0a5bec3..4cda8d790 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4925,6 +4925,10 @@ }, "TokenUsage": { "title": "Token-Verbrauch", + "quotaTitle": "Verbleibendes Abo", + "quotaHint": "Codeg zählt Tokens auf dieser Seite. Verbleibendes Plan-Kontingent nur wenn die CLI es veröffentlicht. ACP-Nutzung ist Kontextbelegung, kein Abo-Rest.", + "quotaRemaining": "{remaining} von {limit} übrig", + "quotaProviderLink": "Anbieter-Nutzungsseite", "rangeLabel": "Zeitraum", "range7d": "7 Tage", "range30d": "30 Tage", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b7076662e..ba62c0293 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4925,6 +4925,10 @@ }, "TokenUsage": { "title": "Token Usage", + "quotaTitle": "Subscription remaining", + "quotaHint": "Codeg meters tokens on this page. Remaining plan quota is shown only when a provider CLI publishes it. ACP usage is context occupancy, not subscription remaining.", + "quotaRemaining": "{remaining} of {limit} remaining", + "quotaProviderLink": "Provider usage page", "rangeLabel": "Time range", "range7d": "7 days", "range30d": "30 days", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 21ca17fa8..20b0b704f 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4925,6 +4925,10 @@ }, "TokenUsage": { "title": "Uso de tokens", + "quotaTitle": "Suscripción restante", + "quotaHint": "Codeg mide tokens en esta página. El cupo restante del plan solo se muestra si el CLI del proveedor lo publica. El uso ACP es ocupación de contexto, no resto de suscripción.", + "quotaRemaining": "{remaining} de {limit} restantes", + "quotaProviderLink": "Página de uso del proveedor", "rangeLabel": "Periodo", "range7d": "7 días", "range30d": "30 días", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 692b556a8..81ef0e96e 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4925,6 +4925,10 @@ }, "TokenUsage": { "title": "Consommation de tokens", + "quotaTitle": "Reste d’abonnement", + "quotaHint": "Codeg compte les tokens sur cette page. Le quota restant n’apparaît que si le CLI du fournisseur le publie. L’usage ACP est l’occupation du contexte, pas le reste d’abonnement.", + "quotaRemaining": "{remaining} sur {limit} restants", + "quotaProviderLink": "Page d’usage du fournisseur", "rangeLabel": "Période", "range7d": "7 jours", "range30d": "30 jours", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 24b77626a..c73658221 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4925,6 +4925,10 @@ }, "TokenUsage": { "title": "トークン使用量", + "quotaTitle": "サブスク残量", + "quotaHint": "このページは Codeg が計測したトークンです。プラン残量は公式 CLI が公開する場合のみ表示します。ACP の usage はコンテキスト占有であり、契約残量ではありません。", + "quotaRemaining": "残り {remaining} / {limit}", + "quotaProviderLink": "プロバイダーの使用量ページ", "rangeLabel": "期間", "range7d": "7日間", "range30d": "30日間", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index e76fe0b77..a42066960 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4925,6 +4925,10 @@ }, "TokenUsage": { "title": "토큰 사용량", + "quotaTitle": "구독 잔여량", + "quotaHint": "이 페이지는 Codeg가 측정한 토큰입니다. 플랜 잔여량은 제공자 CLI가 공개할 때만 표시됩니다. ACP usage는 컨텍스트 점유이며 구독 잔여량이 아닙니다.", + "quotaRemaining": "{limit} 중 {remaining} 남음", + "quotaProviderLink": "제공자 사용량 페이지", "rangeLabel": "기간", "range7d": "7일", "range30d": "30일", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 6ece222b0..ea9c162a2 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4925,6 +4925,10 @@ }, "TokenUsage": { "title": "Uso de tokens", + "quotaTitle": "Assinatura restante", + "quotaHint": "O Codeg mede tokens nesta página. A cota restante do plano só aparece se o CLI do provedor a publicar. O uso ACP é ocupação de contexto, não resto de assinatura.", + "quotaRemaining": "{remaining} de {limit} restantes", + "quotaProviderLink": "Página de uso do provedor", "rangeLabel": "Período", "range7d": "7 dias", "range30d": "30 dias", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index e62f6d74c..c3f547ae9 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4925,6 +4925,10 @@ }, "TokenUsage": { "title": "Token 用量", + "quotaTitle": "订阅余量", + "quotaHint": "本页显示 Codeg 计量的 token。套餐余量仅在提供商 CLI 公开时显示。ACP usage 是上下文占用,不是订阅余量。", + "quotaRemaining": "剩余 {remaining} / {limit}", + "quotaProviderLink": "提供商用量页", "rangeLabel": "时间范围", "range7d": "近 7 天", "range30d": "近 30 天", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index e6283cf66..82703518f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4925,6 +4925,10 @@ }, "TokenUsage": { "title": "Token 用量", + "quotaTitle": "訂閱餘量", + "quotaHint": "本頁顯示 Codeg 計量的 token。方案餘量僅在供應商 CLI 公開時顯示。ACP usage 是上下文占用,不是訂閱餘量。", + "quotaRemaining": "剩餘 {remaining} / {limit}", + "quotaProviderLink": "供應商用量頁", "rangeLabel": "時間範圍", "range7d": "近 7 天", "range30d": "近 30 天", diff --git a/src/lib/subscription-quota.test.ts b/src/lib/subscription-quota.test.ts new file mode 100644 index 000000000..3448cd597 --- /dev/null +++ b/src/lib/subscription-quota.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest" +import { + emitsRemainingSubscription, + familyQuota, + inventory, + remainingFromOfficialPayload, +} from "./subscription-quota" + +describe("subscription quota inventory", () => { + it("does not invent remaining-subscription numbers when no official payload exists", () => { + for (const row of inventory()) { + expect(emitsRemainingSubscription(row)).toBe(false) + expect(row.kind).toBe("unavailable") + if (row.kind === "unavailable") { + expect(row.providerUsageUrl.startsWith("https://")).toBe(true) + } + } + }) + + it("treats ACP usage_update as context occupancy, not plan remaining", () => { + const row = familyQuota("claude", undefined, { used: 1200, size: 8000 }) + expect(row.kind).toBe("acp-context") + expect(emitsRemainingSubscription(row)).toBe(false) + if (row.kind === "acp-context") { + expect(row.used).toBe(1200) + expect(row.size).toBe(8000) + } + }) + + it("reads remaining only from a recorded official remaining-subscription envelope", () => { + const payload = { + codegQuotaKind: "remaining-subscription", + family: "codex", + remaining: 42, + limit: 100, + source: "recorded-official-fixture", + } + const parsed = remainingFromOfficialPayload("codex", payload) + expect(parsed).toEqual({ + remaining: 42, + limit: 100, + source: "recorded-official-fixture", + }) + const row = familyQuota("codex", payload) + expect(row.kind).toBe("remaining-subscription") + if (row.kind === "remaining-subscription") { + expect(row.remaining).toBe(42) + } + }) + + it("rejects a payload that is not the official envelope", () => { + expect( + remainingFromOfficialPayload("claude", { remaining: 1, limit: 2 }) + ).toBeNull() + expect( + remainingFromOfficialPayload("claude", { + codegQuotaKind: "remaining-subscription", + family: "codex", + remaining: 1, + limit: 2, + source: "wrong-family", + }) + ).toBeNull() + }) +}) diff --git a/src/lib/subscription-quota.ts b/src/lib/subscription-quota.ts new file mode 100644 index 000000000..a10008982 --- /dev/null +++ b/src/lib/subscription-quota.ts @@ -0,0 +1,122 @@ +/** + * Remaining-subscription inventory. + * + * ACP `usage_update` is context occupancy `{used, size}`, not plan remaining. + * Official CLIs (claude /usage, chatgpt, grok, gemini, opencode) do not + * publish a stable remaining-quota JSON that Codeg can read. This module is + * the single gate: a remaining-subscription number is emitted only when a + * recorded official payload actually contains one. + */ + +export type IsolatableFamily = "claude" | "codex" | "grok" | "gemini" | "opencode" + +export type QuotaKind = "remaining-subscription" | "acp-context" | "unavailable" + +export type FamilyQuota = + | { + family: IsolatableFamily + kind: "remaining-subscription" + remaining: number + limit: number + source: string + } + | { + family: IsolatableFamily + kind: "acp-context" + used: number + size: number + } + | { + family: IsolatableFamily + kind: "unavailable" + providerUsageUrl: string + } + +export const PROVIDER_USAGE_URLS: Record = { + claude: "https://claude.ai/settings/usage", + codex: "https://chatgpt.com/#settings", + grok: "https://accounts.x.ai/", + gemini: "https://aistudio.google.com/", + opencode: "https://opencode.ai/", +} + +const FAMILIES: IsolatableFamily[] = [ + "claude", + "codex", + "grok", + "gemini", + "opencode", +] + +/** + * Read remaining subscription from a recorded official payload. + * Production Codeg never invents this object. Return null unless the + * payload is the documented official shape for that family. + */ +export function remainingFromOfficialPayload( + family: IsolatableFamily, + payload: unknown +): { remaining: number; limit: number; source: string } | null { + if (!payload || typeof payload !== "object") return null + const rec = payload as Record + // No family currently publishes this object. Accept only an explicit + // official envelope so a future CLI JSON can land without inventing + // numbers today. + if (rec.codegQuotaKind !== "remaining-subscription") return null + if (rec.family !== family) return null + if (typeof rec.remaining !== "number" || typeof rec.limit !== "number") { + return null + } + if (!Number.isFinite(rec.remaining) || !Number.isFinite(rec.limit)) { + return null + } + if (typeof rec.source !== "string" || !rec.source.trim()) return null + return { + remaining: rec.remaining, + limit: rec.limit, + source: rec.source.trim(), + } +} + +export function acpContextFromPayload( + payload: unknown +): { used: number; size: number } | null { + if (!payload || typeof payload !== "object") return null + const rec = payload as Record + if (typeof rec.used !== "number" || typeof rec.size !== "number") return null + if (!Number.isFinite(rec.used) || !Number.isFinite(rec.size)) return null + return { used: rec.used, size: rec.size } +} + +export function familyQuota( + family: IsolatableFamily, + officialPayload?: unknown, + acpUsage?: unknown +): FamilyQuota { + const remaining = remainingFromOfficialPayload(family, officialPayload) + if (remaining) { + return { family, kind: "remaining-subscription", ...remaining } + } + const context = acpContextFromPayload(acpUsage) + if (context) { + return { family, kind: "acp-context", ...context } + } + return { + family, + kind: "unavailable", + providerUsageUrl: PROVIDER_USAGE_URLS[family], + } +} + +export function inventory( + officialByFamily: Partial> = {}, + acpByFamily: Partial> = {} +): FamilyQuota[] { + return FAMILIES.map((family) => + familyQuota(family, officialByFamily[family], acpByFamily[family]) + ) +} + +export function emitsRemainingSubscription(row: FamilyQuota): boolean { + return row.kind === "remaining-subscription" +} From 0eb301225a829bc58f8876a0b3ae4ca1dd4cd505 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:52:51 -0700 Subject: [PATCH 02/11] Parse official Codex and Claude remaining-quota payloads Codex remaining comes from documented app-server account/rateLimits/read. Claude remaining comes from the /usage HUD five_hour utilization payload. Other families still link to the provider page instead of inventing a number. --- src/lib/subscription-quota.test.ts | 40 +++++++------- src/lib/subscription-quota.ts | 89 +++++++++++++++++++++--------- 2 files changed, 85 insertions(+), 44 deletions(-) diff --git a/src/lib/subscription-quota.test.ts b/src/lib/subscription-quota.test.ts index 3448cd597..a3a02677f 100644 --- a/src/lib/subscription-quota.test.ts +++ b/src/lib/subscription-quota.test.ts @@ -27,38 +27,40 @@ describe("subscription quota inventory", () => { } }) - it("reads remaining only from a recorded official remaining-subscription envelope", () => { + it("reads Codex remaining from documented account/rateLimits/read", () => { const payload = { - codegQuotaKind: "remaining-subscription", - family: "codex", - remaining: 42, - limit: 100, - source: "recorded-official-fixture", + rateLimits: { + primary: { usedPercent: 42, resetsAt: 1_775_000_000 }, + secondary: { usedPercent: 10, resetsAt: 1_775_500_000 }, + }, } const parsed = remainingFromOfficialPayload("codex", payload) expect(parsed).toEqual({ - remaining: 42, + remaining: 58, limit: 100, - source: "recorded-official-fixture", + source: "codex account/rateLimits/read", }) - const row = familyQuota("codex", payload) - expect(row.kind).toBe("remaining-subscription") - if (row.kind === "remaining-subscription") { - expect(row.remaining).toBe(42) + expect(familyQuota("codex", payload).kind).toBe("remaining-subscription") + }) + + it("reads Claude remaining from the /usage HUD payload", () => { + const payload = { + five_hour: { utilization: 0.42, resets_at: "2026-02-28T17:00:00Z" }, + seven_day: { utilization: 0.61, resets_at: "2026-03-07T08:00:00Z" }, } + const parsed = remainingFromOfficialPayload("claude", payload) + expect(parsed?.source).toBe("claude /usage") + expect(parsed?.remaining).toBeCloseTo(58) + expect(familyQuota("claude", payload).kind).toBe("remaining-subscription") }) - it("rejects a payload that is not the official envelope", () => { + it("rejects a payload that is not the official family shape", () => { expect( remainingFromOfficialPayload("claude", { remaining: 1, limit: 2 }) ).toBeNull() expect( - remainingFromOfficialPayload("claude", { - codegQuotaKind: "remaining-subscription", - family: "codex", - remaining: 1, - limit: 2, - source: "wrong-family", + remainingFromOfficialPayload("grok", { + rateLimits: { primary: { usedPercent: 10 } }, }) ).toBeNull() }) diff --git a/src/lib/subscription-quota.ts b/src/lib/subscription-quota.ts index a10008982..b7288050d 100644 --- a/src/lib/subscription-quota.ts +++ b/src/lib/subscription-quota.ts @@ -2,10 +2,11 @@ * Remaining-subscription inventory. * * ACP `usage_update` is context occupancy `{used, size}`, not plan remaining. - * Official CLIs (claude /usage, chatgpt, grok, gemini, opencode) do not - * publish a stable remaining-quota JSON that Codeg can read. This module is - * the single gate: a remaining-subscription number is emitted only when a - * recorded official payload actually contains one. + * Official remaining-quota sources we parse: + * Codex: documented app-server `account/rateLimits/read` + * (primary = 5-hour window, secondary = weekly). + * Claude: the `/usage` HUD payload (`five_hour` / `seven_day` utilization). + * Gemini / Grok / OpenCode still have no machine-readable remaining quota. */ export type IsolatableFamily = "claude" | "codex" | "grok" | "gemini" | "opencode" @@ -48,34 +49,72 @@ const FAMILIES: IsolatableFamily[] = [ "opencode", ] +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null +} + +function percentRemaining(usedPercent: unknown): number | null { + if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { + return null + } + return Math.max(0, Math.min(100, 100 - usedPercent)) +} + +function utilizationRemaining(utilization: unknown): number | null { + if (typeof utilization !== "number" || !Number.isFinite(utilization)) { + return null + } + return Math.max(0, Math.min(100, (1 - utilization) * 100)) +} + +/** Documented Codex app-server `account/rateLimits/read` result. */ +export function remainingFromCodexAppServer( + payload: unknown +): { remaining: number; limit: number; source: string } | null { + const rec = asRecord(payload) + if (!rec) return null + const result = asRecord(rec.result) ?? rec + const limits = asRecord(result.rateLimits) + if (!limits) return null + const primary = asRecord(limits.primary) + const remaining = percentRemaining(primary?.usedPercent) + if (remaining == null) return null + return { + remaining, + limit: 100, + source: "codex account/rateLimits/read", + } +} + +/** Claude `/usage` HUD payload (`five_hour.utilization`). */ +export function remainingFromClaudeUsageHud( + payload: unknown +): { remaining: number; limit: number; source: string } | null { + const rec = asRecord(payload) + if (!rec) return null + const five = asRecord(rec.five_hour) + const remaining = utilizationRemaining(five?.utilization) + if (remaining == null) return null + return { + remaining, + limit: 100, + source: "claude /usage", + } +} + /** * Read remaining subscription from a recorded official payload. - * Production Codeg never invents this object. Return null unless the - * payload is the documented official shape for that family. + * Production Codeg never invents this object. */ export function remainingFromOfficialPayload( family: IsolatableFamily, payload: unknown ): { remaining: number; limit: number; source: string } | null { - if (!payload || typeof payload !== "object") return null - const rec = payload as Record - // No family currently publishes this object. Accept only an explicit - // official envelope so a future CLI JSON can land without inventing - // numbers today. - if (rec.codegQuotaKind !== "remaining-subscription") return null - if (rec.family !== family) return null - if (typeof rec.remaining !== "number" || typeof rec.limit !== "number") { - return null - } - if (!Number.isFinite(rec.remaining) || !Number.isFinite(rec.limit)) { - return null - } - if (typeof rec.source !== "string" || !rec.source.trim()) return null - return { - remaining: rec.remaining, - limit: rec.limit, - source: rec.source.trim(), - } + if (family === "codex") return remainingFromCodexAppServer(payload) + if (family === "claude") return remainingFromClaudeUsageHud(payload) + return null } export function acpContextFromPayload( From c0fae860035c60eda0f0888e92d235c9304fa050 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:41:16 -0700 Subject: [PATCH 03/11] Read live Codex remaining quota from app-server subscription-quota now calls official `codex app-server --stdio` `account/rateLimits/read` and parses the live envelope (primary usedPercent, window, reset, extra limit ids). Local CLI check: Claude `auth status` and `claude -p /usage` do not publish remaining plan quota. Grok has no usage command. OpenCode `stats` is historical cost, not remaining. Those rows stay links. --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/subscription_quota.rs | 255 ++++++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/web/handlers/mod.rs | 1 + .../src/web/handlers/subscription_quota.rs | 10 + src-tauri/src/web/router.rs | 4 + .../token-usage/subscription-quota-panel.tsx | 110 ++++++-- src/i18n/messages/ar.json | 3 + src/i18n/messages/de.json | 3 + src/i18n/messages/en.json | 3 + src/i18n/messages/es.json | 3 + src/i18n/messages/fr.json | 3 + src/i18n/messages/ja.json | 3 + src/i18n/messages/ko.json | 3 + src/i18n/messages/pt.json | 3 + src/i18n/messages/zh-CN.json | 3 + src/i18n/messages/zh-TW.json | 3 + src/lib/api.ts | 11 + src/lib/subscription-quota.test.ts | 61 ++++- src/lib/subscription-quota.ts | 98 ++++++- 20 files changed, 539 insertions(+), 44 deletions(-) create mode 100644 src-tauri/src/commands/subscription_quota.rs create mode 100644 src-tauri/src/web/handlers/subscription_quota.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index dcf009cc3..5c4d46607 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -34,6 +34,7 @@ pub mod remote_proxy; pub mod remote_workspace; pub mod science; pub mod session_info; +pub mod subscription_quota; pub mod system_settings; pub mod terminal; pub mod token_usage; diff --git a/src-tauri/src/commands/subscription_quota.rs b/src-tauri/src/commands/subscription_quota.rs new file mode 100644 index 000000000..1129f636a --- /dev/null +++ b/src-tauri/src/commands/subscription_quota.rs @@ -0,0 +1,255 @@ +//! Official remaining-subscription reads. +//! +//! Codex publishes remaining plan quota through the documented app-server +//! JSON-RPC method `account/rateLimits/read`. This module talks to that +//! method over `codex app-server --stdio` and returns the official `result` +//! object. It never invents a remaining number. +//! +//! Claude / Grok / Gemini / OpenCode have no matching official remaining +//! payload on this host (verified against the live CLIs). Those families +//! stay unavailable at the TypeScript inventory layer. + +use std::process::Stdio; +use std::time::Duration; + +use serde::Serialize; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::time::timeout; + +use crate::app_error::{AppCommandError, AppErrorCode}; + +const READ_DEADLINE: Duration = Duration::from_secs(12); +const INIT_ID: u64 = 1; +const LIMITS_ID: u64 = 2; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OfficialQuotaRead { + pub family: &'static str, + /// Official JSON from the CLI, or `null` when that CLI did not publish + /// a remaining-quota payload. Missing CLI is not an error. + pub payload: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable_reason: Option, +} + +pub fn extract_rate_limits_result(messages: &[Value]) -> Option { + for message in messages { + let Some(obj) = message.as_object() else { + continue; + }; + if obj.get("id").and_then(Value::as_u64) != Some(LIMITS_ID) { + continue; + } + if obj.contains_key("error") { + return None; + } + if let Some(result) = obj.get("result") { + if result.get("rateLimits").is_some() { + return Some(result.clone()); + } + } + } + None +} + +fn initialize_request() -> Value { + json!({ + "jsonrpc": "2.0", + "id": INIT_ID, + "method": "initialize", + "params": { + "clientInfo": { + "name": "codeg", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": {} + } + }) +} + +fn rate_limits_request() -> Value { + json!({ + "jsonrpc": "2.0", + "id": LIMITS_ID, + "method": "account/rateLimits/read", + "params": {} + }) +} + +async fn read_codex_rate_limits_from_child() -> Result, AppCommandError> { + let mut child = crate::process::tokio_command("codex") + .args(["app-server", "--stdio"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|err| { + AppCommandError::new( + AppErrorCode::DependencyMissing, + "Codex CLI is not available", + ) + .with_detail(err.to_string()) + })?; + + let mut stdin = child.stdin.take().ok_or_else(|| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "Codex stdin missing") + })?; + let stdout = child.stdout.take().ok_or_else(|| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "Codex stdout missing") + })?; + + let write = async { + for request in [initialize_request(), rate_limits_request()] { + let mut line = serde_json::to_vec(&request).map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "encode RPC") + .with_detail(err.to_string()) + })?; + line.push(b'\n'); + stdin.write_all(&line).await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "write RPC") + .with_detail(err.to_string()) + })?; + } + stdin.flush().await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "flush RPC") + .with_detail(err.to_string()) + })?; + Ok::<(), AppCommandError>(()) + }; + + let collect = async { + let mut reader = BufReader::new(stdout); + let mut messages = Vec::new(); + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line).await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "read RPC") + .with_detail(err.to_string()) + })?; + if n == 0 { + break; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(value) = serde_json::from_str::(trimmed) { + let got_limits = value.get("id").and_then(Value::as_u64) == Some(LIMITS_ID); + messages.push(value); + if got_limits { + break; + } + } + } + Ok::, AppCommandError>(messages) + }; + + let result = timeout(READ_DEADLINE, async { + write.await?; + collect.await + }) + .await; + + let _ = child.kill().await; + + match result { + Ok(Ok(messages)) => Ok(extract_rate_limits_result(&messages)), + Ok(Err(err)) => Err(err), + Err(_) => Err(AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + "Codex app-server timed out", + )), + } +} + +pub async fn read_codex_subscription_quota_core() -> OfficialQuotaRead { + match read_codex_rate_limits_from_child().await { + Ok(Some(payload)) => OfficialQuotaRead { + family: "codex", + payload: Some(payload), + unavailable_reason: None, + }, + Ok(None) => OfficialQuotaRead { + family: "codex", + payload: None, + unavailable_reason: Some("codex app-server did not return rateLimits".into()), + }, + Err(err) => OfficialQuotaRead { + family: "codex", + payload: None, + unavailable_reason: Some(err.message), + }, + } +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn subscription_quota_codex() -> Result { + Ok(read_codex_subscription_quota_core().await) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_live_account_rate_limits_read_shape() { + let messages = vec![ + json!({"id": 1, "result": {"userAgent": "Codex Desktop"}}), + json!({ + "id": 2, + "result": { + "rateLimits": { + "limitId": "codex", + "primary": { + "usedPercent": 100, + "windowDurationMins": 10080, + "resetsAt": 1787196797 + }, + "secondary": null, + "credits": { + "hasCredits": false, + "unlimited": false, + "balance": "0" + }, + "planType": "pro", + "rateLimitReachedType": "rate_limit_reached" + }, + "rateLimitsByLimitId": { + "codex": { + "limitId": "codex", + "primary": { "usedPercent": 100 } + }, + "codex_spark": { + "limitId": "codex_spark", + "limitName": "GPT-5.3-Codex-Spark", + "primary": { "usedPercent": 0 } + } + } + } + }), + ]; + let result = extract_rate_limits_result(&messages).expect("result"); + assert_eq!( + result["rateLimits"]["primary"]["usedPercent"], + json!(100) + ); + assert_eq!( + result["rateLimitsByLimitId"]["codex_spark"]["primary"]["usedPercent"], + json!(0) + ); + } + + #[test] + fn ignores_rpc_error_and_missing_id() { + let messages = vec![ + json!({"id": 2, "error": {"message": "unauthorized"}}), + json!({"method": "remoteControl/status/changed", "params": {}}), + ]; + assert!(extract_rate_limits_result(&messages).is_none()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8401d450d..125ae9fa3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -76,6 +76,7 @@ mod tauri_app { remote_workspace as remote_workspace_commands, science as science_commands, session_info as session_info_commands, system_settings, terminal as terminal_commands, + subscription_quota as subscription_quota_commands, token_usage as token_usage_commands, forge as forge_commands, version_control, windows, work_task as work_task_commands, workspace_state as workspace_state_commands, @@ -1355,6 +1356,7 @@ mod tauri_app { token_usage_commands::token_usage_facets, token_usage_commands::token_usage_status, token_usage_commands::token_usage_sync, + subscription_quota_commands::subscription_quota_codex, work_task_commands::work_task_list, work_task_commands::work_task_get, work_task_commands::work_task_events, diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 97b7d86f9..0c6f93c81 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -29,6 +29,7 @@ pub mod question; pub mod quick_messages; pub mod science; pub mod session_info; +pub mod subscription_quota; pub mod system_settings; pub mod terminal; pub mod token_usage; diff --git a/src-tauri/src/web/handlers/subscription_quota.rs b/src-tauri/src/web/handlers/subscription_quota.rs new file mode 100644 index 000000000..d473ef7d6 --- /dev/null +++ b/src-tauri/src/web/handlers/subscription_quota.rs @@ -0,0 +1,10 @@ +use axum::Json; + +use crate::app_error::AppCommandError; +use crate::commands::subscription_quota::{ + read_codex_subscription_quota_core, OfficialQuotaRead, +}; + +pub async fn subscription_quota_codex() -> Result, AppCommandError> { + Ok(Json(read_codex_subscription_quota_core().await)) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index d898fcf82..ec31194b5 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -1273,6 +1273,10 @@ pub fn build_router( "/token_usage_sync", post(handlers::token_usage::token_usage_sync), ) + .route( + "/subscription_quota_codex", + post(handlers::subscription_quota::subscription_quota_codex), + ) // ─── Work tasks ─── .route("/work_task_list", post(handlers::work_task::work_task_list)) .route("/work_task_get", post(handlers::work_task::work_task_get)) diff --git a/src/components/token-usage/subscription-quota-panel.tsx b/src/components/token-usage/subscription-quota-panel.tsx index 3a951749b..ec274719e 100644 --- a/src/components/token-usage/subscription-quota-panel.tsx +++ b/src/components/token-usage/subscription-quota-panel.tsx @@ -1,13 +1,41 @@ "use client" -import { useTranslations } from "next-intl" +import { useEffect, useState } from "react" +import { useLocale, useTranslations } from "next-intl" import { ExternalLink } from "lucide-react" -import { inventory } from "@/lib/subscription-quota" +import { subscriptionQuotaCodex } from "@/lib/api" +import { inventory, type IsolatableFamily } from "@/lib/subscription-quota" import { openUrl } from "@/lib/platform" export function SubscriptionQuotaPanel() { const t = useTranslations("TokenUsage") - const rows = inventory() + const locale = useLocale() + const [official, setOfficial] = useState< + Partial> + >({}) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + let cancelled = false + void subscriptionQuotaCodex() + .then((read) => { + if (cancelled) return + if (read.payload) { + setOfficial({ codex: read.payload }) + } + }) + .catch(() => { + // Missing CLI or a dead app-server is "unavailable", not a toast. + }) + .finally(() => { + if (!cancelled) setLoaded(true) + }) + return () => { + cancelled = true + } + }, []) + + const rows = inventory(official) return (
@@ -17,32 +45,58 @@ export function SubscriptionQuotaPanel() {

    {rows.map((row) => ( -
  • - {row.family} - {row.kind === "remaining-subscription" ? ( - - {t("quotaRemaining", { - remaining: row.remaining, - limit: row.limit, +
  • +
    + {row.family} + {row.kind === "remaining-subscription" ? ( + + {t("quotaRemaining", { + remaining: Math.round(row.remaining), + limit: row.limit, + })} + + ) : row.family === "codex" && !loaded ? ( + {t("quotaLoading")} + ) : ( + + )} +
    + {row.kind === "remaining-subscription" && row.resetsAt ? ( +
    + {t("quotaResets", { + when: new Date(row.resetsAt * 1000).toLocaleString(locale, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }), })} - - ) : ( - - )} +
    + ) : null} + {row.kind === "remaining-subscription" + ? row.extras?.map((extra) => ( +
    + {t("quotaExtraRemaining", { + name: extra.label ?? row.family, + remaining: Math.round(extra.remaining), + })} +
    + )) + : null}
  • ))}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index c4a346fda..30262de0d 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4929,6 +4929,9 @@ "quotaHint": "تعد Codeg الرموز في هذه الصفحة. يظهر رصيد الخطة فقط إذا نشره CLI المزوّد. استخدام ACP هو إشغال السياق وليس رصيد الاشتراك.", "quotaRemaining": "متبقٍ {remaining} من {limit}", "quotaProviderLink": "صفحة استخدام المزوّد", + "quotaLoading": "جارٍ قراءة الحصة الرسمية…", + "quotaResets": "يُعاد التعيين {when}", + "quotaExtraRemaining": "{name}: متبقٍ {remaining}", "rangeLabel": "النطاق الزمني", "range7d": "٧ أيام", "range30d": "٣٠ يومًا", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4cda8d790..ddcd0e406 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4929,6 +4929,9 @@ "quotaHint": "Codeg zählt Tokens auf dieser Seite. Verbleibendes Plan-Kontingent nur wenn die CLI es veröffentlicht. ACP-Nutzung ist Kontextbelegung, kein Abo-Rest.", "quotaRemaining": "{remaining} von {limit} übrig", "quotaProviderLink": "Anbieter-Nutzungsseite", + "quotaLoading": "Offizielle Quote wird gelesen…", + "quotaResets": "Zurückgesetzt {when}", + "quotaExtraRemaining": "{name}: {remaining} übrig", "rangeLabel": "Zeitraum", "range7d": "7 Tage", "range30d": "30 Tage", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ba62c0293..010dab7fa 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4929,6 +4929,9 @@ "quotaHint": "Codeg meters tokens on this page. Remaining plan quota is shown only when a provider CLI publishes it. ACP usage is context occupancy, not subscription remaining.", "quotaRemaining": "{remaining} of {limit} remaining", "quotaProviderLink": "Provider usage page", + "quotaLoading": "Reading official quota…", + "quotaResets": "Resets {when}", + "quotaExtraRemaining": "{name}: {remaining} remaining", "rangeLabel": "Time range", "range7d": "7 days", "range30d": "30 days", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 20b0b704f..f3834e563 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4929,6 +4929,9 @@ "quotaHint": "Codeg mide tokens en esta página. El cupo restante del plan solo se muestra si el CLI del proveedor lo publica. El uso ACP es ocupación de contexto, no resto de suscripción.", "quotaRemaining": "{remaining} de {limit} restantes", "quotaProviderLink": "Página de uso del proveedor", + "quotaLoading": "Leyendo la cuota oficial…", + "quotaResets": "Se restablece {when}", + "quotaExtraRemaining": "{name}: {remaining} restantes", "rangeLabel": "Periodo", "range7d": "7 días", "range30d": "30 días", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 81ef0e96e..0c1046667 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4929,6 +4929,9 @@ "quotaHint": "Codeg compte les tokens sur cette page. Le quota restant n’apparaît que si le CLI du fournisseur le publie. L’usage ACP est l’occupation du contexte, pas le reste d’abonnement.", "quotaRemaining": "{remaining} sur {limit} restants", "quotaProviderLink": "Page d’usage du fournisseur", + "quotaLoading": "Lecture du quota officiel…", + "quotaResets": "Réinitialisation {when}", + "quotaExtraRemaining": "{name} : {remaining} restants", "rangeLabel": "Période", "range7d": "7 jours", "range30d": "30 jours", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c73658221..ac3cc14a0 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4929,6 +4929,9 @@ "quotaHint": "このページは Codeg が計測したトークンです。プラン残量は公式 CLI が公開する場合のみ表示します。ACP の usage はコンテキスト占有であり、契約残量ではありません。", "quotaRemaining": "残り {remaining} / {limit}", "quotaProviderLink": "プロバイダーの使用量ページ", + "quotaLoading": "公式クォータを読み込み中…", + "quotaResets": "{when} にリセット", + "quotaExtraRemaining": "{name}: 残り {remaining}", "rangeLabel": "期間", "range7d": "7日間", "range30d": "30日間", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index a42066960..ac1391295 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4929,6 +4929,9 @@ "quotaHint": "이 페이지는 Codeg가 측정한 토큰입니다. 플랜 잔여량은 제공자 CLI가 공개할 때만 표시됩니다. ACP usage는 컨텍스트 점유이며 구독 잔여량이 아닙니다.", "quotaRemaining": "{limit} 중 {remaining} 남음", "quotaProviderLink": "제공자 사용량 페이지", + "quotaLoading": "공식 할당량 읽는 중…", + "quotaResets": "{when}에 재설정", + "quotaExtraRemaining": "{name}: {remaining} 남음", "rangeLabel": "기간", "range7d": "7일", "range30d": "30일", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index ea9c162a2..dae30c11b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4929,6 +4929,9 @@ "quotaHint": "O Codeg mede tokens nesta página. A cota restante do plano só aparece se o CLI do provedor a publicar. O uso ACP é ocupação de contexto, não resto de assinatura.", "quotaRemaining": "{remaining} de {limit} restantes", "quotaProviderLink": "Página de uso do provedor", + "quotaLoading": "Lendo a cota oficial…", + "quotaResets": "Reinicia {when}", + "quotaExtraRemaining": "{name}: {remaining} restantes", "rangeLabel": "Período", "range7d": "7 dias", "range30d": "30 dias", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index c3f547ae9..fbb7f6285 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4929,6 +4929,9 @@ "quotaHint": "本页显示 Codeg 计量的 token。套餐余量仅在提供商 CLI 公开时显示。ACP usage 是上下文占用,不是订阅余量。", "quotaRemaining": "剩余 {remaining} / {limit}", "quotaProviderLink": "提供商用量页", + "quotaLoading": "正在读取官方额度…", + "quotaResets": "{when} 重置", + "quotaExtraRemaining": "{name}:剩余 {remaining}", "rangeLabel": "时间范围", "range7d": "近 7 天", "range30d": "近 30 天", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 82703518f..84f160189 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4929,6 +4929,9 @@ "quotaHint": "本頁顯示 Codeg 計量的 token。方案餘量僅在供應商 CLI 公開時顯示。ACP usage 是上下文占用,不是訂閱餘量。", "quotaRemaining": "剩餘 {remaining} / {limit}", "quotaProviderLink": "供應商用量頁", + "quotaLoading": "正在讀取官方額度…", + "quotaResets": "{when} 重置", + "quotaExtraRemaining": "{name}:剩餘 {remaining}", "rangeLabel": "時間範圍", "range7d": "近 7 天", "range30d": "近 30 天", diff --git a/src/lib/api.ts b/src/lib/api.ts index 55033ae8a..eb4e845e3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3001,6 +3001,17 @@ export async function tokenUsageSync( return getTransport().call("token_usage_sync", { mode }) } +export type OfficialQuotaRead = { + family: string + payload: unknown | null + unavailableReason?: string | null +} + +/** Official Codex app-server `account/rateLimits/read`. Missing CLI is null. */ +export async function subscriptionQuotaCodex(): Promise { + return getTransport().call("subscription_quota_codex") +} + // Automations export async function automationList(): Promise { diff --git a/src/lib/subscription-quota.test.ts b/src/lib/subscription-quota.test.ts index a3a02677f..7bbf775e0 100644 --- a/src/lib/subscription-quota.test.ts +++ b/src/lib/subscription-quota.test.ts @@ -35,14 +35,65 @@ describe("subscription quota inventory", () => { }, } const parsed = remainingFromOfficialPayload("codex", payload) - expect(parsed).toEqual({ - remaining: 58, - limit: 100, - source: "codex account/rateLimits/read", - }) + expect(parsed?.remaining).toBe(58) + expect(parsed?.limit).toBe(100) + expect(parsed?.source).toBe("codex account/rateLimits/read") + expect(parsed?.resetsAt).toBe(1_775_000_000) expect(familyQuota("codex", payload).kind).toBe("remaining-subscription") }) + it("parses the live Codex app-server envelope from this machine", () => { + // Sanitized from `codex app-server --stdio` + account/rateLimits/read + // on 2026-08-15. Numbers are real; ids are generic. + const payload = { + id: 2, + result: { + rateLimits: { + limitId: "codex", + limitName: null, + primary: { + usedPercent: 100, + windowDurationMins: 10080, + resetsAt: 1787196797, + }, + secondary: null, + credits: { hasCredits: false, unlimited: false, balance: "0" }, + planType: "pro", + rateLimitReachedType: "rate_limit_reached", + }, + rateLimitsByLimitId: { + codex: { + limitId: "codex", + primary: { usedPercent: 100 }, + }, + codex_spark: { + limitId: "codex_spark", + limitName: "GPT-5.3-Codex-Spark", + primary: { + usedPercent: 0, + windowDurationMins: 10080, + resetsAt: 1787423547, + }, + }, + }, + }, + } + const parsed = remainingFromOfficialPayload("codex", payload) + expect(parsed?.remaining).toBe(0) + expect(parsed?.planType).toBe("pro") + expect(parsed?.rateLimitReached).toBe(true) + expect(parsed?.windowDurationMins).toBe(10080) + expect(parsed?.extras).toEqual([ + { + remaining: 100, + usedPercent: 0, + windowDurationMins: 10080, + resetsAt: 1787423547, + label: "GPT-5.3-Codex-Spark", + }, + ]) + }) + it("reads Claude remaining from the /usage HUD payload", () => { const payload = { five_hour: { utilization: 0.42, resets_at: "2026-02-28T17:00:00Z" }, diff --git a/src/lib/subscription-quota.ts b/src/lib/subscription-quota.ts index b7288050d..f1146bbf0 100644 --- a/src/lib/subscription-quota.ts +++ b/src/lib/subscription-quota.ts @@ -2,17 +2,33 @@ * Remaining-subscription inventory. * * ACP `usage_update` is context occupancy `{used, size}`, not plan remaining. - * Official remaining-quota sources we parse: - * Codex: documented app-server `account/rateLimits/read` - * (primary = 5-hour window, secondary = weekly). - * Claude: the `/usage` HUD payload (`five_hour` / `seven_day` utilization). - * Gemini / Grok / OpenCode still have no machine-readable remaining quota. + * + * Official remaining-quota sources, verified against live CLIs: + * Codex: documented app-server `account/rateLimits/read`. + * Live result (2026-08-15) is `{ rateLimits.primary.usedPercent, + * windowDurationMins, resetsAt, planType, rateLimitsByLimitId }`. + * `primary` is the current window (here a 10080-minute week), not a + * guaranteed 5-hour window. + * Claude: `claude auth status` returns subscription type only. + * `claude -p /usage` returns a prose sentence, not `five_hour`. + * The `/usage` HUD parser is kept for that payload if one arrives; + * Codeg does not scrape undocumented Anthropic HTTP endpoints. + * Gemini / Grok / OpenCode: no remaining-quota command. OpenCode `stats` + * is historical token/cost, not plan remaining. */ export type IsolatableFamily = "claude" | "codex" | "grok" | "gemini" | "opencode" export type QuotaKind = "remaining-subscription" | "acp-context" | "unavailable" +export type QuotaWindow = { + remaining: number + usedPercent: number + windowDurationMins?: number + resetsAt?: number + label?: string +} + export type FamilyQuota = | { family: IsolatableFamily @@ -20,6 +36,11 @@ export type FamilyQuota = remaining: number limit: number source: string + planType?: string + rateLimitReached?: boolean + extras?: QuotaWindow[] + resetsAt?: number + windowDurationMins?: number } | { family: IsolatableFamily @@ -69,22 +90,66 @@ function utilizationRemaining(utilization: unknown): number | null { return Math.max(0, Math.min(100, (1 - utilization) * 100)) } +function windowFromLimit(limit: Record): QuotaWindow | null { + const primary = asRecord(limit.primary) + const remaining = percentRemaining(primary?.usedPercent) + if (remaining == null || typeof primary?.usedPercent !== "number") return null + const windowDurationMins = + typeof primary.windowDurationMins === "number" + ? primary.windowDurationMins + : undefined + const resetsAt = + typeof primary.resetsAt === "number" ? primary.resetsAt : undefined + const label = + typeof limit.limitName === "string" + ? limit.limitName + : typeof limit.limitId === "string" + ? limit.limitId + : undefined + return { remaining, usedPercent: primary.usedPercent, windowDurationMins, resetsAt, label } +} + /** Documented Codex app-server `account/rateLimits/read` result. */ export function remainingFromCodexAppServer( payload: unknown -): { remaining: number; limit: number; source: string } | null { +): { + remaining: number + limit: number + source: string + planType?: string + rateLimitReached?: boolean + extras?: QuotaWindow[] + resetsAt?: number + windowDurationMins?: number +} | null { const rec = asRecord(payload) if (!rec) return null const result = asRecord(rec.result) ?? rec const limits = asRecord(result.rateLimits) if (!limits) return null - const primary = asRecord(limits.primary) - const remaining = percentRemaining(primary?.usedPercent) - if (remaining == null) return null + const primary = windowFromLimit(limits) + if (!primary) return null + const extras: QuotaWindow[] = [] + const byId = asRecord(result.rateLimitsByLimitId) + const primaryId = typeof limits.limitId === "string" ? limits.limitId : null + if (byId) { + for (const [id, value] of Object.entries(byId)) { + if (primaryId && id === primaryId) continue + const extra = asRecord(value) + if (!extra) continue + const parsed = windowFromLimit(extra) + if (parsed) extras.push(parsed) + } + } return { - remaining, + remaining: primary.remaining, limit: 100, source: "codex account/rateLimits/read", + planType: typeof limits.planType === "string" ? limits.planType : undefined, + rateLimitReached: limits.rateLimitReachedType === "rate_limit_reached", + extras: extras.length ? extras : undefined, + resetsAt: primary.resetsAt, + windowDurationMins: primary.windowDurationMins, } } @@ -108,10 +173,21 @@ export function remainingFromClaudeUsageHud( * Read remaining subscription from a recorded official payload. * Production Codeg never invents this object. */ +export type OfficialRemaining = { + remaining: number + limit: number + source: string + planType?: string + rateLimitReached?: boolean + extras?: QuotaWindow[] + resetsAt?: number + windowDurationMins?: number +} + export function remainingFromOfficialPayload( family: IsolatableFamily, payload: unknown -): { remaining: number; limit: number; source: string } | null { +): OfficialRemaining | null { if (family === "codex") return remainingFromCodexAppServer(payload) if (family === "claude") return remainingFromClaudeUsageHud(payload) return null From 07ba2ebf19f69a586b6df9d172c595ea86ef6364 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:19:04 -0700 Subject: [PATCH 04/11] Read Claude remaining quota from the same OAuth usage API /usage uses There is no `claude usage` CLI. Live `GET /api/oauth/usage` with the local Claude Code OAuth token returns five_hour / seven_day utilization as 0-100 percents. That is the HUD payload community monitors already parse. The panel fetches it next to Codex. Utilization is not treated as a 0-1 fraction. Weekly and extra-usage windows show as extras. Tokens are never logged. --- src-tauri/src/commands/subscription_quota.rs | 116 +++++++++++++++++- src-tauri/src/lib.rs | 1 + .../src/web/handlers/subscription_quota.rs | 6 +- src-tauri/src/web/router.rs | 4 + .../token-usage/subscription-quota-panel.tsx | 28 +++-- src/lib/api.ts | 5 + src/lib/subscription-quota.test.ts | 27 +++- src/lib/subscription-quota.ts | 61 +++++++-- 8 files changed, 219 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/commands/subscription_quota.rs b/src-tauri/src/commands/subscription_quota.rs index 1129f636a..a92c7b075 100644 --- a/src-tauri/src/commands/subscription_quota.rs +++ b/src-tauri/src/commands/subscription_quota.rs @@ -5,10 +5,14 @@ //! method over `codex app-server --stdio` and returns the official `result` //! object. It never invents a remaining number. //! -//! Claude / Grok / Gemini / OpenCode have no matching official remaining -//! payload on this host (verified against the live CLIs). Those families -//! stay unavailable at the TypeScript inventory layer. +//! Claude has no `usage` CLI. The `/usage` HUD reads +//! `GET https://api.anthropic.com/api/oauth/usage` with the local Claude +//! Code OAuth token (`~/.claude/.credentials.json`). That is the same +//! endpoint community monitors use. Grok / Gemini / OpenCode still have +//! no remaining-quota command. +use std::fs; +use std::path::Path; use std::process::Stdio; use std::time::Duration; @@ -192,6 +196,100 @@ pub async fn subscription_quota_codex() -> Result Option { + let value: Value = serde_json::from_str(text).ok()?; + value + .get("claudeAiOauth") + .and_then(|oauth| oauth.get("accessToken")) + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .map(str::to_string) +} + +fn claude_credentials_path() -> Option { + dirs::home_dir().map(|home| home.join(".claude").join(".credentials.json")) +} + +fn read_claude_oauth_access_token(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + claude_oauth_access_token_from_credentials(&text) +} + +async fn fetch_claude_oauth_usage(token: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|err| { + AppCommandError::new(AppErrorCode::NetworkError, "HTTP client") + .with_detail(err.to_string()) + })?; + let response = client + .get("https://api.anthropic.com/api/oauth/usage") + .header("Authorization", format!("Bearer {token}")) + .header("anthropic-beta", "oauth-2025-04-20") + .header("User-Agent", "codeg") + .header("Accept", "application/json") + .send() + .await + .map_err(|err| { + AppCommandError::new(AppErrorCode::NetworkError, "Claude usage request failed") + .with_detail(err.to_string()) + })?; + let status = response.status(); + if !status.is_success() { + return Err(AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + format!("Claude usage HTTP {status}"), + )); + } + response.json::().await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "Claude usage JSON") + .with_detail(err.to_string()) + }) +} + +pub async fn read_claude_subscription_quota_core() -> OfficialQuotaRead { + let Some(path) = claude_credentials_path() else { + return OfficialQuotaRead { + family: "claude", + payload: None, + unavailable_reason: Some("home directory unavailable".into()), + }; + }; + let Some(token) = read_claude_oauth_access_token(&path) else { + return OfficialQuotaRead { + family: "claude", + payload: None, + unavailable_reason: Some("Claude Code is not signed in".into()), + }; + }; + match fetch_claude_oauth_usage(&token).await { + Ok(payload) if payload.get("five_hour").is_some() || payload.get("seven_day").is_some() => { + OfficialQuotaRead { + family: "claude", + payload: Some(payload), + unavailable_reason: None, + } + } + Ok(_) => OfficialQuotaRead { + family: "claude", + payload: None, + unavailable_reason: Some("Claude usage payload missing five_hour/seven_day".into()), + }, + Err(err) => OfficialQuotaRead { + family: "claude", + payload: None, + unavailable_reason: Some(err.message), + }, + } +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn subscription_quota_claude() -> Result { + Ok(read_claude_subscription_quota_core().await) +} + #[cfg(test)] mod tests { use super::*; @@ -252,4 +350,16 @@ mod tests { ]; assert!(extract_rate_limits_result(&messages).is_none()); } + + #[test] + fn reads_claude_oauth_access_token_without_logging_it() { + let text = r#"{ + "claudeAiOauth": { "accessToken": "tok_test_value", "subscriptionType": "max" } + }"#; + assert_eq!( + claude_oauth_access_token_from_credentials(text).as_deref(), + Some("tok_test_value") + ); + assert!(claude_oauth_access_token_from_credentials("{}").is_none()); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 125ae9fa3..ed6162b13 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1357,6 +1357,7 @@ mod tauri_app { token_usage_commands::token_usage_status, token_usage_commands::token_usage_sync, subscription_quota_commands::subscription_quota_codex, + subscription_quota_commands::subscription_quota_claude, work_task_commands::work_task_list, work_task_commands::work_task_get, work_task_commands::work_task_events, diff --git a/src-tauri/src/web/handlers/subscription_quota.rs b/src-tauri/src/web/handlers/subscription_quota.rs index d473ef7d6..476e41b99 100644 --- a/src-tauri/src/web/handlers/subscription_quota.rs +++ b/src-tauri/src/web/handlers/subscription_quota.rs @@ -2,9 +2,13 @@ use axum::Json; use crate::app_error::AppCommandError; use crate::commands::subscription_quota::{ - read_codex_subscription_quota_core, OfficialQuotaRead, + read_claude_subscription_quota_core, read_codex_subscription_quota_core, OfficialQuotaRead, }; pub async fn subscription_quota_codex() -> Result, AppCommandError> { Ok(Json(read_codex_subscription_quota_core().await)) } + +pub async fn subscription_quota_claude() -> Result, AppCommandError> { + Ok(Json(read_claude_subscription_quota_core().await)) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index ec31194b5..88b075cc2 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -1277,6 +1277,10 @@ pub fn build_router( "/subscription_quota_codex", post(handlers::subscription_quota::subscription_quota_codex), ) + .route( + "/subscription_quota_claude", + post(handlers::subscription_quota::subscription_quota_claude), + ) // ─── Work tasks ─── .route("/work_task_list", post(handlers::work_task::work_task_list)) .route("/work_task_get", post(handlers::work_task::work_task_get)) diff --git a/src/components/token-usage/subscription-quota-panel.tsx b/src/components/token-usage/subscription-quota-panel.tsx index ec274719e..e501712b7 100644 --- a/src/components/token-usage/subscription-quota-panel.tsx +++ b/src/components/token-usage/subscription-quota-panel.tsx @@ -3,7 +3,10 @@ import { useEffect, useState } from "react" import { useLocale, useTranslations } from "next-intl" import { ExternalLink } from "lucide-react" -import { subscriptionQuotaCodex } from "@/lib/api" +import { + subscriptionQuotaClaude, + subscriptionQuotaCodex, +} from "@/lib/api" import { inventory, type IsolatableFamily } from "@/lib/subscription-quota" import { openUrl } from "@/lib/platform" @@ -17,15 +20,21 @@ export function SubscriptionQuotaPanel() { useEffect(() => { let cancelled = false - void subscriptionQuotaCodex() - .then((read) => { + void Promise.allSettled([ + subscriptionQuotaCodex(), + subscriptionQuotaClaude(), + ]) + .then((results) => { if (cancelled) return - if (read.payload) { - setOfficial({ codex: read.payload }) + const next: Partial> = {} + const [codex, claude] = results + if (codex.status === "fulfilled" && codex.value.payload) { + next.codex = codex.value.payload } - }) - .catch(() => { - // Missing CLI or a dead app-server is "unavailable", not a toast. + if (claude.status === "fulfilled" && claude.value.payload) { + next.claude = claude.value.payload + } + setOfficial(next) }) .finally(() => { if (!cancelled) setLoaded(true) @@ -55,7 +64,8 @@ export function SubscriptionQuotaPanel() { limit: row.limit, })} - ) : row.family === "codex" && !loaded ? ( + ) : (row.family === "codex" || row.family === "claude") && + !loaded ? ( {t("quotaLoading")} ) : (
-
+
+
)} @@ -2599,6 +2600,7 @@ export function ConversationDetailPanel() { folderPath={activeTabFolder?.path} title={activeTab.title} status={activeTab.status as ConversationStatus | undefined} + agentType={activeTab.agentType} /> )} diff --git a/src/components/conversations/session-quota-chip.tsx b/src/components/conversations/session-quota-chip.tsx new file mode 100644 index 000000000..ae28377bd --- /dev/null +++ b/src/components/conversations/session-quota-chip.tsx @@ -0,0 +1,170 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useLocale, useTranslations } from "next-intl" +import { Gauge } from "lucide-react" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Progress } from "@/components/ui/progress" +import { + subscriptionQuotaClaude, + subscriptionQuotaCodex, + subscriptionQuotaGrok, +} from "@/lib/api" +import { + familyFromAgentType, + familyQuota, + type FamilyQuota, + type IsolatableFamily, + type OfficialQuotaSlot, +} from "@/lib/subscription-quota" +import type { AgentType } from "@/lib/types" + +/** + * Quiet remaining-quota chip for the current conversation header. + * Same official sources as the Token Usage page; one family only so the + * chat view does not become a second dashboard. + */ +export function SessionQuotaChip({ agentType }: { agentType: AgentType }) { + const family = familyFromAgentType(agentType) + if (!family) return null + return +} + +const QUOTA_FETCHERS: Record< + IsolatableFamily, + | (() => Promise<{ payload?: unknown; extraSlots?: OfficialQuotaSlot[] }>) + | null +> = { + claude: subscriptionQuotaClaude, + codex: subscriptionQuotaCodex, + grok: subscriptionQuotaGrok, + gemini: null, + opencode: null, +} + +function SessionQuotaChipInner({ family }: { family: IsolatableFamily }) { + const t = useTranslations("TokenUsage") + const locale = useLocale() + const fetchQuota = QUOTA_FETCHERS[family] + const [fetched, setFetched] = useState(null) + const [fetchDone, setFetchDone] = useState(false) + + useEffect(() => { + if (!fetchQuota) return + let cancelled = false + void fetchQuota() + .then((value) => { + if (cancelled) return + setFetched( + familyQuota(family, value.payload, undefined, value.extraSlots) + ) + }) + .catch(() => { + if (!cancelled) setFetched(familyQuota(family)) + }) + .finally(() => { + if (!cancelled) setFetchDone(true) + }) + return () => { + cancelled = true + } + }, [family, fetchQuota]) + + // A family with no official endpoint has nothing to wait for, so its row is + // derived during render rather than pushed through state by the effect. + const localRow = useMemo( + () => (fetchQuota ? null : familyQuota(family)), + [family, fetchQuota] + ) + const row = fetchQuota ? fetched : localRow + const loaded = fetchQuota ? fetchDone : true + + const label = useMemo(() => { + if (!loaded || !row) return t("quotaLoading") + if (row.kind === "remaining-subscription") { + return t("quotaRemaining", { + remaining: Math.round(row.remaining), + limit: row.limit, + }) + } + return t("quotaTitle") + }, [loaded, row, t]) + + const windows = + row?.kind === "remaining-subscription" + ? [ + { + label: row.planType ?? family, + remaining: row.remaining, + usedPercent: Math.max(0, Math.min(100, 100 - row.remaining)), + resetsAt: row.resetsAt, + }, + ...(row.extras ?? []), + ] + : [] + + return ( + + + + + +
{t("quotaTitle")}
+

+ {t("quotaHint")} +

+ {row?.kind === "remaining-subscription" ? ( +
    + {windows.map((w) => ( +
  • +
    + + {w.label ?? family} + + + {t("quotaRemaining", { + remaining: Math.round(w.remaining), + limit: 100, + })} + +
    + + {w.resetsAt ? ( +
    + {t("quotaResets", { + when: new Date(w.resetsAt * 1000).toLocaleString(locale, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }), + })} +
    + ) : null} +
  • + ))} +
+ ) : ( +

+ {loaded ? t("quotaProviderLink") : t("quotaLoading")} +

+ )} +
+
+ ) +} diff --git a/src/lib/subscription-quota.test.ts b/src/lib/subscription-quota.test.ts index 890af3d24..09318e219 100644 --- a/src/lib/subscription-quota.test.ts +++ b/src/lib/subscription-quota.test.ts @@ -2,11 +2,26 @@ import { describe, expect, it } from "vitest" import { attachExtraSlots, emitsRemainingSubscription, + familyFromAgentType, familyQuota, inventory, remainingFromOfficialPayload, } from "./subscription-quota" +describe("familyFromAgentType", () => { + it("maps built-ins and extra isolator slots", () => { + expect(familyFromAgentType("claude_code")).toBe("claude") + expect(familyFromAgentType("custom:claude-code-2")).toBe("claude") + expect(familyFromAgentType("codex")).toBe("codex") + expect(familyFromAgentType("custom:codex-2")).toBe("codex") + expect(familyFromAgentType("grok")).toBe("grok") + expect(familyFromAgentType("gemini")).toBe("gemini") + expect(familyFromAgentType("open_code")).toBe("opencode") + expect(familyFromAgentType("cursor")).toBeNull() + expect(familyFromAgentType(null)).toBeNull() + }) +}) + describe("subscription quota inventory", () => { it("does not invent remaining-subscription numbers when no official payload exists", () => { for (const row of inventory()) { diff --git a/src/lib/subscription-quota.ts b/src/lib/subscription-quota.ts index e225b764c..95dbaacae 100644 --- a/src/lib/subscription-quota.ts +++ b/src/lib/subscription-quota.ts @@ -24,6 +24,29 @@ export type IsolatableFamily = "claude" | "codex" | "grok" | "gemini" | "opencode" +/** Map a conversation's agent_type (built-in or `custom:-N`) to the + * remaining-quota family, or null when that agent has no remaining-quota + * source. Extra slots keep the family isolator (`custom:claude-code-2`). */ +export function familyFromAgentType( + agentType: string | null | undefined +): IsolatableFamily | null { + if (!agentType) return null + const s = agentType.toLowerCase() + if (s === "claude_code" || s.startsWith("custom:claude")) return "claude" + if (s === "codex" || s.startsWith("custom:codex")) return "codex" + if (s === "grok" || s.startsWith("custom:grok")) return "grok" + if (s === "gemini" || s.startsWith("custom:gemini")) return "gemini" + if ( + s === "open_code" || + s.startsWith("custom:opencode") || + s.startsWith("custom:open-code") || + s.startsWith("custom:open_code") + ) { + return "opencode" + } + return null +} + export type QuotaKind = "remaining-subscription" | "acp-context" | "unavailable" export type QuotaWindow = { From 9604d7ead2682211c54912d1113c33ea35f07b24 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:00:52 -0700 Subject: [PATCH 08/11] chore: format after rebase onto main --- .../token-usage/subscription-quota-panel.tsx | 10 +++++--- src/lib/subscription-quota.test.ts | 8 ++++--- src/lib/subscription-quota.ts | 23 ++++++++++++------- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/components/token-usage/subscription-quota-panel.tsx b/src/components/token-usage/subscription-quota-panel.tsx index 8a90e5a15..83a64ced7 100644 --- a/src/components/token-usage/subscription-quota-panel.tsx +++ b/src/components/token-usage/subscription-quota-panel.tsx @@ -40,11 +40,13 @@ export function SubscriptionQuotaPanel() { const [codex, claude, grok] = results if (codex.status === "fulfilled") { if (codex.value.payload) next.codex = codex.value.payload - if (codex.value.extraSlots?.length) slots.codex = codex.value.extraSlots + if (codex.value.extraSlots?.length) + slots.codex = codex.value.extraSlots } if (claude.status === "fulfilled") { if (claude.value.payload) next.claude = claude.value.payload - if (claude.value.extraSlots?.length) slots.claude = claude.value.extraSlots + if (claude.value.extraSlots?.length) + slots.claude = claude.value.extraSlots } if (grok.status === "fulfilled") { if (grok.value.payload) next.grok = grok.value.payload @@ -85,7 +87,9 @@ export function SubscriptionQuotaPanel() { row.family === "claude" || row.family === "grok") && !loaded ? ( - {t("quotaLoading")} + + {t("quotaLoading")} + ) : (