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..562c282629f 100644 --- a/packages/core/src/kilocode/provider-usage.ts +++ b/packages/core/src/kilocode/provider-usage.ts @@ -9,13 +9,15 @@ 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") -interface AdapterContext { +export interface AdapterContext { + providers: readonly ProviderV2.Info[] candidates: readonly Candidate[] failedCandidates: readonly Candidate["providerID"][] cloud: (() => Promise) | undefined @@ -34,9 +36,10 @@ interface AdapterResult { items: ReadonlyArray } -interface Adapter { +export interface Adapter { cachePrefixes: readonly string[] cloudScoped?: boolean + valid?: () => boolean run(ctx: AdapterContext): Promise } @@ -80,8 +83,6 @@ const minimax: Adapter = { }, } -const registry: readonly Adapter[] = [managed, minimax] - export class ServiceError extends Schema.TaggedErrorClass()("ProviderUsageServiceError", { message: Schema.String, }) {} @@ -122,6 +123,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?.retryable === false) return next if (!previous || (previous.fetchState !== "ready" && previous.fetchState !== "stale")) return next return { ...previous, @@ -311,6 +313,7 @@ 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), failedCandidates, token, @@ -325,12 +328,14 @@ 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 ctx: AdapterContext = { + providers: current.providers, candidates: current.candidates, failedCandidates: current.failedCandidates, cloud: @@ -347,19 +352,23 @@ function makeService( 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 })), ), ), ) @@ -367,7 +376,8 @@ function makeService( (value): value is string => value !== undefined, ) return { - items: results.flatMap((result) => result.items), + // 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 new file mode 100644 index 00000000000..31cbaf3fed1 --- /dev/null +++ b/packages/core/src/kilocode/provider-usage/codex.ts @@ -0,0 +1,360 @@ +import type { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage" +import { Effect } from "effect" +import { createHash } from "node:crypto" +import { Integration } from "../../integration" +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" +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 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 Edu", + edu_plus: "ChatGPT Edu Plus", + edu_pro: "ChatGPT Edu Pro", + team: "ChatGPT Business", + free: "ChatGPT Free", + go: "ChatGPT Go", +} + +interface Candidate { + label: string + access: string + account?: string +} + +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 } : {}), + }, + } +}) + +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 + reset?: number + after?: number +} + +interface Rate { + primary?: Window + secondary?: Window +} + +interface Native { + plan?: string + rate?: Rate + additional: { id: string; 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 { + primary: window(input.primary_window), + secondary: window(input.secondary_window), + } +} + +export function decode(input: unknown): Native { + 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: input.plan_type, + rate: main, + 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(id: string, name: string, rate: Rate | undefined, now: number) { + if (!rate) return [] + return ( + [ + ["primary", rate.primary], + ["secondary", rate.secondary], + ] as const + ).flatMap(([slot, value]) => { + if (!value) return [] + 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: `${id}-${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: percent === 100 ? "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", "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: typeof plan === "string" ? 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..3ef35fbbc35 --- /dev/null +++ b/packages/core/test/kilocode-provider-usage-codex.test.ts @@ -0,0 +1,711 @@ +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 = 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 pending.promise + } + 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.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) + }), + ) + }), + ) + + 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( + 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 [200, 401, 403, 503]) { + it.live(`handles HTTP ${status} without exposing private upstream errors or invalid quota`, () => { + 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 }) => + 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: retryable ? "stale" : "unavailable", + error: { retryable }, + }) + 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") + }), + ) + }) + } +}) + +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({ + 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("preserves independent window usage and native IDs when named quotas are reordered", () => { + const value = decode( + payload({ + rate_limit: { + 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-fast", + rate_limit: { allowed: false, limit_reached: true, primary_window: window(-10) }, + }, + { + limit_name: "Spark-Fast", + metered_feature: "spark_fast", + rate_limit: { allowed: true, limit_reached: false, primary_window: window(60) }, + }, + ], + }), + ) + const first = 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: 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(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", () => { + 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( + payload({ 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/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index bc4e1b4a187..48e0f64dd92 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -2680,24 +2680,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 }> = [] 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 bb4af4688df..d8b7e629e1c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -628,6 +628,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 7164d12067d..7a5d9934aec 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -643,6 +643,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 5b24b72bec6..f27b9927274 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -683,6 +683,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 de48c261236..29c7bd3caa4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -681,6 +681,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 792a7803cf8..c474f8a1875 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -693,6 +693,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 b2ab38fa975..bd3bd385406 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -589,6 +589,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 8e0543c5bb0..78ce71763d3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -687,6 +687,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 464455225dd..0abf2d09994 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -595,6 +595,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 1b283e4b6b5..c240ccc49d9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -694,6 +694,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 f0b0f601f0a..d256a7678d9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -521,6 +521,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 41070429ff9..140e65283c0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.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/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 06965185adb..2355506df1e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -636,6 +636,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 0a3d506a62e..3140837850c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -635,6 +635,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 ff80538cfe4..6afb5330f10 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -643,6 +643,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 d9c986cd2ab..e6876d861ff 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -639,6 +639,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 034053a8836..42a1e39a7cc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -680,6 +680,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 2f4492db159..8807c308b15 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -673,6 +673,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 7d29435c174..c2937e16d11 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -632,6 +632,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 16ccd393adb..d1de350ad3a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -633,6 +633,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 e6a0f865096..8240d1fdf8a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -656,6 +656,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 dbeb5597d64..7a2d7f338e8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -616,6 +616,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) => (