diff --git a/.env.example b/.env.example index 21c798c55e..83075c4d01 100644 --- a/.env.example +++ b/.env.example @@ -2892,6 +2892,14 @@ QUOTA_STORE_DRIVER=sqlite # PROMPTQL_TOKEN_REFRESH_URL=https://auth.pro.ql.app/ddn/project/token # PROMPTQL_POLL_TIMEOUT_MS=180000 +# ───────────────────────────────────────────────────────────────────────────── +# Kilo Code usage quotas (src/shared/constants/providers/kilocode.ts) +# Personal USD balance and Kilo Pass usage lookup. Optional — the default +# points at the public Kilo API; override only for a relay/test fixture. +# Authentication uses the connection's existing OAuth access token. +# Used by: open-sse/services/usage/kilocode.ts +# ───────────────────────────────────────────────────────────────────────────── +# KILO_API_URL=https://api.kilo.ai # ───────────────────────────────────────────────────────────────────────────── # HyperAgent web provider (Unofficial/Experimental — src/shared/constants/providers/web-cookie.ts) # Reverse-engineered session bridge for hyperagent.com. Optional — defaults diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 4847137a66..f871e16a60 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -975,6 +975,16 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov --- +## Kilo Code Usage Quotas + +Personal USD balance and Kilo Pass usage lookup for the Kilo Code provider. Optional — the default points at the public Kilo API; override only for a relay/test fixture. Authentication uses the connection's existing OAuth access token. + +| Variable | Default | Source File | Description | +| ---------------- | ---------------------- | ----------------------------------------- | ------------------------------------------------------- | +| `KILO_API_URL` | `https://api.kilo.ai` | `open-sse/services/usage/kilocode.ts` | Base URL used to fetch personal Kilo Code balance and Kilo Pass usage. | + +--- + ## Adobe Firefly Web Provider (Unofficial/Experimental) Browser-driven session refresh for the Adobe Firefly web provider diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index c78e57f2ff..b3f03afc20 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -73,6 +73,7 @@ import { getCommandCodeUsage } from "./usage/command-code.ts"; import { getQwenTokenPlanUsage } from "./usage/qwen-token-plan.ts"; import { getConolUsage } from "./conolUsage.ts"; import { getAgentrouterUsage } from "./usage/agentrouter.ts"; +import { getKilocodeUsage } from "./usage/kilocode.ts"; type JsonRecord = Record; type UsageProviderConnection = JsonRecord & { @@ -205,6 +206,8 @@ export async function getUsageForProvider( return await getConolUsage(apiKey || accessToken, providerSpecificData); case "agentrouter": return await getAgentrouterUsage(id, connection); + case "kilocode": + return await getKilocodeUsage(id, connection); default: return { message: `Usage API not implemented for ${provider}` }; } @@ -244,4 +247,5 @@ export const __testing = { mapSubscriptionTierStringToPlanLabel, toDisplayLabel, getKiroUsage, + getKilocodeUsage, }; diff --git a/open-sse/services/usage/fetcherProviders.ts b/open-sse/services/usage/fetcherProviders.ts index 1ddfd74cc2..d299748933 100644 --- a/open-sse/services/usage/fetcherProviders.ts +++ b/open-sse/services/usage/fetcherProviders.ts @@ -73,6 +73,7 @@ export const USAGE_FETCHER_PROVIDERS = [ "cnl", // AgentRouter (New-API) console balance (GET /api/user/self) "agentrouter", + "kilocode", ] as const; export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number]; diff --git a/open-sse/services/usage/kilocode.ts b/open-sse/services/usage/kilocode.ts new file mode 100644 index 0000000000..a734e95dba --- /dev/null +++ b/open-sse/services/usage/kilocode.ts @@ -0,0 +1,334 @@ +/** + * usage/kilocode.ts — Kilo Code balance + Kilo Pass usage fetcher (Provider Limits). + * + * Two independent upstream requests per usage fetch, both authenticated with the + * existing kilocode OAuth access token (personal scope; no organization support): + * - GET {KILO_API_URL|https://api.kilo.ai}/api/profile/balance → personal USD balance + * - GET {KILO_API_URL|https://api.kilo.ai}/api/trpc/kiloPass.getState?batch=1&input={"0":null} + * → Kilo Pass subscription state (official tRPC endpoint, Kilo-Org/kilocode contract) + * + * The two requests fail independently: a Kilo Pass error never hides the personal + * balance and vice versa. Only when both are unavailable does the dashboard fall + * back to the existing { message } convention. + */ +import type { UsageQuota } from "./quota.ts"; +import { parseResetTime } from "./quota.ts"; +import { toRecord, toNumber, roundCurrency } from "./scalars.ts"; + +/** Upstream API base. Environment override mirrors sibling fetchers. */ +const KILO_API_BASE: string = process.env.KILO_API_URL || "https://api.kilo.ai"; +const BALANCE_PATH = "/api/profile/balance"; +const BALANCE_URL = `${KILO_API_BASE}${BALANCE_PATH}`; +const PASS_PATH = "/api/trpc/kiloPass.getState"; + +const KILO_EDITOR_NAME = "OmniRoute"; +const FETCH_TIMEOUT_MS = 8_000; + +/** Fallback token for Kilo's anonymous freetier (registry anonymousApiKey). + * Balance/pass endpoints require authenticated accounts, value rejected + * before any request made. */ +const KILO_ANONYMOUS_TOKEN = "anonymous"; + +/** Live subscription statuses that represent an active Kilo Pass, per the + * official Kilo-Org/kilocode parseKiloPassState contract. The cloud returns + * full records after cancellation too; only these statuses consume credits. */ +const KILO_PASS_LIVE_STATUSES = new Set(["active", "past_due", "trialing"]); + +/** Kilo Pass subscription state (mirrors official Kilo-Org/kilocode KiloPassState). */ +export interface KiloPassState { + currentPeriodBaseCreditsUsd: number; + currentPeriodUsageUsd: number; + currentPeriodBonusCreditsUsd: number; + nextBillingAt: string | null; +} + +function readAccessToken(connection: Record): string | null { + const value = connection["accessToken"]; + if (typeof value === "string" && value.trim().length > 0) return value; + return null; +} + +function isAnonymousToken(token: string): boolean { + return token.trim() === KILO_ANONYMOUS_TOKEN; +} + +function kiloHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + "X-KILOCODE-EDITORNAME": KILO_EDITOR_NAME, + "Content-Type": "application/json", + Accept: "application/json", + }; +} + +/** Extract non-negative USD balance from upstream JSON body. Returns null + * when value missing, null, negative, not numeric. */ +export function parseKilocodeBalance(data: unknown): number | null { + const obj = toRecord(data); + if (obj.balance === undefined || obj.balance === null) return null; + const balance = toNumber(obj.balance, Number.NaN); + if (!Number.isFinite(balance) || balance < 0) return null; + return roundCurrency(balance); +} + +/** Coerce a USD credit amount the way the official client does: finite + * non-negative numbers pass through, everything else becomes 0. */ +function passUsd(value: unknown): number { + const num = toNumber(value, 0); + return Number.isFinite(num) && num >= 0 ? num : 0; +} + +/** + * Parse Kilo Pass state from the tRPC response, mirroring the official + * Kilo-Org/kilocode parseKiloPassState semantics exactly: + * - batched tRPC shape: [{ result: { data: { json: { subscription } } } }] + * - unbatched result.data.json: { result: { data: { json: { subscription } } } } + * - plain result.data (no superjson json wrapper): { result: { data: { subscription } } } + * - plain fallback: { subscription } + * - requires at least one period amount present (base or usage) + * - status, when present as string, must be a live status + * - negative/non-finite amounts clamp to 0; invalid dates become null + * + * Returns null when no live pass data is present (no pass, canceled, expired, + * missing fields, malformed tRPC envelope). + */ +export function parseKiloPassState(value: unknown): KiloPassState | null { + const item = Array.isArray(value) ? value[0] : value; + const data = toRecord(toRecord(toRecord(item)?.result)?.data); + // Official Kilo-Org/kilocode fallback chain: data.json envelope first, + // then the tRPC data object itself (plain-JSON responses carry the + // subscription there without a superjson json wrapper), then raw payload. + const jsonValue = data?.json; + const root = + jsonValue !== null && typeof jsonValue === "object" && !Array.isArray(jsonValue) + ? toRecord(jsonValue) + : Object.keys(data).length > 0 + ? data + : toRecord(value); + const sub = toRecord(root?.subscription); + + if (!sub || (sub.currentPeriodBaseCreditsUsd == null && sub.currentPeriodUsageUsd == null)) { + return null; + } + if (typeof sub.status === "string" && !KILO_PASS_LIVE_STATUSES.has(sub.status)) { + return null; + } + + const next = sub.nextBillingAt ?? sub.nextRenewalAt; + + return { + currentPeriodBaseCreditsUsd: passUsd(sub.currentPeriodBaseCreditsUsd), + currentPeriodUsageUsd: passUsd(sub.currentPeriodUsageUsd), + currentPeriodBonusCreditsUsd: passUsd(sub.currentPeriodBonusCreditsUsd), + // Normalize to the ISO format OmniRoute expects; invalid dates must not + // break the whole fetch (parseResetTime returns null instead). + nextBillingAt: parseResetTime(typeof next === "string" ? next : null), + }; +} + +/** + * Fetch Kilo Pass state. Returns null on any failure (HTTP error, network, + * timeout, malformed body, no live pass) — matches the official client's + * silent-degradation contract. Never throws, never logs tokens/bodies. + */ +export async function fetchKiloPassState(token: string): Promise { + try { + const params = new URLSearchParams({ + batch: "1", + input: JSON.stringify({ "0": null }), + }); + const response = await fetch(`${KILO_API_BASE}${PASS_PATH}?${params}`, { + method: "GET", + headers: kiloHeaders(token), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) return null; + return parseKiloPassState(await response.json()); + } catch { + return null; + } +} + +/** Build normalized usage response from successful balance fetch. */ +export function buildKilocodeUsageResult(balance: number): { + plan: string; + quotas: Record; +} { + const balanceQuota: UsageQuota = { + used: 0, + total: 0, + remaining: balance, + remainingPercentage: balance > 0 ? 100 : 0, + resetAt: null, + unlimited: true, + currency: "USD", + displayName: "Balance (USD)", + }; + + return { + plan: "Kilo Code", + quotas: { balance: balanceQuota }, + }; +} + +/** + * Build Kilo Pass quota entries. Remaining pass credits follow the official + * Kilo Pass meter semantics (total pool = base + bonus, used consumed from it): + * remaining = max(0, base + bonus - usage). resetAt carries nextBillingAt on + * the period-defining Base Credits row. + */ +export function buildKiloPassUsageResult(pass: KiloPassState): { + plan: string; + quotas: Record; +} { + const base = pass.currentPeriodBaseCreditsUsd; + const bonus = pass.currentPeriodBonusCreditsUsd; + const usage = pass.currentPeriodUsageUsd; + const remaining = Math.max(0, roundCurrency(base + bonus - usage)); + + const quotas: Record = { + kiloPassBase: { + used: 0, + total: base, + remaining: base, + remainingPercentage: base > 0 ? 100 : 0, + resetAt: pass.nextBillingAt, + unlimited: false, + currency: "USD", + displayName: "Base Credits", + }, + kiloPassBonus: { + used: 0, + total: bonus, + remaining: bonus, + remainingPercentage: bonus > 0 ? 100 : 0, + resetAt: null, + unlimited: false, + currency: "USD", + displayName: "Bonus Credits", + }, + kiloPassUsage: { + used: usage, + total: base + bonus, + remaining, + remainingPercentage: base + bonus > 0 ? Math.max(0, (remaining / (base + bonus)) * 100) : 0, + resetAt: pass.nextBillingAt, + unlimited: false, + currency: "USD", + displayName: "Kilo Pass Usage", + }, + kiloPassRemaining: { + used: 0, + total: 0, + remaining, + remainingPercentage: remaining > 0 ? 100 : 0, + resetAt: pass.nextBillingAt, + unlimited: false, + currency: "USD", + displayName: "Pass Remaining", + }, + }; + + return { + plan: "Kilo Code", + quotas, + }; +} + +/** + * Fetch balance from upstream API. Throws with the historical per-status + * messages so getKilocodeUsage can surface the same diagnostics as before + * when the pass request fails alongside it. + */ +async function fetchBalance(token: string): Promise { + let response: Response; + try { + response = await fetch(BALANCE_URL, { + method: "GET", + headers: kiloHeaders(token), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + } catch (error) { + throw new Error(`Kilo Code balance error: ${(error as Error).message}`); + } + + if (response.status === 401 || response.status === 403) { + throw new Error("Kilo Code token expired access denied. Please re-authenticate connection."); + } + if (response.status === 429) { + throw new Error("Kilo Code balance request rate limited. Try again later."); + } + if (!response.ok) { + throw new Error(`Kilo Code balance request failed with HTTP ${response.status}.`); + } + + let data: unknown; + try { + data = await response.json(); + } catch (error) { + throw new Error(`Kilo Code balance error: ${(error as Error).message}`); + } + + const balance = parseKilocodeBalance(data); + if (balance === null) { + throw new Error("Kilo Code balance response invalid missing balance value."); + } + return balance; +} + +/** Fetch and normalize Kilo Code balance + Kilo Pass usage for connection. */ +export async function getKilocodeUsage( + _connectionId: string | undefined, + connection?: Record +): Promise< + { plan: string; quotas: Record } | { plan: string; message: string } +> { + const token = connection ? readAccessToken(connection) : null; + if (connection?.["apiKey"] !== undefined && !token) { + return { + plan: "Kilo Code", + message: "Kilo Code balance uses Kilo Code OAuth account; separate API key not supported.", + }; + } + if (!token) { + return { + plan: "Kilo Code", + message: "Kilo Code balance not available. Add Kilo Code account view usage.", + }; + } + if (isAnonymousToken(token)) { + return { + plan: "Kilo Code", + message: + "Kilo Code balance only available authenticated accounts. Free anonymous usage balance.", + }; + } + + // Both requests share one usage fetch but fail independently. + const [balanceSettled, passSettled] = await Promise.allSettled([ + fetchBalance(token), + fetchKiloPassState(token), + ]); + + const balance = balanceSettled.status === "fulfilled" ? balanceSettled.value : null; + const pass = passSettled.status === "fulfilled" ? passSettled.value : null; + + if (balance !== null && pass !== null) { + return { + plan: "Kilo Code", + quotas: { + ...buildKilocodeUsageResult(balance).quotas, + ...buildKiloPassUsageResult(pass).quotas, + }, + }; + } + if (balance !== null) return buildKilocodeUsageResult(balance); + if (pass !== null) return buildKiloPassUsageResult(pass); + + const balanceError = + balanceSettled.status === "rejected" ? (balanceSettled.reason as Error).message : null; + return { + plan: "Kilo Code", + message: balanceError ?? "Kilo Code usage unavailable. Try again later.", + }; +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/KiloPassMeter.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/KiloPassMeter.tsx new file mode 100644 index 0000000000..9d519bd1d8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/KiloPassMeter.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useMemo } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { getBarColor } from "../utils"; +import { translateUsageOrFallback } from "../i18nFallback"; + +interface KiloPassMeterProps { + base: number; + bonus: number; + used: number; + total: number; + remaining: number; + nextBillingAt?: string | null; + balance?: number | null; +} + +function formatCurrency(value: number, currency: string = "USD"): string { + return value.toLocaleString(undefined, { + style: "currency", + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +function calculateDaysUntil(dateString: string | null | undefined): number | null { + if (!dateString) return null; + try { + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return null; + const now = Date.now(); + const diff = date.getTime() - now; + if (diff <= 0) return null; + return Math.ceil(diff / (1000 * 60 * 60 * 24)); + } catch { + return null; + } +} + +type KiloPassMeterValues = Pick< + KiloPassMeterProps, + "base" | "bonus" | "used" | "total" | "remaining" +>; + +function nonNegativeNumber(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function percentage(value: number, total: number): number { + return total > 0 ? Math.min(100, (value / total) * 100) : 0; +} + +export function buildKiloPassMeterModel({ + base, + bonus, + used, + total, + remaining, +}: KiloPassMeterValues) { + const paid = nonNegativeNumber(base); + const bonusAmount = nonNegativeNumber(bonus); + const usedAmount = nonNegativeNumber(used); + const totalAmount = nonNegativeNumber(total); + const remainingAmount = nonNegativeNumber(remaining); + const paidUsed = Math.min(usedAmount, paid); + const bonusUsed = Math.min(Math.max(usedAmount - paid, 0), bonusAmount); + + return { + paid, + bonus: bonusAmount, + used: usedAmount, + total: totalAmount, + remaining: remainingAmount, + progressValue: Math.min(totalAmount, usedAmount), + paidPercent: percentage(paid, totalAmount), + bonusPercent: percentage(bonusAmount, totalAmount), + paidUsedPercent: percentage(paidUsed, paid), + bonusUsedPercent: percentage(bonusUsed, bonusAmount), + hasPaidSegment: paid > 0, + hasBonusSegment: bonusAmount > 0, + }; +} + +export default function KiloPassMeter({ + base, + bonus, + used, + total, + remaining, + nextBillingAt, + balance, +}: KiloPassMeterProps) { + const t = useTranslations("usage"); + const locale = useLocale(); + + const model = useMemo( + () => buildKiloPassMeterModel({ base, bonus, used, total, remaining }), + [base, bonus, used, total, remaining] + ); + + const colors = getBarColor( + model.total > 0 ? 100 - (model.progressValue / model.total) * 100 : 100 + ); + const daysUntilRenewal = calculateDaysUntil(nextBillingAt); + const renewalDate = nextBillingAt + ? new Date(nextBillingAt).toLocaleDateString(locale, { + month: "short", + day: "numeric", + year: "numeric", + }) + : null; + + return ( +
+ {/* Header: Usage / Total */} +
+ + {translateUsageOrFallback(t, "kiloPassUsageLabel", "This month's usage")} + + + {formatCurrency(model.used)} / {formatCurrency(model.total)} + +
+ +
+
+ {model.hasPaidSegment && ( + + +
+
+ + {translateUsageOrFallback(t, "kiloPassRemaining", "Remaining")} + {formatCurrency(model.remaining)} +
+
+ + {/* Renewal info */} + {daysUntilRenewal !== null && renewalDate && ( +
+ + + {translateUsageOrFallback(t, "kiloPassRenews", "Renews in {count} days", { + count: daysUntilRenewal, + })} + + ({renewalDate}) +
+ )} + + {/* Account Balance (separate from Kilo Pass) */} + {balance !== null && balance !== undefined && ( +
+
+ + + {translateUsageOrFallback(t, "kiloAccountBalance", "Account Balance")} + +
+ + {formatCurrency(balance)} + +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardBody.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardBody.tsx index 17fc516cf6..2ff15773da 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardBody.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardBody.tsx @@ -3,6 +3,8 @@ import { useTranslations } from "next-intl"; import { formatQuotaLabel, getBarColor, getQuotaRemainingPercentage, topQuotas } from "../utils"; import QuotaMiniBar from "../QuotaMiniBar"; +import KiloPassMeter from "./KiloPassMeter"; +import { isKiloPassDisplayRow } from "../quotaParsing"; import { translateUsageOrFallback } from "../i18nFallback"; const CURRENCY_SYMBOLS: Record = { @@ -23,6 +25,8 @@ interface Props { loading: boolean; error: string | null; message: string | null; + /** Collapsed Kilo Pass display row for dedicated meter rendering (kilocode only). */ + kiloPassRow?: any | null; } const MAX_VISIBLE_DEFAULT = 3; @@ -88,6 +92,7 @@ export default function QuotaCardBody({ loading, error, message, + kiloPassRow = null, }: Props) { const t = useTranslations("usage"); @@ -128,9 +133,22 @@ export default function QuotaCardBody({ return (
- {visible.map((q, i) => ( - - ))} + {visible.map((q, i) => + isKiloPassDisplayRow(q) ? ( + + ) : ( + + ) + )} {hidden > 0 && (
+{hidden} more
)} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx index f248ac5f1b..8dcc5c3397 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx @@ -19,7 +19,14 @@ import { } from "../utils"; import QuotaMiniBar from "../QuotaMiniBar"; import { translateUsageOrFallback, type UsageTranslationValues } from "../i18nFallback"; -import { hasFixedQuotaOrder, hasCanonicalWindowOrder, sortQuotasByWindow } from "../quotaParsing"; +import { + findKiloPassQuotaRow, + isKiloPassDisplayRow, + hasFixedQuotaOrder, + hasCanonicalWindowOrder, + sortQuotasByWindow, +} from "../quotaParsing"; +import KiloPassMeter from "./KiloPassMeter"; const CURRENCY_SYMBOLS: Record = { USD: "$", @@ -153,17 +160,34 @@ interface Props { function QuotaDetailRow({ q, + kiloPassRow = null, onHideQuota, onOpenResetCredits, loadingResetCredits = false, }: { q: any; + /** Collapsed Kilo Pass display row; present only on kilocode provider cards. */ + kiloPassRow?: any | null; onHideQuota?: (quota: any) => void; onOpenResetCredits?: () => void; loadingResetCredits?: boolean; }) { const t = useTranslations("usage"); const canHide = typeof onHideQuota === "function" && !q.isCredits && !q.isResetCredits; + if (isKiloPassDisplayRow(q)) { + return ( + + ); + } + if (q.isResetCredits) { const count = Number(q.creditCount ?? q.remaining ?? 0); const colors = getBarColor(q.remainingPercentage ?? 100); @@ -332,6 +356,10 @@ export default function QuotaCardExpanded({ ); const hiddenCount = sortedQuotas.length - visibleQuotas.length; + // Only Kilo Code cards host the dedicated Kilo Pass meter; the collapsed display row carries + // the derived meter values while balance/renewal metadata render below it. + const kiloPassRow = providerId === "kilocode" ? findKiloPassQuotaRow(sortedQuotas) : null; + const refreshedLabel = refreshedAt ? new Date(refreshedAt).toLocaleTimeString([], { hour: "2-digit", @@ -367,6 +395,7 @@ export default function QuotaCardExpanded({ parseAgentrouterQuota(quotaKey, quota)); } +/** + * Kilo Code quota parser. Personal balance keeps the credits-style USD row; the four raw Kilo Pass + * quota keys (kiloPassBase/kiloPassBonus/kiloPassUsage/kiloPassRemaining) are collapsed into one + * display row that carries the real meter semantics: used = currentPeriodUsageUsd, total = base + + * bonus, remaining = max(0, total - used). The collapsed row feeds the dedicated KiloPassMeter + * component; the raw technical keys must never surface as individual rows because the generic + * credits renderer would display creditCount (= remaining) for the usage entry, making "Usage" + * read identical to "Remaining". + */ +const KILO_PASS_DISPLAY_ROW = "kiloPass"; + +function kiloNumber(value: any): number { + const num = Number(value); + return Number.isFinite(num) && num > 0 ? num : 0; +} + +function roundKiloCurrency(value: number): number { + return Math.round(value * 100) / 100; +} + +/** Display-only reset timestamp; invalid input yields null instead of a broken countdown. */ +function formatKiloResetDate(resetAt: any): string | null { + if (typeof resetAt !== "string" || !resetAt.trim()) return null; + const parsed = Date.parse(resetAt); + if (!Number.isFinite(parsed) || parsed <= 0) return null; + return new Date(parsed).toISOString(); +} + +function parseKilocode(data: any) { + const rows: any[] = []; + let base = 0; + let bonus = 0; + let usage = 0; + let passResetAt: any = null; + let balanceRemaining: number | null = null; + + for (const [quotaKey, quota] of quotaEntries(data)) { + if (quotaKey === "kiloPassBase") { + base = kiloNumber(quota?.total ?? quota?.remaining); + passResetAt = passResetAt ?? quota?.resetAt ?? null; + continue; + } + if (quotaKey === "kiloPassBonus") { + bonus = kiloNumber(quota?.total ?? quota?.remaining); + continue; + } + if (quotaKey === "kiloPassUsage") { + usage = kiloNumber(quota?.used); + passResetAt = passResetAt ?? quota?.resetAt ?? null; + continue; + } + if (quotaKey === "kiloPassRemaining") { + // Derived value (base + bonus - usage); wire-format only. + continue; + } + if (quotaKey === "balance") { + const remaining = kiloNumber(quota?.remaining); + balanceRemaining = remaining; + const remainingPercentage = + safePercentage(quota?.remainingPercentage) ?? (remaining > 0 ? 100 : 0); + rows.push( + buildCreditsQuota("balance", remaining, remainingPercentage, { + currency: quota?.currency || "USD", + displayName: quota?.displayName, + resetAt: null, + unlimited: true, + }) + ); + continue; + } + rows.push(normalizeQuotaEntry(quotaKey, quota)); + } + + const total = roundKiloCurrency(base + bonus); + if (total > 0 || usage > 0) { + const remaining = Math.max(0, roundKiloCurrency(total - usage)); + rows.push({ + name: KILO_PASS_DISPLAY_ROW, + displayName: "Kilo Pass", + kiloPass: true, + kiloPassBase: base, + kiloPassBonus: bonus, + ...(balanceRemaining !== null ? { kiloPassBalance: balanceRemaining } : {}), + used: usage, + total, + remaining, + remainingPercentage: total > 0 ? Math.max(0, (remaining / total) * 100) : 0, + resetAt: formatKiloResetDate(passResetAt), + unlimited: false, + currency: "USD", + }); + } + + return rows; +} + +/** Finds the collapsed Kilo Pass display row within parsed quota rows, if present. */ +export function findKiloPassQuotaRow(quotas: any[] | undefined | null): any | null { + if (!Array.isArray(quotas)) return null; + return quotas.find((quota) => quota?.kiloPass === true) ?? null; +} + +export function isKiloPassDisplayRow(quota: any): boolean { + return quota?.kiloPass === true || quota?.name === KILO_PASS_DISPLAY_ROW; +} + function parseProviderQuotas(providerId: string, data: any) { if (providerId === "github") return parseGithub(data); if (["glm", "glm-cn", "glmt", "opencode-go"].includes(providerId)) return parseGlmFamily(data); @@ -314,6 +420,7 @@ function parseProviderQuotas(providerId: string, data: any) { if (providerId === "codex") return parseCodex(data); if (providerId === "claude") return parseClaude(data); if (providerId === "deepseek") return parseDeepseek(data); + if (providerId === "kilocode") return parseKilocode(data); if (providerId === "agentrouter") return parseAgentrouter(data); return parseGeneric(data); } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index f6845db460..f9655d36bd 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -9230,6 +9230,13 @@ "kimiExtraUsageFrozen": "Frozen", "kimiExtraUsageUnavailable": "Unavailable", "kimiMonthlyUsed": "Used this month", + "kiloAccountBalance": "Kontoguthaben", + "kiloPassBonus": "Verfügbarer Bonus", + "kiloPassMeterLabel": "Kilo-Pass-Nutzungsanzeige", + "kiloPassPaid": "Bezahlt", + "kiloPassRemaining": "Verbleibend", + "kiloPassRenews": "Erneuert sich in {count} Tagen", + "kiloPassUsageLabel": "Nutzung diesen Monat", "kimiMonthlyLimit": "Monthly limit", "kimiMonthlyLimitUnlimited": "Unlimited", "kimiAdditionalCredits": "Additional Credits", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 21c687067e..d509052e4b 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -9219,6 +9219,13 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "month", "grokAdditionalCredits": "Additional Credits", + "kiloAccountBalance": "Account Balance", + "kiloPassBonus": "Available bonus", + "kiloPassMeterLabel": "Kilo Pass usage meter", + "kiloPassPaid": "Paid", + "kiloPassRemaining": "Remaining", + "kiloPassRenews": "Renews {count} days", + "kiloPassUsageLabel": "This month's usage", "kimiExtraUsageCredits": "Extra Usage Credits", "kimiExtraUsage": "Extra Usage", "kimiExtraUsageEnabled": "Enabled", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 7e312b87c3..cd37e15bf9 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -9220,6 +9220,13 @@ "grokAutoTopUpMax": "máximo", "grokAutoTopUpMonth": "mês", "grokAdditionalCredits": "Créditos adicionais", + "kiloAccountBalance": "__MISSING__:Account Balance", + "kiloPassBonus": "__MISSING__:Available bonus", + "kiloPassMeterLabel": "__MISSING__:Kilo Pass usage meter", + "kiloPassPaid": "__MISSING__:Paid", + "kiloPassRemaining": "__MISSING__:Remaining", + "kiloPassRenews": "__MISSING__:Renews {count} days", + "kiloPassUsageLabel": "__MISSING__:This month's usage", "kimiExtraUsageCredits": "Créditos de uso extra", "kimiExtraUsage": "Uso extra", "kimiExtraUsageEnabled": "Ativado", @@ -12957,6 +12964,18 @@ } } }, + "combo": { + "sort": { + "label": "Ordenar por", + "method": { + "manual": "Manual", + "provider": "Provedor", + "score": "Pontuação (modelos gratuitos)", + "name": "Nome" + }, + "scoreHint": "A ordenação por pontuação vale só para provedores gratuitos; os demais ficam onde estão." + } + }, "comboControl": { "title": "Central de Controle de Combo", "unavailable": "Central de Controle de Combo indisponível", @@ -13978,17 +13997,5 @@ "cta": "Obter uma chave de API", "partnerLinkNote": "Link de parceiro", "dismissAriaLabel": "Dispensar" - }, - "combo": { - "sort": { - "label": "Ordenar por", - "method": { - "manual": "Manual", - "provider": "Provedor", - "score": "Pontuação (modelos gratuitos)", - "name": "Nome" - }, - "scoreHint": "A ordenação por pontuação vale só para provedores gratuitos; os demais ficam onde estão." - } } } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index be5654950d..67f1f6867b 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -9220,6 +9220,13 @@ "grokAutoTopUpMax": "tối đa", "grokAutoTopUpMonth": "tháng", "grokAdditionalCredits": "Tín dụng bổ sung", + "kiloAccountBalance": "Số dư tài khoản", + "kiloPassBonus": "Bonus khả dụng", + "kiloPassMeterLabel": "Đồng hồ mức sử dụng Kilo Pass", + "kiloPassPaid": "Đã thanh toán", + "kiloPassRemaining": "Còn lại", + "kiloPassRenews": "Gia hạn sau {count} ngày", + "kiloPassUsageLabel": "Mức sử dụng tháng này", "kimiExtraUsageCredits": "Tín dụng sử dụng bổ sung", "kimiExtraUsage": "Sử dụng bổ sung", "kimiExtraUsageEnabled": "Đã bật", @@ -11286,16 +11293,16 @@ "detail": { "collapse": "Thu gọn {title}", "expand": "Mở rộng {title}", - "collapseAllLevels": "Thu gọn tất cả", - "collapseOneLevel": "Thu gọn một cấp", - "currentExpandLevel": "Cấp mở rộng hiện tại", - "expandOneLevel": "Mở rộng một cấp", - "expandAllLevels": "Mở rộng tất cả", "copyTitle": "Sao chép {title}", "copied": "Đã sao chép!", "copy": "Sao chép", "autoscrollOn": "Tự động cuộn: bật", "autoscrollOff": "Tự động cuộn: tắt", + "collapseAllLevels": "Thu gọn tất cả", + "collapseOneLevel": "Thu gọn một cấp", + "currentExpandLevel": "Cấp mở rộng hiện tại", + "expandOneLevel": "Mở rộng một cấp", + "expandAllLevels": "Mở rộng tất cả", "payload": { "clientRawRequest": "Yêu cầu thô của client", "clientRequest": "Yêu cầu client", @@ -12957,6 +12964,18 @@ } } }, + "combo": { + "sort": { + "label": "Sắp xếp theo", + "method": { + "manual": "Thủ công", + "provider": "Nhà cung cấp", + "score": "Điểm (mô hình miễn phí)", + "name": "Tên" + }, + "scoreHint": "Xếp hạng theo điểm chỉ áp dụng cho nhà cung cấp miễn phí; các nhà cung cấp khác giữ nguyên vị trí." + } + }, "comboControl": { "title": "Trung tâm điều khiển combo", "unavailable": "Trung tâm điều khiển combo không khả dụng", @@ -13978,17 +13997,5 @@ "cta": "Lấy khóa API", "partnerLinkNote": "Liên kết đối tác", "dismissAriaLabel": "Đóng" - }, - "combo": { - "sort": { - "label": "Sắp xếp theo", - "method": { - "manual": "Thủ công", - "provider": "Nhà cung cấp", - "score": "Điểm (mô hình miễn phí)", - "name": "Tên" - }, - "scoreHint": "Xếp hạng theo điểm chỉ áp dụng cho nhà cung cấp miễn phí; các nhà cung cấp khác giữ nguyên vị trí." - } } } diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 001ac195e5..b154da15a3 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -539,6 +539,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "qwen-cloud-token-plan", // AgentRouter (New-API) console balance quota (consoleApiKey + newApiUserId) "agentrouter", + // Kilo Code personal USD balance (GET /api/profile/balance, existing OAuth token) + "kilocode", ]; // ── Zod validation, lazily on first AI_PROVIDERS access (perf: skips the walk diff --git a/tests/unit/kilo-pass-meter-rendering.test.tsx b/tests/unit/kilo-pass-meter-rendering.test.tsx new file mode 100644 index 0000000000..7e51dd0507 --- /dev/null +++ b/tests/unit/kilo-pass-meter-rendering.test.tsx @@ -0,0 +1,247 @@ +// @vitest-environment jsdom +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing"; +import QuotaCardExpanded from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded"; +import KiloPassMeter, { + buildKiloPassMeterModel, +} from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/KiloPassMeter"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en-US", + useTranslations: () => + Object.assign( + (key: string, values?: { count?: number; pct?: number }) => { + if (key === "kiloPassUsageLabel") return "This month's usage"; + if (key === "kiloPassPaid") return "Paid"; + if (key === "kiloPassBonus") return "Available bonus"; + if (key === "kiloPassRemaining") return "Remaining"; + if (key === "kiloAccountBalance") return "Account Balance"; + if (key === "percentLeft") return `${values?.pct}% left`; + return key; + }, + { has: () => true } + ), +})); + +const rawKilocodeUsage = { + quotas: { + balance: { + remaining: 11.51, + remainingPercentage: 100, + currency: "USD", + displayName: "Personal Balance", + unlimited: true, + }, + kiloPassBase: { + total: 49, + remaining: 49, + resetAt: "2030-09-15T00:00:00.000Z", + }, + kiloPassBonus: { total: 24.5, remaining: 24.5 }, + kiloPassUsage: { + used: 73.55, + total: 73.5, + resetAt: "2030-09-15T00:00:00.000Z", + }, + kiloPassRemaining: { remaining: 0, total: 73.5 }, + }, +}; + +describe("Kilo Pass meter production render path", () => { + it("renders the dedicated meter rather than a generic 0% left quota row", () => { + const quotas = parseQuotaData("kilocode", rawKilocodeUsage); + const kiloPass = quotas.find((quota) => quota.kiloPass); + + expect(kiloPass).toMatchObject({ + name: "kiloPass", + kiloPass: true, + kiloPassBase: 49, + kiloPassBonus: 24.5, + used: 73.55, + total: 73.5, + remaining: 0, + kiloPassBalance: 11.51, + }); + + const html = renderToStaticMarkup( + {}} + onOpenCutoff={() => {}} + onOpenCost={() => {}} + canEditCutoff={false} + hasCutoffOverrides={false} + /> + ); + + expect(html).toContain("This month's usage"); + expect(html).toContain("Paid"); + expect(html).toContain("Available bonus"); + expect(html).toContain("Remaining"); + expect(html).toContain("Account Balance"); + expect(html).toContain("$73.55"); + expect(html).toContain("$73.50"); + expect(html).toContain('aria-valuemax="73.5"'); + expect(html).toContain('aria-valuenow="73.5"'); + expect(html).not.toContain("0% left"); + }); +}); + +const renderMeter = (props: Partial> = {}) => + renderToStaticMarkup( + + ); + +describe("KiloPassMeter segment model", () => { + it("splits the pool into paid and bonus segments and uses paid credits first", () => { + const model = buildKiloPassMeterModel({ + base: 49, + bonus: 24.5, + used: 47.95, + total: 73.5, + remaining: 25.55, + }); + + expect(model.paidPercent).toBeCloseTo(66.67, 1); + expect(model.bonusPercent).toBeCloseTo(33.33, 1); + expect(model.paidUsedPercent).toBeCloseTo(97.86, 1); + expect(model.bonusUsedPercent).toBe(0); + }); + + it("fills paid completely before consuming the bonus segment", () => { + const model = buildKiloPassMeterModel({ + base: 49, + bonus: 24.5, + used: 60, + total: 73.5, + remaining: 13.5, + }); + + expect(model.paidUsedPercent).toBe(100); + expect(model.bonusUsedPercent).toBeCloseTo(44.9, 1); + }); + + it("fills both segments exactly when usage reaches the total", () => { + const model = buildKiloPassMeterModel({ + base: 49, + bonus: 24.5, + used: 73.5, + total: 73.5, + remaining: 0, + }); + + expect(model.paidUsedPercent).toBe(100); + expect(model.bonusUsedPercent).toBe(100); + expect(model.progressValue).toBe(73.5); + }); + + it("clamps only visual progress when usage exceeds the total", () => { + const model = buildKiloPassMeterModel({ + base: 49, + bonus: 24.5, + used: 73.55, + total: 73.5, + remaining: 0, + }); + + expect(model.used).toBe(73.55); + expect(model.progressValue).toBe(73.5); + expect(model.paidUsedPercent).toBe(100); + expect(model.bonusUsedPercent).toBe(100); + }); + + it("handles absent paid or bonus pools and zero totals without NaN percentages", () => { + const onlyPaid = buildKiloPassMeterModel({ + base: 49, + bonus: 0, + used: 10, + total: 49, + remaining: 39, + }); + const onlyBonus = buildKiloPassMeterModel({ + base: 0, + bonus: 24.5, + used: 10, + total: 24.5, + remaining: 14.5, + }); + const empty = buildKiloPassMeterModel({ + base: 0, + bonus: 0, + used: 0, + total: 0, + remaining: 0, + }); + + expect(onlyPaid.bonusPercent).toBe(0); + expect(onlyBonus.paidPercent).toBe(0); + expect(empty.paidPercent).toBe(0); + expect(empty.bonusPercent).toBe(0); + expect(empty.paidUsedPercent).toBe(0); + expect(empty.bonusUsedPercent).toBe(0); + }); +}); + +describe("KiloPassMeter segmented presentation", () => { + it("renders two labeled segments, their boundary, semantic usage, and separate account balance", () => { + const html = renderMeter(); + + expect(html).toContain('data-kilo-pass-segment="paid"'); + expect(html).toContain('data-kilo-pass-segment="bonus"'); + expect(html).toContain('data-kilo-pass-boundary="true"'); + expect(html).toContain('aria-valuemin="0"'); + expect(html).toContain('aria-valuemax="73.5"'); + expect(html).toContain('aria-valuenow="47.95"'); + expect(html).toContain("Paid"); + expect(html).toContain("Available bonus"); + expect(html).toContain("Account Balance"); + expect(html).toContain("$11.51"); + expect(html).not.toContain("kiloPassBase"); + expect(html).not.toContain("kiloPassBonus"); + expect(html).not.toContain("kiloPassUsage"); + }); + + it("omits the bonus segment and boundary when no free bonus exists", () => { + const html = renderMeter({ bonus: 0, total: 49, used: 10, remaining: 39 }); + + expect(html).toContain('data-kilo-pass-segment="paid"'); + expect(html).not.toContain('data-kilo-pass-segment="bonus"'); + expect(html).not.toContain('data-kilo-pass-boundary="true"'); + }); + + it("omits the paid segment when only the free bonus pool exists", () => { + const html = renderMeter({ base: 0, bonus: 24.5, total: 24.5, used: 10, remaining: 14.5 }); + + expect(html).not.toContain('data-kilo-pass-segment="paid"'); + expect(html).toContain('data-kilo-pass-segment="bonus"'); + }); + + it("keeps the real overage in text while clamping its accessible progress value", () => { + const html = renderMeter({ used: 73.55, remaining: 0 }); + + expect(html).toContain("$73.55 / $73.50"); + expect(html).toContain('aria-valuenow="73.5"'); + }); + + it("renders a stable empty meter for a zero total", () => { + const html = renderMeter({ base: 0, bonus: 0, used: 0, total: 0, remaining: 0, balance: null }); + + expect(html).toContain('aria-valuemax="0"'); + expect(html).toContain('aria-valuenow="0"'); + expect(html).not.toContain("NaN"); + }); +}); diff --git a/tests/unit/kilocode-usage-wiring.test.ts b/tests/unit/kilocode-usage-wiring.test.ts new file mode 100644 index 0000000000..128a2f9709 --- /dev/null +++ b/tests/unit/kilocode-usage-wiring.test.ts @@ -0,0 +1,261 @@ +/** + * Kilo Code usage wiring tests: provider visibility (USAGE_SUPPORTED_PROVIDERS), + * fetcher registration (USAGE_FETCHER_PROVIDERS), dispatcher routing in + * getUsageForProvider(), and the Dashboard quota parser (kilocode is rendered + * through the AgentRouter/USD-credit parser so the exact dollar balance is + * shown instead of a bare percentage). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts"; +import { supportsProviderQuota } from "../../src/shared/utils/providerQuotaVisibility.ts"; +import { USAGE_FETCHER_PROVIDERS, getUsageForProvider } from "../../open-sse/services/usage.ts"; +import { + findKiloPassQuotaRow, + parseQuotaData, +} from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function mockBalance(status: number, body: unknown) { + globalThis.fetch = (async () => { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +} + +test("kilocode is registered in USAGE_SUPPORTED_PROVIDERS", () => { + assert.equal( + USAGE_SUPPORTED_PROVIDERS.includes("kilocode" as (typeof USAGE_SUPPORTED_PROVIDERS)[number]), + true + ); +}); + +test("supportsProviderQuota('kilocode') is true", () => { + assert.equal(supportsProviderQuota("kilocode"), true); +}); + +test("kilocode is registered in USAGE_FETCHER_PROVIDERS", () => { + assert.equal( + USAGE_FETCHER_PROVIDERS.includes("kilocode" as (typeof USAGE_FETCHER_PROVIDERS)[number]), + true + ); +}); + +test("getUsageForProvider dispatches kilocode to the Kilo balance fetcher", async () => { + mockBalance(200, { balance: 12.34 }); + + const usage = (await getUsageForProvider({ + id: "conn-kilo", + provider: "kilocode", + accessToken: "oauth-token-123", + })) as { + plan?: string; + quotas?: Record< + string, + { remaining?: number; currency?: string; displayName?: string; remainingPercentage?: number } + >; + message?: string; + }; + + assert.equal(usage.plan, "Kilo Code"); + assert.ok(usage.quotas); + const balance = usage.quotas.balance; + assert.ok(balance, "expected a balance quota entry"); + assert.equal(balance.remaining, 12.34); + assert.equal(balance.currency, "USD"); + assert.equal(balance.displayName, "Balance (USD)"); + assert.equal(usage.message, undefined); +}); + +test("kilocode balance quota parses as USD credit row in the Dashboard parser", async () => { + const usage = { + plan: "Kilo Code", + quotas: { + balance: { + used: 0, + total: 0, + remaining: 12.34, + remainingPercentage: 100, + resetAt: null, + unlimited: true, + currency: "USD", + displayName: "Balance (USD)", + }, + }, + }; + + const rows = parseQuotaData("kilocode", usage) as Array<{ + isCredits?: boolean; + currency?: string; + creditCount?: number; + remainingPercentage?: number; + }>; + + assert.equal(rows.length, 1); + const [row] = rows; + assert.equal(row.isCredits, true, "renderer only formats USD when isCredits is true"); + assert.equal(row.currency, "USD", "renderer looks up CURRENCY_SYMBOLS[q.currency]"); + assert.equal(row.creditCount, 12.34, "renderer displays q.creditCount as the dollar amount"); + assert.equal(row.remainingPercentage, 100, "funded wallet must not read as exhausted"); +}); + +test("kilocode balance + Kilo Pass quotas collapse into one meter row (balance untouched)", async () => { + const balanceQuota = { + used: 0, + total: 0, + remaining: 11.51, + remainingPercentage: 100, + resetAt: null, + unlimited: true, + currency: "USD", + displayName: "Balance (USD)", + }; + const baseQuota = { + used: 0, + total: 50, + remaining: 50, + remainingPercentage: 100, + resetAt: "2026-09-15T00:00:00.000Z", + unlimited: false, + currency: "USD", + displayName: "Base Credits", + }; + const bonusQuota = { + used: 0, + total: 5, + remaining: 5, + remainingPercentage: 100, + resetAt: null, + unlimited: false, + currency: "USD", + displayName: "Bonus Credits", + }; + const usageQuota = { + used: 30, + total: 55, + remaining: 25, + remainingPercentage: 45.45, + resetAt: "2026-09-15T00:00:00.000Z", + unlimited: false, + currency: "USD", + displayName: "Kilo Pass Usage", + }; + const remainingQuota = { + used: 0, + total: 0, + remaining: 25, + remainingPercentage: 100, + resetAt: "2026-09-15T00:00:00.000Z", + unlimited: false, + currency: "USD", + displayName: "Pass Remaining", + }; + const usage = { + plan: "Kilo Code", + quotas: { + balance: balanceQuota, + kiloPassBase: baseQuota, + kiloPassBonus: bonusQuota, + kiloPassUsage: usageQuota, + kiloPassRemaining: remainingQuota, + }, + }; + + const rows = parseQuotaData("kilocode", usage) as Array<{ + name?: string; + isCredits?: boolean; + currency?: string; + creditCount?: number; + displayName?: string; + unlimited?: boolean; + kiloPass?: boolean; + kiloPassBase?: number; + kiloPassBonus?: number; + kiloPassBalance?: number; + used?: number; + total?: number; + remaining?: number; + remainingPercentage?: number; + resetAt?: string | null; + }>; + assert.equal( + rows.length, + 2, + "four raw Kilo Pass keys must collapse into one meter row; balance stays separate" + ); + + const balance = rows.find((r) => r.name === "balance"); + assert.ok(balance, "personal balance row must survive"); + assert.equal(balance?.isCredits, true); + assert.equal(balance?.currency, "USD"); + assert.equal(balance?.creditCount, 11.51); + assert.equal(balance?.unlimited, true); + + const pass = findKiloPassQuotaRow(rows); + assert.ok(pass, "collapsed Kilo Pass row must be discoverable via findKiloPassQuotaRow"); + assert.equal(pass?.kiloPass, true); + assert.equal(pass?.displayName, "Kilo Pass"); + assert.equal(pass?.currency, "USD"); + assert.equal(pass?.kiloPassBase, 50); + assert.equal(pass?.kiloPassBonus, 5); + assert.equal(pass?.kiloPassBalance, 11.51, "wallet balance rides along for meter footer"); + assert.equal(pass?.used, 30); + assert.equal(pass?.total, 55, "meter total must be base + bonus"); + assert.equal(pass?.remaining, 25); + assert.equal(pass?.remainingPercentage, (25 / 55) * 100); + assert.equal(pass?.resetAt, "2026-09-15T00:00:00.000Z"); + assert.equal(pass?.unlimited, false); +}); + +test("getUsageForProvider returns Kilo Pass quotas through dispatcher", async () => { + const subscription = { + status: "active", + currentPeriodBaseCreditsUsd: 50, + currentPeriodUsageUsd: 30, + currentPeriodBonusCreditsUsd: 5, + nextBillingAt: "2026-09-15T00:00:00.000Z", + }; + const json = { subscription }; + const data = { json }; + const result = { data }; + const passPayload = [{ result }]; + + globalThis.fetch = (async (url: unknown) => { + const u = String(url); + if (u.includes("/api/profile/balance")) { + return new Response(JSON.stringify({ balance: 11.51 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify(passPayload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const usage = (await getUsageForProvider({ + id: "conn-kilo-pass", + provider: "kilocode", + accessToken: "oauth-token-123", + })) as { + plan?: string; + quotas?: Record; + message?: string; + }; + assert.equal(usage.plan, "Kilo Code"); + assert.equal(usage.message, undefined); + assert.ok(usage.quotas); + assert.equal(usage.quotas.balance.remaining, 11.51); + assert.equal(usage.quotas.kiloPassBase.remaining, 50); + assert.equal(usage.quotas.kiloPassUsage.used, 30); + assert.equal(usage.quotas.kiloPassRemaining.remaining, 25); +}); diff --git a/tests/unit/kilocode-usage.test.ts b/tests/unit/kilocode-usage.test.ts new file mode 100644 index 0000000000..0d90311277 --- /dev/null +++ b/tests/unit/kilocode-usage.test.ts @@ -0,0 +1,512 @@ +/** + * Kilo Code balance + Kilo Pass usage fetcher tests. + * Covers OAuth-only credential resolution, anonymous-token rejection, + * KILO_API_URL override, balance parsing, Kilo Pass parsing, partial + * failures between the two endpoints, and quota building. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + getKilocodeUsage, + parseKilocodeBalance, + parseKiloPassState, + buildKilocodeUsageResult, + buildKiloPassUsageResult, +} from "../../open-sse/services/usage/kilocode.ts"; + +const originalFetch = globalThis.fetch; +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +interface RecordedCall { + url: string; + init: RequestInit; +} + +function jsonResponse(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** + * Dual fetch recorder: balance endpoint gets balanceBody/balanceStatus, + * Kilo Pass endpoint gets passBody/passStatus. + */ +function recordDualFetch( + balanceBody: unknown, + balanceStatus: number, + passBody: unknown, + passStatus: number +): Array { + const calls: Array = []; + globalThis.fetch = async (url: unknown, init: unknown) => { + const u = String(url); + calls.push({ url: u, init: (init ?? {}) as RequestInit }); + const isBalance = u.includes("/api/profile/balance"); + return isBalance + ? jsonResponse(balanceBody, balanceStatus) + : jsonResponse(passBody, passStatus); + }; + return calls; +} + +function activePassPayload(): unknown { + const subscription: Record = { + tier: "tier_19", + status: "active", + currentPeriodBaseCreditsUsd: 50, + currentPeriodUsageUsd: 30, + currentPeriodBonusCreditsUsd: 5, + nextBillingAt: "2026-09-15T00:00:00.000Z", + }; + const json: Record = { subscription }; + const data: Record = { json }; + const result: Record = { data }; + return [{ result }]; +} + +const PASS_ACTIVE = activePassPayload(); +const baseConnection: Record = { + provider: "kilocode", + accessToken: "oauth-token-123", +}; + +interface QuotaEntry { + remaining?: number; + used?: number; + total?: number; + remainingPercentage?: number; + resetAt?: string | null; + currency?: string; + displayName?: string; + unlimited?: boolean; +} + +interface UsageResult { + plan?: string; + quotas?: Record; + message?: string; +} + +// OAuth-only credential tests + +test("no OAuth access token: no fetch, readable message", async () => { + const calls = recordDualFetch({ balance: 1 }, 200, PASS_ACTIVE, 200); + const usage = (await getKilocodeUsage("conn-none", { provider: "kilocode" })) as UsageResult; + assert.equal(calls.length, 0); + assert.ok(typeof usage.message === "string" && usage.message.length > 0); +}); + +test("anonymous token: no fetch, readable message", async () => { + const calls = recordDualFetch({ balance: 1 }, 200, PASS_ACTIVE, 200); + const usage = (await getKilocodeUsage("conn-anon", { + provider: "kilocode", + accessToken: "anonymous", + })) as UsageResult; + assert.equal(calls.length, 0); + assert.ok(typeof usage.message === "string"); +}); + +test("apiKey without OAuth accessToken: no fetch, API key not used", async () => { + const calls = recordDualFetch({ balance: 1 }, 200, PASS_ACTIVE, 200); + const usage = (await getKilocodeUsage("conn-apikey", { + provider: "kilocode", + apiKey: "kilo-pat-999", + })) as UsageResult; + assert.equal(calls.length, 0); + assert.ok(/OAuth/i.test(usage.message ?? "")); +}); + +test("apiKey with OAuth accessToken: OAuth token wins", async () => { + const calls = recordDualFetch({ balance: 7.5 }, 200, PASS_ACTIVE, 200); + await getKilocodeUsage("conn-both", { + provider: "kilocode", + accessToken: "oauth-token-123", + apiKey: "kilo-pat-999", + }); + assert.equal(calls.length, 2); + for (const call of calls) { + const headers = call.init.headers as Record; + assert.equal(headers.Authorization, "Bearer oauth-token-123"); + } +}); + +// Success: both endpoints + +test("success: balance + Kilo Pass both present", async () => { + const calls = recordDualFetch({ balance: 12.34 }, 200, PASS_ACTIVE, 200); + const usage = (await getKilocodeUsage("conn-1", baseConnection)) as UsageResult; + assert.equal(usage.plan, "Kilo Code"); + assert.equal(calls.length, 2); + const urls = calls.map((c) => c.url); + assert.ok(urls.some((u) => u.includes("/api/profile/balance"))); + assert.ok(urls.some((u) => u.includes("/api/trpc/kiloPass.getState"))); + for (const call of calls) { + const headers = call.init.headers as Record; + assert.equal(headers.Authorization, "Bearer oauth-token-123"); + assert.equal(headers["X-KILOCODE-EDITORNAME"], "OmniRoute"); + } + const quotas = usage.quotas ?? {}; + assert.equal(quotas.balance.remaining, 12.34); + assert.equal(quotas.kiloPassBase.remaining, 50); + assert.equal(quotas.kiloPassBonus.remaining, 5); + assert.equal(quotas.kiloPassUsage.used, 30); + assert.equal(quotas.kiloPassRemaining.remaining, 25); +}); + +test("success: exact $0 balance is valid, not missing", async () => { + recordDualFetch({ balance: 0 }, 200, PASS_ACTIVE, 200); + const usage = (await getKilocodeUsage("conn-0", baseConnection)) as UsageResult; + assert.equal(usage.quotas?.balance.remaining, 0); + assert.equal(usage.quotas?.balance.remainingPercentage, 0); + assert.equal(typeof usage.message, "undefined"); +}); + +// Case A: pass amounts surface correctly + +test("case A: Base 50, Usage 30, Bonus 5, Remaining 25", async () => { + recordDualFetch({ balance: 10 }, 200, PASS_ACTIVE, 200); + const usage = (await getKilocodeUsage("conn-a", baseConnection)) as UsageResult; + const quotas = usage.quotas ?? {}; + assert.equal(quotas.kiloPassBase.remaining, 50); + assert.equal(quotas.kiloPassBase.total, 50); + assert.equal(quotas.kiloPassUsage.used, 30); + assert.equal(quotas.kiloPassBonus.remaining, 5); + assert.equal(quotas.kiloPassBonus.total, 5); + assert.equal(quotas.kiloPassRemaining.remaining, 25); +}); + +test("Kilo Pass total and remaining preserve distinct usage semantics", () => { + const result = buildKiloPassUsageResult({ + currentPeriodBaseCreditsUsd: 49, + currentPeriodBonusCreditsUsd: 24.5, + currentPeriodUsageUsd: 47.95, + nextBillingAt: null, + }); + + assert.equal(result.quotas.kiloPassUsage.total, 73.5); + assert.equal(result.quotas.kiloPassUsage.used, 47.95); + assert.equal(result.quotas.kiloPassRemaining.remaining, 25.55); + assert.notEqual(result.quotas.kiloPassUsage.used, result.quotas.kiloPassRemaining.remaining); +}); + +// Case B: nextBillingAt attached to period quotas + +test("case B: nextBillingAt normalized and attached", async () => { + recordDualFetch({ balance: 10 }, 200, PASS_ACTIVE, 200); + const usage = (await getKilocodeUsage("conn-b", baseConnection)) as UsageResult; + const quotas = usage.quotas ?? {}; + assert.equal(quotas.kiloPassBase.resetAt, "2026-09-15T00:00:00.000Z"); + assert.equal(quotas.kiloPassUsage.resetAt, "2026-09-15T00:00:00.000Z"); + assert.equal(quotas.kiloPassRemaining.resetAt, "2026-09-15T00:00:00.000Z"); + assert.equal(quotas.kiloPassBonus.resetAt, null); +}); + +// Case C: balance stays separate from pass quotas + +test("case C: balance not merged into pass remaining", async () => { + recordDualFetch({ balance: 11.51 }, 200, PASS_ACTIVE, 200); + const usage = (await getKilocodeUsage("conn-c", baseConnection)) as UsageResult; + const quotas = usage.quotas ?? {}; + assert.equal(quotas.balance.remaining, 11.51); + assert.equal(quotas.kiloPassRemaining.remaining, 25); +}); + +// Case D: balance OK, pass fails + +test("case D: pass HTTP 500 leaves balance intact", async () => { + recordDualFetch({ balance: 12.34 }, 200, { error: "boom" }, 500); + const usage = (await getKilocodeUsage("conn-d", baseConnection)) as UsageResult; + assert.equal(usage.plan, "Kilo Code"); + assert.equal(usage.quotas?.balance.remaining, 12.34); + assert.equal(typeof usage.message, "undefined"); +}); + +// Case E: pass OK, balance fails + +test("case E: balance HTTP 500 leaves pass intact", async () => { + recordDualFetch({ error: "boom" }, 500, PASS_ACTIVE, 200); + const usage = (await getKilocodeUsage("conn-e", baseConnection)) as UsageResult; + assert.equal(usage.plan, "Kilo Code"); + assert.equal(usage.quotas?.kiloPassBase.remaining, 50); + assert.equal(typeof usage.message, "undefined"); +}); + +// Case F: no active pass + +test("case F: no subscription leaves balance intact, no pass quotas", async () => { + recordDualFetch({ balance: 8.0 }, 200, [{ result: { data: { json: {} } } }], 200); + const usage = (await getKilocodeUsage("conn-f", baseConnection)) as UsageResult; + assert.equal(usage.quotas?.balance.remaining, 8.0); + assert.equal(typeof usage.quotas?.kiloPassBase, "undefined"); + assert.equal(typeof usage.message, "undefined"); +}); + +// Cases G/H: pass 401 / 429 degrade gracefully + +test("case G: pass 401 leaves balance intact", async () => { + recordDualFetch({ balance: 5.0 }, 200, { error: "unauthorized" }, 401); + const usage = (await getKilocodeUsage("conn-g", baseConnection)) as UsageResult; + assert.equal(usage.quotas?.balance.remaining, 5.0); + assert.equal(typeof usage.message, "undefined"); +}); + +test("case H: pass 429 leaves balance intact", async () => { + recordDualFetch({ balance: 5.0 }, 200, { error: "limited" }, 429); + const usage = (await getKilocodeUsage("conn-h", baseConnection)) as UsageResult; + assert.equal(usage.quotas?.balance.remaining, 5.0); + assert.equal(typeof usage.message, "undefined"); +}); + +// Case I: invalid pass JSON degrades gracefully + +test("case I: invalid pass JSON leaves balance intact", async () => { + globalThis.fetch = async (url: unknown) => { + const u = String(url); + if (u.includes("/api/profile/balance")) { + return jsonResponse({ balance: 5.0 }, 200); + } + return new Response("not-json", { status: 200 }); + }; + const usage = (await getKilocodeUsage("conn-i", baseConnection)) as UsageResult; + assert.equal(usage.quotas?.balance.remaining, 5.0); + assert.equal(typeof usage.message, "undefined"); +}); + +// Case J: pass response missing required fields + +test("case J: pass missing fields leaves balance intact", async () => { + const incomplete = [{ result: { data: { json: { subscription: { tier: "tier_1" } } } } }]; + recordDualFetch({ balance: 3.0 }, 200, incomplete, 200); + const usage = (await getKilocodeUsage("conn-j", baseConnection)) as UsageResult; + assert.equal(usage.quotas?.balance.remaining, 3.0); + assert.equal(typeof usage.quotas?.kiloPassBase, "undefined"); +}); + +// Case K: null pass response + +test("case K: null pass body leaves balance intact", async () => { + recordDualFetch({ balance: 2.0 }, 200, null, 200); + const usage = (await getKilocodeUsage("conn-k", baseConnection)) as UsageResult; + assert.equal(usage.quotas?.balance.remaining, 2.0); + assert.equal(typeof usage.message, "undefined"); +}); + +// Case L: negative or non-finite credits clamped to 0 + +test("case L: negative and non-finite credits clamp to 0", () => { + const sub: Record = { + status: "active", + currentPeriodBaseCreditsUsd: -10, + currentPeriodUsageUsd: Number.NaN, + currentPeriodBonusCreditsUsd: Number.POSITIVE_INFINITY, + }; + const state = parseKiloPassState({ subscription: sub }); + assert.notEqual(state, null); + assert.equal(state?.currentPeriodBaseCreditsUsd, 0); + assert.equal(state?.currentPeriodUsageUsd, 0); + assert.equal(state?.currentPeriodBonusCreditsUsd, 0); +}); + +// Case M: zero amounts are valid + +test("case M: zero base/bonus/usage treated as valid", () => { + const sub: Record = { + status: "active", + currentPeriodBaseCreditsUsd: 0, + currentPeriodUsageUsd: 0, + currentPeriodBonusCreditsUsd: 0, + }; + const state = parseKiloPassState({ subscription: sub }); + assert.notEqual(state, null); + const result = buildKiloPassUsageResult(state as NonNullable); + assert.equal(result.quotas.kiloPassBase.remaining, 0); + assert.equal(result.quotas.kiloPassRemaining.remaining, 0); +}); + +// parseKiloPassState unit tests + +test("parseKiloPassState: batched tRPC shape", () => { + const state = parseKiloPassState(PASS_ACTIVE); + assert.notEqual(state, null); + assert.equal(state?.currentPeriodBaseCreditsUsd, 50); + assert.equal(state?.currentPeriodUsageUsd, 30); + assert.equal(state?.currentPeriodBonusCreditsUsd, 5); + assert.equal(state?.nextBillingAt, "2026-09-15T00:00:00.000Z"); +}); + +test("parseKiloPassState: plain subscription shape", () => { + const sub: Record = { + status: "trialing", + currentPeriodBaseCreditsUsd: 19, + currentPeriodUsageUsd: 0.01, + currentPeriodBonusCreditsUsd: 29.85, + nextBillingAt: "2026-07-20T09:30:20.806Z", + }; + const state = parseKiloPassState({ subscription: sub }); + assert.notEqual(state, null); + assert.equal(state?.currentPeriodBaseCreditsUsd, 19); +}); + +test("parseKiloPassState: canceled and expired status return null", () => { + const canceled: Record = { + subscription: { + status: "canceled", + currentPeriodBaseCreditsUsd: 19, + currentPeriodUsageUsd: 0, + }, + }; + assert.equal(parseKiloPassState(canceled), null); + const expired: Record = { + subscription: { + status: "expired", + currentPeriodBaseCreditsUsd: 19, + currentPeriodUsageUsd: 0, + }, + }; + assert.equal(parseKiloPassState(expired), null); +}); + +test("parseKiloPassState: past_due status accepted", () => { + const sub: Record = { + status: "past_due", + currentPeriodBaseCreditsUsd: 19, + currentPeriodUsageUsd: 5, + }; + const state = parseKiloPassState({ subscription: sub }); + assert.notEqual(state, null); + assert.equal(state?.currentPeriodBaseCreditsUsd, 19); +}); + +test("parseKiloPassState: missing amounts return null", () => { + assert.equal(parseKiloPassState({ status: "none" }), null); + assert.equal(parseKiloPassState({}), null); + assert.equal(parseKiloPassState(null), null); +}); + +test("parseKiloPassState: invalid nextBillingAt becomes null", () => { + const sub: Record = { + status: "active", + currentPeriodBaseCreditsUsd: 10, + currentPeriodUsageUsd: 0, + nextBillingAt: "not-a-date", + }; + const state = parseKiloPassState({ subscription: sub }); + assert.notEqual(state, null); + assert.equal(state?.nextBillingAt, null); +}); + +// Both endpoints fail + +test("both endpoints fail: graceful message", async () => { + recordDualFetch({ error: "boom" }, 500, { error: "boom" }, 500); + const usage = (await getKilocodeUsage("conn-both-fail", baseConnection)) as UsageResult; + assert.equal(usage.plan, "Kilo Code"); + assert.ok(typeof usage.message === "string"); + assert.equal(usage.quotas, undefined); +}); + +// Balance error diagnostics when pass also unavailable + +test("balance 401 with pass down: re-auth message", async () => { + recordDualFetch({ error: "unauthorized" }, 401, { error: "unauthorized" }, 401); + const usage = (await getKilocodeUsage("conn-401", baseConnection)) as UsageResult; + assert.ok(/expired|denied|re-authenticate/i.test(usage.message ?? "")); +}); + +test("balance 429 with pass down: rate-limit message", async () => { + recordDualFetch({ error: "limited" }, 429, { error: "limited" }, 429); + const usage = (await getKilocodeUsage("conn-429", baseConnection)) as UsageResult; + assert.ok(/rate limit/i.test(usage.message ?? "")); +}); + +test("network error on both endpoints: graceful message", async () => { + globalThis.fetch = async () => { + throw new Error("network down"); + }; + const usage = (await getKilocodeUsage("conn-net", baseConnection)) as UsageResult; + assert.ok(typeof usage.message === "string"); +}); + +test("balance malformed JSON with pass down: message", async () => { + globalThis.fetch = async (url: unknown) => { + const u = String(url); + if (u.includes("/api/profile/balance")) { + return new Response("not-json", { status: 200 }); + } + return jsonResponse({}, 200); + }; + const usage = (await getKilocodeUsage("conn-badjson", baseConnection)) as UsageResult; + assert.ok(typeof usage.message === "string"); +}); + +test("balance payload without balance field with pass down: message", async () => { + recordDualFetch({ credits: 10 }, 200, {}, 200); + const usage = (await getKilocodeUsage("conn-nobal", baseConnection)) as UsageResult; + assert.ok(typeof usage.message === "string"); +}); + +test("negative balance rejected when pass also down", async () => { + recordDualFetch({ balance: -1 }, 200, {}, 200); + const usage = (await getKilocodeUsage("conn-neg", baseConnection)) as UsageResult; + assert.ok(typeof usage.message === "string"); +}); + +// parseKilocodeBalance unit tests + +test("parseKilocodeBalance: rejects missing/null/negative/non-finite", () => { + assert.equal(parseKilocodeBalance(undefined), null); + assert.equal(parseKilocodeBalance(null), null); + assert.equal(parseKilocodeBalance({}), null); + assert.equal(parseKilocodeBalance({ balance: null }), null); + assert.equal(parseKilocodeBalance({ balance: -1 }), null); + assert.equal(parseKilocodeBalance({ balance: Number.NaN }), null); + assert.equal(parseKilocodeBalance({ balance: Number.POSITIVE_INFINITY }), null); +}); + +test("parseKilocodeBalance: accepts valid values with rounding", () => { + assert.equal(parseKilocodeBalance({ balance: 12.345 }), 12.35); + assert.equal(parseKilocodeBalance({ balance: 0 }), 0); + assert.equal(parseKilocodeBalance({ balance: 12.34 }), 12.34); +}); + +// buildKilocodeUsageResult unit tests + +test("buildKilocodeUsageResult: exact remaining, USD credits shape", () => { + const result = buildKilocodeUsageResult(12.34); + assert.equal(result.plan, "Kilo Code"); + assert.equal(result.quotas.balance.remaining, 12.34); + assert.equal(result.quotas.balance.currency, "USD"); + assert.equal(result.quotas.balance.remainingPercentage, 100); + assert.equal(result.quotas.balance.resetAt, null); + assert.equal(result.quotas.balance.unlimited, true); +}); + +// buildKiloPassUsageResult unit tests + +test("buildKiloPassUsageResult: correct quota entries", () => { + const result = buildKiloPassUsageResult({ + currentPeriodBaseCreditsUsd: 50, + currentPeriodUsageUsd: 30, + currentPeriodBonusCreditsUsd: 5, + nextBillingAt: "2026-09-15T00:00:00.000Z", + }); + assert.equal(result.quotas.kiloPassBase.remaining, 50); + assert.equal(result.quotas.kiloPassBase.resetAt, "2026-09-15T00:00:00.000Z"); + assert.equal(result.quotas.kiloPassBonus.remaining, 5); + assert.equal(result.quotas.kiloPassUsage.used, 30); + assert.equal(result.quotas.kiloPassRemaining.remaining, 25); +}); + +test("buildKiloPassUsageResult: usage exceeding pool clamps to 0", () => { + const result = buildKiloPassUsageResult({ + currentPeriodBaseCreditsUsd: 10, + currentPeriodUsageUsd: 20, + currentPeriodBonusCreditsUsd: 5, + nextBillingAt: null, + }); + assert.equal(result.quotas.kiloPassRemaining.remaining, 0); +});