diff --git a/.changeset/provider-usage-center.md b/.changeset/provider-usage-center.md new file mode 100644 index 00000000000..29bde035ef6 --- /dev/null +++ b/.changeset/provider-usage-center.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": minor +"@kilocode/kilo-ui": minor +"kilo-code": minor +--- + +View current provider plan usage and quota windows in the CLI and VS Code profile. diff --git a/bun.lock b/bun.lock index ee02897b47e..229bc772e3e 100644 --- a/bun.lock +++ b/bun.lock @@ -357,7 +357,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.22", + "version": "7.4.23", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", diff --git a/packages/core/src/kilocode/provider-usage.ts b/packages/core/src/kilocode/provider-usage.ts new file mode 100644 index 00000000000..a27f4a3bd68 --- /dev/null +++ b/packages/core/src/kilocode/provider-usage.ts @@ -0,0 +1,395 @@ +export * as ProviderUsage from "./provider-usage" + +import { Context, Effect, Layer, Schema } from "effect" +import { createHash } from "node:crypto" +import { ProviderUsage as Contract } from "@opencode-ai/schema/kilocode/provider-usage" +import { Catalog } from "../catalog" +import { makeGlobalNode, makeLocationNode } from "../effect/app-node" +import { Integration } from "../integration" +import { PluginV2 } from "../plugin" +import { ProviderV2 } from "../provider" +import * as Cloud from "./provider-usage/cloud" +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 { + candidates: readonly Candidate[] + failedCandidates: readonly Candidate["providerID"][] + cloud: (() => Promise) | undefined + token: string | undefined + cloudIdentity: string | undefined + cloudReliable: boolean + fetch: typeof fetch + usage: typeof Cloud.fetchCodingPlanUsage + identityCurrent(identity: string): boolean + source(id: string, load: () => Promise, identity?: string): Promise + preserve(prefix: string, identity?: string): Contract.UsageSnapshot[] + prune(prefix: string, keep: string[]): void +} + +interface AdapterResult { + items: ReadonlyArray +} + +interface Adapter { + cachePrefixes: readonly string[] + cloudScoped?: boolean + run(ctx: AdapterContext): Promise +} + +const managed: Adapter = { + cachePrefixes: ["kilo-managed:"], + cloudScoped: true, + async run(ctx) { + if (!ctx.cloud || !ctx.token || !ctx.cloudIdentity) { + return { items: ctx.cloudReliable ? [] : ctx.preserve("kilo-managed:") } + } + const state = await ctx.cloud() + if (!ctx.identityCurrent(ctx.cloudIdentity)) return { items: [] } + if (!state.plans.ok || !state.byok.ok) return { items: ctx.preserve("kilo-managed:", ctx.cloudIdentity) } + const token = ctx.token + const identity = ctx.cloudIdentity + const detected = Cloud.plans(state) + const ids = detected.map((subscription) => `kilo-managed:${subscription.id}`) + ctx.prune("kilo-managed:", ids) + return { + items: await Promise.all( + detected.map((subscription) => + ctx.source(`kilo-managed:${subscription.id}`, () => Cloud.managed(token, subscription, ctx.usage), identity), + ), + ), + } + }, +} + +const minimax: Adapter = { + cachePrefixes: ["minimax-direct-"], + async run(ctx) { + const items = await direct(ctx.candidates, ctx.fetch, ctx.source) + // A candidate is either live or failed, never both, so the id sets are disjoint. + const stale = ctx.failedCandidates.flatMap((id) => ctx.preserve(`minimax-direct-${bindings[id].region}`)) + const merged = [...items, ...stale] + ctx.prune( + "minimax-direct-", + merged.map((item) => item.id), + ) + return { items: merged } + }, +} + +const registry: readonly Adapter[] = [managed, minimax] + +export class ServiceError extends Schema.TaggedErrorClass()("ProviderUsageServiceError", { + message: Schema.String, +}) {} + +interface SourceCell { + identity?: string + value?: Contract.UsageSnapshot + expires: number + updatedAt?: string + inflight?: Promise +} + +interface CloudCell { + value?: Cloud.CloudState + expires: number + updatedAt?: string + inflight?: Promise +} + +interface State { + sources: Map + cloud: CloudCell + cloudIdentity?: string +} + +function fingerprint(value: string) { + return createHash("sha256").update(value).digest("hex") +} + +function scopeCloudCache(state: State, token: string | undefined) { + const identity = token ? fingerprint(token) : undefined + if (state.cloudIdentity === identity) return identity + state.cloudIdentity = identity + state.cloud = { expires: 0 } + prune(state, "kilo-managed:", []) + return identity +} + +function stale(next: Contract.UsageSnapshot, previous: Contract.UsageSnapshot | undefined) { + if (next.fetchState !== "unavailable" && next.fetchState !== "error") return next + if (!previous || (previous.fetchState !== "ready" && previous.fetchState !== "stale")) return next + return { + ...previous, + fetchState: "stale" as const, + planState: next.planState, + routingState: next.routingState, + managementUrl: next.managementUrl, + error: next.error, + } +} + +function source( + state: State, + id: string, + force: boolean, + load: () => Promise, + identity?: string, +) { + const existing = state.sources.get(id) + const cell: SourceCell = existing && existing.identity === identity ? existing : { expires: 0, identity } + state.sources.set(id, cell) + if (!force && cell.value && cell.expires > Date.now()) return Promise.resolve(cell.value) + if (cell.inflight) return cell.inflight + + const task = load() + .then((item) => { + const value = stale(item, cell.value) + if (state.sources.get(id) !== cell) return value + cell.value = value + cell.updatedAt = new Date().toISOString() + cell.expires = Date.now() + (value.fetchState === "ready" ? successTtl : errorTtl) + return value + }) + .finally(() => { + cell.inflight = undefined + }) + cell.inflight = task + return task +} + +function preserve(state: State, prefix: string, identity?: string) { + const items: Contract.UsageSnapshot[] = [] + for (const [id, cell] of state.sources) { + if (!id.startsWith(prefix) || (identity !== undefined && cell.identity !== identity) || !cell.value) continue + const loaded = cell.value.fetchState === "ready" || cell.value.fetchState === "stale" + const value = loaded + ? { + ...cell.value, + fetchState: "stale" as const, + error: { + code: "source_refresh_unavailable", + message: "The latest usage could not be loaded.", + retryable: true, + }, + } + : cell.value + cell.value = value + cell.updatedAt = new Date().toISOString() + cell.expires = Date.now() + errorTtl + items.push(value) + } + return items +} + +function prune(state: State, prefix: string, keep: string[]) { + const ids = new Set(keep) + for (const id of state.sources.keys()) { + if (!id.startsWith(prefix) || ids.has(id)) continue + state.sources.delete(id) + } +} + +function cloud(state: State, token: string, identity: string, force: boolean, transport: TransportInterface) { + if (state.cloudIdentity !== identity) return Cloud.load(token, transport) + const cell = state.cloud + if (!force && cell.value && cell.expires > Date.now()) return Promise.resolve(cell.value) + if (cell.inflight) return cell.inflight + + const task = Cloud.load(token, transport) + .then((value) => { + if (state.cloudIdentity !== identity) return value + const failed = Object.values(value).some((result) => !result.ok) + const previous = cell.value + if (failed && previous) { + cell.expires = Date.now() + errorTtl + return previous + } + cell.value = value + cell.updatedAt = new Date().toISOString() + cell.expires = Date.now() + (failed ? errorTtl : successTtl) + return value + }) + .finally(() => { + cell.inflight = undefined + }) + cell.inflight = task + return task +} + +export interface Interface { + readonly get: () => Effect.Effect + readonly refresh: () => Effect.Effect +} + +export class Service extends Context.Service()("@kilocode/ProviderUsage") {} + +export interface TransportInterface { + readonly fetch: typeof fetch + readonly plans: typeof Cloud.fetchCodingPlanSubscriptions + readonly byok: typeof Cloud.fetchByokEntries + readonly usage: typeof Cloud.fetchCodingPlanUsage +} + +export class Transport extends Context.Service()("@kilocode/ProviderUsageTransport") {} + +const transportLayer = Layer.succeed(Transport, { + fetch, + plans: Cloud.fetchCodingPlanSubscriptions, + byok: Cloud.fetchByokEntries, + usage: Cloud.fetchCodingPlanUsage, +}) + +export const transportNode = makeGlobalNode({ service: Transport, layer: transportLayer, deps: [] }) + +const credential = Effect.fn("ProviderUsage.credential")(function* ( + integrations: Integration.Interface, + id: Integration.ID, +) { + const connection = yield* integrations.connection.active(id) + if (!connection) return undefined + return yield* integrations.connection + .resolve(connection) + .pipe(Effect.mapError(() => new ServiceError({ message: `Unable to resolve provider credential: ${id}` }))) +}) + +const resolve = Effect.fn("ProviderUsage.resolveCredential")(function* ( + integrations: Integration.Interface, + id: Integration.ID, +) { + return yield* credential(integrations, id).pipe( + Effect.map((value) => ({ ok: true as const, value })), + Effect.catch(() => Effect.succeed({ ok: false as const })), + ) +}) + +function configuredKey(provider: ProviderV2.Info) { + const header = Object.entries(provider.request.headers).find(([key]) => key.toLowerCase() === "x-api-key")?.[1] + const value = provider.request.body.apiKey ?? provider.api.settings?.apiKey ?? header + return typeof value === "string" ? value : undefined +} + +function nonempty(value: unknown) { + if (typeof value !== "string") return undefined + const text = value.trim() + return text || undefined +} + +const inputs = Effect.fn("ProviderUsage.inputs")(function* ( + catalog: Catalog.Interface, + integrations: Integration.Interface, +) { + const providers = yield* catalog.provider.all() + const byID = new Map(providers.map((provider) => [provider.id, provider])) + const failedCandidates: Candidate["providerID"][] = [] + const candidates = yield* Effect.forEach(Object.keys(bindings) as (keyof typeof bindings)[], (providerID) => + Effect.gen(function* () { + const provider = byID.get(ProviderV2.ID.make(providerID)) + if (!provider || provider.disabled) return undefined + const resolved = yield* resolve(integrations, provider.integrationID ?? Integration.ID.make(provider.id)) + if (!resolved.ok) failedCandidates.push(providerID) + const value = resolved.ok + ? resolved.value?.type === "key" + ? resolved.value.key + : configuredKey(provider) + : undefined + if (typeof value !== "string" || !value.trim().startsWith("sk-cp")) return undefined + return { providerID, label: provider.name, key: value.trim() } satisfies Candidate + }), + ) + const kilo = yield* resolve(integrations, Integration.ID.make("kilo")) + const kiloProvider = byID.get(ProviderV2.ID.kilo) + const configuredOrg = nonempty(process.env.KILO_ORG_ID) ?? nonempty(kiloProvider?.request.body.kilocodeOrganizationId) + const organization = + configuredOrg !== undefined || + (kilo.ok && kilo.value?.type === "oauth" && nonempty(kilo.value.metadata?.accountID) !== undefined) + const cloudReliable = organization || kilo.ok + const token = + kilo.ok && kilo.value?.type === "oauth" && !organization && kilo.value.access ? kilo.value.access : undefined + return { + candidates: candidates.filter((item): item is Candidate => item !== undefined), + failedCandidates, + token, + cloudReliable, + } +}) + +function makeService( + catalog: Catalog.Interface, + integrations: Integration.Interface, + transport: TransportInterface, + ready: Effect.Effect, +) { + const state: State = { sources: new Map(), cloud: { expires: 0 } } + + 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 = { + candidates: current.candidates, + failedCandidates: current.failedCandidates, + cloud: + current.token && cloudIdentity + ? () => cloud(state, current.token!, cloudIdentity, force, transport) + : undefined, + token: current.token, + cloudIdentity, + cloudReliable: current.cloudReliable, + fetch: transport.fetch, + usage: transport.usage, + identityCurrent: (identity) => state.cloudIdentity === identity, + source: (id, load, identity) => source(state, id, force, load, identity), + preserve: (prefix, identity) => preserve(state, prefix, identity), + prune: (prefix, keep) => prune(state, prefix, keep), + } + const 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), + ), + }), + ), + ), + ), + ) + const stamps = [state.cloud.updatedAt, ...state.sources.values().map((cell) => cell.updatedAt)].filter( + (value): value is string => value !== undefined, + ) + return { + items: results.flatMap((result) => result.items), + generatedAt: stamps.toSorted().at(-1) ?? new Date().toISOString(), + } satisfies Contract.Info + }) + + return Service.of({ + get: () => evaluate(false), + refresh: () => evaluate(true), + }) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + return makeService(yield* Catalog.Service, yield* Integration.Service, yield* Transport, plugins.wait(readyPlugin)) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Catalog.node, Integration.node, PluginV2.node, transportNode], +}) + +export { Contract as Schema } diff --git a/packages/core/src/kilocode/provider-usage/cloud.ts b/packages/core/src/kilocode/provider-usage/cloud.ts new file mode 100644 index 00000000000..74464ba45ec --- /dev/null +++ b/packages/core/src/kilocode/provider-usage/cloud.ts @@ -0,0 +1,138 @@ +import { + fetchByokEntries, + fetchCodingPlanSubscriptions, + fetchCodingPlanUsage, + type ByokEntry, + type CodingPlanQuotaWindow, + type CodingPlanSubscription, +} from "@kilocode/kilo-gateway" +import type { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage" + +export { fetchByokEntries, fetchCodingPlanSubscriptions, fetchCodingPlanUsage } + +export interface CloudState { + plans: Result + byok: Result +} + +type Result = { ok: true; value: T } | { ok: false } + +const safe = async (promise: Promise): Promise> => + promise.then( + (value) => ({ ok: true, value }), + () => ({ ok: false }), + ) + +export async function load( + token: string, + transport: { + plans: typeof fetchCodingPlanSubscriptions + byok: typeof fetchByokEntries + } = { plans: fetchCodingPlanSubscriptions, byok: fetchByokEntries }, +): Promise { + const [plans, byok] = await Promise.all([safe(transport.plans(token)), safe(transport.byok(token))]) + return { plans, byok } +} + +function base() { + if (!process.env.KILO_API_URL) return "https://app.kilo.ai" + try { + return new URL(process.env.KILO_API_URL).origin + } catch { + return "https://app.kilo.ai" + } +} + +const error = (code: string, message: string) => ({ code, message, retryable: true }) + +function installed(subscription: CodingPlanSubscription, state: Result) { + if (!state.ok || !subscription.canQueryUsage || !subscription.hasInstalledByokKey) return false + return state.value.some( + (item) => + item.provider_id === subscription.providerId && item.management_source === "coding_plan" && item.is_enabled, + ) +} + +export function plans(state: CloudState) { + if (!state.plans.ok) return [] + return state.plans.value + .filter((item) => (item.status === "active" || item.status === "past_due") && installed(item, state.byok)) + .sort((a, b) => a.id.localeCompare(b.id)) +} + +function durationMs(period: CodingPlanQuotaWindow["period"]) { + const multipliers = { + hour: 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + week: 7 * 24 * 60 * 60 * 1000, + } as const + if (period.unit === "month") return undefined + return period.value * multipliers[period.unit] +} + +function window(subscriptionId: string, value: CodingPlanQuotaWindow): ProviderUsage.UsageWindow { + const remaining = value.remainingPercent + const duration = durationMs(value.period) + return { + id: `${subscriptionId}:${value.id}`, + resource: "subscription", + unit: "percent", + orientation: "remaining_percent", + used: Math.max(0, 100 - remaining), + remaining, + limit: 100, + period: value.period, + ...(duration !== undefined ? { durationMs: duration } : {}), + resetAt: value.resetsAt, + state: remaining <= 0 ? "exhausted" : "active", + } +} + +export async function managed( + token: string, + subscription: CodingPlanSubscription, + usage: typeof fetchCodingPlanUsage = fetchCodingPlanUsage, +): Promise { + const fetchedAt = new Date().toISOString() + const planState = subscription.cancelAtPeriodEnd + ? "canceling" + : subscription.status === "past_due" + ? "past_due" + : "active" + const id = `kilo-managed:${subscription.id}` + const managementUrl = `${base()}/subscriptions/coding-plans/${subscription.id}` + + return usage(token, subscription.id) + .then((usage) => { + const windows = usage.subscription.windows.map((item) => window(usage.subscription.id, item)) + return { + id, + providerID: usage.subscription.providerId, + sourceKind: "kilo_managed", + providerLabel: usage.subscription.providerName, + planLabel: usage.subscription.planName, + sourceLabel: "via Kilo", + fetchState: "ready", + planState, + routingState: "active", + fetchedAt: usage.fetchedAt, + managementUrl, + windows, + } satisfies ProviderUsage.UsageSnapshot + }) + .catch(() => ({ + id, + providerID: subscription.providerId, + sourceKind: "kilo_managed", + providerLabel: subscription.providerName, + planLabel: subscription.planName, + sourceLabel: "via Kilo", + fetchState: "unavailable", + planState, + routingState: "active", + fetchedAt, + managementUrl, + windows: [], + error: error("managed_subscription_unavailable", "Usage unavailable."), + })) +} diff --git a/packages/core/src/kilocode/provider-usage/minimax/native.ts b/packages/core/src/kilocode/provider-usage/minimax/native.ts new file mode 100644 index 00000000000..0d93b264706 --- /dev/null +++ b/packages/core/src/kilocode/provider-usage/minimax/native.ts @@ -0,0 +1,34 @@ +import { Schema } from "effect" + +const IntegerField = Schema.Int +// Matches the cloud schema: remaining percent is a 0-100 share of the base quota; boosts scale it separately. +const PercentField = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)).check(Schema.isLessThanOrEqualTo(100)) + +export const ModelRemains = Schema.Struct({ + model_name: Schema.String, + current_interval_total_count: Schema.optional(IntegerField), + current_interval_usage_count: Schema.optional(IntegerField), + start_time: Schema.optional(IntegerField), + end_time: Schema.optional(IntegerField), + remains_time: Schema.optional(IntegerField), + interval_boost_permille: Schema.optional(IntegerField), + current_interval_remaining_percent: Schema.optional(PercentField), + current_interval_status: Schema.optional(IntegerField), + current_weekly_total_count: Schema.optional(IntegerField), + current_weekly_usage_count: Schema.optional(IntegerField), + weekly_start_time: Schema.optional(IntegerField), + weekly_end_time: Schema.optional(IntegerField), + weekly_remains_time: Schema.optional(IntegerField), + weekly_boost_permille: Schema.optional(IntegerField), + current_weekly_remaining_percent: Schema.optional(PercentField), + current_weekly_status: Schema.optional(IntegerField), +}).annotate({ identifier: "MiniMaxModelRemains" }) +export type ModelRemains = typeof ModelRemains.Type + +export const Native = Schema.Struct({ + base_resp: Schema.Struct({ status_code: IntegerField }), + model_remains: Schema.Array(ModelRemains), +}).annotate({ identifier: "MiniMaxNativeUsage" }) +export type Native = typeof Native.Type + +export const decode = Schema.decodeUnknownSync(Native) diff --git a/packages/core/src/kilocode/provider-usage/minimax/usage.ts b/packages/core/src/kilocode/provider-usage/minimax/usage.ts new file mode 100644 index 00000000000..dac61af1117 --- /dev/null +++ b/packages/core/src/kilocode/provider-usage/minimax/usage.ts @@ -0,0 +1,284 @@ +import { createHash } from "node:crypto" +import { decode, type ModelRemains, type Native } from "./native" +import type { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage" + +export const bindings = { + "minimax-coding-plan": { + region: "global", + url: "https://api.minimax.io/v1/token_plan/remains", + manage: "https://platform.minimax.io/subscribe/token-plan", + }, + "minimax-cn-coding-plan": { + region: "china", + url: "https://api.minimaxi.com/v1/token_plan/remains", + manage: "https://platform.minimaxi.com/subscribe/token-plan", + }, +} as const + +type ProviderID = keyof typeof bindings + +const timeout = 5_000 +const limit = 64 * 1024 + +class MiniMaxUsageError extends Error { + constructor(readonly code: "network" | "http" | "too_large" | "invalid" | "application") { + super("MiniMax usage is temporarily unavailable.") + this.name = "MiniMaxUsageError" + } +} + +async function text(response: Response) { + const declared = Number(response.headers.get("content-length")) + if (Number.isFinite(declared) && declared > limit) { + response.body?.cancel().catch(() => undefined) + throw new MiniMaxUsageError("too_large") + } + + if (!response.body) { + const value = await response.arrayBuffer() + if (value.byteLength > limit) throw new MiniMaxUsageError("too_large") + 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 MiniMaxUsageError("too_large") + } + 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(providerID: ProviderID, key: string, fetcher: typeof fetch = fetch): Promise { + const response = await fetcher(bindings[providerID].url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${key}`, + }, + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(timeout), + }).catch(() => { + throw new MiniMaxUsageError("network") + }) + if (!response.ok) { + response.body?.cancel().catch(() => undefined) + throw new MiniMaxUsageError("http") + } + + const body = await text(response) + const native = (() => { + try { + return decode(JSON.parse(body)) + } catch { + throw new MiniMaxUsageError("invalid") + } + })() + if (native.base_resp.status_code !== 0) throw new MiniMaxUsageError("application") + return native +} + +function reset(end: number | undefined, remains: number | undefined, fetchedAt: string) { + if (end !== undefined && end > 0) return new Date(end).toISOString() + if (remains !== undefined && remains > 0) return new Date(Date.parse(fetchedAt) + remains).toISOString() + return undefined +} + +function duration(start: number | undefined, end: number | undefined) { + if (start === undefined || end === undefined || end <= start) return undefined + return end - start +} + +const hourMs = 60 * 60 * 1000 +const dayMs = 24 * hourMs +const weekMs = 7 * dayMs + +function cadence(kind: "interval" | "weekly", span: number | undefined): ProviderUsage.UsagePeriod | undefined { + if (kind === "weekly") return { unit: "week", value: 1 } + if (span === undefined) return undefined + if (span % weekMs === 0) return { unit: "week", value: span / weekMs } + if (span % dayMs === 0) return { unit: "day", value: span / dayMs } + if (span % hourMs === 0) return { unit: "hour", value: span / hourMs } + return undefined +} + +function window( + row: ModelRemains, + kind: "interval" | "weekly", + fetchedAt: string, +): ProviderUsage.UsageWindow | undefined { + const weekly = kind === "weekly" + const percent = weekly ? row.current_weekly_remaining_percent : row.current_interval_remaining_percent + const status = weekly ? row.current_weekly_status : row.current_interval_status + const total = weekly ? row.current_weekly_total_count : row.current_interval_total_count + const count = weekly ? row.current_weekly_usage_count : row.current_interval_usage_count + const start = weekly ? row.weekly_start_time : row.start_time + const end = weekly ? row.weekly_end_time : row.end_time + const remains = weekly ? row.weekly_remains_time : row.remains_time + const boost = weekly ? row.weekly_boost_permille : row.interval_boost_permille + const span = duration(start, end) + const base = { + id: `${row.model_name}-${kind}`, + resource: row.model_name, + period: cadence(kind, span), + durationMs: span, + resetAt: reset(end, remains, fetchedAt), + } + + if (status === 3) return { ...base, unit: "unknown", orientation: "amount", state: "not_in_plan" } + + if (percent !== undefined) { + const factor = boost !== undefined && boost > 0 ? boost / 1000 : 1 + const cap = 100 * factor + // The status flag is authoritative: an exhausted window has zero remaining even when the percent field lags. + const remaining = status === 2 ? 0 : percent * factor + return { + ...base, + unit: factor === 1 ? "percent" : "standard_units", + orientation: factor === 1 ? "remaining_percent" : "amount", + used: Math.max(0, cap - remaining), + remaining, + limit: cap, + state: status === 2 || remaining <= 0 ? "exhausted" : "active", + } + } + + if (total !== undefined && total > 0 && count !== undefined && count >= 0) { + // Despite the name, MiniMax's *_usage_count fields report the remaining quota, not the consumed amount. + const remaining = status === 2 ? 0 : count + return { + ...base, + unit: "count", + orientation: "count", + used: Math.max(0, total - remaining), + remaining, + limit: total, + state: status === 2 || remaining === 0 ? "exhausted" : "active", + } + } + + if (status === undefined && total === undefined && count === undefined) return undefined + return { + ...base, + unit: "unknown", + orientation: "amount", + state: status === 2 ? "exhausted" : "unknown", + } +} + +export function normalize( + native: Native, + input: { + id: string + providerID: string + sourceLabel: string + managementUrl: string + fetchedAt: string + }, +): ProviderUsage.UsageSnapshot { + const windows = native.model_remains + .filter((row) => row.model_name !== "video") + .flatMap((row) => + (["interval", "weekly"] as const).flatMap((kind) => { + const value = window(row, kind, input.fetchedAt) + return value ? [value] : [] + }), + ) + + return { + id: input.id, + providerID: input.providerID, + sourceKind: "direct", + providerLabel: "MiniMax", + planLabel: "MiniMax Token Plan", + sourceLabel: input.sourceLabel, + fetchState: "ready", + planState: "active", + routingState: "not_applicable", + fetchedAt: input.fetchedAt, + managementUrl: input.managementUrl, + windows, + } +} + +const unavailable = ( + id: string, + providerID: string, + label: string, + managementUrl: string, +): ProviderUsage.UsageSnapshot => ({ + id, + providerID, + sourceKind: "direct", + providerLabel: "MiniMax", + planLabel: "MiniMax Token Plan", + sourceLabel: label, + fetchState: "unavailable", + planState: "unknown", + routingState: "not_applicable", + managementUrl, + windows: [], + error: { code: "direct_minimax_unavailable", message: "Usage unavailable.", retryable: true }, +}) + +export interface Candidate { + providerID: ProviderID + label: string + key: string +} + +export async function direct( + candidates: readonly Candidate[], + fetcher: typeof fetch = fetch, + cached: ( + id: string, + load: () => Promise, + identity?: string, + ) => Promise = (_id, load) => load(), +) { + // Each configured provider is an independent plan with a stable per-region + // cache cell, even when providers share a credential. + return Promise.all( + candidates + .filter((candidate) => candidate.key.startsWith("sk-cp")) + .map((candidate) => { + const binding = bindings[candidate.providerID] + const id = `minimax-direct-${binding.region}` + // The key fingerprint scopes the cache cell to the configured credential, so + // swapping keys never reuses the previous account's quota via TTL or stale fallback. + const identity = createHash("sha256").update(candidate.key).digest("hex") + return cached( + id, + () => + query(candidate.providerID, candidate.key, fetcher) + .then((native) => + normalize(native, { + id, + providerID: candidate.providerID, + sourceLabel: candidate.label, + managementUrl: binding.manage, + fetchedAt: new Date().toISOString(), + }), + ) + .catch(() => unavailable(id, candidate.providerID, candidate.label, binding.manage)), + identity, + ) + }), + ) +} diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 09b3b69b12f..2256ccd6bfc 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -12,6 +12,7 @@ import { FileSystemSearch } from "./filesystem/search" import { Watcher } from "./filesystem/watcher" import { Image } from "./image" import { Integration } from "./integration" +import { ProviderUsage } from "./kilocode/provider-usage" // kilocode_change import { Location } from "./location" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" @@ -48,6 +49,7 @@ export const locationServices = LayerNode.group([ Reference.node, Integration.node, Catalog.node, + ProviderUsage.node, // kilocode_change AISDK.node, PluginV2.node, PluginInternal.node, diff --git a/packages/core/test/kilocode-provider-usage-cloud.test.ts b/packages/core/test/kilocode-provider-usage-cloud.test.ts new file mode 100644 index 00000000000..bb1b2b0cb02 --- /dev/null +++ b/packages/core/test/kilocode-provider-usage-cloud.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test" +import * as Cloud from "../src/kilocode/provider-usage/cloud" + +const subscription = { + id: "byteplus-plan", + planId: "byteplus-coding-plan-team-lite", + planName: "BytePlus Coding Plan Lite", + providerName: "BytePlus", + providerId: "byteplus-coding", + canQueryUsage: true, + hasInstalledByokKey: true, + status: "active" as const, + cancelAtPeriodEnd: false, +} + +const state = (enabled = true) => ({ + plans: { ok: true as const, value: [subscription] }, + byok: { + ok: true as const, + value: [ + { + id: "managed-byteplus", + provider_id: "byteplus-coding", + management_source: "coding_plan" as const, + is_enabled: enabled, + }, + ], + }, +}) + +describe("managed provider usage", () => { + test("requires Cloud usage readiness and a matching enabled managed key", () => { + expect(Cloud.plans(state())).toEqual([subscription]) + expect(Cloud.plans(state(false))).toEqual([]) + expect( + Cloud.plans({ ...state(), plans: { ok: true, value: [{ ...subscription, canQueryUsage: false }] } }), + ).toEqual([]) + expect( + Cloud.plans({ ...state(), plans: { ok: true, value: [{ ...subscription, hasInstalledByokKey: false }] } }), + ).toEqual([]) + expect( + Cloud.plans({ + ...state(), + byok: { ok: true, value: [{ ...state().byok.value[0]!, management_source: "user" as const }] }, + }), + ).toEqual([]) + expect( + Cloud.plans({ + ...state(), + byok: { ok: true, value: [{ ...state().byok.value[0]!, provider_id: "minimax" }] }, + }), + ).toEqual([]) + }) + + test("normalizes BytePlus windows through the generic managed path", async () => { + const result = await Cloud.managed("token", subscription, async () => ({ + schemaVersion: 1, + fetchedAt: "2026-08-07T12:00:00.000Z", + subscription: { + id: subscription.id, + planName: subscription.planName, + providerId: subscription.providerId, + providerName: subscription.providerName, + windows: [ + { + id: "monthly", + remainingPercent: 75, + resetsAt: "2026-09-01T00:00:00.000Z", + period: { unit: "month", value: 1 }, + }, + ], + }, + })) + + expect(result).toMatchObject({ + providerID: "byteplus-coding", + providerLabel: "BytePlus", + planLabel: "BytePlus Coding Plan Lite", + sourceKind: "kilo_managed", + windows: [ + { + id: "byteplus-plan:monthly", + resource: "subscription", + period: { unit: "month", value: 1 }, + remaining: 75, + used: 25, + limit: 100, + }, + ], + }) + expect(result.windows[0]).not.toHaveProperty("durationMs") + }) +}) diff --git a/packages/core/test/kilocode-provider-usage-location.test.ts b/packages/core/test/kilocode-provider-usage-location.test.ts new file mode 100644 index 00000000000..239f2c16a9a --- /dev/null +++ b/packages/core/test/kilocode-provider-usage-location.test.ts @@ -0,0 +1,37 @@ +import { describe, expect } from "bun:test" +import { Context, Effect } from "effect" +import { AppNodeBuilder } from "../src/effect/app-node-builder" +import { ProviderUsage } from "../src/kilocode/provider-usage" +import { Location } from "../src/location" +import { LocationServiceMap } from "../src/location-services" +import { AbsolutePath } from "../src/schema" +import { WorkspaceV2 } from "../src/workspace" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(AppNodeBuilder.build(LocationServiceMap.node)) + +describe("ProviderUsage location lifecycle", () => { + it.live("reuses the same location and isolates workspace-qualified locations", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.scoped( + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const directory = AbsolutePath.make(dir.path) + const first = Location.Ref.make({ directory, workspaceID: WorkspaceV2.ID.make("wrk_workspace_a") }) + const same = Location.Ref.make({ directory, workspaceID: WorkspaceV2.ID.make("wrk_workspace_a") }) + const second = Location.Ref.make({ directory, workspaceID: WorkspaceV2.ID.make("wrk_workspace_b") }) + const firstService = Context.get(yield* locations.contextEffect(first), ProviderUsage.Service) + + expect(Context.get(yield* locations.contextEffect(same), ProviderUsage.Service)).toBe(firstService) + expect(Context.get(yield* locations.contextEffect(second), ProviderUsage.Service)).not.toBe(firstService) + }), + ), + ), + ), + ) +}) diff --git a/packages/core/test/kilocode-provider-usage-minimax.test.ts b/packages/core/test/kilocode-provider-usage-minimax.test.ts new file mode 100644 index 00000000000..d9cae551d19 --- /dev/null +++ b/packages/core/test/kilocode-provider-usage-minimax.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, mock, test } from "bun:test" +import { decode } from "../src/kilocode/provider-usage/minimax/native" +import { direct, normalize, query, type Candidate } from "../src/kilocode/provider-usage/minimax/usage" +import type { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage" + +const native = (row: Record) => + decode({ + base_resp: { status_code: 0, status_msg: "stripped" }, + model_remains: [{ model_name: "general", ...row }], + unknown: "stripped", + }) + +const options = { + id: "usage", + providerID: "minimax-coding-plan", + sourceLabel: "Direct", + managementUrl: "https://platform.minimax.io/subscribe/token-plan", + fetchedAt: "2026-06-19T00:00:00.000Z", +} + +const candidate = (providerID: Candidate["providerID"], key: string): Candidate => ({ + providerID, + label: providerID === "minimax-cn-coding-plan" ? "MiniMax China" : "MiniMax Global", + key, +}) + +const response = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) + +describe("MiniMax usage normalization", () => { + test("normalizes direct native percentage payloads", () => { + const payload = native({ + current_interval_total_count: 1500, + current_interval_usage_count: 1, + current_interval_remaining_percent: 80, + current_interval_status: 1, + start_time: 1_781_827_200_000, + end_time: 1_781_845_200_000, + }) + + const direct = normalize(payload, options) + expect(direct.windows[0]).toMatchObject({ + orientation: "remaining_percent", + remaining: 80, + used: 20, + limit: 100, + period: { unit: "hour", value: 5 }, + resetAt: "2026-06-19T05:00:00.000Z", + }) + expect(direct.windows[0]?.remaining).not.toBe(1) + }) + + test("omits video quotas while preserving image quota state", () => { + const value = decode({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "video", + current_interval_remaining_percent: 100, + current_interval_status: 1, + }, + { + model_name: "image", + current_interval_remaining_percent: 70, + current_interval_status: 1, + current_weekly_status: 3, + }, + { + model_name: "general", + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_status: 1, + }, + ], + }) + const direct = normalize(value, options) + + expect(direct.windows.some((window) => window.resource === "video")).toBe(false) + expect(direct.windows.find((window) => window.id === "image-interval")).toMatchObject({ + resource: "image", + remaining: 70, + state: "active", + }) + expect(direct.windows.find((window) => window.id === "image-weekly")?.state).toBe("not_in_plan") + expect(direct.windows.find((window) => window.id === "general-interval")?.state).toBe("unknown") + }) + + test("uses positive count-only usage_count as remaining", () => { + const item = normalize( + native({ + current_interval_total_count: 1500, + current_interval_usage_count: 1200, + current_interval_status: 1, + }), + options, + ) + + expect(item.windows[0]).toMatchObject({ orientation: "count", remaining: 1200, used: 300, limit: 1500 }) + }) + + test("applies weekly boosts as capacity without clamping to 100", () => { + const item = normalize( + native({ + current_weekly_remaining_percent: 100, + current_weekly_status: 1, + weekly_boost_permille: 1500, + }), + options, + ) + + expect(item.windows[0]).toMatchObject({ + unit: "standard_units", + orientation: "amount", + remaining: 150, + limit: 150, + period: { unit: "week", value: 1 }, + }) + }) + + test("prefers absolute reset timestamps over remaining duration", () => { + const item = normalize( + native({ + current_interval_remaining_percent: 50, + current_interval_status: 1, + end_time: 1_781_845_200_000, + remains_time: 60_000, + }), + options, + ) + + expect(item.windows[0]?.resetAt).toBe("2026-06-19T05:00:00.000Z") + }) +}) + +describe("MiniMax usage window calculations", () => { + test("clamps used at zero when the remaining count exceeds the total", () => { + const item = normalize( + native({ + current_interval_total_count: 1500, + current_interval_usage_count: 1600, + current_interval_status: 1, + }), + options, + ) + + expect(item.windows[0]).toMatchObject({ + orientation: "count", + remaining: 1600, + used: 0, + limit: 1500, + state: "active", + }) + }) + + test("marks count windows exhausted when nothing remains or the status flags it", () => { + const drained = normalize( + native({ + current_interval_total_count: 1500, + current_interval_usage_count: 0, + current_interval_status: 1, + }), + options, + ) + expect(drained.windows[0]).toMatchObject({ remaining: 0, used: 1500, state: "exhausted" }) + + const flagged = normalize( + native({ + current_interval_total_count: 1500, + current_interval_usage_count: 800, + current_interval_status: 2, + }), + options, + ) + expect(flagged.windows[0]).toMatchObject({ remaining: 0, used: 1500, state: "exhausted" }) + }) + + test("treats an exhausted status as authoritative over lagging percent fields", () => { + const item = normalize(native({ current_interval_remaining_percent: 12, current_interval_status: 2 }), options) + + expect(item.windows[0]).toMatchObject({ remaining: 0, used: 100, limit: 100, state: "exhausted" }) + }) + + test("reads weekly count windows from the weekly fields", () => { + const item = normalize( + native({ + current_weekly_total_count: 6000, + current_weekly_usage_count: 4500, + current_weekly_status: 1, + }), + options, + ) + + expect(item.windows).toHaveLength(1) + expect(item.windows[0]).toMatchObject({ + id: "general-weekly", + orientation: "count", + remaining: 4500, + used: 1500, + limit: 6000, + period: { unit: "week", value: 1 }, + }) + }) + + test("rejects out-of-range percent values like the cloud schema does", () => { + expect(() => native({ current_interval_remaining_percent: 150 })).toThrow() + expect(() => native({ current_weekly_remaining_percent: -1 })).toThrow() + }) + + test("accepts the permille boost spelling and falls back to plain percent without a boost", () => { + const boosted = normalize( + native({ + current_interval_remaining_percent: 50, + current_interval_status: 1, + interval_boost_permille: 2000, + }), + options, + ) + expect(boosted.windows[0]).toMatchObject({ + unit: "standard_units", + orientation: "amount", + remaining: 100, + used: 100, + limit: 200, + }) + + const plain = normalize(native({ current_interval_remaining_percent: 50, current_interval_status: 1 }), options) + expect(plain.windows[0]).toMatchObject({ + unit: "percent", + orientation: "remaining_percent", + remaining: 50, + used: 50, + limit: 100, + }) + }) + + test("marks percent windows exhausted when nothing remains", () => { + const item = normalize(native({ current_interval_remaining_percent: 0, current_interval_status: 1 }), options) + + expect(item.windows[0]).toMatchObject({ remaining: 0, used: 100, state: "exhausted" }) + }) + + test("derives the period from the window span when it is a round unit", () => { + const start = 1_781_827_200_000 + const item = normalize( + native({ + current_interval_remaining_percent: 80, + current_interval_status: 1, + start_time: start, + end_time: start + 14 * 24 * 60 * 60 * 1000, + }), + options, + ) + + expect(item.windows[0]?.period).toEqual({ unit: "week", value: 2 }) + expect(item.windows[0]?.durationMs).toBe(14 * 24 * 60 * 60 * 1000) + }) + + test("omits the period when the span is not a round hour, day, or week", () => { + const start = 1_781_827_200_000 + const item = normalize( + native({ + current_interval_remaining_percent: 80, + current_interval_status: 1, + start_time: start, + end_time: start + 90 * 60 * 1000, + }), + options, + ) + + expect(item.windows[0]?.period).toBeUndefined() + expect(item.windows[0]?.durationMs).toBe(90 * 60 * 1000) + }) + + test("omits the duration and period when the window timestamps are inverted", () => { + const start = 1_781_827_200_000 + const item = normalize( + native({ + current_interval_remaining_percent: 80, + current_interval_status: 1, + start_time: start, + end_time: start, + }), + options, + ) + + expect(item.windows[0]?.durationMs).toBeUndefined() + expect(item.windows[0]?.period).toBeUndefined() + }) + + test("falls back to remains_time when end_time is missing or zero", () => { + const item = normalize( + native({ + current_interval_remaining_percent: 80, + current_interval_status: 1, + end_time: 0, + remains_time: 3_600_000, + }), + options, + ) + expect(item.windows[0]?.resetAt).toBe("2026-06-19T01:00:00.000Z") + + const none = normalize(native({ current_interval_remaining_percent: 80, current_interval_status: 1 }), options) + expect(none.windows[0]?.resetAt).toBeUndefined() + }) + + test("omits windows with no usage signals and keeps status-only windows as unknown", () => { + const empty = normalize(native({}), options) + expect(empty.windows).toEqual([]) + + const item = normalize(native({ current_interval_status: 1 }), options) + expect(item.windows).toHaveLength(1) + expect(item.windows[0]).toMatchObject({ unit: "unknown", orientation: "amount", state: "unknown" }) + }) +}) + +describe("MiniMax usage transport and detection", () => { + test("uses fixed hosts and ignores configured base URLs", async () => { + const fn = mock(() => Promise.resolve(response({ base_resp: { status_code: 0 }, model_remains: [] }))) + + await query("minimax-coding-plan", "sk-cp-secret", fn as unknown as typeof fetch) + + expect(fn).toHaveBeenCalledTimes(1) + const call = fn.mock.calls[0] as unknown as [string, RequestInit] + expect(call[0]).toBe("https://api.minimax.io/v1/token_plan/remains") + expect(call[1]).toMatchObject({ method: "GET", cache: "no-store", redirect: "error" }) + expect(new Headers(call[1].headers).get("authorization")).toBe("Bearer sk-cp-secret") + }) + + test("does not query PAYG keys or provider IDs", async () => { + const fn = mock(() => Promise.resolve(response({}))) + const items = await direct([candidate("minimax-coding-plan", "sk-api-payg")], fn as unknown as typeof fetch) + + expect(items).toEqual([]) + expect(fn).not.toHaveBeenCalled() + }) + + test("keeps same-key providers as independent regional plans", async () => { + const fn = mock((url: string | URL | Request) => + Promise.resolve( + String(url).includes("api.minimax.io") + ? response({}, 401) + : response({ + base_resp: { status_code: 0 }, + model_remains: [{ model_name: "general", current_interval_remaining_percent: 90 }], + }), + ), + ) + const items = await direct( + [candidate("minimax-coding-plan", "sk-cp-shared"), candidate("minimax-cn-coding-plan", "sk-cp-shared")], + fn as unknown as typeof fetch, + ) + + expect(fn).toHaveBeenCalledTimes(2) + expect(items).toHaveLength(2) + expect(items.find((item) => item.id === "minimax-direct-global")).toMatchObject({ + providerID: "minimax-coding-plan", + sourceLabel: "MiniMax Global", + fetchState: "unavailable", + }) + expect(items.find((item) => item.id === "minimax-direct-china")).toMatchObject({ + providerID: "minimax-cn-coding-plan", + sourceLabel: "MiniMax China", + fetchState: "ready", + }) + expect(JSON.stringify(items)).not.toContain("sk-cp-shared") + }) + + test("scopes the cache cell identity to the credential fingerprint", async () => { + const fn = mock(() => Promise.resolve(response({ base_resp: { status_code: 0 }, model_remains: [] }))) + const seen: Array<{ id: string; identity?: string }> = [] + const cached = (id: string, load: () => Promise, identity?: string) => { + seen.push({ id, identity }) + return load() + } + + await direct([candidate("minimax-coding-plan", "sk-cp-key-a")], fn as unknown as typeof fetch, cached) + await direct([candidate("minimax-coding-plan", "sk-cp-key-b")], fn as unknown as typeof fetch, cached) + + expect(seen.map((item) => item.id)).toEqual(["minimax-direct-global", "minimax-direct-global"]) + expect(seen[0].identity).toHaveLength(64) + expect(seen[0].identity).not.toBe(seen[1].identity) + expect(JSON.stringify(seen)).not.toContain("sk-cp") + }) + + test("returns per-provider unavailable items when every query fails", async () => { + const fn = mock(() => Promise.resolve(response({ message: "raw failure" }, 500))) + const items = await direct( + [candidate("minimax-coding-plan", "sk-cp-shared"), candidate("minimax-cn-coding-plan", "sk-cp-shared")], + fn as unknown as typeof fetch, + ) + + expect(items).toHaveLength(2) + expect(items.every((item) => item.fetchState === "unavailable")).toBe(true) + expect(JSON.stringify(items)).not.toContain("raw failure") + }) +}) diff --git a/packages/core/test/kilocode-provider-usage.test.ts b/packages/core/test/kilocode-provider-usage.test.ts new file mode 100644 index 00000000000..78d641af8fa --- /dev/null +++ b/packages/core/test/kilocode-provider-usage.test.ts @@ -0,0 +1,694 @@ +import { describe, expect, mock, setSystemTime } from "bun:test" +import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" +import { Catalog } from "../src/catalog" +import { Credential } from "../src/credential" +import { Integration } from "../src/integration" +import { ProviderUsage } from "../src/kilocode/provider-usage" +import { PluginV2 } from "../src/plugin" +import { ProviderV2 } from "../src/provider" +import { testEffect } from "./lib/effect" + +const provider = ProviderV2.ID.make("minimax-coding-plan") +const chinaProvider = ProviderV2.ID.make("minimax-cn-coding-plan") +const integration = Integration.ID.make("minimax-coding-plan") +const chinaIntegration = Integration.ID.make("minimax-cn-coding-plan") +const kilo = Integration.ID.make("kilo") + +type CatalogInput = { + apiKey?: string + setting?: string + header?: string + headerName?: string + organization?: string | (() => string | undefined) + china?: boolean +} + +const catalog = (input?: CatalogInput) => { + const providers = () => { + const organization = typeof input?.organization === "function" ? input.organization() : input?.organization + const info = ProviderV2.Info.make({ + id: provider, + name: "MiniMax Global", + api: { type: "native", settings: input?.setting ? { apiKey: input.setting } : {} }, + request: { + headers: input?.header ? { [input.headerName ?? "x-api-key"]: input.header } : {}, + body: input?.apiKey ? { apiKey: input.apiKey } : {}, + }, + }) + const kiloInfo = ProviderV2.Info.make({ + id: ProviderV2.ID.kilo, + name: "Kilo", + api: { type: "native", settings: {} }, + request: { headers: {}, body: organization ? { kilocodeOrganizationId: organization } : {} }, + }) + const china = ProviderV2.Info.make({ + id: chinaProvider, + name: "MiniMax China", + api: { type: "native", settings: {} }, + request: { headers: {}, body: {} }, + }) + return input?.china ? [info, china, kiloInfo] : [info, kiloInfo] + } + return Layer.mock(Catalog.Service)({ + provider: { + get: () => Effect.succeed(undefined), + all: () => Effect.sync(providers), + available: () => Effect.sync(providers), + }, + model: { + get: () => Effect.succeed(undefined), + all: () => Effect.succeed([]), + available: () => Effect.succeed([]), + default: () => Effect.succeed(undefined), + small: () => Effect.succeed(undefined), + }, + }) +} + +type DirectInput = string | ((id: Integration.ID) => string | undefined) | undefined + +const directValue = (input: DirectInput, id: Integration.ID) => (typeof input === "function" ? input(id) : input) + +const connections = (input: DirectInput, accountID?: string, failure?: () => "global" | "china" | "kilo" | undefined) => + Layer.mock(Integration.Service)({ + connection: { + active: (id) => + Effect.sync(() => { + const direct = directValue(input, id) + return id === kilo || ((id === integration || id === chinaIntegration) && direct) + ? { + type: "credential" as const, + id: Credential.ID.make( + id === kilo ? "cred_kilo" : id === chinaIntegration ? "cred_direct_cn" : "cred_direct", + ), + label: "test", + } + : undefined + }), + resolve: (connection) => + Effect.suspend(() => { + const target = + connection.type === "credential" && connection.id === "cred_direct_cn" ? chinaIntegration : integration + const direct = directValue(input, target) + const kind = + connection.type === "credential" && connection.id === "cred_kilo" + ? "kilo" + : target === chinaIntegration + ? "china" + : "global" + if (failure?.() === kind) + return Effect.fail(new Integration.AuthorizationError({ cause: `${kind} credential failure` })) + return Effect.succeed( + kind === "kilo" + ? Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("oauth"), + access: "cloud-token", + refresh: "cloud-refresh", + expires: Date.now() + 60_000, + metadata: accountID ? { accountID } : undefined, + }) + : direct + ? Credential.Key.make({ type: "key", key: direct }) + : undefined, + ) + }), + key: () => Effect.void, + oauth: () => Effect.die("unused"), + update: () => Effect.void, + remove: () => Effect.void, + }, + attempt: { + status: () => Effect.die("unused"), + complete: () => Effect.void, + cancel: () => Effect.void, + }, + }) + +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 subscription = { + id: "byteplus-plan", + planId: "byteplus-coding-plan-team-lite", + planName: "BytePlus Coding Plan Lite", + providerName: "BytePlus", + providerId: "byteplus-coding", + canQueryUsage: true, + hasInstalledByokKey: true, + status: "active" as const, + cancelAtPeriodEnd: false, +} + +const byok = { + id: "managed-byteplus", + provider_id: "byteplus-coding", + management_source: "coding_plan" as const, + is_enabled: true, +} + +const transport = (calls: { direct: number; cloud: number }, remaining = 80) => + Layer.succeed(ProviderUsage.Transport, { + fetch: mock(() => { + calls.direct++ + return Promise.resolve(native(remaining)) + }) as unknown as typeof fetch, + plans: async () => { + calls.cloud++ + return [] + }, + byok: async () => [], + usage: async () => { + throw new Error("unused") + }, + }) + +const plugins = Layer.mock(PluginV2.Service)({ + add: () => Effect.void, + remove: () => Effect.void, + wait: () => Effect.void, +}) + +const configuredLayer = (input: { + calls: { direct: number; cloud: number } + direct?: DirectInput + accountID?: string + config?: CatalogInput + failure?: () => "global" | "china" | "kilo" | undefined + transport?: ProviderUsage.TransportInterface +}) => + Layer.fresh(ProviderUsage.layer).pipe( + Layer.provide(catalog(input.config)), + Layer.provide(connections(input.direct, input.accountID, input.failure)), + Layer.provide(input.transport ? Layer.succeed(ProviderUsage.Transport, input.transport) : transport(input.calls)), + Layer.provide(plugins), + ) + +const layer = ( + calls: { direct: number; cloud: number }, + direct: DirectInput = "sk-cp-direct", + accountID?: string, + config?: CatalogInput, + failure?: () => "global" | "china" | "kilo" | undefined, +) => configuredLayer({ calls, direct, accountID, config, failure }) + +const it = testEffect(Layer.empty) + +const service = Effect.fn("ProviderUsageTest.service")(function* (service: ProviderUsage.Interface) { + const first = yield* service.get() + const cached = yield* service.get() + const refreshed = yield* service.refresh() + return { first, cached, refreshed } +}) + +describe("ProviderUsage location service", () => { + it.live("caches one location and forces every source on refresh", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + const scope = yield* Scope.make() + const usage = Context.get(yield* Layer.buildWithScope(layer(calls), scope), ProviderUsage.Service) + + const result = yield* service(usage) + + expect(result.first.items).toHaveLength(1) + expect(result.cached).toEqual(result.first) + expect(result.refreshed.items).toHaveLength(1) + expect(calls).toEqual({ direct: 2, cloud: 2 }) + yield* Scope.close(scope, Exit.void) + }), + ) + + it.live("isolates state between location-layer instances", () => + Effect.gen(function* () { + const firstCalls = { direct: 0, cloud: 0 } + const secondCalls = { direct: 0, cloud: 0 } + const firstScope = yield* Scope.make() + const secondScope = yield* Scope.make() + const first = Context.get(yield* Layer.buildWithScope(layer(firstCalls), firstScope), ProviderUsage.Service) + const second = Context.get(yield* Layer.buildWithScope(layer(secondCalls), secondScope), ProviderUsage.Service) + + expect((yield* first.get()).items[0]?.windows[0]?.remaining).toBe(80) + expect((yield* second.get()).items[0]?.windows[0]?.remaining).toBe(80) + expect(firstCalls.direct).toBe(1) + expect(secondCalls.direct).toBe(1) + }), + ) + + it.live("suppresses personal Cloud calls for organization OAuth", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope(layer(calls, "sk-cp-direct", "org"), scope), + ProviderUsage.Service, + ) + + expect((yield* usage.get()).items).toHaveLength(1) + expect(calls).toEqual({ direct: 1, cloud: 0 }) + }), + ) + + it.live("uses every canonical config-defined coding-plan key location", () => + Effect.gen(function* () { + for (const config of [ + { apiKey: "sk-cp-body" }, + { setting: "sk-cp-setting" }, + { header: "sk-cp-header", headerName: "X-API-Key" }, + ]) { + const calls = { direct: 0, cloud: 0 } + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope(layer(calls, undefined, "org", config), scope), + ProviderUsage.Service, + ) + expect((yield* usage.get()).items[0]?.providerID).toBe("minimax-coding-plan") + expect(calls.direct).toBe(1) + } + }), + ) + + it.live("suppresses personal Cloud calls for configured organization routing", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope(layer(calls, "sk-cp-direct", undefined, { organization: "org" }), scope), + ProviderUsage.Service, + ) + + expect((yield* usage.get()).items).toHaveLength(1) + expect(calls).toEqual({ direct: 1, cloud: 0 }) + }), + ) + + it.live("ignores empty configured organization routing", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope(layer(calls, "sk-cp-direct", undefined, { organization: " " }), scope), + ProviderUsage.Service, + ) + + expect((yield* usage.get()).items).toHaveLength(1) + expect(calls).toEqual({ direct: 1, cloud: 1 }) + }), + ) + + it.live("replaces cached direct usage when the credential changes", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + let key = "sk-cp-first" + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope( + layer(calls, () => key, "org"), + scope, + ), + ProviderUsage.Service, + ) + + expect((yield* usage.get()).items[0]?.windows[0]?.remaining).toBe(80) + key = "sk-cp-second" + expect((yield* usage.get()).items[0]?.windows[0]?.remaining).toBe(80) + expect(calls.direct).toBe(2) + }), + ) + + it.live("coalesces a forced refresh with an in-flight read", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + const started = yield* Deferred.make() + let release!: (value: Response) => void + const response = new Promise((resolve) => (release = resolve)) + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope( + configuredLayer({ + calls, + direct: "sk-cp-direct", + accountID: "org", + transport: { + fetch: (() => { + calls.direct++ + Effect.runSync(Deferred.succeed(started, undefined)) + return response + }) as unknown as typeof fetch, + plans: async () => [], + byok: async () => [], + usage: async () => { + throw new Error("unused") + }, + }, + }), + scope, + ), + ProviderUsage.Service, + ) + + const first = yield* usage.get().pipe(Effect.forkChild) + yield* Deferred.await(started) + const second = yield* usage.refresh().pipe(Effect.forkChild) + yield* Effect.yieldNow + release(native(80)) + + expect((yield* Fiber.join(first)).items).toHaveLength(1) + expect((yield* Fiber.join(second)).items).toHaveLength(1) + expect(calls.direct).toBe(1) + }), + ) + + it.live("serves stale data after a failed refresh and recovers on retry", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + const responses = [native(80), new Response("private upstream error", { status: 503 }), native(70)] + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope( + configuredLayer({ + calls, + direct: "sk-cp-direct", + accountID: "org", + transport: { + fetch: mock(() => { + calls.direct++ + return Promise.resolve(responses.shift()!) + }) as unknown as typeof fetch, + plans: async () => [], + byok: async () => [], + usage: async () => { + throw new Error("unused") + }, + }, + }), + scope, + ), + ProviderUsage.Service, + ) + + const ready = yield* usage.get() + const stale = yield* usage.refresh() + const recovered = yield* usage.refresh() + + expect(ready.items[0]).toMatchObject({ fetchState: "ready", windows: [{ remaining: 80 }] }) + expect(stale.items[0]).toMatchObject({ fetchState: "stale", windows: [{ remaining: 80 }] }) + expect(recovered.items[0]).toMatchObject({ fetchState: "ready", windows: [{ remaining: 70 }] }) + expect(JSON.stringify(stale)).not.toContain("private upstream error") + }), + ) + + it.live("expires the success cache and retries failures on the shorter error TTL", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + const responses = [native(80), new Response("upstream failure", { status: 503 }), native(70)] + const base = Date.parse("2026-08-12T00:00:00.000Z") + setSystemTime(new Date(base)) + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope( + configuredLayer({ + calls, + direct: "sk-cp-direct", + accountID: "org", + transport: { + fetch: mock(() => { + calls.direct++ + return Promise.resolve(responses.shift()!) + }) as unknown as typeof fetch, + plans: async () => [], + byok: async () => [], + usage: async () => { + throw new Error("unused") + }, + }, + }), + scope, + ), + ProviderUsage.Service, + ) + + expect((yield* usage.get()).items[0]).toMatchObject({ fetchState: "ready", windows: [{ remaining: 80 }] }) + expect((yield* usage.get()).items[0]).toMatchObject({ fetchState: "ready" }) + expect(calls.direct).toBe(1) + + // Success TTL (60s) elapsed: the next get refetches and degrades to stale on failure. + setSystemTime(new Date(base + 61_000)) + expect((yield* usage.get()).items[0]).toMatchObject({ fetchState: "stale", windows: [{ remaining: 80 }] }) + expect((yield* usage.get()).items[0]).toMatchObject({ fetchState: "stale" }) + expect(calls.direct).toBe(2) + + // Error TTL (10s) elapsed: the failure is retried and recovers. + setSystemTime(new Date(base + 72_000)) + expect((yield* usage.get()).items[0]).toMatchObject({ fetchState: "ready", windows: [{ remaining: 70 }] }) + expect(calls.direct).toBe(3) + yield* Scope.close(scope, Exit.void) + }).pipe(Effect.ensuring(Effect.sync(() => setSystemTime()))), + ) + + it.live("prunes authoritative removal but preserves a transient credential failure", () => + Effect.gen(function* () { + const removedCalls = { direct: 0, cloud: 0 } + let removedKey: string | undefined = "sk-cp-present" + const removedScope = yield* Scope.make() + const removed = Context.get( + yield* Layer.buildWithScope( + layer(removedCalls, () => removedKey, "org"), + removedScope, + ), + ProviderUsage.Service, + ) + expect((yield* removed.get()).items).toHaveLength(1) + removedKey = undefined + expect((yield* removed.get()).items).toEqual([]) + + const failedCalls = { direct: 0, cloud: 0 } + let failure: "global" | undefined + const failedScope = yield* Scope.make() + const failed = Context.get( + yield* Layer.buildWithScope( + layer(failedCalls, "sk-cp-present", "org", undefined, () => failure), + failedScope, + ), + ProviderUsage.Service, + ) + expect((yield* failed.get()).items).toHaveLength(1) + failure = "global" + expect((yield* failed.get()).items[0]).toMatchObject({ fetchState: "stale" }) + expect(failedCalls.direct).toBe(1) + }), + ) + + it.live("preserves only the failed direct provider while pruning a removed sibling", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + let chinaKey: string | undefined = "sk-cp-china" + let failure: "global" | undefined + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope( + configuredLayer({ + calls, + accountID: "org", + config: { china: true, apiKey: "sk-cp-fallback" }, + direct: (id) => (id === chinaIntegration ? chinaKey : "sk-cp-global"), + failure: () => failure, + }), + scope, + ), + ProviderUsage.Service, + ) + + expect((yield* usage.get()).items).toHaveLength(2) + failure = "global" + chinaKey = undefined + const result = yield* usage.get() + + expect(result.items).toHaveLength(1) + expect(result.items[0]).toMatchObject({ providerID: "minimax-coding-plan", fetchState: "stale" }) + expect(calls.direct).toBe(2) + }), + ) + + it.live("keeps same-key providers independent when one credential fails", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + let failure: "global" | undefined + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope( + configuredLayer({ + calls, + accountID: "org", + config: { china: true }, + direct: "sk-cp-shared", + failure: () => failure, + }), + scope, + ), + ProviderUsage.Service, + ) + + expect((yield* usage.get()).items).toHaveLength(2) + failure = "global" + const result = yield* usage.get() + + expect(result.items).toHaveLength(2) + expect(result.items.find((item) => item.id === "minimax-direct-global")).toMatchObject({ fetchState: "stale" }) + expect(result.items.find((item) => item.id === "minimax-direct-china")).toMatchObject({ fetchState: "ready" }) + // The surviving sibling serves from cache; the failed one is not refetched. + expect(calls.direct).toBe(2) + }), + ) + + it.live("refreshes a rotated credential while preserving the failed sibling's cached usage", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + let failure: "global" | undefined + let key = "sk-cp-shared" + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope( + configuredLayer({ + calls, + accountID: "org", + config: { china: true }, + direct: () => key, + failure: () => failure, + }), + scope, + ), + ProviderUsage.Service, + ) + + expect((yield* usage.get()).items).toHaveLength(2) + failure = "global" + key = "sk-cp-rotated" + const result = yield* usage.get() + + expect(result.items).toHaveLength(2) + expect(result.items.find((item) => item.id === "minimax-direct-global")).toMatchObject({ fetchState: "stale" }) + expect(result.items.find((item) => item.id === "minimax-direct-china")).toMatchObject({ fetchState: "ready" }) + expect(calls.direct).toBe(3) + }), + ) + + it.live("omits a failed provider with no cached usage", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope( + configuredLayer({ + calls, + accountID: "org", + config: { china: true }, + direct: "sk-cp-shared", + failure: () => "global" as const, + }), + scope, + ), + ProviderUsage.Service, + ) + + const result = yield* usage.get() + + expect(result.items).toHaveLength(1) + expect(result.items[0]).toMatchObject({ id: "minimax-direct-china", fetchState: "ready" }) + expect(calls.direct).toBe(1) + }), + ) + + it.live("preserves managed usage across metadata and credential failures while direct usage refreshes", () => + Effect.gen(function* () { + const calls = { direct: 0, cloud: 0 } + let byokFailure = false + let usageFailure = false + let credentialFailure: "kilo" | undefined + let organization: string | undefined + const scope = yield* Scope.make() + const usage = Context.get( + yield* Layer.buildWithScope( + configuredLayer({ + calls, + direct: "sk-cp-direct", + config: { organization: () => organization }, + failure: () => credentialFailure, + transport: { + fetch: mock(() => { + calls.direct++ + return Promise.resolve(native(80)) + }) as unknown as typeof fetch, + plans: async () => { + calls.cloud++ + return [subscription] + }, + byok: async () => { + if (byokFailure) throw new Error("private metadata failure") + return [byok] + }, + usage: async () => { + if (usageFailure) throw new Error("private usage failure") + return { + schemaVersion: 1, + fetchedAt: "2026-08-09T12:00:00.000Z", + subscription: { + id: subscription.id, + planName: subscription.planName, + providerId: subscription.providerId, + providerName: subscription.providerName, + windows: [ + { + id: "monthly", + remainingPercent: 75, + resetsAt: "2026-09-01T00:00:00.000Z", + period: { unit: "month", value: 1 }, + }, + ], + }, + } + }, + }, + }), + scope, + ), + ProviderUsage.Service, + ) + + expect((yield* usage.get()).items).toHaveLength(2) + byokFailure = true + const partial = yield* usage.refresh() + // Discovery failure retains the last good Cloud state, so usage still refreshes. + expect(partial.items.find((item) => item.sourceKind === "kilo_managed")).toMatchObject({ fetchState: "ready" }) + expect(partial.items.find((item) => item.sourceKind === "direct")).toMatchObject({ fetchState: "ready" }) + expect(JSON.stringify(partial)).not.toContain("private metadata failure") + + usageFailure = true + const degraded = yield* usage.refresh() + expect(degraded.items.find((item) => item.sourceKind === "kilo_managed")).toMatchObject({ fetchState: "stale" }) + expect(JSON.stringify(degraded)).not.toContain("private usage failure") + + byokFailure = false + usageFailure = false + credentialFailure = "kilo" + const credential = yield* usage.get() + expect(credential.items.find((item) => item.sourceKind === "kilo_managed")).toMatchObject({ + fetchState: "stale", + }) + expect(credential.items.find((item) => item.sourceKind === "direct")).toBeDefined() + + organization = "org" + const organizationResult = yield* usage.get() + expect(organizationResult.items.find((item) => item.sourceKind === "kilo_managed")).toBeUndefined() + expect(organizationResult.items.find((item) => item.sourceKind === "direct")).toBeDefined() + }), + ) +}) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/empty-usage-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/empty-usage-chromium-linux.png new file mode 100644 index 00000000000..9f22f8df7bb --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/empty-usage-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3bb2c260c3294003140fe03f1732915536aab2733151ac17e27bf2a7c40dff2 +size 23882 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png index b1b38a2c688..4002c4d794a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2f353022aa26937291c9d2d3a34bba222e87695e609cc5de4f98cbe57e90a7c0 -size 19828 +oid sha256:9b4c9589d542c89b2f2ce43773088e144da6fba554da790251df9b7df70326a2 +size 33911 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png index bd4801d5d69..3fccee428c8 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f5fe1c7f2960984736863aac8dc720eff1cf60cc5b1991e966740b395565b8ad -size 16069 +oid sha256:b5807f0f023b36cf7f2d85a2891108275a513c31fa2cc6b76b6356e6ec1dba3b +size 33844 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/not-logged-in-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/not-logged-in-chromium-linux.png index f49f4d723e7..40e67dfdc2d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/not-logged-in-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/not-logged-in-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b70dcaf2b3f89e0644fb47273881076cbb84cf5d36ce506685689968edb021dc -size 5527 +oid sha256:4ff5dbb499e51fa149032b492a6d9a61ec2f54ddd5af3d3f786794d9bbee3d20 +size 17803 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/organization-context-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/organization-context-chromium-linux.png new file mode 100644 index 00000000000..060d3e7ecda --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/organization-context-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a634c466f2d854a245c210b695f86363ec9bfe76cbf4cb2c1e1baf174e010935 +size 22571 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/scrollable-usage-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/scrollable-usage-chromium-linux.png new file mode 100644 index 00000000000..508f63700e2 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/scrollable-usage-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65c9606f05c45b1f143e6283c41a667322d13d728c6c728e627c8075a1df1fc0 +size 22986 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/stale-and-unavailable-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/stale-and-unavailable-chromium-linux.png new file mode 100644 index 00000000000..2c266cd94b4 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/stale-and-unavailable-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:445a492fadd3bda00cb28c4942ea57e003be9788bd920414a20f75491a8ea817 +size 38665 diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 1ee145de153..ed4713cfa26 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -21,6 +21,7 @@ "./fim": "./src/fim.ts", "./edit": "./src/edit.ts", "./edit-prompt": "./src/edit-prompt.ts", + "./provider-usage": "./src/provider-usage.ts", "./tui": "./src/tui.ts" }, "files": [ diff --git a/packages/kilo-gateway/src/api/kilo-pass.ts b/packages/kilo-gateway/src/api/kilo-pass.ts index 014cb594d75..b59052b0ae5 100644 --- a/packages/kilo-gateway/src/api/kilo-pass.ts +++ b/packages/kilo-gateway/src/api/kilo-pass.ts @@ -10,12 +10,17 @@ function num(value: unknown) { return typeof value === "number" && Number.isFinite(value) ? value : 0 } +// Cloud returns the full subscription record even after cancellation; only +// these statuses represent a pass the user can actually consume. +const live = new Set(["active", "past_due", "trialing"]) + export function parseKiloPassState(value: unknown): KiloPassState | null { const item = Array.isArray(value) ? value[0] : value const data = record(record(record(item)?.result)?.data) const root = record(data?.json) ?? data ?? record(value) const sub = record(root?.subscription) if (!sub || (sub.currentPeriodBaseCreditsUsd == null && sub.currentPeriodUsageUsd == null)) return null + if (typeof sub.status === "string" && !live.has(sub.status)) return null const next = sub.nextBillingAt ?? sub.nextRenewalAt return { diff --git a/packages/kilo-gateway/src/api/trpc.ts b/packages/kilo-gateway/src/api/trpc.ts new file mode 100644 index 00000000000..c6cc4ccac3e --- /dev/null +++ b/packages/kilo-gateway/src/api/trpc.ts @@ -0,0 +1,175 @@ +import { z } from "zod" +import { buildKiloHeaders } from "../headers.js" +import { KILO_API_BASE } from "./constants.js" + +const timeout = 5_000 +const limit = 512 * 1024 + +const CodingPlanSubscriptionSchema = z.object({ + id: z.string(), + planId: z.string(), + planName: z.string(), + providerName: z.string(), + providerId: z.string(), + canQueryUsage: z.boolean(), + hasInstalledByokKey: z.boolean(), + status: z.enum(["active", "past_due", "canceled"]), + cancelAtPeriodEnd: z.boolean(), +}) + +const ByokEntrySchema = z.object({ + id: z.string(), + provider_id: z.string(), + management_source: z.enum(["user", "coding_plan"]), + is_enabled: z.boolean(), +}) + +const CodingPlanQuotaWindowSchema = z.object({ + id: z + .string() + .min(1) + .max(64) + .regex(/^[a-z][a-z0-9_]*$/), + remainingPercent: z.number().finite().nonnegative(), + resetsAt: z.iso.datetime(), + startsAt: z.iso.datetime().optional(), + period: z.object({ + unit: z.enum(["hour", "day", "week", "month"]), + value: z.number().int().positive(), + }), +}) + +const CodingPlanQuotaWindowsSchema = z + .array(CodingPlanQuotaWindowSchema) + .min(1) + .max(16) + .superRefine((windows, ctx) => { + const ids = new Set() + for (const [index, window] of windows.entries()) { + if (ids.has(window.id)) { + ctx.addIssue({ code: "custom", message: "Quota window IDs must be unique.", path: [index, "id"] }) + } + ids.add(window.id) + } + }) + +export const CodingPlanUsageSchema = z.object({ + schemaVersion: z.literal(1), + fetchedAt: z.iso.datetime(), + subscription: z.object({ + id: z.string(), + planName: z.string().min(1), + providerId: z.string().min(1), + providerName: z.string().min(1), + windows: CodingPlanQuotaWindowsSchema, + }), +}) + +const envelope = z.object({ + result: z.object({ data: z.unknown() }).optional(), + error: z.unknown().optional(), +}) + +export type CodingPlanSubscription = z.infer +export type ByokEntry = z.infer +export type CodingPlanUsage = z.infer +export type CodingPlanQuotaWindow = z.infer + +async function read(response: Response) { + const declared = Number(response.headers.get("content-length")) + if (Number.isFinite(declared) && declared > limit) { + response.body?.cancel().catch(() => undefined) + throw new CloudTrpcError("protocol", response.status) + } + if (!response.body) { + const body = await response.arrayBuffer() + if (body.byteLength > limit) throw new CloudTrpcError("protocol", response.status) + return new TextDecoder().decode(body) + } + + 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 CloudTrpcError("protocol", response.status) + } + chunks.push(chunk.value) + } + const body = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + return new TextDecoder().decode(body) +} + +export class CloudTrpcError extends Error { + constructor( + readonly kind: "network" | "http" | "protocol" | "procedure" | "schema", + readonly status?: number, + ) { + super("Kilo Cloud data is temporarily unavailable.") + this.name = "CloudTrpcError" + } +} + +async function query(procedure: string, token: string, schema: z.ZodType, input?: unknown): Promise { + const params = new URLSearchParams() + if (input !== undefined) params.set("input", JSON.stringify(input)) + const suffix = params.size ? `?${params.toString()}` : "" + const response = await fetch(`${KILO_API_BASE}/api/trpc/${procedure}${suffix}`, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...buildKiloHeaders(), + }, + redirect: "error", + signal: AbortSignal.timeout(timeout), + }).catch(() => { + throw new CloudTrpcError("network") + }) + + const body = await read(response).catch((error) => { + if (error instanceof CloudTrpcError) throw error + throw new CloudTrpcError("protocol", response.status) + }) + + const parsed = (() => { + try { + return envelope.parse(JSON.parse(body)) + } catch { + throw new CloudTrpcError("protocol", response.status) + } + })() + if (parsed.error != null) throw new CloudTrpcError("procedure", response.status) + if (!response.ok) throw new CloudTrpcError("http", response.status) + if (!parsed.result) throw new CloudTrpcError("protocol", response.status) + + const data = parsed.result.data + const value = typeof data === "object" && data !== null && "json" in data ? (data as { json: unknown }).json : data + const result = schema.safeParse(value) + if (!result.success) throw new CloudTrpcError("schema", response.status) + return result.data +} + +export function fetchCodingPlanSubscriptions(token: string) { + return query("codingPlans.listSubscriptions", token, z.array(CodingPlanSubscriptionSchema)) +} + +export function fetchByokEntries(token: string) { + return query("byok.list", token, z.array(ByokEntrySchema), {}) +} + +export async function fetchCodingPlanUsage(token: string, subscriptionId: string) { + const usage = await query("codingPlans.getUsage", token, CodingPlanUsageSchema, { subscriptionId }) + if (usage.subscription.id !== subscriptionId) throw new CloudTrpcError("schema") + return usage +} diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index 4df0e8a7681..ecf12c3a7f5 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -70,6 +70,14 @@ export { type OrganizationModeConfig, } from "./api/modes.js" export { fetchKilocodeNotifications, type KilocodeNotification } from "./api/notifications.js" +export { + fetchByokEntries, + fetchCodingPlanSubscriptions, + fetchCodingPlanUsage, + type ByokEntry, + type CodingPlanSubscription, + type CodingPlanQuotaWindow, +} from "./api/trpc.js" export { fetchCloudSession, fetchCloudSessionForImport, diff --git a/packages/kilo-gateway/src/provider-usage.ts b/packages/kilo-gateway/src/provider-usage.ts new file mode 100644 index 00000000000..00c8e37ac85 --- /dev/null +++ b/packages/kilo-gateway/src/provider-usage.ts @@ -0,0 +1,110 @@ +/** + * Shared display formatting for provider usage windows. + * + * This is the single source of truth for how a quota window is presented. + * Both the TUI dialog (packages/opencode) and the VS Code webview consume it + * so the two surfaces can never drift; labels are injectable for i18n. + */ + +export interface UsagePeriodLike { + unit: "hour" | "day" | "week" | "month" + value: number +} + +export interface UsageWindowLike { + state: "active" | "exhausted" | "unlimited" | "not_in_plan" | "unknown" + orientation: "used_percent" | "remaining_percent" | "amount" | "count" + unit: string + resource: string + period?: UsagePeriodLike + used?: number + remaining?: number + limit?: number +} + +export interface UsageLabels { + unlimited: string + notInPlan: string + unknown: string + exhausted: string + used(value: string): string + remaining(value: string): string + remainingOf(value: string, limit: string): string + usedOf(value: string, limit: string): string + quota: string + daily: string + weekly: string + monthly: string + hours(count: number): string + days(count: number): string + weeks(count: number): string + months(count: number): string + /** Display name for MiniMax's pooled "general" resource. */ + shared: string + scoped(resource: string, period: string): string +} + +export const english: UsageLabels = { + unlimited: "Unlimited", + notInPlan: "Not in plan", + unknown: "Unknown", + exhausted: "Exhausted", + used: (value) => `${value} used`, + remaining: (value) => `${value} remaining`, + remainingOf: (value, limit) => `${value} of ${limit} remaining`, + usedOf: (value, limit) => `${value} of ${limit} used`, + quota: "Quota", + daily: "Daily quota", + weekly: "Weekly quota", + monthly: "Monthly quota", + hours: (count) => `${count}-hour quota`, + days: (count) => `${count}-day quota`, + weeks: (count) => `${count}-week quota`, + months: (count) => `${count}-month quota`, + shared: "Shared", + scoped: (resource, period) => `${resource} · ${period}`, +} + +const period = (value: UsagePeriodLike, labels: UsageLabels) => { + if (value.unit === "hour") return labels.hours(value.value) + if (value.unit === "day") return value.value === 1 ? labels.daily : labels.days(value.value) + if (value.unit === "week") return value.value === 1 ? labels.weekly : labels.weeks(value.value) + return value.value === 1 ? labels.monthly : labels.months(value.value) +} + +export const windowLabel = (window: UsageWindowLike, labels: UsageLabels = english) => { + const phrase = window.period ? period(window.period, labels) : labels.quota + // Plan-level windows ("subscription") are the whole card; named resources prefix theirs. + if (window.resource === "subscription") return phrase + return labels.scoped(window.resource === "general" ? labels.shared : window.resource, phrase) +} + +const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 2 }) + +const amount = (value: number, unit: string) => { + if (unit === "USD") return `$${value.toFixed(2)}` + if (unit === "percent") return `${number(value)}%` + if (unit === "count") return number(value) + return `${number(value)} ${unit}` +} + +export const formatWindow = (window: UsageWindowLike, labels: UsageLabels = english) => { + if (window.state === "unlimited") return labels.unlimited + if (window.state === "not_in_plan") return labels.notInPlan + if (window.state === "unknown") return labels.unknown + if (window.orientation === "used_percent" && window.used !== undefined) return labels.used(`${number(window.used)}%`) + if (window.orientation === "remaining_percent" && window.remaining !== undefined) + return labels.remaining(`${number(window.remaining)}%`) + if (window.remaining !== undefined && window.limit !== undefined) + return labels.remainingOf(amount(window.remaining, window.unit), amount(window.limit, window.unit)) + if (window.used !== undefined && window.limit !== undefined) + return labels.usedOf(amount(window.used, window.unit), amount(window.limit, window.unit)) + return window.state === "exhausted" ? labels.exhausted : labels.unknown +} + +export const windowProgress = (window: UsageWindowLike) => { + if (window.limit === undefined || window.limit <= 0) return undefined + if (window.used !== undefined) return Math.min(100, Math.max(0, (window.used / window.limit) * 100)) + if (window.remaining !== undefined) return Math.min(100, Math.max(0, 100 - (window.remaining / window.limit) * 100)) + return undefined +} diff --git a/packages/kilo-gateway/test/api/kilo-pass.test.ts b/packages/kilo-gateway/test/api/kilo-pass.test.ts index 49cf45c9601..fa7791b6472 100644 --- a/packages/kilo-gateway/test/api/kilo-pass.test.ts +++ b/packages/kilo-gateway/test/api/kilo-pass.test.ts @@ -61,6 +61,30 @@ describe("parseKiloPassState", () => { expect(parseKiloPassState({ status: "none" })).toBeNull() }) + test("hides canceled and expired subscriptions that still report period credits", () => { + const payload = (status: string) => [ + { + result: { + data: { + subscription: { + tier: "tier_19", + status, + cancelAtPeriodEnd: true, + currentPeriodBaseCreditsUsd: 19, + currentPeriodUsageUsd: 0, + currentPeriodBonusCreditsUsd: null, + nextBillingAt: null, + }, + }, + }, + }, + ] + + expect(parseKiloPassState(payload("canceled"))).toBeNull() + expect(parseKiloPassState(payload("expired"))).toBeNull() + expect(parseKiloPassState(payload("past_due"))).toMatchObject({ currentPeriodBaseCreditsUsd: 19 }) + }) + test("silently ignores transport failures", async () => { const prev = global.fetch const warn = spyOn(console, "warn").mockImplementation(() => undefined) diff --git a/packages/kilo-gateway/test/api/trpc.test.ts b/packages/kilo-gateway/test/api/trpc.test.ts new file mode 100644 index 00000000000..a328e313573 --- /dev/null +++ b/packages/kilo-gateway/test/api/trpc.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { + CloudTrpcError, + fetchByokEntries, + fetchCodingPlanSubscriptions, + fetchCodingPlanUsage, +} from "../../src/api/trpc" + +const original = global.fetch + +const result = (data: unknown, status = 200) => + new Response(JSON.stringify({ result: { data: { json: data } } }), { + status, + headers: { "content-type": "application/json" }, + }) + +const subscription = { + id: "subscription", + planId: "minimax-token-plan-plus", + planName: "Token Plan Plus", + providerName: "MiniMax", + providerId: "minimax", + canQueryUsage: true, + hasInstalledByokKey: true, + status: "active", + cancelAtPeriodEnd: false, +} + +const quota = (id = "plan") => ({ + schemaVersion: 1, + fetchedAt: "2026-06-19T00:00:00.000Z", + subscription: { + id, + planName: "Token Plan Plus", + providerId: "minimax", + providerName: "MiniMax", + windows: [ + { + id: "short_term", + remainingPercent: 80, + resetsAt: "2026-06-19T05:00:00.000Z", + period: { unit: "hour", value: 5 }, + }, + { + id: "weekly", + remainingPercent: 150, + resetsAt: "2026-06-26T00:00:00.000Z", + period: { unit: "week", value: 1 }, + }, + ], + }, +}) + +afterEach(() => { + global.fetch = original +}) + +describe("Cloud tRPC client", () => { + test("uses unbatched GET queries without an organization header", async () => { + const fn = mock(() => + Promise.resolve( + result([ + { + ...subscription, + routeLabel: "MiniMax via Kilo Gateway", + billingPeriodDays: 30, + currentPeriodStart: "2026-06-01T00:00:00.000Z", + currentPeriodEnd: "2026-07-01T00:00:00.000Z", + creditRenewalAt: "2026-07-01T00:00:00.000Z", + paymentGraceExpiresAt: null, + canceledAt: null, + cancellationReason: null, + createdAt: "2026-06-01T00:00:00.000Z", + costKiloCredits: 20, + additive: "ignored", + }, + ]), + ), + ) + global.fetch = fn as unknown as typeof fetch + + const subscriptions = await fetchCodingPlanSubscriptions("secret-token") + + expect(subscriptions).toHaveLength(1) + expect(subscriptions[0]).not.toHaveProperty("additive") + const call = fn.mock.calls[0] as unknown as [string, RequestInit] + const url = new URL(call[0]) + expect(url.pathname).toBe("/api/trpc/codingPlans.listSubscriptions") + expect(url.searchParams.has("batch")).toBe(false) + expect(call[1].method).toBe("GET") + expect(new Headers(call[1].headers).get("authorization")).toBe("Bearer secret-token") + expect(new Headers(call[1].headers).has("x-kilocode-organizationid")).toBe(false) + expect(call[1].redirect).toBe("error") + expect(call[1].signal).toBeInstanceOf(AbortSignal) + }) + + test("encodes query input", async () => { + global.fetch = mock(() => Promise.resolve(result([]))) as unknown as typeof fetch + await fetchByokEntries("token") + const call = (global.fetch as unknown as { mock: { calls: Array<[string, RequestInit]> } }).mock.calls[0] + const url = new URL(call[0]) + expect(url.pathname).toBe("/api/trpc/byok.list") + expect(JSON.parse(url.searchParams.get("input") ?? "null")).toEqual({}) + }) + + test("validates every supported procedure projection", async () => { + const payloads: Record = { + "codingPlans.getUsage": { + ...quota(), + additive: "stripped", + subscription: { + ...quota().subscription, + windows: quota().subscription.windows.map((window, index) => + index === 0 ? { ...window, providerPrivate: "stripped" } : window, + ), + }, + }, + } + global.fetch = mock((input: string | URL | Request) => { + const procedure = new URL(String(input)).pathname.split("/").at(-1) ?? "" + return Promise.resolve(result(payloads[procedure])) + }) as unknown as typeof fetch + + const usage = await fetchCodingPlanUsage("token", "plan") + expect(usage).toEqual(quota()) + const call = (global.fetch as unknown as { mock: { calls: Array<[string]> } }).mock.calls[0] + expect(JSON.parse(new URL(call[0]).searchParams.get("input") ?? "null")).toEqual({ subscriptionId: "plan" }) + }) + + test("decodes procedure errors even when HTTP is successful", async () => { + global.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ error: { json: { message: "raw private error" } } }), { status: 200 }), + ), + ) as unknown as typeof fetch + + const error = await fetchCodingPlanSubscriptions("secret-token").catch((value) => value) + expect(error).toBeInstanceOf(CloudTrpcError) + expect(error).toMatchObject({ kind: "procedure", message: "Kilo Cloud data is temporarily unavailable." }) + // Include non-enumerable Error surfaces that JSON.stringify would omit. + const surface = `${error.name} ${error.message} ${error.stack} ${JSON.stringify(error)}` + expect(surface).not.toContain("raw private error") + expect(surface).not.toContain("secret-token") + }) + + test("tolerates an explicit null error field in successful envelopes", async () => { + global.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ result: { data: { json: [] } }, error: null }))), + ) as unknown as typeof fetch + + await expect(fetchCodingPlanSubscriptions("token")).resolves.toEqual([]) + }) + + test("maps malformed envelopes and schema failures safely", async () => { + global.fetch = mock(() => Promise.resolve(new Response("not-json"))) as unknown as typeof fetch + await expect(fetchCodingPlanSubscriptions("token")).rejects.toMatchObject({ kind: "protocol" }) + + global.fetch = mock(() => Promise.resolve(result({ status: "unknown" }))) as unknown as typeof fetch + await expect(fetchCodingPlanSubscriptions("token")).rejects.toMatchObject({ kind: "schema" }) + }) + + test.each([ + ["unknown version", { schemaVersion: 2 }], + ["mismatched subscription", quota("other")], + [ + "duplicate windows", + { + ...quota(), + subscription: { + ...quota().subscription, + windows: [quota().subscription.windows[1], quota().subscription.windows[1]], + }, + }, + ], + [ + "missing period", + { + ...quota(), + subscription: { + ...quota().subscription, + windows: [{ ...quota().subscription.windows[1], period: undefined }], + }, + }, + ], + ])("rejects %s usage payloads", async (_description, payload) => { + global.fetch = mock(() => Promise.resolve(result(payload))) as unknown as typeof fetch + + await expect(fetchCodingPlanUsage("token", "plan")).rejects.toMatchObject({ kind: "schema" }) + }) +}) diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 018f3863f5a..667243f41e8 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -32,6 +32,7 @@ "./app-icon": "./src/components/app-icon.tsx", "./markdown": "./src/components/markdown.tsx", "./keybind": "./src/components/keybind.tsx", + "./kilo-pass-meter": "./src/components/kilo-pass-meter.tsx", "./popover": "./src/components/popover.tsx", "./hover-card": "./src/components/hover-card.tsx", "./dropdown-menu": "./src/components/dropdown-menu.tsx", diff --git a/packages/kilo-ui/src/components/card.css b/packages/kilo-ui/src/components/card.css index 360d1028608..13df2bc4a5f 100644 --- a/packages/kilo-ui/src/components/card.css +++ b/packages/kilo-ui/src/components/card.css @@ -5,6 +5,14 @@ padding: 8px; border: 1px solid var(--border-weak-base); + [data-slot="card-header"] { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px 12px; + } + &[data-variant="error"] { padding: 12px; background-color: color-mix(in srgb, var(--surface-critical-strong) 10%, var(--surface-inset-base)); diff --git a/packages/kilo-ui/src/components/card.tsx b/packages/kilo-ui/src/components/card.tsx index e023f2946d2..b9a64b1d961 100644 --- a/packages/kilo-ui/src/components/card.tsx +++ b/packages/kilo-ui/src/components/card.tsx @@ -1 +1,12 @@ +import { type ComponentProps, splitProps } from "solid-js" + export * from "@opencode-ai/ui/card" + +export function CardHeader(props: ComponentProps<"div">) { + const [local, rest] = splitProps(props, ["children", "class", "classList"]) + return ( +
+ {local.children} +
+ ) +} diff --git a/packages/kilo-ui/src/components/kilo-pass-meter.css b/packages/kilo-ui/src/components/kilo-pass-meter.css new file mode 100644 index 00000000000..6203997e8a4 --- /dev/null +++ b/packages/kilo-ui/src/components/kilo-pass-meter.css @@ -0,0 +1,130 @@ +[data-component="kilo-pass-meter"] { + display: flex; + flex-direction: column; + gap: 5px; + + [data-slot="kilo-pass-meter-header"] { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + color: var(--text-base); + font-size: var(--font-size-small); + } + + [data-slot="kilo-pass-meter-header"] strong { + text-align: right; + font-weight: var(--font-weight-medium); + font-variant-numeric: tabular-nums; + } + + [data-slot="kilo-pass-meter-track"] { + position: relative; + height: 8px; + overflow: visible; + border-radius: 4px; + background: var(--border-weak-base); + } + + :is( + [data-slot="kilo-pass-meter-paid-background"], + [data-slot="kilo-pass-meter-bonus-background"], + [data-slot="kilo-pass-meter-paid-fill"], + [data-slot="kilo-pass-meter-bonus-fill"] + ) { + position: absolute; + inset-block: 0; + } + + [data-slot="kilo-pass-meter-paid-background"] { + left: 0; + background: color-mix(in srgb, var(--icon-warning-base) 18%, transparent); + } + + [data-slot="kilo-pass-meter-bonus-background"] { + background: color-mix(in srgb, var(--icon-success-base) 18%, transparent); + } + + [data-slot="kilo-pass-meter-paid-fill"] { + left: 0; + border-radius: 4px 0 0 4px; + background: var(--icon-warning-base); + } + + [data-slot="kilo-pass-meter-bonus-fill"] { + border-radius: 0 4px 4px 0; + background: var(--icon-success-base); + } + + [data-slot="kilo-pass-meter-boundary"] { + position: absolute; + top: 100%; + width: 2px; + height: 5px; + margin-left: -1px; + background: var(--text-weak); + } + + [data-slot="kilo-pass-meter-amounts"] { + position: relative; + height: 15px; + color: var(--icon-warning-base); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + font-variant-numeric: tabular-nums; + } + + [data-slot="kilo-pass-meter-amounts"] > span { + position: absolute; + transform: translateX(-50%); + } + + /* Boundary labels at the track edges stay inside the card. */ + [data-slot="kilo-pass-meter-amounts"] > span[data-pin="end"] { + right: 0; + left: auto !important; + transform: none; + } + + [data-slot="kilo-pass-meter-amounts"] > span[data-pin="start"] { + left: 0 !important; + transform: none; + } + + [data-slot="kilo-pass-meter-bonus-amount"] { + right: 0; + left: auto !important; + color: var(--icon-success-base); + transform: none !important; + } + + [data-slot="kilo-pass-meter-legend"], + [data-slot="kilo-pass-meter-legend"] > span { + display: flex; + align-items: center; + } + + [data-slot="kilo-pass-meter-legend"] { + justify-content: space-between; + color: var(--text-weak); + font-size: var(--font-size-small); + } + + [data-slot="kilo-pass-meter-legend"] > span { + gap: 6px; + } + + [data-slot="kilo-pass-meter-legend"] i { + width: 8px; + height: 8px; + border-radius: 50%; + } + + [data-slot="kilo-pass-meter-paid-dot"] { + background: var(--icon-warning-base); + } + + [data-slot="kilo-pass-meter-bonus-dot"] { + background: var(--icon-success-base); + } +} diff --git a/packages/kilo-ui/src/components/kilo-pass-meter.tsx b/packages/kilo-ui/src/components/kilo-pass-meter.tsx new file mode 100644 index 00000000000..62a18a5ac52 --- /dev/null +++ b/packages/kilo-ui/src/components/kilo-pass-meter.tsx @@ -0,0 +1,105 @@ +import { type ComponentProps, type JSX, splitProps } from "solid-js" + +export interface KiloPassMeterProps extends Omit, "children"> { + used: number + paid: number + bonus: number + label: JSX.Element + paidLabel: JSX.Element + bonusLabel: JSX.Element + format: (value: number) => string +} + +export function KiloPassMeter(props: KiloPassMeterProps) { + const [local, rest] = splitProps(props, [ + "used", + "paid", + "bonus", + "label", + "paidLabel", + "bonusLabel", + "format", + "class", + "classList", + ]) + const model = () => { + const paid = Math.max(0, local.paid) + const bonus = Math.max(0, local.bonus) + const used = Math.max(0, local.used) + const total = paid + bonus + // With no credits at all the track stays empty instead of rendering a + // full-width paid allocation for a $0 pass. + const boundary = total > 0 ? (paid / total) * 100 : 0 + const filled = total > 0 ? Math.min(100, (used / total) * 100) : 0 + return { + paid, + bonus, + used, + total, + boundary, + paidFill: Math.min(filled, boundary), + bonusFill: Math.max(0, filled - boundary), + } + } + + return ( +
+
+ {local.label} + + {local.format(model().used)} / {local.format(model().total)} + +
+