mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
Merge pull request #13519 from Kilo-Org/feat-codex-usage-core-review-ready
feat: show Codex usage in provider usage center
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Show ChatGPT Codex quota alongside other provider usage in the CLI and VS Code.
|
||||
@@ -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<Cloud.CloudState>) | undefined
|
||||
@@ -34,9 +36,10 @@ interface AdapterResult {
|
||||
items: ReadonlyArray<Contract.UsageSnapshot>
|
||||
}
|
||||
|
||||
interface Adapter {
|
||||
export interface Adapter {
|
||||
cachePrefixes: readonly string[]
|
||||
cloudScoped?: boolean
|
||||
valid?: () => boolean
|
||||
run(ctx: AdapterContext): Promise<AdapterResult>
|
||||
}
|
||||
|
||||
@@ -80,8 +83,6 @@ const minimax: Adapter = {
|
||||
},
|
||||
}
|
||||
|
||||
const registry: readonly Adapter[] = [managed, minimax]
|
||||
|
||||
export class ServiceError extends Schema.TaggedErrorClass<ServiceError>()("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<void>,
|
||||
) {
|
||||
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
|
||||
})
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, unknown> {
|
||||
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<Native> {
|
||||
const headers: Record<string, string> = {
|
||||
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<string, number>()
|
||||
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"))
|
||||
}
|
||||
@@ -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<Response>
|
||||
|
||||
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<string, unknown> = {}) => ({
|
||||
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 = <A, E, R>(
|
||||
handler: RequestHandler,
|
||||
body: (value: Fixture) => Effect.Effect<A, E, R>,
|
||||
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<void>()
|
||||
const pending = Promise.withResolvers<Response>()
|
||||
|
||||
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<void>()
|
||||
const replaced = yield* Deferred.make<void>()
|
||||
const pending = Promise.withResolvers<Response>()
|
||||
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")
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -2680,24 +2680,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
private async fetchAndSendProviderUsage(force = false): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -32,11 +32,18 @@ const benign = (value: unknown): unknown =>
|
||||
apply: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
|
||||
function bridge(usage: UsageClient) {
|
||||
function bridge(usage: UsageClient, pending?: Promise<void>) {
|
||||
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<void>()
|
||||
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 }> = []
|
||||
|
||||
@@ -109,6 +109,10 @@ const UsageCard: Component<{
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={props.item.fetchState === "ready" && props.item.windows.length === 0}>
|
||||
<CardDescription>{props.language.t("profile.usage.state.empty")}</CardDescription>
|
||||
</Show>
|
||||
|
||||
<div class="provider-usage-resources">
|
||||
<For each={props.item.windows}>
|
||||
{(window) => {
|
||||
|
||||
+1
@@ -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": "الخطة: الحالة غير معروفة",
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
+1
@@ -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": "طرح: وضعیت نامشخص",
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
+1
@@ -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": "プラン:ステータス不明",
|
||||
|
||||
+1
@@ -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": "요금제: 상태 알 수 없음",
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
+1
@@ -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": "Тариф: Статус неизвестен",
|
||||
|
||||
+1
@@ -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": "แผน: ไม่ทราบสถานะ",
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
+1
@@ -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": "План: Статус невідомий",
|
||||
|
||||
+1
@@ -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": "套餐:状态未知",
|
||||
|
||||
+1
@@ -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": "方案:狀態未知",
|
||||
|
||||
@@ -22,6 +22,9 @@ function Item(props: { item: ProviderUsageSnapshot }) {
|
||||
<text fg={props.item.fetchState === "ready" ? theme.textMuted : theme.warning}>
|
||||
{props.item.fetchState === "ready" ? props.item.planState : props.item.fetchState}
|
||||
</text>
|
||||
<Show when={props.item.fetchState === "ready" && props.item.windows.length === 0}>
|
||||
<text fg={theme.textMuted}>No usage limits reported.</text>
|
||||
</Show>
|
||||
<For each={props.item.windows}>
|
||||
{(window) => (
|
||||
<box>
|
||||
|
||||
Reference in New Issue
Block a user