diff --git a/.changeset/codex-usage-visibility.md b/.changeset/codex-usage-visibility.md new file mode 100644 index 0000000000..a386be2d73 --- /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 a27f4a3bd6..a92db73357 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 0000000000..7164329e4c --- /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 0000000000..4687f83d38 --- /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 2c8451f768..07e6b2b053 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 69b73384ba..e262e4248b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -603,6 +603,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 49e1e409db..0d7a5063f2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -618,6 +618,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 333f063bf6..9fdf1b1732 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -658,6 +658,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 b38f118524..76bed004b7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -656,6 +656,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 376b4b43eb..cc3866755e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -668,6 +668,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 03b2150f01..f62aa121fc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -567,6 +567,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 1229a5e73c..6c292d22e4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -662,6 +662,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 8da19e2b78..46ac85c2aa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -570,6 +570,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 0fce1d864a..5fe980ac02 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -669,6 +669,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 e679b00553..37e0309634 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -497,6 +497,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 07ebc3c4c5..e62e9cbff7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -651,6 +651,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 cd76691dfe..dc7d5f5751 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -611,6 +611,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 2716c88c01..ce095405fa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -610,6 +610,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 70d7a8cbca..90fd34fffd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -618,6 +618,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 940fdb7924..444ca06472 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -614,6 +614,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 bf50538442..df63a3b3c7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -655,6 +655,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 fe7e687401..6870df3337 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -648,6 +648,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 ac7fddbd19..4ec0a32374 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -607,6 +607,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 bab5fba3d8..498b915dab 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -608,6 +608,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 ed70000dea..7a06677892 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.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/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 8c0b62d92b..62f8f66f25 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -592,6 +592,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 08c9d1bc19..0aab7e1fb7 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) => (