From 9e61d686699002a0a940084b62974a73932d76c3 Mon Sep 17 00:00:00 2001
From: Josh Lambert
Date: Thu, 27 Aug 2026 12:17:01 -0400
Subject: [PATCH 1/5] feat: show Codex usage in provider usage center
---
.changeset/codex-usage-visibility.md | 6 +
packages/core/src/kilocode/provider-usage.ts | 83 ++-
.../core/src/kilocode/provider-usage/codex.ts | 288 ++++++++
.../kilocode-provider-usage-codex.test.ts | 620 ++++++++++++++++++
.../components/profile/ProviderUsageCards.tsx | 4 +
.../kilo-vscode/webview-ui/src/i18n/ar.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/br.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/bs.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/da.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/de.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/en.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/es.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/fa.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/fr.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/it.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/ja.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/ko.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/nl.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/no.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/pl.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/ru.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/th.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/tr.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/uk.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/zh.ts | 1 +
.../kilo-vscode/webview-ui/src/i18n/zht.ts | 1 +
.../components/dialog-provider-usage.tsx | 3 +
27 files changed, 1023 insertions(+), 2 deletions(-)
create mode 100644 .changeset/codex-usage-visibility.md
create mode 100644 packages/core/src/kilocode/provider-usage/codex.ts
create mode 100644 packages/core/test/kilocode-provider-usage-codex.test.ts
diff --git a/.changeset/codex-usage-visibility.md b/.changeset/codex-usage-visibility.md
new file mode 100644
index 00000000000..a386be2d731
--- /dev/null
+++ b/.changeset/codex-usage-visibility.md
@@ -0,0 +1,6 @@
+---
+"@kilocode/cli": minor
+"kilo-code": minor
+---
+
+Show ChatGPT Codex quota alongside other provider usage in the CLI and VS Code.
diff --git a/packages/core/src/kilocode/provider-usage.ts b/packages/core/src/kilocode/provider-usage.ts
index a27f4a3bd68..a92db73357e 100644
--- a/packages/core/src/kilocode/provider-usage.ts
+++ b/packages/core/src/kilocode/provider-usage.ts
@@ -9,14 +9,22 @@ import { Integration } from "../integration"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
import * as Cloud from "./provider-usage/cloud"
+import * as Codex from "./provider-usage/codex"
import { bindings, direct, type Candidate } from "./provider-usage/minimax/usage"
const successTtl = 60_000
const errorTtl = 10_000
const readyPlugin = PluginV2.ID.make("config-provider")
+type Input =
+ | { status: "absent" }
+ | { status: "failed"; connection: string }
+ | { status: "ready"; connection: string; identity: string; candidate: Codex.Candidate }
+
interface AdapterContext {
candidates: readonly Candidate[]
+ codex: Input
+ scope: string | undefined
failedCandidates: readonly Candidate["providerID"][]
cloud: (() => Promise) | undefined
token: string | undefined
@@ -25,6 +33,7 @@ interface AdapterContext {
fetch: typeof fetch
usage: typeof Cloud.fetchCodingPlanUsage
identityCurrent(identity: string): boolean
+ current(identity: string): boolean
source(id: string, load: () => Promise, identity?: string): Promise
preserve(prefix: string, identity?: string): Contract.UsageSnapshot[]
prune(prefix: string, keep: string[]): void
@@ -80,7 +89,21 @@ const minimax: Adapter = {
},
}
-const registry: readonly Adapter[] = [managed, minimax]
+const codex: Adapter = {
+ cachePrefixes: ["codex-chatgpt"],
+ async run(ctx) {
+ if (ctx.codex.status === "absent") return { items: [] }
+ if (ctx.codex.status === "failed") {
+ return { items: ctx.scope ? ctx.preserve("codex-chatgpt", ctx.scope) : [] }
+ }
+ const current = ctx.codex
+ if (!ctx.current(current.identity)) return { items: [] }
+ const item = await ctx.source("codex-chatgpt", () => Codex.load(current.candidate, ctx.fetch), current.identity)
+ return { items: ctx.current(current.identity) ? [item] : [] }
+ },
+}
+
+const registry: readonly Adapter[] = [managed, minimax, codex]
export class ServiceError extends Schema.TaggedErrorClass()("ProviderUsageServiceError", {
message: Schema.String,
@@ -105,6 +128,7 @@ interface State {
sources: Map
cloud: CloudCell
cloudIdentity?: string
+ codex?: { connection: string; identity: string }
}
function fingerprint(value: string) {
@@ -122,6 +146,7 @@ function scopeCloudCache(state: State, token: string | undefined) {
function stale(next: Contract.UsageSnapshot, previous: Contract.UsageSnapshot | undefined) {
if (next.fetchState !== "unavailable" && next.fetchState !== "error") return next
+ if (next.error?.code === "codex_auth_unavailable" && !next.error.retryable) return next
if (!previous || (previous.fetchState !== "ready" && previous.fetchState !== "stale")) return next
return {
...previous,
@@ -279,12 +304,57 @@ function nonempty(value: unknown) {
return text || undefined
}
+const openai = Effect.fn("ProviderUsage.openai")(function* (
+ provider: ProviderV2.Info | undefined,
+ integrations: Integration.Interface,
+) {
+ if (!provider || provider.disabled) return { status: "absent" as const }
+ const connection = yield* integrations.connection.active(provider.integrationID ?? Integration.ID.make(provider.id))
+ if (!connection) return { status: "absent" as const }
+ const marker = fingerprint(`${connection.type}:${connection.type === "credential" ? connection.id : connection.name}`)
+ const resolved = yield* integrations.connection.resolve(connection).pipe(
+ Effect.map((value) => ({ ok: true as const, value })),
+ Effect.catch(() => Effect.succeed({ ok: false as const })),
+ )
+ if (!resolved.ok) return { status: "failed" as const, connection: marker }
+ if (resolved.value?.type !== "oauth" || !resolved.value.access) return { status: "absent" as const }
+ const raw = resolved.value.metadata?.accountID
+ const account = typeof raw === "string" && /^[A-Za-z0-9._-]{1,256}$/.test(raw) ? raw : undefined
+ return {
+ status: "ready" as const,
+ connection: marker,
+ identity: fingerprint(
+ JSON.stringify([marker, typeof raw === "string" ? raw : "", resolved.value.access, resolved.value.refresh]),
+ ),
+ candidate: {
+ label: provider.name,
+ access: resolved.value.access,
+ ...(account ? { account } : {}),
+ },
+ }
+})
+
+function scoped(state: State, current: Input) {
+ if (current.status === "failed" && state.codex?.connection === current.connection) return state.codex.identity
+ if (
+ current.status === "ready" &&
+ state.codex?.connection === current.connection &&
+ state.codex.identity === current.identity
+ ) {
+ return current.identity
+ }
+ state.codex = current.status === "ready" ? { connection: current.connection, identity: current.identity } : undefined
+ prune(state, "codex-chatgpt", [])
+ return state.codex?.identity
+}
+
const inputs = Effect.fn("ProviderUsage.inputs")(function* (
catalog: Catalog.Interface,
integrations: Integration.Interface,
) {
const providers = yield* catalog.provider.all()
const byID = new Map(providers.map((provider) => [provider.id, provider]))
+ const codex = yield* openai(byID.get(ProviderV2.ID.openai), integrations)
const failedCandidates: Candidate["providerID"][] = []
const candidates = yield* Effect.forEach(Object.keys(bindings) as (keyof typeof bindings)[], (providerID) =>
Effect.gen(function* () {
@@ -312,6 +382,7 @@ const inputs = Effect.fn("ProviderUsage.inputs")(function* (
kilo.ok && kilo.value?.type === "oauth" && !organization && kilo.value.access ? kilo.value.access : undefined
return {
candidates: candidates.filter((item): item is Candidate => item !== undefined),
+ codex,
failedCandidates,
token,
cloudReliable,
@@ -330,8 +401,11 @@ function makeService(
yield* ready
const current = yield* inputs(catalog, integrations)
const cloudIdentity = current.cloudReliable ? scopeCloudCache(state, current.token) : state.cloudIdentity
+ const identity = scoped(state, current.codex)
const ctx: AdapterContext = {
candidates: current.candidates,
+ codex: current.codex,
+ scope: identity,
failedCandidates: current.failedCandidates,
cloud:
current.token && cloudIdentity
@@ -343,6 +417,7 @@ function makeService(
fetch: transport.fetch,
usage: transport.usage,
identityCurrent: (identity) => state.cloudIdentity === identity,
+ current: (identity) => state.codex?.identity === identity,
source: (id, load, identity) => source(state, id, force, load, identity),
preserve: (prefix, identity) => preserve(state, prefix, identity),
prune: (prefix, keep) => prune(state, prefix, keep),
@@ -367,7 +442,11 @@ function makeService(
(value): value is string => value !== undefined,
)
return {
- items: results.flatMap((result) => result.items),
+ items: results
+ .flatMap((result) => result.items)
+ .filter(
+ (item) => item.id !== "codex-chatgpt" || (identity !== undefined && state.codex?.identity === identity),
+ ),
generatedAt: stamps.toSorted().at(-1) ?? new Date().toISOString(),
} satisfies Contract.Info
})
diff --git a/packages/core/src/kilocode/provider-usage/codex.ts b/packages/core/src/kilocode/provider-usage/codex.ts
new file mode 100644
index 00000000000..7164329e4c1
--- /dev/null
+++ b/packages/core/src/kilocode/provider-usage/codex.ts
@@ -0,0 +1,288 @@
+import type { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage"
+
+const url = "https://chatgpt.com/backend-api/wham/usage"
+const manage = "https://chatgpt.com/codex/settings/usage"
+const limit = 64 * 1024
+const timeout = 5_000
+const maximum = 8_640_000_000_000_000
+
+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",
+}
+
+export interface Candidate {
+ label: string
+ access: string
+ account?: string
+}
+
+interface Window {
+ used: number
+ duration?: number
+ reset?: number
+ after?: number
+}
+
+interface Rate {
+ allowed?: boolean
+ reached?: boolean
+ primary?: Window
+ secondary?: Window
+}
+
+interface Native {
+ plan?: string
+ rate?: Rate
+ additional: { name: string; rate: Rate }[]
+}
+
+class Failure extends Error {
+ constructor(readonly code: "network" | "auth" | "http" | "size" | "invalid") {
+ super(code === "auth" ? "ChatGPT authentication is unavailable." : "Codex usage is unavailable.")
+ }
+}
+
+function object(input: unknown): input is Record {
+ return typeof input === "object" && input !== null && !Array.isArray(input)
+}
+
+function number(input: unknown) {
+ return typeof input === "number" && Number.isFinite(input) ? input : undefined
+}
+
+function window(input: unknown): Window | undefined {
+ if (!object(input)) return undefined
+ const used = number(input.used_percent)
+ if (used === undefined) return undefined
+ return {
+ used,
+ duration: number(input.limit_window_seconds),
+ reset: number(input.reset_at),
+ after: number(input.reset_after_seconds),
+ }
+}
+
+function rate(input: unknown): Rate | undefined {
+ if (!object(input)) return undefined
+ return {
+ allowed: typeof input.allowed === "boolean" ? input.allowed : undefined,
+ reached: typeof input.limit_reached === "boolean" ? input.limit_reached : undefined,
+ primary: window(input.primary_window),
+ secondary: window(input.secondary_window),
+ }
+}
+
+export function decode(input: unknown): Native {
+ if (!object(input)) throw new Failure("invalid")
+ const additional = Array.isArray(input.additional_rate_limits)
+ ? input.additional_rate_limits.flatMap((item) => {
+ if (!object(item)) return []
+ const limit = rate(item.rate_limit)
+ if (!limit) return []
+ const name =
+ typeof item.limit_name === "string" && item.limit_name.trim()
+ ? item.limit_name.trim()
+ : typeof item.metered_feature === "string" && item.metered_feature.trim()
+ ? item.metered_feature.trim()
+ : "Additional quota"
+ return [{ name, rate: limit }]
+ })
+ : []
+ return {
+ plan: typeof input.plan_type === "string" && input.plan_type ? input.plan_type : undefined,
+ rate: rate(input.rate_limit),
+ additional,
+ }
+}
+
+async function body(response: Response) {
+ const declared = Number(response.headers.get("content-length"))
+ if (Number.isFinite(declared) && declared > limit) {
+ response.body?.cancel().catch(() => undefined)
+ throw new Failure("size")
+ }
+ if (!response.body) {
+ const value = await response.arrayBuffer()
+ if (value.byteLength > limit) throw new Failure("size")
+ return new TextDecoder().decode(value)
+ }
+ 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 Failure("size")
+ }
+ chunks.push(chunk.value)
+ }
+ const value = new Uint8Array(size)
+ let offset = 0
+ for (const chunk of chunks) {
+ value.set(chunk, offset)
+ offset += chunk.byteLength
+ }
+ return new TextDecoder().decode(value)
+}
+
+export async function query(candidate: Candidate, fetcher: typeof fetch = fetch): Promise {
+ const headers: Record = {
+ Accept: "application/json",
+ Authorization: `Bearer ${candidate.access}`,
+ }
+ if (candidate.account) headers["ChatGPT-Account-Id"] = candidate.account
+ const response = await fetcher(url, {
+ method: "GET",
+ headers,
+ cache: "no-store",
+ redirect: "error",
+ signal: AbortSignal.timeout(timeout),
+ }).catch(() => {
+ throw new Failure("network")
+ })
+ if (!response.ok) {
+ response.body?.cancel().catch(() => undefined)
+ throw new Failure(response.status === 401 || response.status === 403 ? "auth" : "http")
+ }
+ const text = await body(response)
+ try {
+ return decode(JSON.parse(text))
+ } catch {
+ throw new Failure("invalid")
+ }
+}
+
+function reset(value: Window, now: number) {
+ const direct = value.reset === undefined ? undefined : value.reset * 1000
+ if (direct !== undefined && direct > 0 && direct <= maximum) return new Date(direct).toISOString()
+ const offset = value.after === undefined ? undefined : value.after * 1000
+ const relative = offset === undefined ? undefined : now + offset
+ if (
+ relative !== undefined &&
+ offset !== undefined &&
+ offset > 0 &&
+ relative <= maximum &&
+ Number.isSafeInteger(relative)
+ ) {
+ return new Date(relative).toISOString()
+ }
+ return undefined
+}
+
+function period(duration: number): ProviderUsage.UsagePeriod | undefined {
+ for (const [unit, seconds] of [
+ ["week", 604_800],
+ ["day", 86_400],
+ ["hour", 3_600],
+ ] as const) {
+ if (duration % seconds === 0) return { unit, value: duration / seconds }
+ }
+ return undefined
+}
+
+function windows(name: string, rate: Rate | undefined, now: number, used: Map) {
+ if (!rate) return []
+ const clean =
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "") || "quota"
+ let count = (used.get(clean) ?? 0) + 1
+ let slug = count === 1 ? clean : `${clean}-${count}`
+ while (used.has(slug)) {
+ count++
+ slug = `${clean}-${count}`
+ }
+ used.set(clean, count)
+ used.set(slug, 1)
+ return (
+ [
+ ["primary", rate.primary],
+ ["secondary", rate.secondary],
+ ] as const
+ ).flatMap(([slot, value]) => {
+ if (!value) return []
+ const exhausted = rate.allowed === false || rate.reached === true || value.used >= 100
+ const percent = exhausted ? 100 : Math.min(100, Math.max(0, value.used))
+ const duration =
+ value.duration !== undefined && value.duration > 0 && Number.isSafeInteger(value.duration * 1000)
+ ? value.duration
+ : undefined
+ return [
+ {
+ id: `${slug}-${slot}`,
+ resource: name,
+ unit: "percent",
+ orientation: "used_percent",
+ used: percent,
+ remaining: 100 - percent,
+ limit: 100,
+ durationMs: duration === undefined ? undefined : duration * 1000,
+ period: duration === undefined ? undefined : period(duration),
+ resetAt: reset(value, now),
+ state: exhausted ? "exhausted" : "active",
+ } satisfies ProviderUsage.UsageWindow,
+ ]
+ })
+}
+
+export function normalize(native: Native, label = "OpenAI"): ProviderUsage.UsageSnapshot {
+ const now = Date.now()
+ const seen = new Map()
+ const main = windows("Codex", native.rate, now, seen)
+ const additional = native.additional.flatMap((item) => windows(item.name, item.rate, now, seen))
+ return {
+ id: "codex-chatgpt",
+ providerID: "openai",
+ sourceKind: "direct",
+ providerLabel: label,
+ planLabel: native.plan ? (plans[native.plan.toLowerCase()] ?? `ChatGPT ${native.plan}`) : "ChatGPT Codex",
+ sourceLabel: "ChatGPT OAuth",
+ fetchState: "ready",
+ planState: "active",
+ routingState: "not_applicable",
+ fetchedAt: new Date(now).toISOString(),
+ managementUrl: manage,
+ windows: [...main, ...additional],
+ }
+}
+
+function unavailable(label: string, auth: boolean): ProviderUsage.UsageSnapshot {
+ return {
+ id: "codex-chatgpt",
+ providerID: "openai",
+ sourceKind: "direct",
+ providerLabel: label,
+ planLabel: "ChatGPT Codex",
+ sourceLabel: "ChatGPT OAuth",
+ fetchState: "unavailable",
+ planState: "unknown",
+ routingState: "not_applicable",
+ managementUrl: manage,
+ windows: [],
+ error: {
+ code: auth ? "codex_auth_unavailable" : "codex_usage_unavailable",
+ message: auth ? "Reconnect ChatGPT to view Codex usage." : "Usage unavailable.",
+ retryable: !auth,
+ },
+ }
+}
+
+export function load(candidate: Candidate, fetcher: typeof fetch = fetch) {
+ return query(candidate, fetcher)
+ .then((native) => normalize(native, candidate.label))
+ .catch((error) => unavailable(candidate.label, error instanceof Failure && error.code === "auth"))
+}
diff --git a/packages/core/test/kilocode-provider-usage-codex.test.ts b/packages/core/test/kilocode-provider-usage-codex.test.ts
new file mode 100644
index 00000000000..4687f83d38a
--- /dev/null
+++ b/packages/core/test/kilocode-provider-usage-codex.test.ts
@@ -0,0 +1,620 @@
+import { describe, expect, test } from "bun:test"
+import { Deferred, Effect, Fiber, Layer } from "effect"
+import path from "node:path"
+import { Catalog } from "../src/catalog"
+import { Credential } from "../src/credential"
+import { Database } from "../src/database/database"
+import { AppNodeBuilder } from "../src/effect/app-node-builder"
+import { LayerNode } from "../src/effect/layer-node"
+import { Global } from "../src/global"
+import { Integration } from "../src/integration"
+import { ProviderUsage } from "../src/kilocode/provider-usage"
+import { decode, load, normalize } from "../src/kilocode/provider-usage/codex"
+import { Location } from "../src/location"
+import { PluginV2 } from "../src/plugin"
+import { ProviderV2 } from "../src/provider"
+import { AbsolutePath } from "../src/schema"
+import { location } from "./fixture/location"
+import { tmpdir } from "./fixture/tmpdir"
+import { testEffect } from "./lib/effect"
+
+const openai = Integration.ID.make("openai")
+const minimax = Integration.ID.make("minimax-coding-plan")
+const method = Integration.MethodID.make("chatgpt-browser")
+const it = testEffect(Layer.empty)
+
+type RequestHandler = (input: string | URL | Request, init?: RequestInit) => Promise
+
+type Fixture = {
+ usage: ProviderUsage.Interface
+ catalog: Catalog.Interface
+ integrations: Integration.Interface
+ credentials: Credential.Interface
+ requests: Array<{ url: string; init: RequestInit }>
+ refreshes: { count: number }
+}
+
+const window = (used: number, seconds = 18_000) => ({
+ used_percent: used,
+ limit_window_seconds: seconds,
+ reset_after_seconds: seconds,
+ reset_at: Math.floor(Date.now() / 1000) + seconds,
+})
+
+const payload = (overrides: Record = {}) => ({
+ plan_type: "plus",
+ rate_limit: {
+ allowed: true,
+ limit_reached: false,
+ primary_window: window(20),
+ secondary_window: window(35, 604_800),
+ },
+ additional_rate_limits: [],
+ ...overrides,
+})
+
+const native = (remaining: number) =>
+ Response.json({
+ base_resp: { status_code: 0 },
+ model_remains: [
+ {
+ model_name: "general",
+ current_interval_remaining_percent: remaining,
+ current_interval_status: 1,
+ },
+ ],
+ })
+
+const fixture = (
+ handler: RequestHandler,
+ body: (value: Fixture) => Effect.Effect,
+ opts: { minimax?: boolean; failure?: () => boolean } = {},
+) =>
+ Effect.acquireRelease(
+ Effect.promise(() => tmpdir()),
+ (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
+ ).pipe(
+ Effect.flatMap((dir) => {
+ const requests: Fixture["requests"] = []
+ const refreshes = { count: 0 }
+ const transport = Layer.succeed(ProviderUsage.Transport, {
+ fetch: Object.assign(
+ (input: string | URL | Request, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
+ requests.push({ url, init: init ?? {} })
+ return handler(input, init)
+ },
+ { preconnect: fetch.preconnect },
+ ),
+ plans: async () => [],
+ byok: async () => [],
+ usage: async () => {
+ throw new Error("Unexpected managed usage request")
+ },
+ })
+ const layer = AppNodeBuilder.build(
+ LayerNode.group([ProviderUsage.node, Catalog.node, Integration.node, Credential.node, PluginV2.node]),
+ [
+ [
+ Global.node,
+ Global.layerWith({
+ home: dir.path,
+ data: dir.path,
+ cache: dir.path,
+ config: dir.path,
+ state: dir.path,
+ tmp: dir.path,
+ bin: dir.path,
+ log: dir.path,
+ repos: dir.path,
+ }),
+ ],
+ [Database.node, Database.layerFromPath(path.join(dir.path, "provider-usage.sqlite"))],
+ [
+ Location.node,
+ Layer.succeed(
+ Location.Service,
+ Location.Service.of(location(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
+ ),
+ ],
+ [ProviderUsage.transportNode, transport],
+ ],
+ )
+
+ return Effect.gen(function* () {
+ const plugins = yield* PluginV2.Service
+ const catalog = yield* Catalog.Service
+ const integrations = yield* Integration.Service
+ const credentials = yield* Credential.Service
+ const usage = yield* ProviderUsage.Service
+
+ yield* plugins.add(PluginV2.ID.make("config-provider"), (host) =>
+ Effect.gen(function* () {
+ yield* host.integration.transform((draft) => {
+ draft.method.update({
+ integrationID: openai,
+ method: { id: method, type: "oauth", label: "ChatGPT" },
+ authorize: () => Effect.die("Unexpected OAuth authorization"),
+ refresh: (value) =>
+ Effect.suspend(() => {
+ refreshes.count++
+ if (opts.failure?.()) return Effect.fail(new Error("private OAuth refresh failure"))
+ return Effect.succeed(
+ Credential.OAuth.make({
+ ...value,
+ methodID: method,
+ access: "refreshed-access-token",
+ refresh: "rotated-refresh-token",
+ expires: Date.now() + 3_600_000,
+ }),
+ )
+ }),
+ })
+ draft.method.update({
+ integrationID: openai,
+ method: { type: "key", label: "API key" },
+ })
+ if (opts.minimax)
+ draft.method.update({
+ integrationID: minimax,
+ method: { type: "key", label: "MiniMax API key" },
+ })
+ })
+ yield* host.catalog.transform((draft) => {
+ draft.provider.update(openai, (provider) => {
+ provider.name = "OpenAI"
+ provider.integrationID = openai
+ })
+ if (opts.minimax)
+ draft.provider.update(minimax, (provider) => {
+ provider.name = "MiniMax Global"
+ provider.integrationID = minimax
+ })
+ })
+ }),
+ )
+
+ return yield* body({ usage, catalog, integrations, credentials, requests, refreshes })
+ }).pipe(Effect.provide(layer))
+ }),
+ )
+
+const connect = Effect.fn("CodexProviderUsageTest.connect")(function* (
+ credentials: Credential.Interface,
+ input: { access?: string; account?: string; expires?: number } = {},
+) {
+ return yield* credentials.create({
+ integrationID: openai,
+ label: input.account ?? "Personal",
+ value: Credential.OAuth.make({
+ type: "oauth",
+ methodID: method,
+ access: input.access ?? "codex-access-token",
+ refresh: "codex-refresh-token",
+ expires: input.expires ?? Date.now() + 3_600_000,
+ metadata: input.account ? { accountID: input.account } : undefined,
+ }),
+ })
+})
+
+describe("Codex provider usage service", () => {
+ it.live("uses OAuth bearer and account headers with bounded direct transport settings", () =>
+ fixture(
+ async () => Response.json(payload()),
+ ({ usage, credentials, requests }) =>
+ Effect.gen(function* () {
+ yield* connect(credentials, { account: "acct-personal" })
+ const result = yield* usage.get()
+ const headers = new Headers(requests[0]?.init.headers)
+
+ expect(requests).toHaveLength(1)
+ expect(requests[0]?.url).toBe("https://chatgpt.com/backend-api/wham/usage")
+ expect(requests[0]?.init).toMatchObject({ method: "GET", cache: "no-store", redirect: "error" })
+ expect(requests[0]?.init.signal).toBeInstanceOf(AbortSignal)
+ expect(headers.get("authorization")).toBe("Bearer codex-access-token")
+ expect(headers.get("chatgpt-account-id")).toBe("acct-personal")
+ expect(result.items).toHaveLength(1)
+ expect(result.items[0]).toMatchObject({
+ id: "codex-chatgpt",
+ providerID: "openai",
+ sourceKind: "direct",
+ fetchState: "ready",
+ planState: "active",
+ routingState: "not_applicable",
+ windows: [
+ {
+ orientation: "used_percent",
+ unit: "percent",
+ used: 20,
+ remaining: 80,
+ limit: 100,
+ durationMs: 18_000_000,
+ period: { unit: "hour", value: 5 },
+ state: "active",
+ },
+ {
+ orientation: "used_percent",
+ used: 35,
+ remaining: 65,
+ durationMs: 604_800_000,
+ period: { unit: "week", value: 1 },
+ },
+ ],
+ })
+ expect(JSON.stringify(result)).not.toContain("codex-access-token")
+ expect(JSON.stringify(result)).not.toContain("acct-personal")
+ }),
+ ),
+ )
+
+ it.live("omits the account header and accepts a successful response without windows", () =>
+ fixture(
+ async () => Response.json(payload({ rate_limit: null })),
+ ({ usage, credentials, requests }) =>
+ Effect.gen(function* () {
+ yield* connect(credentials)
+ const result = yield* usage.get()
+
+ expect(new Headers(requests[0]?.init.headers).has("chatgpt-account-id")).toBe(false)
+ expect(result.items[0]).toMatchObject({ id: "codex-chatgpt", fetchState: "ready", windows: [] })
+ }),
+ ),
+ )
+
+ it.live("refreshes expired OAuth credentials through the registered implementation and persists them", () =>
+ fixture(
+ async () => Response.json(payload()),
+ ({ usage, credentials, requests, refreshes }) =>
+ Effect.gen(function* () {
+ const saved = yield* connect(credentials, { account: "acct-refresh", expires: Date.now() - 1 })
+ const result = yield* usage.get()
+ const stored = yield* credentials.get(saved.id)
+ const headers = new Headers(requests[0]?.init.headers)
+
+ expect(result.items[0]?.fetchState).toBe("ready")
+ expect(refreshes.count).toBe(1)
+ expect(headers.get("authorization")).toBe("Bearer refreshed-access-token")
+ expect(headers.get("chatgpt-account-id")).toBe("acct-refresh")
+ expect(stored?.value).toMatchObject({
+ type: "oauth",
+ access: "refreshed-access-token",
+ refresh: "rotated-refresh-token",
+ metadata: { accountID: "acct-refresh" },
+ })
+ expect((yield* usage.get()).items[0]?.fetchState).toBe("ready")
+ expect(refreshes.count).toBe(1)
+ expect(requests).toHaveLength(1)
+ }),
+ ),
+ )
+
+ it.live("preserves transient refresh failures only for the same active credential", () => {
+ const failure = { current: false }
+ return fixture(
+ async () => Response.json(payload()),
+ ({ usage, credentials, requests, refreshes }) =>
+ Effect.gen(function* () {
+ const saved = yield* connect(credentials, { account: "acct-original" })
+ const ready = (yield* usage.get()).items[0]
+ expect(ready?.fetchState).toBe("ready")
+ expect(ready?.windows[0]?.used).toBe(20)
+
+ yield* credentials.update(saved.id, {
+ value: Credential.OAuth.make({
+ type: "oauth",
+ methodID: method,
+ access: "codex-access-token",
+ refresh: "codex-refresh-token",
+ expires: Date.now() - 1,
+ metadata: { accountID: "acct-original" },
+ }),
+ })
+ failure.current = true
+ const stale = yield* usage.get()
+
+ expect(stale.items[0]?.fetchState).toBe("stale")
+ expect(stale.items[0]?.windows[0]?.used).toBe(20)
+ expect(JSON.stringify(stale)).not.toContain("private OAuth refresh failure")
+ expect(requests).toHaveLength(1)
+ expect(refreshes.count).toBe(1)
+
+ yield* connect(credentials, { account: "acct-replacement", expires: Date.now() - 1 })
+ expect((yield* usage.get()).items).toEqual([])
+ expect(requests).toHaveLength(1)
+ expect(refreshes.count).toBe(2)
+ }),
+ { failure: () => failure.current },
+ )
+ })
+
+ it.live("invalidates cached usage when an account changes despite reusing its access token", () =>
+ fixture(
+ async (_input, init) =>
+ Response.json(
+ payload({
+ rate_limit: {
+ allowed: true,
+ limit_reached: false,
+ primary_window: window(new Headers(init?.headers).get("chatgpt-account-id") === "acct-first" ? 15 : 75),
+ },
+ }),
+ ),
+ ({ usage, credentials, requests }) =>
+ Effect.gen(function* () {
+ yield* connect(credentials, { access: "shared-access-token", account: "acct-first" })
+ expect((yield* usage.get()).items[0]?.windows[0]).toMatchObject({ used: 15 })
+
+ yield* connect(credentials, { access: "shared-access-token", account: "acct-second" })
+ expect((yield* usage.get()).items[0]?.windows[0]).toMatchObject({ used: 75 })
+ expect(requests).toHaveLength(2)
+ expect(new Headers(requests[1]?.init.headers).get("chatgpt-account-id")).toBe("acct-second")
+ }),
+ ),
+ )
+
+ it.live("prevents an old in-flight account request from overwriting its replacement", () =>
+ Effect.gen(function* () {
+ const started = yield* Deferred.make()
+ const pending: { release?: (response: Response) => void } = {}
+
+ return yield* fixture(
+ (input, init) => {
+ if (new Headers(init?.headers).get("chatgpt-account-id") === "acct-old") {
+ Effect.runSync(Deferred.succeed(started, undefined))
+ return new Promise((resolve) => {
+ pending.release = resolve
+ })
+ }
+ return Promise.resolve(Response.json(payload({ rate_limit: { allowed: true, primary_window: window(70) } })))
+ },
+ ({ usage, credentials, requests }) =>
+ Effect.gen(function* () {
+ yield* connect(credentials, { access: "shared-access-token", account: "acct-old" })
+ const first = yield* usage.get().pipe(Effect.forkChild)
+ yield* Deferred.await(started).pipe(Effect.timeout("2 seconds"))
+
+ yield* connect(credentials, { access: "shared-access-token", account: "acct-new" })
+ const second = yield* usage.get()
+ expect(second.items[0]?.windows[0]).toMatchObject({ used: 70 })
+ expect(requests).toHaveLength(2)
+
+ pending.release?.(Response.json(payload({ rate_limit: { allowed: true, primary_window: window(10) } })))
+ expect((yield* Fiber.join(first)).items).toEqual([])
+ expect((yield* usage.get()).items[0]?.windows[0]).toMatchObject({ used: 70 })
+ expect(requests).toHaveLength(2)
+ }),
+ )
+ }),
+ )
+
+ for (const state of ["disabled", "removed"]) {
+ it.live(`prunes cached usage when the OpenAI provider is ${state}`, () =>
+ fixture(
+ async () => Response.json(payload()),
+ ({ usage, catalog, credentials, requests }) =>
+ Effect.gen(function* () {
+ yield* connect(credentials)
+ expect((yield* usage.get()).items).toHaveLength(1)
+
+ yield* catalog.transform((draft) => {
+ if (state === "removed") return draft.provider.remove(ProviderV2.ID.openai)
+ draft.provider.update(ProviderV2.ID.openai, (provider) => {
+ provider.disabled = true
+ })
+ })
+ expect((yield* usage.get()).items).toEqual([])
+ expect(requests).toHaveLength(1)
+ }),
+ ),
+ )
+ }
+
+ it.live("prunes OAuth usage after API-key takeover and logout", () =>
+ fixture(
+ async () => Response.json(payload()),
+ ({ usage, integrations, credentials, requests }) =>
+ Effect.gen(function* () {
+ yield* connect(credentials, { account: "acct-key" })
+ expect((yield* usage.get()).items).toHaveLength(1)
+
+ yield* integrations.connection.key({ integrationID: openai, key: "private-api-key" })
+ expect((yield* usage.get()).items).toEqual([])
+
+ const restored = yield* connect(credentials, { account: "acct-logout" })
+ expect((yield* usage.get()).items).toHaveLength(1)
+ yield* integrations.connection.remove(restored.id)
+ expect((yield* usage.get()).items).toEqual([])
+ expect(requests).toHaveLength(2)
+ }),
+ ),
+ )
+
+ it.live("keeps Codex and MiniMax independent when either upstream fails", () =>
+ fixture(
+ (() => {
+ const calls = { codex: 0, minimax: 0 }
+ return async (input: string | URL | Request) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
+ if (url.includes("chatgpt.com")) {
+ calls.codex++
+ return calls.codex === 1 ? new Response("private codex failure", { status: 503 }) : Response.json(payload())
+ }
+ calls.minimax++
+ return calls.minimax === 1 ? native(80) : new Response("private minimax failure", { status: 503 })
+ }
+ })(),
+ ({ usage, credentials }) =>
+ Effect.gen(function* () {
+ yield* connect(credentials, { account: "acct-both" })
+ yield* credentials.create({
+ integrationID: minimax,
+ value: Credential.Key.make({ type: "key", key: "sk-cp-minimax-secret" }),
+ })
+
+ const first = yield* usage.get()
+ expect(first.items.find((item) => item.id === "codex-chatgpt")).toMatchObject({
+ fetchState: "unavailable",
+ windows: [],
+ })
+ expect(first.items.find((item) => item.id === "minimax-direct-global")).toMatchObject({
+ fetchState: "ready",
+ windows: [{ remaining: 80 }],
+ })
+
+ const second = yield* usage.refresh()
+ expect(second.items.find((item) => item.id === "codex-chatgpt")).toMatchObject({ fetchState: "ready" })
+ expect(second.items.find((item) => item.id === "minimax-direct-global")).toMatchObject({
+ fetchState: "stale",
+ windows: [{ remaining: 80 }],
+ })
+ expect(JSON.stringify([first, second])).not.toContain("private")
+ expect(JSON.stringify([first, second])).not.toContain("sk-cp-minimax-secret")
+ }),
+ { minimax: true },
+ ),
+ )
+
+ for (const status of [401, 403, 503]) {
+ it.live(`handles HTTP ${status} without exposing private upstream errors or invalid quota`, () => {
+ const responses = [Response.json(payload()), new Response("private upstream secret", { status })]
+ return fixture(
+ async () => responses.shift()!,
+ ({ usage, credentials }) =>
+ Effect.gen(function* () {
+ yield* connect(credentials, { account: "acct-errors" })
+ const ready = yield* usage.get()
+ const failed = yield* usage.refresh()
+ const item = failed.items[0]
+
+ expect(ready.items[0]).toMatchObject({ fetchState: "ready", windows: [{ used: 20 }, { used: 35 }] })
+ expect(item).toMatchObject({
+ id: "codex-chatgpt",
+ fetchState: status === 503 ? "stale" : "unavailable",
+ error: { retryable: status === 503 },
+ })
+ expect(item?.windows).toHaveLength(status === 503 ? 2 : 0)
+ expect(JSON.stringify(failed)).not.toContain("private upstream secret")
+ expect(JSON.stringify(failed)).not.toContain("codex-access-token")
+ }),
+ )
+ })
+ }
+})
+
+describe("Codex usage normalization", () => {
+ test("preserves valid sibling windows when adjacent native windows are malformed", () => {
+ const value = decode(
+ payload({
+ rate_limit: {
+ allowed: true,
+ primary_window: { ...window(20), used_percent: "invalid private usage" },
+ secondary_window: window(45, 86_400),
+ },
+ additional_rate_limits: [
+ { limit_name: "Malformed", metered_feature: "bad", rate_limit: "invalid" },
+ {
+ limit_name: "Spark",
+ metered_feature: "spark",
+ rate_limit: { allowed: true, primary_window: window(30, 3_600) },
+ },
+ ],
+ }),
+ )
+ const item = normalize(value)
+
+ expect(item.fetchState).toBe("ready")
+ expect(item.windows).toHaveLength(2)
+ expect(item.windows.map((entry) => entry.used)).toEqual([45, 30])
+ expect(item.windows[0]).toMatchObject({ durationMs: 86_400_000, period: { unit: "day", value: 1 } })
+ expect(JSON.stringify(item)).not.toContain("invalid private usage")
+ })
+
+ test("clamps percentages, marks exhausted limits, and produces stable unique duplicate slugs", () => {
+ const value = decode(
+ payload({
+ rate_limit: {
+ allowed: true,
+ limit_reached: false,
+ primary_window: window(-10),
+ secondary_window: window(140, 604_800),
+ },
+ additional_rate_limits: [
+ {
+ limit_name: "Spark Fast",
+ metered_feature: "spark",
+ rate_limit: { allowed: false, limit_reached: true, primary_window: window(25) },
+ },
+ {
+ limit_name: "Spark-Fast",
+ metered_feature: "spark",
+ rate_limit: { allowed: true, limit_reached: false, primary_window: window(60) },
+ },
+ ],
+ }),
+ )
+ const first = normalize(value)
+ const second = normalize(value)
+ const ids = first.windows.map((entry) => entry.id)
+
+ expect(first.fetchState).toBe("ready")
+ expect(first.windows).toHaveLength(4)
+ expect(first.windows[0]).toMatchObject({ used: 0, remaining: 100, state: "active" })
+ expect(first.windows[1]).toMatchObject({ used: 100, remaining: 0, state: "exhausted" })
+ expect(first.windows[2]).toMatchObject({ used: 100, remaining: 0, state: "exhausted" })
+ expect(first.windows[3]).toMatchObject({ used: 60, remaining: 40, state: "active" })
+ expect(new Set(ids).size).toBe(ids.length)
+ expect(second.windows.map((entry) => entry.id)).toEqual(ids)
+ })
+
+ test("retains non-round durations without fabricating a named period", () => {
+ const value = decode(
+ payload({
+ rate_limit: {
+ allowed: true,
+ primary_window: window(20, 5_400),
+ secondary_window: window(40, 172_800),
+ },
+ }),
+ )
+ const item = normalize(value)
+
+ expect(item.windows[0]).toMatchObject({ durationMs: 5_400_000 })
+ expect(item.windows[0]?.period).toBeUndefined()
+ expect(item.windows[1]).toMatchObject({ durationMs: 172_800_000, period: { unit: "day", value: 2 } })
+ })
+
+ test("falls back from overflowing timestamps and rejects overflowing fallback durations", () => {
+ const value = decode(
+ payload({
+ rate_limit: {
+ allowed: true,
+ primary_window: { ...window(20), reset_at: Number.MAX_SAFE_INTEGER, reset_after_seconds: 3_600 },
+ secondary_window: { ...window(30), reset_at: -1, reset_after_seconds: Number.MAX_SAFE_INTEGER },
+ },
+ }),
+ )
+ const item = normalize(value)
+
+ expect(item.fetchState).toBe("ready")
+ expect(item.windows[0]?.resetAt).toBe(new Date(Date.parse(item.fetchedAt!) + 3_600_000).toISOString())
+ expect(item.windows[1]?.resetAt).toBeUndefined()
+ })
+})
+
+describe("Codex usage transport", () => {
+ for (const mode of ["declared", "streamed"]) {
+ test(`rejects oversized ${mode} bodies without exposing their contents`, async () => {
+ const response = Response.json(
+ { private: mode === "declared" ? "secret" : "secret".padEnd(64 * 1024, "x") },
+ { headers: mode === "declared" ? { "content-length": String(64 * 1024 + 1) } : undefined },
+ )
+ const item = await load(
+ { label: "OpenAI", access: "codex-access-token" },
+ Object.assign(async () => response, { preconnect: fetch.preconnect }),
+ )
+
+ expect(item).toMatchObject({ id: "codex-chatgpt", fetchState: "unavailable", windows: [] })
+ expect(JSON.stringify(item)).not.toContain("secret")
+ })
+ }
+})
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 2c8451f768a..07e6b2b0536 100644
--- a/packages/kilo-vscode/webview-ui/src/components/profile/ProviderUsageCards.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/profile/ProviderUsageCards.tsx
@@ -109,6 +109,10 @@ const UsageCard: Component<{
+
+ {props.language.t("profile.usage.state.empty")}
+
+
{(window) => {
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
index 306c376f51c..e2ee884459a 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
@@ -624,6 +624,7 @@ export const dict = {
"profile.usage.source.direct": "مباشر",
"profile.usage.state.stale": "يتم عرض بيانات الاستخدام في آخر تحديث.",
"profile.usage.state.unavailable": "بيانات الاستخدام غير متوفرة.",
+ "profile.usage.state.empty": "لم يتم الإبلاغ عن أي حدود للاستخدام.",
"profile.usage.plan.pastDue": "الخطة: الدفع متأخر",
"profile.usage.plan.canceling": "الخطة: تُلغى في نهاية الفترة",
"profile.usage.plan.unknown": "الخطة: الحالة غير معروفة",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts
index cefdb2bc055..fc5ec03e999 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts
@@ -639,6 +639,7 @@ export const dict = {
"profile.usage.source.direct": "Direto",
"profile.usage.state.stale": "Exibindo os dados de uso da última atualização.",
"profile.usage.state.unavailable": "Dados de uso indisponíveis.",
+ "profile.usage.state.empty": "Nenhum limite de uso informado.",
"profile.usage.plan.pastDue": "Plano: Pagamento em atraso",
"profile.usage.plan.canceling": "Plano: Cancela no fim do período",
"profile.usage.plan.unknown": "Plano: Status desconhecido",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
index f59c127a645..1eadb6be484 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
@@ -679,6 +679,7 @@ export const dict = {
"profile.usage.source.direct": "Direktno",
"profile.usage.state.stale": "Prikazuju se posljednji ažurirani podaci o korištenju.",
"profile.usage.state.unavailable": "Podaci o korištenju nisu dostupni.",
+ "profile.usage.state.empty": "Nisu prijavljena ograničenja korištenja.",
"profile.usage.plan.pastDue": "Plan: Plaćanje kasni",
"profile.usage.plan.canceling": "Plan: Otkazuje se na kraju perioda",
"profile.usage.plan.unknown": "Plan: Status nepoznat",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts
index d9527f0d0ca..3413581d7a5 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts
@@ -677,6 +677,7 @@ export const dict = {
"profile.usage.source.direct": "Direkte",
"profile.usage.state.stale": "Viser de senest opdaterede forbrugsdata.",
"profile.usage.state.unavailable": "Forbrugsdata er ikke tilgængelige.",
+ "profile.usage.state.empty": "Ingen forbrugsgrænser rapporteret.",
"profile.usage.plan.pastDue": "Abonnement: Betaling forfalden",
"profile.usage.plan.canceling": "Abonnement: Opsiges ved periodens udgang",
"profile.usage.plan.unknown": "Abonnement: Status ukendt",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts
index b5bba64b8bb..38aed15d45e 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts
@@ -689,6 +689,7 @@ export const dict = {
"profile.usage.source.direct": "Direkt",
"profile.usage.state.stale": "Zuletzt aktualisierte Nutzungsdaten werden angezeigt.",
"profile.usage.state.unavailable": "Nutzungsdaten nicht verfügbar.",
+ "profile.usage.state.empty": "Keine Nutzungslimits gemeldet.",
"profile.usage.plan.pastDue": "Tarif: Zahlung überfällig",
"profile.usage.plan.canceling": "Tarif: Kündigung zum Ende des Abrechnungszeitraums",
"profile.usage.plan.unknown": "Tarif: Status unbekannt",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts
index 9114ce5d80b..18494e9b949 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts
@@ -585,6 +585,7 @@ export const dict = {
"profile.usage.source.direct": "Direct",
"profile.usage.state.stale": "Showing last updated usage.",
"profile.usage.state.unavailable": "Usage unavailable.",
+ "profile.usage.state.empty": "No usage limits reported.",
"profile.usage.plan.pastDue": "Plan: Past due",
"profile.usage.plan.canceling": "Plan: Cancels at period end",
"profile.usage.plan.unknown": "Plan: Status unknown",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts
index f611422bbc4..a9f4db57b81 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts
@@ -683,6 +683,7 @@ export const dict = {
"profile.usage.source.direct": "Directo",
"profile.usage.state.stale": "Se muestran los últimos datos de uso actualizados.",
"profile.usage.state.unavailable": "Datos de uso no disponibles.",
+ "profile.usage.state.empty": "No se informaron límites de uso.",
"profile.usage.plan.pastDue": "Plan: Pago atrasado",
"profile.usage.plan.canceling": "Plan: Se cancela al final del período",
"profile.usage.plan.unknown": "Plan: Estado desconocido",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts
index a5b4ebb2535..639a0da4bd4 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts
@@ -591,6 +591,7 @@ export const dict = {
"profile.usage.source.direct": "مستقیم",
"profile.usage.state.stale": "آخرین میزان استفاده بهروزشده نمایش داده میشود.",
"profile.usage.state.unavailable": "میزان استفاده در دسترس نیست.",
+ "profile.usage.state.empty": "هیچ محدودیتی برای استفاده گزارش نشده است.",
"profile.usage.plan.pastDue": "طرح: سررسید گذشته",
"profile.usage.plan.canceling": "طرح: در پایان دوره لغو میشود",
"profile.usage.plan.unknown": "طرح: وضعیت نامشخص",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
index 4456149a9f0..e3fdbdcfa5b 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
@@ -690,6 +690,7 @@ export const dict = {
"profile.usage.source.direct": "Direct",
"profile.usage.state.stale": "Affichage des dernières données d'utilisation mises à jour.",
"profile.usage.state.unavailable": "Données d'utilisation indisponibles.",
+ "profile.usage.state.empty": "Aucune limite d'utilisation signalée.",
"profile.usage.plan.pastDue": "Forfait : paiement en retard",
"profile.usage.plan.canceling": "Forfait : résiliation à la fin de la période",
"profile.usage.plan.unknown": "Forfait : statut inconnu",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts
index 965d1864332..2be07d52b76 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts
@@ -517,6 +517,7 @@ export const dict = {
"profile.usage.source.direct": "Diretto",
"profile.usage.state.stale": "Vengono mostrati i dati di utilizzo dell'ultimo aggiornamento.",
"profile.usage.state.unavailable": "Dati di utilizzo non disponibili.",
+ "profile.usage.state.empty": "Nessun limite di utilizzo segnalato.",
"profile.usage.plan.pastDue": "Piano: Pagamento scaduto",
"profile.usage.plan.canceling": "Piano: Si annulla al termine del periodo",
"profile.usage.plan.unknown": "Piano: Stato sconosciuto",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
index 5390f050f98..459e82c15b6 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
@@ -672,6 +672,7 @@ export const dict = {
"profile.usage.source.direct": "直接",
"profile.usage.state.stale": "最後に更新された使用状況を表示しています。",
"profile.usage.state.unavailable": "使用状況を取得できません。",
+ "profile.usage.state.empty": "使用量の上限は報告されていません。",
"profile.usage.plan.pastDue": "プラン:支払い期限切れ",
"profile.usage.plan.canceling": "プラン:期間終了時に解約",
"profile.usage.plan.unknown": "プラン:ステータス不明",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
index e8cca743950..de9294281d5 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
@@ -632,6 +632,7 @@ export const dict = {
"profile.usage.source.direct": "직접",
"profile.usage.state.stale": "마지막으로 업데이트된 사용량을 표시합니다.",
"profile.usage.state.unavailable": "사용량을 확인할 수 없습니다.",
+ "profile.usage.state.empty": "보고된 사용량 한도가 없습니다.",
"profile.usage.plan.pastDue": "요금제: 결제 기한 지남",
"profile.usage.plan.canceling": "요금제: 기간 종료 시 취소",
"profile.usage.plan.unknown": "요금제: 상태 알 수 없음",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
index 4297718a8bc..461397ca859 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
@@ -631,6 +631,7 @@ export const dict = {
"profile.usage.source.direct": "Direct",
"profile.usage.state.stale": "De laatst bijgewerkte gebruiksgegevens worden weergegeven.",
"profile.usage.state.unavailable": "Gebruiksgegevens niet beschikbaar.",
+ "profile.usage.state.empty": "Geen gebruikslimieten gemeld.",
"profile.usage.plan.pastDue": "Abonnement: Betaling achterstallig",
"profile.usage.plan.canceling": "Abonnement: Wordt aan het einde van de periode opgezegd",
"profile.usage.plan.unknown": "Abonnement: Status onbekend",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts
index 7517685af26..cb3ae09634f 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts
@@ -639,6 +639,7 @@ export const dict = {
"profile.usage.source.direct": "Direkte",
"profile.usage.state.stale": "Viser sist oppdaterte forbruksdata.",
"profile.usage.state.unavailable": "Forbruksdata er utilgjengelige.",
+ "profile.usage.state.empty": "Ingen forbruksgrenser rapportert.",
"profile.usage.plan.pastDue": "Abonnement: Betaling forfalt",
"profile.usage.plan.canceling": "Abonnement: Avsluttes ved periodens slutt",
"profile.usage.plan.unknown": "Abonnement: Status ukjent",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
index f3b7f7b69ab..cbd40e56508 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
@@ -635,6 +635,7 @@ export const dict = {
"profile.usage.source.direct": "Bezpośrednio",
"profile.usage.state.stale": "Wyświetlane są ostatnio zaktualizowane dane o wykorzystaniu.",
"profile.usage.state.unavailable": "Dane o wykorzystaniu są niedostępne.",
+ "profile.usage.state.empty": "Nie zgłoszono limitów wykorzystania.",
"profile.usage.plan.pastDue": "Plan: Zaległa płatność",
"profile.usage.plan.canceling": "Plan: Zostanie anulowany z końcem okresu",
"profile.usage.plan.unknown": "Plan: Status nieznany",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
index 40ee03fe664..0d4e2652e74 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
@@ -676,6 +676,7 @@ export const dict = {
"profile.usage.source.direct": "Напрямую",
"profile.usage.state.stale": "Показаны последние обновлённые данные об использовании.",
"profile.usage.state.unavailable": "Данные об использовании недоступны.",
+ "profile.usage.state.empty": "Лимиты использования не указаны.",
"profile.usage.plan.pastDue": "Тариф: Платёж просрочен",
"profile.usage.plan.canceling": "Тариф: Отмена в конце периода",
"profile.usage.plan.unknown": "Тариф: Статус неизвестен",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts
index 23e5af37c66..31ba961c5dd 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts
@@ -669,6 +669,7 @@ export const dict = {
"profile.usage.source.direct": "โดยตรง",
"profile.usage.state.stale": "กำลังแสดงข้อมูลการใช้งานที่อัปเดตล่าสุด",
"profile.usage.state.unavailable": "ไม่มีข้อมูลการใช้งาน",
+ "profile.usage.state.empty": "ไม่มีการรายงานขีดจำกัดการใช้งาน",
"profile.usage.plan.pastDue": "แผน: ค้างชำระ",
"profile.usage.plan.canceling": "แผน: ยกเลิกเมื่อสิ้นสุดรอบ",
"profile.usage.plan.unknown": "แผน: ไม่ทราบสถานะ",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
index 93b35125a02..f4b56d8c229 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
@@ -628,6 +628,7 @@ export const dict = {
"profile.usage.source.direct": "Doğrudan",
"profile.usage.state.stale": "Son güncellenen kullanım verileri gösteriliyor.",
"profile.usage.state.unavailable": "Kullanım verileri kullanılamıyor.",
+ "profile.usage.state.empty": "Herhangi bir kullanım sınırı bildirilmedi.",
"profile.usage.plan.pastDue": "Plan: Ödeme gecikmiş",
"profile.usage.plan.canceling": "Plan: Dönem sonunda iptal edilecek",
"profile.usage.plan.unknown": "Plan: Durum bilinmiyor",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
index 16e3e16a943..42183b6a319 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
@@ -629,6 +629,7 @@ export const dict = {
"profile.usage.source.direct": "Напряму",
"profile.usage.state.stale": "Показано останні оновлені дані про використання.",
"profile.usage.state.unavailable": "Дані про використання недоступні.",
+ "profile.usage.state.empty": "Про обмеження використання не повідомлено.",
"profile.usage.plan.pastDue": "План: Платіж прострочено",
"profile.usage.plan.canceling": "План: Скасування наприкінці періоду",
"profile.usage.plan.unknown": "План: Статус невідомий",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
index 5f540b8352a..5b09462d2b1 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
@@ -652,6 +652,7 @@ export const dict = {
"profile.usage.source.direct": "直接",
"profile.usage.state.stale": "正在显示上次更新的用量。",
"profile.usage.state.unavailable": "用量数据不可用。",
+ "profile.usage.state.empty": "未报告任何用量限制。",
"profile.usage.plan.pastDue": "套餐:付款逾期",
"profile.usage.plan.canceling": "套餐:将在周期结束时取消",
"profile.usage.plan.unknown": "套餐:状态未知",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
index ca0ccfc3f16..54e46cca287 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
@@ -612,6 +612,7 @@ export const dict = {
"profile.usage.source.direct": "直接",
"profile.usage.state.stale": "正在顯示上次更新的用量。",
"profile.usage.state.unavailable": "無法取得用量資料。",
+ "profile.usage.state.empty": "未回報任何用量限制。",
"profile.usage.plan.pastDue": "方案:付款逾期",
"profile.usage.plan.canceling": "方案:將於週期結束時取消",
"profile.usage.plan.unknown": "方案:狀態未知",
diff --git a/packages/opencode/src/kilocode/components/dialog-provider-usage.tsx b/packages/opencode/src/kilocode/components/dialog-provider-usage.tsx
index 08c9d1bc190..0aab7e1fb72 100644
--- a/packages/opencode/src/kilocode/components/dialog-provider-usage.tsx
+++ b/packages/opencode/src/kilocode/components/dialog-provider-usage.tsx
@@ -22,6 +22,9 @@ function Item(props: { item: ProviderUsageSnapshot }) {
{props.item.fetchState === "ready" ? props.item.planState : props.item.fetchState}
+
+ No usage limits reported.
+
{(window) => (
From 832693958cc02009608348f76a2acbe71ba8f82b Mon Sep 17 00:00:00 2001
From: Josh Lambert
Date: Thu, 10 Sep 2026 12:28:47 -0400
Subject: [PATCH 2/5] fix(core): correct Codex quota normalization and plan
labels
---
.../core/src/kilocode/provider-usage/codex.ts | 96 ++++++++++---------
.../kilocode-provider-usage-codex.test.ts | 95 +++++++++++++-----
2 files changed, 121 insertions(+), 70 deletions(-)
diff --git a/packages/core/src/kilocode/provider-usage/codex.ts b/packages/core/src/kilocode/provider-usage/codex.ts
index 7164329e4c1..a37e3f3e1ac 100644
--- a/packages/core/src/kilocode/provider-usage/codex.ts
+++ b/packages/core/src/kilocode/provider-usage/codex.ts
@@ -10,11 +10,18 @@ const plans: Record = {
plus: "ChatGPT Plus",
pro: "ChatGPT Pro",
prolite: "ChatGPT Pro Lite",
- business: "ChatGPT Business",
+ business: "ChatGPT Enterprise",
+ self_serve_business_prolite: "ChatGPT Business Premium",
+ self_serve_business_usage_based: "ChatGPT Business",
+ ent26: "ChatGPT Enterprise",
+ enterprise_cbp_automation: "ChatGPT Enterprise (Automation)",
+ enterprise_cbp_usage_based: "ChatGPT Enterprise",
enterprise: "ChatGPT Enterprise",
edu: "ChatGPT Edu",
- education: "ChatGPT Education",
- team: "ChatGPT Team",
+ education: "ChatGPT Edu",
+ edu_plus: "ChatGPT Edu Plus",
+ edu_pro: "ChatGPT Edu Pro",
+ team: "ChatGPT Business",
free: "ChatGPT Free",
go: "ChatGPT Go",
}
@@ -33,8 +40,6 @@ interface Window {
}
interface Rate {
- allowed?: boolean
- reached?: boolean
primary?: Window
secondary?: Window
}
@@ -42,7 +47,7 @@ interface Rate {
interface Native {
plan?: string
rate?: Rate
- additional: { name: string; rate: Rate }[]
+ additional: { id: string; name: string; rate: Rate }[]
}
class Failure extends Error {
@@ -74,32 +79,40 @@ function window(input: unknown): Window | undefined {
function rate(input: unknown): Rate | undefined {
if (!object(input)) return undefined
return {
- allowed: typeof input.allowed === "boolean" ? input.allowed : undefined,
- reached: typeof input.limit_reached === "boolean" ? input.limit_reached : undefined,
primary: window(input.primary_window),
secondary: window(input.secondary_window),
}
}
export function decode(input: unknown): Native {
- if (!object(input)) throw new Failure("invalid")
- const additional = Array.isArray(input.additional_rate_limits)
- ? input.additional_rate_limits.flatMap((item) => {
- if (!object(item)) return []
- const limit = rate(item.rate_limit)
- if (!limit) return []
- const name =
- typeof item.limit_name === "string" && item.limit_name.trim()
- ? item.limit_name.trim()
- : typeof item.metered_feature === "string" && item.metered_feature.trim()
- ? item.metered_feature.trim()
- : "Additional quota"
- return [{ name, rate: limit }]
- })
- : []
+ if (!object(input) || typeof input.plan_type !== "string" || !input.plan_type.trim()) throw new Failure("invalid")
+ if (input.additional_rate_limits != null && !Array.isArray(input.additional_rate_limits)) throw new Failure("invalid")
+ const entries = input.additional_rate_limits ?? []
+ const main = rate(input.rate_limit)
+ const additional = entries.flatMap((item) => {
+ if (!object(item)) return []
+ const limit = rate(item.rate_limit)
+ if (!limit) return []
+ const feature = typeof item.metered_feature === "string" ? item.metered_feature.trim() : ""
+ const name =
+ typeof item.limit_name === "string" && item.limit_name.trim()
+ ? item.limit_name.trim()
+ : feature || "Additional quota"
+ return [{ id: feature || name, name, rate: limit }]
+ })
+ const supplied = [input.rate_limit, ...entries.map((item) => (object(item) ? item.rate_limit : item))]
+ if (
+ !main?.primary &&
+ !main?.secondary &&
+ !additional.some((item) => item.rate.primary || item.rate.secondary) &&
+ supplied.some(
+ (value) => value != null && (!object(value) || value.primary_window != null || value.secondary_window != null),
+ )
+ )
+ throw new Failure("invalid")
return {
- plan: typeof input.plan_type === "string" && input.plan_type ? input.plan_type : undefined,
- rate: rate(input.rate_limit),
+ plan: input.plan_type,
+ rate: main,
additional,
}
}
@@ -193,21 +206,8 @@ function period(duration: number): ProviderUsage.UsagePeriod | undefined {
return undefined
}
-function windows(name: string, rate: Rate | undefined, now: number, used: Map) {
+function windows(id: string, name: string, rate: Rate | undefined, now: number) {
if (!rate) return []
- const clean =
- name
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+|-+$/g, "") || "quota"
- let count = (used.get(clean) ?? 0) + 1
- let slug = count === 1 ? clean : `${clean}-${count}`
- while (used.has(slug)) {
- count++
- slug = `${clean}-${count}`
- }
- used.set(clean, count)
- used.set(slug, 1)
return (
[
["primary", rate.primary],
@@ -215,15 +215,14 @@ function windows(name: string, rate: Rate | undefined, now: number, used: Map {
if (!value) return []
- const exhausted = rate.allowed === false || rate.reached === true || value.used >= 100
- const percent = exhausted ? 100 : Math.min(100, Math.max(0, value.used))
+ const percent = Math.min(100, Math.max(0, value.used))
const duration =
value.duration !== undefined && value.duration > 0 && Number.isSafeInteger(value.duration * 1000)
? value.duration
: undefined
return [
{
- id: `${slug}-${slot}`,
+ id: `${id}-${slot}`,
resource: name,
unit: "percent",
orientation: "used_percent",
@@ -233,7 +232,7 @@ function windows(name: string, rate: Rate | undefined, now: number, used: Map()
- const main = windows("Codex", native.rate, now, seen)
- const additional = native.additional.flatMap((item) => windows(item.name, item.rate, now, seen))
+ const main = windows("codex", "Codex", native.rate, now)
+ const additional = native.additional.flatMap((item) => {
+ const count = seen.get(item.id) ?? 0
+ seen.set(item.id, count + 1)
+ return windows(JSON.stringify(["additional", item.id, count]), item.name, item.rate, now)
+ })
+ const plan = plans[native.plan?.toLowerCase() ?? ""]
return {
id: "codex-chatgpt",
providerID: "openai",
sourceKind: "direct",
providerLabel: label,
- planLabel: native.plan ? (plans[native.plan.toLowerCase()] ?? `ChatGPT ${native.plan}`) : "ChatGPT Codex",
+ planLabel: typeof plan === "string" ? plan : "ChatGPT Codex",
sourceLabel: "ChatGPT OAuth",
fetchState: "ready",
planState: "active",
diff --git a/packages/core/test/kilocode-provider-usage-codex.test.ts b/packages/core/test/kilocode-provider-usage-codex.test.ts
index 4687f83d38a..86ec69b2e5f 100644
--- a/packages/core/test/kilocode-provider-usage-codex.test.ts
+++ b/packages/core/test/kilocode-provider-usage-codex.test.ts
@@ -355,15 +355,13 @@ describe("Codex provider usage service", () => {
it.live("prevents an old in-flight account request from overwriting its replacement", () =>
Effect.gen(function* () {
const started = yield* Deferred.make()
- const pending: { release?: (response: Response) => void } = {}
+ const pending = Promise.withResolvers()
return yield* fixture(
(input, init) => {
if (new Headers(init?.headers).get("chatgpt-account-id") === "acct-old") {
Effect.runSync(Deferred.succeed(started, undefined))
- return new Promise((resolve) => {
- pending.release = resolve
- })
+ return pending.promise
}
return Promise.resolve(Response.json(payload({ rate_limit: { allowed: true, primary_window: window(70) } })))
},
@@ -378,7 +376,7 @@ describe("Codex provider usage service", () => {
expect(second.items[0]?.windows[0]).toMatchObject({ used: 70 })
expect(requests).toHaveLength(2)
- pending.release?.(Response.json(payload({ rate_limit: { allowed: true, primary_window: window(10) } })))
+ pending.resolve(Response.json(payload({ rate_limit: { allowed: true, primary_window: window(10) } })))
expect((yield* Fiber.join(first)).items).toEqual([])
expect((yield* usage.get()).items[0]?.windows[0]).toMatchObject({ used: 70 })
expect(requests).toHaveLength(2)
@@ -474,9 +472,10 @@ describe("Codex provider usage service", () => {
),
)
- for (const status of [401, 403, 503]) {
+ for (const status of [200, 401, 403, 503]) {
it.live(`handles HTTP ${status} without exposing private upstream errors or invalid quota`, () => {
- const responses = [Response.json(payload()), new Response("private upstream secret", { status })]
+ const responses = [Response.json(payload()), Response.json({ error: "private upstream secret" }, { status })]
+ const retryable = status !== 401 && status !== 403
return fixture(
async () => responses.shift()!,
({ usage, credentials }) =>
@@ -489,10 +488,10 @@ describe("Codex provider usage service", () => {
expect(ready.items[0]).toMatchObject({ fetchState: "ready", windows: [{ used: 20 }, { used: 35 }] })
expect(item).toMatchObject({
id: "codex-chatgpt",
- fetchState: status === 503 ? "stale" : "unavailable",
- error: { retryable: status === 503 },
+ fetchState: retryable ? "stale" : "unavailable",
+ error: { retryable },
})
- expect(item?.windows).toHaveLength(status === 503 ? 2 : 0)
+ expect(item?.windows).toEqual(retryable ? ready.items[0]?.windows : [])
expect(JSON.stringify(failed)).not.toContain("private upstream secret")
expect(JSON.stringify(failed)).not.toContain("codex-access-token")
}),
@@ -502,6 +501,49 @@ describe("Codex provider usage service", () => {
})
describe("Codex usage normalization", () => {
+ test("distinguishes malformed responses from legitimately absent windows", () => {
+ for (const input of [
+ {},
+ { error: "private upstream failure" },
+ payload({ plan_type: " " }),
+ payload({ rate_limit: "invalid" }),
+ payload({ additional_rate_limits: {} }),
+ payload({ rate_limit: { primary_window: { used_percent: "invalid" } } }),
+ payload({ rate_limit: null, additional_rate_limits: [{ rate_limit: "invalid" }] }),
+ ])
+ expect(() => decode(input)).toThrow("Codex usage is unavailable.")
+ for (const rate_limit of [undefined, null, { primary_window: null, secondary_window: null }]) {
+ expect(normalize(decode(payload({ rate_limit })))).toMatchObject({ fetchState: "ready", windows: [] })
+ }
+ })
+
+ test("matches Codex plan labels without exposing unknown internal identifiers", () => {
+ for (const [plan, label] of [
+ ["self_serve_business_prolite", "ChatGPT Business Premium"],
+ ["self_serve_business_usage_based", "ChatGPT Business"],
+ ["team", "ChatGPT Business"],
+ ["business", "ChatGPT Enterprise"],
+ ["ent26", "ChatGPT Enterprise"],
+ ["enterprise_cbp_automation", "ChatGPT Enterprise (Automation)"],
+ ["enterprise_cbp_usage_based", "ChatGPT Enterprise"],
+ ["enterprise", "ChatGPT Enterprise"],
+ ["edu", "ChatGPT Edu"],
+ ["education", "ChatGPT Edu"],
+ ["edu_plus", "ChatGPT Edu Plus"],
+ ["edu_pro", "ChatGPT Edu Pro"],
+ ["prolite", "ChatGPT Pro Lite"],
+ ["pro", "ChatGPT Pro"],
+ ["PLUS", "ChatGPT Plus"],
+ ["free", "ChatGPT Free"],
+ ["go", "ChatGPT Go"],
+ ["unknown_internal_plan", "ChatGPT Codex"],
+ ["constructor", "ChatGPT Codex"],
+ ["__proto__", "ChatGPT Codex"],
+ ] as const) {
+ expect(normalize(decode(payload({ plan_type: plan }))).planLabel).toBe(label)
+ }
+ })
+
test("preserves valid sibling windows when adjacent native windows are malformed", () => {
const value = decode(
payload({
@@ -529,41 +571,46 @@ describe("Codex usage normalization", () => {
expect(JSON.stringify(item)).not.toContain("invalid private usage")
})
- test("clamps percentages, marks exhausted limits, and produces stable unique duplicate slugs", () => {
+ test("preserves independent window usage and native IDs when named quotas are reordered", () => {
const value = decode(
payload({
rate_limit: {
- allowed: true,
- limit_reached: false,
- primary_window: window(-10),
- secondary_window: window(140, 604_800),
+ allowed: false,
+ limit_reached: true,
+ primary_window: window(140),
+ secondary_window: window(35, 604_800),
},
additional_rate_limits: [
{
limit_name: "Spark Fast",
- metered_feature: "spark",
- rate_limit: { allowed: false, limit_reached: true, primary_window: window(25) },
+ metered_feature: "spark-fast",
+ rate_limit: { allowed: false, limit_reached: true, primary_window: window(-10) },
},
{
limit_name: "Spark-Fast",
- metered_feature: "spark",
+ metered_feature: "spark_fast",
rate_limit: { allowed: true, limit_reached: false, primary_window: window(60) },
},
],
}),
)
const first = normalize(value)
- const second = normalize(value)
+ const second = normalize({
+ ...value,
+ additional: value.additional.toReversed().map((item) => ({ ...item, name: `${item.name} renamed` })),
+ })
const ids = first.windows.map((entry) => entry.id)
expect(first.fetchState).toBe("ready")
expect(first.windows).toHaveLength(4)
- expect(first.windows[0]).toMatchObject({ used: 0, remaining: 100, state: "active" })
- expect(first.windows[1]).toMatchObject({ used: 100, remaining: 0, state: "exhausted" })
- expect(first.windows[2]).toMatchObject({ used: 100, remaining: 0, state: "exhausted" })
+ expect(first.windows[0]).toMatchObject({ used: 100, remaining: 0, state: "exhausted" })
+ expect(first.windows[1]).toMatchObject({ used: 35, remaining: 65, state: "active" })
+ expect(first.windows[2]).toMatchObject({ used: 0, remaining: 100, state: "active" })
expect(first.windows[3]).toMatchObject({ used: 60, remaining: 40, state: "active" })
expect(new Set(ids).size).toBe(ids.length)
- expect(second.windows.map((entry) => entry.id)).toEqual(ids)
+ expect(Object.fromEntries(second.windows.map((entry) => [entry.id, entry.used]))).toEqual(
+ Object.fromEntries(first.windows.map((entry) => [entry.id, entry.used])),
+ )
})
test("retains non-round durations without fabricating a named period", () => {
@@ -605,7 +652,7 @@ describe("Codex usage transport", () => {
for (const mode of ["declared", "streamed"]) {
test(`rejects oversized ${mode} bodies without exposing their contents`, async () => {
const response = Response.json(
- { private: mode === "declared" ? "secret" : "secret".padEnd(64 * 1024, "x") },
+ payload({ private: mode === "declared" ? "secret" : "secret".padEnd(64 * 1024, "x") }),
{ headers: mode === "declared" ? { "content-length": String(64 * 1024 + 1) } : undefined },
)
const item = await load(
From 791a4bc67e196bb6278361b7079399c6cd14c169 Mon Sep 17 00:00:00 2001
From: Josh Lambert
Date: Fri, 11 Sep 2026 17:24:41 -0400
Subject: [PATCH 3/5] fix(vscode): wait for backend before loading provider
usage
---
packages/kilo-vscode/src/KiloProvider.ts | 26 +++++---------
.../tests/unit/provider-usage.test.ts | 34 +++++++++++++++++--
2 files changed, 41 insertions(+), 19 deletions(-)
diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts
index 153505f439a..b80919874e1 100644
--- a/packages/kilo-vscode/src/KiloProvider.ts
+++ b/packages/kilo-vscode/src/KiloProvider.ts
@@ -2627,24 +2627,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async fetchAndSendProviderUsage(force = false): Promise {
const generation = ++this.providerUsageGeneration
- const client = this.client
- if (!client) {
- this.postMessage(
- this.cachedProviderUsageMessage ?? {
- type: "providerUsageLoaded",
- error: "Provider usage could not be loaded.",
- },
- )
- return
- }
-
const directory = this.getProjectDirectory(this.currentSession?.id)
- const result = await (
- force ? client.kilocode.providerUsage.refresh({ directory }) : client.kilocode.providerUsage.get({ directory })
- ).catch((error) => {
- console.error("[Kilo New] KiloProvider: Failed to fetch provider usage:", error)
- return undefined
- })
+ const result = await this.connectionService
+ .getClientAsync(directory)
+ .then((client) =>
+ force ? client.kilocode.providerUsage.refresh({ directory }) : client.kilocode.providerUsage.get({ directory }),
+ )
+ .catch((error) => {
+ console.error("[Kilo New] KiloProvider: Failed to fetch provider usage:", error)
+ return undefined
+ })
if (generation !== this.providerUsageGeneration) return
if (!result?.data) {
if (this.cachedProviderUsageMessage) {
diff --git a/packages/kilo-vscode/tests/unit/provider-usage.test.ts b/packages/kilo-vscode/tests/unit/provider-usage.test.ts
index 530e4ff0075..f3d4e2480e9 100644
--- a/packages/kilo-vscode/tests/unit/provider-usage.test.ts
+++ b/packages/kilo-vscode/tests/unit/provider-usage.test.ts
@@ -32,11 +32,18 @@ const benign = (value: unknown): unknown =>
apply: () => Promise.resolve({ data: [] }),
})
-function bridge(usage: UsageClient) {
+function bridge(usage: UsageClient, pending?: Promise) {
const messages: unknown[] = []
+ const client = benign({ kilocode: { providerUsage: usage } })
const provider = new KiloProvider(
{} as never,
- { getClient: () => benign({ kilocode: { providerUsage: usage } }) } as never,
+ {
+ getClient: () => client,
+ getClientAsync: async () => {
+ await pending
+ return client
+ },
+ } as never,
undefined,
{ projectDirectory: "/repo" },
)
@@ -81,6 +88,29 @@ describe("provider usage presentation", () => {
})
describe("KiloProvider provider usage bridge", () => {
+ it("waits for backend startup before loading profile usage", async () => {
+ const pending = Promise.withResolvers()
+ const requests: unknown[] = []
+ const { internal, messages } = bridge(
+ {
+ get: async (input) => {
+ requests.push(input)
+ return { data }
+ },
+ refresh: async () => ({ data }),
+ },
+ pending.promise,
+ )
+
+ const loading = internal.fetchAndSendProviderUsage()
+ expect(requests).toEqual([])
+ expect(messages).toEqual([])
+ pending.resolve()
+ await loading
+ expect(requests).toEqual([{ directory: "/repo" }])
+ expect(messages).toEqual([{ type: "providerUsageLoaded", data }])
+ })
+
it("uses cache-aware GET on open and forced POST for refresh", async () => {
const get: Array<{ directory?: string }> = []
const refresh: Array<{ directory?: string }> = []
From 51430defa403badb7e178fdd2fdf8f42c4bbf04c Mon Sep 17 00:00:00 2001
From: Josh Lambert
Date: Fri, 11 Sep 2026 18:40:20 -0400
Subject: [PATCH 4/5] refactor(core): isolate Codex usage credential discovery
---
packages/core/src/kilocode/provider-usage.ts | 43 ++----------------
.../core/src/kilocode/provider-usage/codex.ts | 45 ++++++++++++++++++-
2 files changed, 48 insertions(+), 40 deletions(-)
diff --git a/packages/core/src/kilocode/provider-usage.ts b/packages/core/src/kilocode/provider-usage.ts
index a92db73357e..3cf802b7eea 100644
--- a/packages/core/src/kilocode/provider-usage.ts
+++ b/packages/core/src/kilocode/provider-usage.ts
@@ -16,14 +16,9 @@ const successTtl = 60_000
const errorTtl = 10_000
const readyPlugin = PluginV2.ID.make("config-provider")
-type Input =
- | { status: "absent" }
- | { status: "failed"; connection: string }
- | { status: "ready"; connection: string; identity: string; candidate: Codex.Candidate }
-
interface AdapterContext {
candidates: readonly Candidate[]
- codex: Input
+ codex: Codex.Input
scope: string | undefined
failedCandidates: readonly Candidate["providerID"][]
cloud: (() => Promise) | undefined
@@ -146,7 +141,7 @@ function scopeCloudCache(state: State, token: string | undefined) {
function stale(next: Contract.UsageSnapshot, previous: Contract.UsageSnapshot | undefined) {
if (next.fetchState !== "unavailable" && next.fetchState !== "error") return next
- if (next.error?.code === "codex_auth_unavailable" && !next.error.retryable) return next
+ if (next.error?.retryable === false) return next
if (!previous || (previous.fetchState !== "ready" && previous.fetchState !== "stale")) return next
return {
...previous,
@@ -304,37 +299,7 @@ function nonempty(value: unknown) {
return text || undefined
}
-const openai = Effect.fn("ProviderUsage.openai")(function* (
- provider: ProviderV2.Info | undefined,
- integrations: Integration.Interface,
-) {
- if (!provider || provider.disabled) return { status: "absent" as const }
- const connection = yield* integrations.connection.active(provider.integrationID ?? Integration.ID.make(provider.id))
- if (!connection) return { status: "absent" as const }
- const marker = fingerprint(`${connection.type}:${connection.type === "credential" ? connection.id : connection.name}`)
- const resolved = yield* integrations.connection.resolve(connection).pipe(
- Effect.map((value) => ({ ok: true as const, value })),
- Effect.catch(() => Effect.succeed({ ok: false as const })),
- )
- if (!resolved.ok) return { status: "failed" as const, connection: marker }
- if (resolved.value?.type !== "oauth" || !resolved.value.access) return { status: "absent" as const }
- const raw = resolved.value.metadata?.accountID
- const account = typeof raw === "string" && /^[A-Za-z0-9._-]{1,256}$/.test(raw) ? raw : undefined
- return {
- status: "ready" as const,
- connection: marker,
- identity: fingerprint(
- JSON.stringify([marker, typeof raw === "string" ? raw : "", resolved.value.access, resolved.value.refresh]),
- ),
- candidate: {
- label: provider.name,
- access: resolved.value.access,
- ...(account ? { account } : {}),
- },
- }
-})
-
-function scoped(state: State, current: Input) {
+function scoped(state: State, current: Codex.Input) {
if (current.status === "failed" && state.codex?.connection === current.connection) return state.codex.identity
if (
current.status === "ready" &&
@@ -354,7 +319,7 @@ const inputs = Effect.fn("ProviderUsage.inputs")(function* (
) {
const providers = yield* catalog.provider.all()
const byID = new Map(providers.map((provider) => [provider.id, provider]))
- const codex = yield* openai(byID.get(ProviderV2.ID.openai), integrations)
+ const codex = yield* Codex.discover(byID.get(ProviderV2.ID.openai), integrations)
const failedCandidates: Candidate["providerID"][] = []
const candidates = yield* Effect.forEach(Object.keys(bindings) as (keyof typeof bindings)[], (providerID) =>
Effect.gen(function* () {
diff --git a/packages/core/src/kilocode/provider-usage/codex.ts b/packages/core/src/kilocode/provider-usage/codex.ts
index a37e3f3e1ac..e6dcdf8779f 100644
--- a/packages/core/src/kilocode/provider-usage/codex.ts
+++ b/packages/core/src/kilocode/provider-usage/codex.ts
@@ -1,4 +1,8 @@
import type { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage"
+import { Effect } from "effect"
+import { createHash } from "node:crypto"
+import { Integration } from "../../integration"
+import type { ProviderV2 } from "../../provider"
const url = "https://chatgpt.com/backend-api/wham/usage"
const manage = "https://chatgpt.com/codex/settings/usage"
@@ -26,12 +30,51 @@ const plans: Record = {
go: "ChatGPT Go",
}
-export interface Candidate {
+interface Candidate {
label: string
access: string
account?: string
}
+export type Input =
+ | { status: "absent" }
+ | { status: "failed"; connection: string }
+ | { status: "ready"; connection: string; identity: string; candidate: Candidate }
+
+export const discover = Effect.fn("ProviderUsage.Codex.discover")(function* (
+ provider: ProviderV2.Info | undefined,
+ integrations: Integration.Interface,
+) {
+ if (!provider || provider.disabled) return { status: "absent" as const }
+ const connection = yield* integrations.connection.active(provider.integrationID ?? Integration.ID.make(provider.id))
+ if (!connection) return { status: "absent" as const }
+ const marker = createHash("sha256")
+ .update(`${connection.type}:${connection.type === "credential" ? connection.id : connection.name}`)
+ .digest("hex")
+ const resolved = yield* integrations.connection.resolve(connection).pipe(
+ Effect.map((value) => ({ ok: true as const, value })),
+ Effect.catch(() => Effect.succeed({ ok: false as const })),
+ )
+ if (!resolved.ok) return { status: "failed" as const, connection: marker }
+ if (resolved.value?.type !== "oauth" || !resolved.value.access) return { status: "absent" as const }
+ const raw = resolved.value.metadata?.accountID
+ const account = typeof raw === "string" && /^[A-Za-z0-9._-]{1,256}$/.test(raw) ? raw : undefined
+ return {
+ status: "ready" as const,
+ connection: marker,
+ identity: createHash("sha256")
+ .update(
+ JSON.stringify([marker, typeof raw === "string" ? raw : "", resolved.value.access, resolved.value.refresh]),
+ )
+ .digest("hex"),
+ candidate: {
+ label: provider.name,
+ access: resolved.value.access,
+ ...(account ? { account } : {}),
+ },
+ }
+})
+
interface Window {
used: number
duration?: number
From 5b604f08e4037f7fe9a7c9a9a4245c10528c19e8 Mon Sep 17 00:00:00 2001
From: Josh Lambert
Date: Tue, 15 Sep 2026 18:45:33 -0400
Subject: [PATCH 5/5] refactor(core): encapsulate Codex usage lifecycle
---
packages/core/src/kilocode/provider-usage.ts | 74 +++++--------------
.../core/src/kilocode/provider-usage/codex.ts | 39 ++++++++--
.../kilocode-provider-usage-codex.test.ts | 44 +++++++++++
3 files changed, 96 insertions(+), 61 deletions(-)
diff --git a/packages/core/src/kilocode/provider-usage.ts b/packages/core/src/kilocode/provider-usage.ts
index 3cf802b7eea..562c282629f 100644
--- a/packages/core/src/kilocode/provider-usage.ts
+++ b/packages/core/src/kilocode/provider-usage.ts
@@ -16,10 +16,9 @@ const successTtl = 60_000
const errorTtl = 10_000
const readyPlugin = PluginV2.ID.make("config-provider")
-interface AdapterContext {
+export interface AdapterContext {
+ providers: readonly ProviderV2.Info[]
candidates: readonly Candidate[]
- codex: Codex.Input
- scope: string | undefined
failedCandidates: readonly Candidate["providerID"][]
cloud: (() => Promise) | undefined
token: string | undefined
@@ -28,7 +27,6 @@ interface AdapterContext {
fetch: typeof fetch
usage: typeof Cloud.fetchCodingPlanUsage
identityCurrent(identity: string): boolean
- current(identity: string): boolean
source(id: string, load: () => Promise, identity?: string): Promise
preserve(prefix: string, identity?: string): Contract.UsageSnapshot[]
prune(prefix: string, keep: string[]): void
@@ -38,9 +36,10 @@ interface AdapterResult {
items: ReadonlyArray
}
-interface Adapter {
+export interface Adapter {
cachePrefixes: readonly string[]
cloudScoped?: boolean
+ valid?: () => boolean
run(ctx: AdapterContext): Promise
}
@@ -84,22 +83,6 @@ const minimax: Adapter = {
},
}
-const codex: Adapter = {
- cachePrefixes: ["codex-chatgpt"],
- async run(ctx) {
- if (ctx.codex.status === "absent") return { items: [] }
- if (ctx.codex.status === "failed") {
- return { items: ctx.scope ? ctx.preserve("codex-chatgpt", ctx.scope) : [] }
- }
- const current = ctx.codex
- if (!ctx.current(current.identity)) return { items: [] }
- const item = await ctx.source("codex-chatgpt", () => Codex.load(current.candidate, ctx.fetch), current.identity)
- return { items: ctx.current(current.identity) ? [item] : [] }
- },
-}
-
-const registry: readonly Adapter[] = [managed, minimax, codex]
-
export class ServiceError extends Schema.TaggedErrorClass()("ProviderUsageServiceError", {
message: Schema.String,
}) {}
@@ -123,7 +106,6 @@ interface State {
sources: Map
cloud: CloudCell
cloudIdentity?: string
- codex?: { connection: string; identity: string }
}
function fingerprint(value: string) {
@@ -299,27 +281,12 @@ function nonempty(value: unknown) {
return text || undefined
}
-function scoped(state: State, current: Codex.Input) {
- if (current.status === "failed" && state.codex?.connection === current.connection) return state.codex.identity
- if (
- current.status === "ready" &&
- state.codex?.connection === current.connection &&
- state.codex.identity === current.identity
- ) {
- return current.identity
- }
- state.codex = current.status === "ready" ? { connection: current.connection, identity: current.identity } : undefined
- prune(state, "codex-chatgpt", [])
- return state.codex?.identity
-}
-
const inputs = Effect.fn("ProviderUsage.inputs")(function* (
catalog: Catalog.Interface,
integrations: Integration.Interface,
) {
const providers = yield* catalog.provider.all()
const byID = new Map(providers.map((provider) => [provider.id, provider]))
- const codex = yield* Codex.discover(byID.get(ProviderV2.ID.openai), integrations)
const failedCandidates: Candidate["providerID"][] = []
const candidates = yield* Effect.forEach(Object.keys(bindings) as (keyof typeof bindings)[], (providerID) =>
Effect.gen(function* () {
@@ -346,8 +313,8 @@ const inputs = Effect.fn("ProviderUsage.inputs")(function* (
const token =
kilo.ok && kilo.value?.type === "oauth" && !organization && kilo.value.access ? kilo.value.access : undefined
return {
+ providers,
candidates: candidates.filter((item): item is Candidate => item !== undefined),
- codex,
failedCandidates,
token,
cloudReliable,
@@ -361,16 +328,15 @@ function makeService(
ready: Effect.Effect,
) {
const state: State = { sources: new Map(), cloud: { expires: 0 } }
+ const codex = Codex.create(integrations)
const evaluate = Effect.fn("ProviderUsage.evaluate")(function* (force: boolean) {
yield* ready
const current = yield* inputs(catalog, integrations)
const cloudIdentity = current.cloudReliable ? scopeCloudCache(state, current.token) : state.cloudIdentity
- const identity = scoped(state, current.codex)
const ctx: AdapterContext = {
+ providers: current.providers,
candidates: current.candidates,
- codex: current.codex,
- scope: identity,
failedCandidates: current.failedCandidates,
cloud:
current.token && cloudIdentity
@@ -382,24 +348,27 @@ function makeService(
fetch: transport.fetch,
usage: transport.usage,
identityCurrent: (identity) => state.cloudIdentity === identity,
- current: (identity) => state.codex?.identity === identity,
source: (id, load, identity) => source(state, id, force, load, identity),
preserve: (prefix, identity) => preserve(state, prefix, identity),
prune: (prefix, keep) => prune(state, prefix, keep),
}
+ const registry: readonly Adapter[] = [managed, minimax, yield* codex(ctx)]
const results = yield* Effect.promise(() =>
Promise.all(
registry.map((adapter) =>
// Adapters are expected to be total (they absorb their own failures into
// unavailable/stale snapshots). This catch is the containment boundary so a
// faulty future adapter degrades to stale output instead of failing the endpoint.
- adapter.run(ctx).catch(
- (): AdapterResult => ({
- items: adapter.cachePrefixes.flatMap((prefix) =>
- ctx.preserve(prefix, adapter.cloudScoped ? ctx.cloudIdentity : undefined),
- ),
- }),
- ),
+ adapter
+ .run(ctx)
+ .catch(
+ (): AdapterResult => ({
+ items: adapter.cachePrefixes.flatMap((prefix) =>
+ ctx.preserve(prefix, adapter.cloudScoped ? ctx.cloudIdentity : undefined),
+ ),
+ }),
+ )
+ .then((result) => ({ ...result, valid: adapter.valid })),
),
),
)
@@ -407,11 +376,8 @@ function makeService(
(value): value is string => value !== undefined,
)
return {
- items: results
- .flatMap((result) => result.items)
- .filter(
- (item) => item.id !== "codex-chatgpt" || (identity !== undefined && state.codex?.identity === identity),
- ),
+ // An adapter can be invalidated while a slower sibling is still loading.
+ items: results.filter((result) => result.valid?.() !== false).flatMap((result) => result.items),
generatedAt: stamps.toSorted().at(-1) ?? new Date().toISOString(),
} satisfies Contract.Info
})
diff --git a/packages/core/src/kilocode/provider-usage/codex.ts b/packages/core/src/kilocode/provider-usage/codex.ts
index e6dcdf8779f..31cbaf3fed1 100644
--- a/packages/core/src/kilocode/provider-usage/codex.ts
+++ b/packages/core/src/kilocode/provider-usage/codex.ts
@@ -2,7 +2,8 @@ import type { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage"
import { Effect } from "effect"
import { createHash } from "node:crypto"
import { Integration } from "../../integration"
-import type { ProviderV2 } from "../../provider"
+import { ProviderV2 } from "../../provider"
+import type { Adapter, AdapterContext } from "../provider-usage"
const url = "https://chatgpt.com/backend-api/wham/usage"
const manage = "https://chatgpt.com/codex/settings/usage"
@@ -36,12 +37,7 @@ interface Candidate {
account?: string
}
-export type Input =
- | { status: "absent" }
- | { status: "failed"; connection: string }
- | { status: "ready"; connection: string; identity: string; candidate: Candidate }
-
-export const discover = Effect.fn("ProviderUsage.Codex.discover")(function* (
+const discover = Effect.fn("ProviderUsage.Codex.discover")(function* (
provider: ProviderV2.Info | undefined,
integrations: Integration.Interface,
) {
@@ -75,6 +71,35 @@ export const discover = Effect.fn("ProviderUsage.Codex.discover")(function* (
}
})
+export function create(integrations: Integration.Interface) {
+ let state: { connection: string; identity: string } | undefined
+ return Effect.fn("ProviderUsage.Codex.prepare")(function* (ctx: AdapterContext) {
+ const current = yield* discover(
+ ctx.providers.find((provider) => provider.id === ProviderV2.ID.openai),
+ integrations,
+ )
+ const retained =
+ (current.status === "failed" && state?.connection === current.connection) ||
+ (current.status === "ready" && state?.connection === current.connection && state.identity === current.identity)
+ if (!retained) {
+ state = current.status === "ready" ? { connection: current.connection, identity: current.identity } : undefined
+ ctx.prune("codex-chatgpt", [])
+ }
+ const identity = state?.identity
+ const valid = () => identity !== undefined && state?.identity === identity
+ return {
+ cachePrefixes: ["codex-chatgpt"],
+ valid,
+ async run(ctx) {
+ if (!valid() || current.status === "absent") return { items: [] }
+ if (current.status === "failed") return { items: ctx.preserve("codex-chatgpt", identity) }
+ const item = await ctx.source("codex-chatgpt", () => load(current.candidate, ctx.fetch), current.identity)
+ return { items: [item] }
+ },
+ } satisfies Adapter
+ })
+}
+
interface Window {
used: number
duration?: number
diff --git a/packages/core/test/kilocode-provider-usage-codex.test.ts b/packages/core/test/kilocode-provider-usage-codex.test.ts
index 86ec69b2e5f..3ef35fbbc35 100644
--- a/packages/core/test/kilocode-provider-usage-codex.test.ts
+++ b/packages/core/test/kilocode-provider-usage-codex.test.ts
@@ -385,6 +385,50 @@ describe("Codex provider usage service", () => {
}),
)
+ it.live("rechecks a completed Codex result after a delayed sibling and account change", () =>
+ Effect.gen(function* () {
+ const started = yield* Deferred.make()
+ const replaced = yield* Deferred.make()
+ const pending = Promise.withResolvers()
+ return yield* fixture(
+ async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
+ if (!url.includes("chatgpt.com")) {
+ Effect.runSync(Deferred.succeed(started, undefined))
+ return pending.promise
+ }
+ const account = new Headers(init?.headers).get("chatgpt-account-id")
+ if (account === "acct-new") Effect.runSync(Deferred.succeed(replaced, undefined))
+ return Response.json(payload({ rate_limit: { primary_window: window(account === "acct-new" ? 70 : 10) } }))
+ },
+ ({ usage, credentials, integrations }) =>
+ Effect.gen(function* () {
+ yield* connect(credentials, { account: "acct-old" })
+ expect((yield* usage.get()).items[0]?.windows[0]?.used).toBe(10)
+ yield* integrations.connection.key({ integrationID: minimax, key: "sk-cp-sibling" })
+
+ const first = yield* usage.get().pipe(Effect.forkChild)
+ yield* Deferred.await(started).pipe(Effect.timeout("2 seconds"))
+ yield* Effect.yieldNow
+ yield* connect(credentials, { account: "acct-new" })
+ const second = yield* usage.get().pipe(Effect.forkChild)
+ yield* Deferred.await(replaced).pipe(Effect.timeout("2 seconds"))
+ pending.resolve(native(80))
+
+ const previous = yield* Fiber.join(first)
+ expect(previous.items.map((item) => item.providerID)).toEqual(["minimax-coding-plan"])
+ expect(previous.items[0]?.windows[0]?.remaining).toBe(80)
+ const current = yield* Fiber.join(second)
+ expect(current.items.find((item) => item.providerID === "openai")?.windows[0]?.used).toBe(70)
+ expect(current.items.find((item) => item.providerID === "minimax-coding-plan")?.windows[0]?.remaining).toBe(
+ 80,
+ )
+ }),
+ { minimax: true },
+ )
+ }),
+ )
+
for (const state of ["disabled", "removed"]) {
it.live(`prunes cached usage when the OpenAI provider is ${state}`, () =>
fixture(